diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-18 19:03:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-18 19:03:27 +0300 |
| commit | add957285a5ed6bafbfe2ec5b88060fc01ed7082 (patch) | |
| tree | e84b6263e0cbaae4029a339bf2644c032f7326c6 | |
| parent | 4fd086f3807f4b5b1fa414b2d1d6ec0f24c3f9b4 (diff) | |
lsp: strip Markdown code fences from LLM outputs (completions and code actions)
| -rw-r--r-- | internal/lsp/handlers.go | 102 | ||||
| -rw-r--r-- | internal/lsp/handlers_helpers_test.go | 19 |
2 files changed, 85 insertions, 36 deletions
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 43d42c8..57186c1 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -96,24 +96,24 @@ func (s *Server) handleCodeAction(req Request) { } func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { - if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" { - sys := "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable." - user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", instr, cleaned) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - if out := strings.TrimSpace(text); out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} - ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit} - return &ca - } - } else { - logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) - } - } - return nil + if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" { + sys := "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable." + user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", instr, cleaned) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} + ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit} + return &ca + } + } else { + logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) + } + } + return nil } func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction { @@ -137,16 +137,16 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - if out := strings.TrimSpace(text); out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} - ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit} - return &ca - } - } else { - logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) - } - return nil + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} + ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit} + return &ca + } + } else { + logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) + } + return nil } func (s *Server) llmRequestOpts() []llm.RequestOption { @@ -482,13 +482,13 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun // Update response counters (received) s.incRecvCounters(len(text)) s.logLLMStats() - cleaned := strings.TrimSpace(text) - if cleaned != "" { - cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - } - if cleaned == "" { - return nil, false - } + cleaned := stripCodeFences(strings.TrimSpace(text)) + if cleaned != "" { + cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + } + if cleaned == "" { + return nil, false + } return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true } @@ -712,7 +712,7 @@ func isIdentChar(ch byte) bool { // already appears immediately to the left of the cursor on the current line. // Also handles simple '=' assignments. func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string { - s2 := strings.TrimLeft(suggestion, " \t") + s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) { // Ensure only spaces follow in prefix (cursor at end of prefix segment) @@ -750,6 +750,36 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin return suggestion } +// stripCodeFences removes surrounding Markdown code fences from a model +// response when the entire output is wrapped, e.g. starting with "```go" or +// "```" and ending with "```". It returns the inner content unchanged. +func stripCodeFences(s string) string { + t := strings.TrimSpace(s) + if t == "" { + return t + } + lines := splitLines(t) + // find first and last non-empty lines + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + end := len(lines) - 1 + for end >= 0 && strings.TrimSpace(lines[end]) == "" { + end-- + } + if start >= len(lines) || end < 0 || start > end { + return t + } + first := strings.TrimSpace(lines[start]) + last := strings.TrimSpace(lines[end]) + if strings.HasPrefix(first, "```") && last == "```" && end > start { + inner := strings.Join(lines[start+1:end], "\n") + return inner + } + return t +} + func labelForCompletion(cleaned, filter string) string { label := trimLen(firstLine(cleaned)) if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) { diff --git a/internal/lsp/handlers_helpers_test.go b/internal/lsp/handlers_helpers_test.go index 84dce77..f9ed18a 100644 --- a/internal/lsp/handlers_helpers_test.go +++ b/internal/lsp/handlers_helpers_test.go @@ -50,3 +50,22 @@ func TestPromptRemovalEditsForLine_WholeLine(t *testing.T) { } } +func TestStripCodeFences(t *testing.T) { + cases := []struct{ + name string + in string + want string + }{ + {"no fences", "package main\nfunc x(){}", "package main\nfunc x(){}"}, + {"triple backticks no lang", "```\nA\nB\n```", "A\nB"}, + {"triple backticks with lang", "```go\nfmt.Println(\"hi\")\n```", "fmt.Println(\"hi\")"}, + {"leading/trailing spaces", " \n```python\nprint('x')\n```\n ", "print('x')"}, + {"single line fenced", "```go\npackage main\n```", "package main"}, + } + for _, tc := range cases { + got := stripCodeFences(tc.in) + if got != tc.want { + t.Fatalf("%s: got %q want %q", tc.name, got, tc.want) + } + } +} |
