diff options
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/context.go | 116 | ||||
| -rw-r--r-- | internal/lsp/context_test.go | 100 | ||||
| -rw-r--r-- | internal/lsp/document.go | 86 | ||||
| -rw-r--r-- | internal/lsp/document_test.go | 104 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 740 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 460 | ||||
| -rw-r--r-- | internal/lsp/server.go | 63 | ||||
| -rw-r--r-- | internal/lsp/transport.go | 38 | ||||
| -rw-r--r-- | internal/lsp/types.go | 58 |
9 files changed, 934 insertions, 831 deletions
diff --git a/internal/lsp/context.go b/internal/lsp/context.go index 8f345df..e746058 100644 --- a/internal/lsp/context.go +++ b/internal/lsp/context.go @@ -1,8 +1,8 @@ package lsp import ( - "strings" - "hexai/internal/logging" + "hexai/internal/logging" + "strings" ) // buildAdditionalContext builds extra context messages based on the configured mode. @@ -12,71 +12,71 @@ import ( // - file-on-new-func: include full file only when defining a new function // - always-full: always include the full file func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) { - mode := s.contextMode - switch mode { - case "minimal": - return "", false - case "window": - return s.windowContext(uri, pos), true - case "file-on-new-func": - if newFunc { - return s.fullFileContext(uri), true - } - return "", false - case "always-full": - return s.fullFileContext(uri), true - default: - // fallback to minimal if unknown - return "", false - } + mode := s.contextMode + switch mode { + case "minimal": + return "", false + case "window": + return s.windowContext(uri, pos), true + case "file-on-new-func": + if newFunc { + return s.fullFileContext(uri), true + } + return "", false + case "always-full": + return s.fullFileContext(uri), true + default: + // fallback to minimal if unknown + return "", false + } } func (s *Server) windowContext(uri string, pos Position) string { - d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 { - logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) - return "" - } - n := len(d.lines) - half := s.windowLines / 2 - start := pos.Line - half - if start < 0 { - start = 0 - } - end := pos.Line + half + 1 - if end > n { - end = n - } - text := strings.Join(d.lines[start:end], "\n") - return truncateToApproxTokens(text, s.maxContextTokens) + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) + return "" + } + n := len(d.lines) + half := s.windowLines / 2 + start := pos.Line - half + if start < 0 { + start = 0 + } + end := pos.Line + half + 1 + if end > n { + end = n + } + text := strings.Join(d.lines[start:end], "\n") + return truncateToApproxTokens(text, s.maxContextTokens) } func (s *Server) fullFileContext(uri string) string { - d := s.getDocument(uri) - if d == nil { - logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) - return "" - } - return truncateToApproxTokens(d.text, s.maxContextTokens) + d := s.getDocument(uri) + if d == nil { + logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) + return "" + } + return truncateToApproxTokens(d.text, s.maxContextTokens) } // truncateToApproxTokens naively truncates the input to fit approx N tokens. // Uses 4 chars/token heuristic for speed and determinism. func truncateToApproxTokens(text string, maxTokens int) string { - if maxTokens <= 0 { - return "" - } - maxChars := maxTokens * 4 - if len(text) <= maxChars { - return text - } - // try to cut on a line boundary near maxChars - cut := maxChars - if cut > len(text) { - cut = len(text) - } - if i := strings.LastIndex(text[:cut], "\n"); i > 0 { - cut = i - } - return text[:cut] + if maxTokens <= 0 { + return "" + } + maxChars := maxTokens * 4 + if len(text) <= maxChars { + return text + } + // try to cut on a line boundary near maxChars + cut := maxChars + if cut > len(text) { + cut = len(text) + } + if i := strings.LastIndex(text[:cut], "\n"); i > 0 { + cut = i + } + return text[:cut] } diff --git a/internal/lsp/context_test.go b/internal/lsp/context_test.go index 32834b8..fe5d73b 100644 --- a/internal/lsp/context_test.go +++ b/internal/lsp/context_test.go @@ -1,69 +1,69 @@ package lsp import ( - "strconv" - "strings" - "testing" + "strconv" + "strings" + "testing" ) func TestWindowContext_Bounds(t *testing.T) { - s := newTestServer() - s.windowLines = 4 // half=2 - s.maxContextTokens = 9999 - lines := make([]string, 10) - for i := 0; i < 10; i++ { - lines[i] = "L" + strconv.Itoa(i) - } - text := strings.Join(lines, "\n") - uri := "file:///w.go" - s.setDocument(uri, text) - got := s.windowContext(uri, Position{Line: 5, Character: 0}) - // expect lines 3..7 inclusive - want := strings.Join(lines[3:8], "\n") - if got != want { - t.Fatalf("window context got %q want %q", got, want) - } + s := newTestServer() + s.windowLines = 4 // half=2 + s.maxContextTokens = 9999 + lines := make([]string, 10) + for i := 0; i < 10; i++ { + lines[i] = "L" + strconv.Itoa(i) + } + text := strings.Join(lines, "\n") + uri := "file:///w.go" + s.setDocument(uri, text) + got := s.windowContext(uri, Position{Line: 5, Character: 0}) + // expect lines 3..7 inclusive + want := strings.Join(lines[3:8], "\n") + if got != want { + t.Fatalf("window context got %q want %q", got, want) + } } func TestBuildAdditionalContext_Minimal(t *testing.T) { - s := newTestServer() - s.contextMode = "minimal" - if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" { - t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx) - } + s := newTestServer() + s.contextMode = "minimal" + if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" { + t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx) + } } func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) { - s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 9999 - uri := "file:///x.go" - body := "package x\n\nfunc a(){}\n" - s.setDocument(uri, body) - if ctx, ok := s.buildAdditionalContext(true, uri, Position{}); !ok || ctx == "" { - t.Fatalf("expected full context when new func; ok=%v ctx=%q", ok, ctx) - } - if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); ok || ctx != "" { - t.Fatalf("expected no context when not new func; ok=%v ctx=%q", ok, ctx) - } + s := newTestServer() + s.contextMode = "file-on-new-func" + s.maxContextTokens = 9999 + uri := "file:///x.go" + body := "package x\n\nfunc a(){}\n" + s.setDocument(uri, body) + if ctx, ok := s.buildAdditionalContext(true, uri, Position{}); !ok || ctx == "" { + t.Fatalf("expected full context when new func; ok=%v ctx=%q", ok, ctx) + } + if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); ok || ctx != "" { + t.Fatalf("expected no context when not new func; ok=%v ctx=%q", ok, ctx) + } } func TestBuildAdditionalContext_AlwaysFull(t *testing.T) { - s := newTestServer() - s.contextMode = "always-full" - s.maxContextTokens = 9999 - uri := "file:///x.go" - body := "line1\nline2\n" - s.setDocument(uri, body) - if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); !ok || ctx == "" { - t.Fatalf("expected context in always-full; ok=%v ctx=%q", ok, ctx) - } + s := newTestServer() + s.contextMode = "always-full" + s.maxContextTokens = 9999 + uri := "file:///x.go" + body := "line1\nline2\n" + s.setDocument(uri, body) + if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); !ok || ctx == "" { + t.Fatalf("expected context in always-full; ok=%v ctx=%q", ok, ctx) + } } func TestTruncateToApproxTokens(t *testing.T) { - text := strings.Repeat("abcd", 10) // 40 chars - got := truncateToApproxTokens(text, 5) // ~20 chars - if len(got) > 5*4 { - t.Fatalf("truncate exceeded budget: got len=%d budget=%d", len(got), 5*4) - } + text := strings.Repeat("abcd", 10) // 40 chars + got := truncateToApproxTokens(text, 5) // ~20 chars + if len(got) > 5*4 { + t.Fatalf("truncate exceeded budget: got len=%d budget=%d", len(got), 5*4) + } } diff --git a/internal/lsp/document.go b/internal/lsp/document.go index e5eaf06..05f024f 100644 --- a/internal/lsp/document.go +++ b/internal/lsp/document.go @@ -1,8 +1,8 @@ package lsp import ( - "strings" - "time" + "strings" + "time" ) // --- Document store and helpers --- @@ -76,47 +76,47 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f // Heuristic: find nearest preceding line containing "func "; ensure no '{' // appears before the cursor across those lines. func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { - d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 { - return false - } - idx := pos.Line - if idx < 0 { - idx = 0 - } - if idx >= len(d.lines) { - idx = len(d.lines) - 1 - } - // Find signature start - sigStart := -1 - for i := idx; i >= 0; i-- { - if strings.Contains(d.lines[i], "func ") { - sigStart = i - break - } - // stop if we hit a closing brace which likely ends a previous block - if strings.Contains(d.lines[i], "}") { - break - } - } - if sigStart == -1 { - return false - } - // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body - for i := sigStart; i <= idx; i++ { - line := d.lines[i] - brace := strings.Index(line, "{") - if brace >= 0 { - if i < idx { - return false // body started on a previous line - } - // same line as cursor: if brace position < cursor character, then already in body - if pos.Character > brace { - return false - } - } - } - return true + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + return false + } + idx := pos.Line + if idx < 0 { + idx = 0 + } + if idx >= len(d.lines) { + idx = len(d.lines) - 1 + } + // Find signature start + sigStart := -1 + for i := idx; i >= 0; i-- { + if strings.Contains(d.lines[i], "func ") { + sigStart = i + break + } + // stop if we hit a closing brace which likely ends a previous block + if strings.Contains(d.lines[i], "}") { + break + } + } + if sigStart == -1 { + return false + } + // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body + for i := sigStart; i <= idx; i++ { + line := d.lines[i] + brace := strings.Index(line, "{") + if brace >= 0 { + if i < idx { + return false // body started on a previous line + } + // same line as cursor: if brace position < cursor character, then already in body + if pos.Character > brace { + return false + } + } + } + return true } func hasAny(s string, needles []string) bool { diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index 8d81a99..e8fa6bb 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -1,76 +1,76 @@ package lsp import ( - "io" - "log" - "strings" - "testing" + "io" + "log" + "strings" + "testing" ) func newTestServer() *Server { - return &Server{ - logger: log.New(io.Discard, "", 0), - docs: make(map[string]*document), - } + return &Server{ + logger: log.New(io.Discard, "", 0), + docs: make(map[string]*document), + } } func TestSplitLines(t *testing.T) { - in := "a\r\nb\nc" - got := splitLines(in) - want := []string{"a", "b", "c"} - if len(got) != len(want) { - t.Fatalf("len mismatch: got %d want %d", len(got), len(want)) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("line %d: got %q want %q", i, got[i], want[i]) - } - } + in := "a\r\nb\nc" + got := splitLines(in) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("len mismatch: got %d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("line %d: got %q want %q", i, got[i], want[i]) + } + } } func TestLineContext(t *testing.T) { - s := newTestServer() - src := "package main\n\nfunc add(a, b int) int {\n\treturn a + b\n}\n" - uri := "file:///test.go" - s.setDocument(uri, src) + s := newTestServer() + src := "package main\n\nfunc add(a, b int) int {\n\treturn a + b\n}\n" + uri := "file:///test.go" + s.setDocument(uri, src) - // Position on the return line (line 3, zero-based) - above, current, below, funcCtx := s.lineContext(uri, Position{Line: 3, Character: 0}) + // Position on the return line (line 3, zero-based) + above, current, below, funcCtx := s.lineContext(uri, Position{Line: 3, Character: 0}) - if want := "func add(a, b int) int {"; funcCtx != want { - t.Fatalf("funcCtx got %q want %q", funcCtx, want) - } - if want := "func add(a, b int) int {"; above != want { - t.Fatalf("above got %q want %q", above, want) - } - if want := "\treturn a + b"; current != want { - t.Fatalf("current got %q want %q", current, want) - } - if want := "}"; below != want { - t.Fatalf("below got %q want %q", below, want) - } + if want := "func add(a, b int) int {"; funcCtx != want { + t.Fatalf("funcCtx got %q want %q", funcCtx, want) + } + if want := "func add(a, b int) int {"; above != want { + t.Fatalf("above got %q want %q", above, want) + } + if want := "\treturn a + b"; current != want { + t.Fatalf("current got %q want %q", current, want) + } + if want := "}"; below != want { + t.Fatalf("below got %q want %q", below, want) + } } func TestLineContext_EmptyDoc(t *testing.T) { - s := newTestServer() - a, c, b, f := s.lineContext("file:///missing.go", Position{Line: 0, Character: 0}) - if a != "" || b != "" || c != "" || f != "" { - t.Fatalf("expected all empty for missing doc; got above=%q current=%q below=%q func=%q", a, c, b, f) - } + s := newTestServer() + a, c, b, f := s.lineContext("file:///missing.go", Position{Line: 0, Character: 0}) + if a != "" || b != "" || c != "" || f != "" { + t.Fatalf("expected all empty for missing doc; got above=%q current=%q below=%q func=%q", a, c, b, f) + } } func TestTrimLen(t *testing.T) { - long := strings.Repeat("a", 205) - got := trimLen(long) - want := strings.Repeat("a", 200) + "…" - if got != want { - t.Fatalf("trimLen got %q want %q", got, want) - } + long := strings.Repeat("a", 205) + got := trimLen(long) + want := strings.Repeat("a", 200) + "…" + if got != want { + t.Fatalf("trimLen got %q want %q", got, want) + } } func TestFirstLine(t *testing.T) { - s := "first line\r\nsecond line" - if got := firstLine(s); got != "first line" { - t.Fatalf("firstLine got %q want %q", got, "first line") - } + s := "first line\r\nsecond line" + if got := firstLine(s); got != "first line" { + t.Fatalf("firstLine got %q want %q", got, "first line") + } } diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index dce0b8d..a16affb 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -13,11 +13,11 @@ import ( ) func (s *Server) handle(req Request) { - switch req.Method { - case "initialize": - s.handleInitialize(req) - case "initialized": - s.handleInitialized() + switch req.Method { + case "initialize": + s.handleInitialize(req) + case "initialized": + s.handleInitialized() case "shutdown": s.handleShutdown(req) case "exit": @@ -28,15 +28,15 @@ func (s *Server) handle(req Request) { s.handleDidChange(req) case "textDocument/didClose": s.handleDidClose(req) - case "textDocument/completion": - s.handleCompletion(req) - case "textDocument/codeAction": - s.handleCodeAction(req) - default: - if len(req.ID) != 0 { - s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) - } - } + case "textDocument/completion": + s.handleCompletion(req) + case "textDocument/codeAction": + s.handleCodeAction(req) + default: + if len(req.ID) != 0 { + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) + } + } } func (s *Server) handleInitialize(req Request) { @@ -44,90 +44,97 @@ func (s *Server) handleInitialize(req Request) { if s.llmClient != nil { version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]" } - res := InitializeResult{ - Capabilities: ServerCapabilities{ - TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull - CompletionProvider: &CompletionOptions{ - ResolveProvider: false, - // TODO: Make the trigger characters configurable - TriggerCharacters: []string{".", ":", "/", "_"}, - }, - CodeActionProvider: true, - }, - ServerInfo: &ServerInfo{Name: "hexai", Version: version}, - } - s.reply(req.ID, res, nil) + res := InitializeResult{ + Capabilities: ServerCapabilities{ + TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + TriggerCharacters: s.triggerChars, + }, + CodeActionProvider: true, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: version}, + } + s.reply(req.ID, res, nil) } func (s *Server) handleCodeAction(req Request) { - var p CodeActionParams - if err := json.Unmarshal(req.Params, &p); err != nil { - if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) } - return - } - // Extract selected text - d := s.getDocument(p.TextDocument.URI) - if d == nil || len(d.lines) == 0 { - if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) } - return - } - sel := extractRangeText(d, p.Range) - if strings.TrimSpace(sel) == "" || s.llmClient == nil { - if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) } - return - } - - actions := make([]CodeAction, 0, 2) - - // Action 1: Rewrite selection based on first instruction in selection - 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}} - if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil { - out := strings.TrimSpace(text) - if out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} - actions = append(actions, CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit}) - } - } else { - logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) - } - } - - // Action 2: Resolve diagnostics within selection - if diags := s.diagnosticsInRange(p.Context, p.Range); len(diags) > 0 { - // Compose a prompt listing diagnostics relevant to the selected code - sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." - var b strings.Builder - b.WriteString("Diagnostics to resolve (selection only):\n") - for i, dgn := range diags { - // Minimal, user-facing summary; include source if present - if dgn.Source != "" { - fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) - } else { - fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) - } - } - b.WriteString("\nSelected code:\n") - b.WriteString(sel) - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} - if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil { - out := strings.TrimSpace(text) - if out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} - actions = append(actions, CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit}) - } - } else { - logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) - } - } - - if len(req.ID) != 0 { s.reply(req.ID, actions, nil) } + var p CodeActionParams + if err := json.Unmarshal(req.Params, &p); err != nil { + if len(req.ID) != 0 { + s.reply(req.ID, []CodeAction{}, nil) + } + return + } + // Extract selected text + d := s.getDocument(p.TextDocument.URI) + if d == nil || len(d.lines) == 0 { + if len(req.ID) != 0 { + s.reply(req.ID, []CodeAction{}, nil) + } + return + } + sel := extractRangeText(d, p.Range) + if strings.TrimSpace(sel) == "" || s.llmClient == nil { + if len(req.ID) != 0 { + s.reply(req.ID, []CodeAction{}, nil) + } + return + } + + actions := make([]CodeAction, 0, 2) + + // Action 1: Rewrite selection based on first instruction in selection + 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}} + if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil { + out := strings.TrimSpace(text) + if out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} + actions = append(actions, CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit}) + } + } else { + logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) + } + } + + // Action 2: Resolve diagnostics within selection + if diags := s.diagnosticsInRange(p.Context, p.Range); len(diags) > 0 { + // Compose a prompt listing diagnostics relevant to the selected code + sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." + var b strings.Builder + b.WriteString("Diagnostics to resolve (selection only):\n") + for i, dgn := range diags { + // Minimal, user-facing summary; include source if present + if dgn.Source != "" { + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + } else { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + } + } + b.WriteString("\nSelected code:\n") + b.WriteString(sel) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} + if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil { + out := strings.TrimSpace(text) + if out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}} + actions = append(actions, CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit}) + } + } else { + logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) + } + } + + if len(req.ID) != 0 { + s.reply(req.ID, actions, nil) + } } // instructionFromSelection extracts the first instruction from selection text. @@ -135,14 +142,14 @@ func (s *Server) handleCodeAction(req Request) { // a line comment (//, #, --). Returns the instruction string and the selection // text cleaned of the matched instruction marker or comment. func instructionFromSelection(sel string) (string, string) { - lines := splitLines(sel) - for idx, line := range lines { - if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { - lines[idx] = cleaned - return instr, strings.Join(lines, "\n") - } - } - return "", sel + lines := splitLines(sel) + for idx, line := range lines { + if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + } + } + return "", sel } // findFirstInstructionInLine returns the earliest instruction marker on the @@ -155,40 +162,51 @@ func instructionFromSelection(sel string) (string, string) { // - # text // - -- text func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) { - type cand struct{ start, end int; text string } - cands := []cand{} - if t, l, r, ok := findStrictSemicolonTag(line); ok { - cands = append(cands, cand{start: l, end: r, text: t}) - } - if i := strings.Index(line, "/*"); i >= 0 { - if j := strings.Index(line[i+2:], "*/"); j >= 0 { - start := i - end := i + 2 + j + 2 - text := strings.TrimSpace(line[i+2 : i+2+j]) - cands = append(cands, cand{start: start, end: end, text: text}) - } - } - if i := strings.Index(line, "<!--"); i >= 0 { - if j := strings.Index(line[i+4:], "-->"); j >= 0 { - start := i - end := i + 4 + j + 3 - text := strings.TrimSpace(line[i+4 : i+4+j]) - cands = append(cands, cand{start: start, end: end, text: text}) - } - } - if i := strings.Index(line, "//"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if i := strings.Index(line, "#"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) } - if i := strings.Index(line, "--"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) } - if len(cands) == 0 { return "", line, false } - // pick earliest start index - best := cands[0] - for _, c := range cands[1:] { - if c.start >= 0 && (best.start < 0 || c.start < best.start) { - best = c - } - } - cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") - return best.text, cleaned, true + type cand struct { + start, end int + text string + } + cands := []cand{} + if t, l, r, ok := findStrictSemicolonTag(line); ok { + cands = append(cands, cand{start: l, end: r, text: t}) + } + if i := strings.Index(line, "/*"); i >= 0 { + if j := strings.Index(line[i+2:], "*/"); j >= 0 { + start := i + end := i + 2 + j + 2 + text := strings.TrimSpace(line[i+2 : i+2+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, "<!--"); i >= 0 { + if j := strings.Index(line[i+4:], "-->"); j >= 0 { + start := i + end := i + 4 + j + 3 + text := strings.TrimSpace(line[i+4 : i+4+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, "//"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if i := strings.Index(line, "#"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + } + if i := strings.Index(line, "--"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if len(cands) == 0 { + return "", line, false + } + // pick earliest start index + best := cands[0] + for _, c := range cands[1:] { + if c.start >= 0 && (best.start < 0 || c.start < best.start) { + best = c + } + } + cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true } // findStrictSemicolonTag finds ;text; with no space after first ';' and no space @@ -196,91 +214,138 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b // the start index of the opening ';', the end index just after the closing ';', // and whether it was found. func findStrictSemicolonTag(line string) (string, int, int, bool) { - pos := 0 - for pos < len(line) { - j := strings.Index(line[pos:], ";") - if j < 0 { return "", 0, 0, false } - j += pos - // ensure single ';' (not ';;') and non-space after - if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { pos = j + 1; continue } - k := strings.Index(line[j+1:], ";") - if k < 0 { return "", 0, 0, false } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue } - inner := strings.TrimSpace(line[j+1 : closeIdx]) - if inner == "" { pos = closeIdx + 1; continue } - end := closeIdx + 1 - return inner, j, end, true - } - return "", 0, 0, false + pos := 0 + for pos < len(line) { + j := strings.Index(line[pos:], ";") + if j < 0 { + return "", 0, 0, false + } + j += pos + // ensure single ';' (not ';;') and non-space after + if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { + pos = j + 1 + continue + } + k := strings.Index(line[j+1:], ";") + if k < 0 { + return "", 0, 0, false + } + closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + pos = closeIdx + 1 + continue + } + inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" { + pos = closeIdx + 1 + continue + } + end := closeIdx + 1 + return inner, j, end, true + } + return "", 0, 0, false } // diagnosticsInRange parses the CodeAction context and returns diagnostics // that overlap the given selection range. If the context is missing or does // not contain diagnostics, returns an empty slice. func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic { - if len(ctxRaw) == 0 { return nil } - var ctx CodeActionContext - if err := json.Unmarshal(ctxRaw, &ctx); err != nil { return nil } - if len(ctx.Diagnostics) == 0 { return nil } - out := make([]Diagnostic, 0, len(ctx.Diagnostics)) - for _, d := range ctx.Diagnostics { - if rangesOverlap(d.Range, sel) { - out = append(out, d) - } - } - return out + if len(ctxRaw) == 0 { + return nil + } + var ctx CodeActionContext + if err := json.Unmarshal(ctxRaw, &ctx); err != nil { + return nil + } + if len(ctx.Diagnostics) == 0 { + return nil + } + out := make([]Diagnostic, 0, len(ctx.Diagnostics)) + for _, d := range ctx.Diagnostics { + if rangesOverlap(d.Range, sel) { + out = append(out, d) + } + } + return out } // rangesOverlap reports whether two LSP ranges overlap at all. func rangesOverlap(a, b Range) bool { - // Normalize ordering - if greaterPos(a.Start, a.End) { a.Start, a.End = a.End, a.Start } - if greaterPos(b.Start, b.End) { b.Start, b.End = b.End, b.Start } - // a ends before b starts - if lessPos(a.End, b.Start) { return false } - // b ends before a starts - if lessPos(b.End, a.Start) { return false } - return true + // Normalize ordering + if greaterPos(a.Start, a.End) { + a.Start, a.End = a.End, a.Start + } + if greaterPos(b.Start, b.End) { + b.Start, b.End = b.End, b.Start + } + // a ends before b starts + if lessPos(a.End, b.Start) { + return false + } + // b ends before a starts + if lessPos(b.End, a.Start) { + return false + } + return true } func lessPos(p, q Position) bool { - if p.Line != q.Line { return p.Line < q.Line } - return p.Character < q.Character + if p.Line != q.Line { + return p.Line < q.Line + } + return p.Character < q.Character } func greaterPos(p, q Position) bool { - if p.Line != q.Line { return p.Line > q.Line } - return p.Character > q.Character + if p.Line != q.Line { + return p.Line > q.Line + } + return p.Character > q.Character } // extractRangeText returns the exact text within the given document range. func extractRangeText(d *document, r Range) string { - if r.Start.Line == r.End.Line { - line := d.lines[r.Start.Line] - if r.Start.Character < 0 { r.Start.Character = 0 } - if r.End.Character > len(line) { r.End.Character = len(line) } - if r.Start.Character > r.End.Character { return "" } - return line[r.Start.Character:r.End.Character] - } - var b strings.Builder - // first line - first := d.lines[r.Start.Line] - if r.Start.Character < 0 { r.Start.Character = 0 } - if r.Start.Character > len(first) { r.Start.Character = len(first) } - b.WriteString(first[r.Start.Character:]) - b.WriteString("\n") - // middle lines - for i := r.Start.Line + 1; i < r.End.Line; i++ { - b.WriteString(d.lines[i]) - if i+1 <= r.End.Line { b.WriteString("\n") } - } - // last line - last := d.lines[r.End.Line] - if r.End.Character < 0 { r.En |
