diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-29 00:22:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-29 00:22:39 +0300 |
| commit | 0c2994f0065090a4884b28dc27eb760db2dfaab3 (patch) | |
| tree | 687ecd00584feb634a5853f5964028621f0fa1d5 /internal/lsp | |
| parent | d35aaa0227334ab0269b0907491c0682841b9cd5 (diff) | |
lsp: refactor dispatch to handler map; split handlers into feature files (completion, codeaction, init, document); decompose completion logic into small helpers; update review checklist
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/chat_trigger_suppression_test.go | 23 | ||||
| -rw-r--r-- | internal/lsp/codeaction_test.go | 129 | ||||
| -rw-r--r-- | internal/lsp/completion_cache_test.go | 64 | ||||
| -rw-r--r-- | internal/lsp/completion_codex_path_test.go | 92 | ||||
| -rw-r--r-- | internal/lsp/completion_prefix_strip_test.go | 210 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 848 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 214 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 306 | ||||
| -rw-r--r-- | internal/lsp/handlers_document.go | 273 | ||||
| -rw-r--r-- | internal/lsp/handlers_helpers_test.go | 148 | ||||
| -rw-r--r-- | internal/lsp/handlers_init.go | 40 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 270 | ||||
| -rw-r--r-- | internal/lsp/llm_busy_test.go | 41 | ||||
| -rw-r--r-- | internal/lsp/server.go | 28 | ||||
| -rw-r--r-- | internal/lsp/testfakes_test.go | 9 | ||||
| -rw-r--r-- | internal/lsp/types.go | 24 |
16 files changed, 1472 insertions, 1247 deletions
diff --git a/internal/lsp/chat_trigger_suppression_test.go b/internal/lsp/chat_trigger_suppression_test.go index 197fbfb..55a5245 100644 --- a/internal/lsp/chat_trigger_suppression_test.go +++ b/internal/lsp/chat_trigger_suppression_test.go @@ -4,14 +4,17 @@ import "testing" // Ensure completion is suppressed when a chat trigger is at EOL (?>,!>,:>,;>) func TestCompletionSuppressedOnChatTriggerEOL(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - s.llmClient = &countingLLM{} - tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"} - for i, line := range tests { - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://chat-suppr.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("case %d: expected ok=true", i) } - if len(items) != 0 { t.Fatalf("case %d: expected no completion items for EOL chat trigger", i) } - } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + s.llmClient = &countingLLM{} + tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"} + for i, line := range tests { + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://chat-suppr.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("case %d: expected ok=true", i) + } + if len(items) != 0 { + t.Fatalf("case %d: expected no completion items for EOL chat trigger", i) + } + } } - diff --git a/internal/lsp/codeaction_test.go b/internal/lsp/codeaction_test.go index 59b16d8..f5abbbf 100644 --- a/internal/lsp/codeaction_test.go +++ b/internal/lsp/codeaction_test.go @@ -1,71 +1,100 @@ package lsp import ( - "context" - "encoding/json" - "testing" - "hexai/internal/llm" + "context" + "encoding/json" + "hexai/internal/llm" + "testing" ) -type fakeLLM struct{ resp string; err error } +type fakeLLM struct { + resp string + err error +} func (f fakeLLM) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { - return f.resp, f.err + return f.resp, f.err } -func (f fakeLLM) Name() string { return "fake" } +func (f fakeLLM) Name() string { return "fake" } func (f fakeLLM) DefaultModel() string { return "fake-model" } func TestBuildRewriteCodeAction_LazyAndResolves(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: "REWRITTEN"} - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 4}}} - sel := ";rewrite;\nold code" - ca := s.buildRewriteCodeAction(p, sel) - if ca == nil { t.Fatalf("expected code action") } - // Should be lazy (no edit yet) - if ca.Edit != nil { t.Fatalf("expected nil Edit before resolve") } - if len(ca.Data) == 0 { t.Fatalf("expected data payload for lazy resolve") } - // Resolve now - resolved, ok := s.resolveCodeAction(*ca) - if !ok || resolved.Edit == nil { t.Fatalf("expected resolve to produce edit") } - edits := resolved.Edit.Changes[p.TextDocument.URI] - if len(edits) != 1 { t.Fatalf("expected 1 edit, got %d", len(edits)) } - if edits[0].Range != p.Range { t.Fatalf("edit range mismatch: got %+v want %+v", edits[0].Range, p.Range) } - if edits[0].NewText == "" { t.Fatalf("expected non-empty replacement text") } + s := newTestServer() + s.llmClient = fakeLLM{resp: "REWRITTEN"} + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 4}}} + sel := ";rewrite;\nold code" + ca := s.buildRewriteCodeAction(p, sel) + if ca == nil { + t.Fatalf("expected code action") + } + // Should be lazy (no edit yet) + if ca.Edit != nil { + t.Fatalf("expected nil Edit before resolve") + } + if len(ca.Data) == 0 { + t.Fatalf("expected data payload for lazy resolve") + } + // Resolve now + resolved, ok := s.resolveCodeAction(*ca) + if !ok || resolved.Edit == nil { + t.Fatalf("expected resolve to produce edit") + } + edits := resolved.Edit.Changes[p.TextDocument.URI] + if len(edits) != 1 { + t.Fatalf("expected 1 edit, got %d", len(edits)) + } + if edits[0].Range != p.Range { + t.Fatalf("edit range mismatch: got %+v want %+v", edits[0].Range, p.Range) + } + if edits[0].NewText == "" { + t.Fatalf("expected non-empty replacement text") + } } func TestBuildRewriteCodeAction_NoInstruction(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: "IGNORED"} - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{}} - sel := "no instruction here" - if ca := s.buildRewriteCodeAction(p, sel); ca != nil { t.Fatalf("expected nil action when no instruction present") } + s := newTestServer() + s.llmClient = fakeLLM{resp: "IGNORED"} + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{}} + sel := "no instruction here" + if ca := s.buildRewriteCodeAction(p, sel); ca != nil { + t.Fatalf("expected nil action when no instruction present") + } } func TestBuildDiagnosticsCodeAction_LazyAndResolves(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: "FIXED"} - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 10}, End: Position{Line: 12, Character: 5}}} - ctx := CodeActionContext{Diagnostics: []Diagnostic{ - {Range: Range{Start: Position{Line: 11}, End: Position{Line: 11, Character: 10}}, Message: "inside"}, - {Range: Range{Start: Position{Line: 2}, End: Position{Line: 3}}, Message: "outside"}, - }} - raw, _ := json.Marshal(ctx) - p.Context = json.RawMessage(raw) - sel := "some selected code" - ca := s.buildDiagnosticsCodeAction(p, sel) - if ca == nil { t.Fatalf("expected diagnostics code action") } - if ca.Edit != nil { t.Fatalf("expected lazy action without edit") } - if len(ca.Data) == 0 { t.Fatalf("expected data payload for lazy diagnostics action") } - resolved, ok := s.resolveCodeAction(*ca) - if !ok || resolved.Edit == nil { t.Fatalf("expected resolve to produce edit") } + s := newTestServer() + s.llmClient = fakeLLM{resp: "FIXED"} + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 10}, End: Position{Line: 12, Character: 5}}} + ctx := CodeActionContext{Diagnostics: []Diagnostic{ + {Range: Range{Start: Position{Line: 11}, End: Position{Line: 11, Character: 10}}, Message: "inside"}, + {Range: Range{Start: Position{Line: 2}, End: Position{Line: 3}}, Message: "outside"}, + }} + raw, _ := json.Marshal(ctx) + p.Context = json.RawMessage(raw) + sel := "some selected code" + ca := s.buildDiagnosticsCodeAction(p, sel) + if ca == nil { + t.Fatalf("expected diagnostics code action") + } + if ca.Edit != nil { + t.Fatalf("expected lazy action without edit") + } + if len(ca.Data) == 0 { + t.Fatalf("expected data payload for lazy diagnostics action") + } + resolved, ok := s.resolveCodeAction(*ca) + if !ok || resolved.Edit == nil { + t.Fatalf("expected resolve to produce edit") + } } func TestBuildDiagnosticsCodeAction_NoDiagnostics(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: "FIXED"} - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{}} - // empty context - p.Context = json.RawMessage(nil) - if ca := s.buildDiagnosticsCodeAction(p, "sel"); ca != nil { t.Fatalf("expected nil action when no diagnostics") } + s := newTestServer() + s.llmClient = fakeLLM{resp: "FIXED"} + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{}} + // empty context + p.Context = json.RawMessage(nil) + if ca := s.buildDiagnosticsCodeAction(p, "sel"); ca != nil { + t.Fatalf("expected nil action when no diagnostics") + } } diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go index a350281..779f89d 100644 --- a/internal/lsp/completion_cache_test.go +++ b/internal/lsp/completion_cache_test.go @@ -1,42 +1,42 @@ package lsp import ( - "bytes" - "log" - "strings" - "testing" + "bytes" + "log" + "strings" + "testing" - "hexai/internal/logging" + "hexai/internal/logging" ) func TestCompletionCache_IgnoresWhitespaceBeforeCursor(t *testing.T) { - var buf bytes.Buffer - logger := log.New(&buf, "", 0) - s := NewServer(bytes.NewBuffer(nil), &buf, logger, ServerOptions{}) - logging.Bind(logger) - s.triggerChars = []string{" ", "."} - fake := &countingLLM{} - s.llmClient = fake + var buf bytes.Buffer + logger := log.New(&buf, "", 0) + s := NewServer(bytes.NewBuffer(nil), &buf, logger, ServerOptions{}) + logging.Bind(logger) + s.triggerChars = []string{" ", "."} + fake := &countingLLM{} + s.llmClient = fake - // First request with trailing spaces before cursor - line := "foo " - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok || len(items) == 0 || fake.calls != 1 { - t.Fatalf("expected first call to invoke LLM; ok=%v len=%d calls=%d", ok, len(items), fake.calls) - } + // First request with trailing spaces before cursor + line := "foo " + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok || len(items) == 0 || fake.calls != 1 { + t.Fatalf("expected first call to invoke LLM; ok=%v len=%d calls=%d", ok, len(items), fake.calls) + } - // Same logical context but with a different amount of trailing whitespace - line2 := "foo " - p2 := CompletionParams{ Position: Position{ Line: 0, Character: len(line2) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items2, ok2 := s.tryLLMCompletion(p2, "", line2, "", "", "", false, "") - if !ok2 || len(items2) == 0 { - t.Fatalf("expected cache hit to still return items") - } - if fake.calls != 1 { - t.Fatalf("expected cache hit to avoid LLM call; calls=%d", fake.calls) - } - if !strings.Contains(buf.String(), "completion cache hit") { - t.Fatalf("expected log to contain cache hit message, got: %s", buf.String()) - } + // Same logical context but with a different amount of trailing whitespace + line2 := "foo " + p2 := CompletionParams{Position: Position{Line: 0, Character: len(line2)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}} + items2, ok2 := s.tryLLMCompletion(p2, "", line2, "", "", "", false, "") + if !ok2 || len(items2) == 0 { + t.Fatalf("expected cache hit to still return items") + } + if fake.calls != 1 { + t.Fatalf("expected cache hit to avoid LLM call; calls=%d", fake.calls) + } + if !strings.Contains(buf.String(), "completion cache hit") { + t.Fatalf("expected log to contain cache hit message, got: %s", buf.String()) + } } diff --git a/internal/lsp/completion_codex_path_test.go b/internal/lsp/completion_codex_path_test.go index 65ab75a..c8ce912 100644 --- a/internal/lsp/completion_codex_path_test.go +++ b/internal/lsp/completion_codex_path_test.go @@ -1,58 +1,78 @@ package lsp import ( - "context" - "errors" - "testing" + "context" + "errors" + "testing" - "hexai/internal/llm" + "hexai/internal/llm" ) // fakeCodeLLM implements both llm.Client and llm.CodeCompleter. -type fakeCodeLLM struct{ - codeCalls int - chatCalls int - result string - codeErr error +type fakeCodeLLM struct { + codeCalls int + chatCalls int + result string + codeErr error } func (f *fakeCodeLLM) CodeCompletion(_ context.Context, _ string, _ string, n int, _ string, _ float64) ([]string, error) { - f.codeCalls++ - if f.codeErr != nil { return nil, f.codeErr } - if n <= 0 { n = 1 } - out := make([]string, n) - for i := 0; i < n; i++ { out[i] = f.result } - return out, nil + f.codeCalls++ + if f.codeErr != nil { + return nil, f.codeErr + } + if n <= 0 { + n = 1 + } + out := make([]string, n) + for i := 0; i < n; i++ { + out[i] = f.result + } + return out, nil } func (f *fakeCodeLLM) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { - f.chatCalls++ - return "chat", nil + f.chatCalls++ + return "chat", nil } func (f *fakeCodeLLM) Name() string { return "fake" } func (f *fakeCodeLLM) DefaultModel() string { return "m" } func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string) } - fake := &fakeCodeLLM{ result: "DoThing()" } - s.llmClient = fake - line := "obj." - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok || len(items) == 0 { t.Fatalf("expected completion items via CodeCompleter path") } - if fake.codeCalls == 0 { t.Fatalf("expected CodeCompletion to be called") } - if fake.chatCalls != 0 { t.Fatalf("did not expect Chat fallback when CodeCompletion succeeds") } + s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)} + fake := &fakeCodeLLM{result: "DoThing()"} + s.llmClient = fake + line := "obj." + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok || len(items) == 0 { + t.Fatalf("expected completion items via CodeCompleter path") + } + if fake.codeCalls == 0 { + t.Fatalf("expected CodeCompletion to be called") + } + if fake.chatCalls != 0 { + t.Fatalf("did not expect Chat fallback when CodeCompletion succeeds") + } } func TestTryLLMCompletion_FallsBackToChatOnCodeCompleterError(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string) } - fake := &fakeCodeLLM{ result: "DoThing()", codeErr: errors.New("boom") } - s.llmClient = fake - line := "obj." - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://y.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true even on fallback path") } - if len(items) == 0 { t.Fatalf("expected some items from Chat fallback") } - if fake.codeCalls == 0 { t.Fatalf("expected CodeCompletion to be attempted first") } - if fake.chatCalls == 0 { t.Fatalf("expected Chat fallback to be called when CodeCompletion errors") } + s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)} + fake := &fakeCodeLLM{result: "DoThing()", codeErr: errors.New("boom")} + s.llmClient = fake + line := "obj." + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://y.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true even on fallback path") + } + if len(items) == 0 { + t.Fatalf("expected some items from Chat fallback") + } + if fake.codeCalls == 0 { + t.Fatalf("expected CodeCompletion to be attempted first") + } + if fake.chatCalls == 0 { + t.Fatalf("expected Chat fallback to be called when CodeCompletion errors") + } } diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go index 9953714..64cca49 100644 --- a/internal/lsp/completion_prefix_strip_test.go +++ b/internal/lsp/completion_prefix_strip_test.go @@ -1,120 +1,158 @@ package lsp import ( - "encoding/json" - "testing" + "encoding/json" + "testing" ) func TestStripDuplicateGeneralPrefix_ExactOverlap(t *testing.T) { - prefix := "func New " - sugg := "func New() *CustData" - got := stripDuplicateGeneralPrefix(prefix, sugg) - // We expect the already typed prefix to be removed from the suggestion. - if got == sugg { - t.Fatalf("expected duplicate prefix to be stripped; got unchanged: %q", got) - } - if got != "() *CustData" { - t.Fatalf("got %q want %q", got, "() *CustData") - } + prefix := "func New " + sugg := "func New() *CustData" + got := stripDuplicateGeneralPrefix(prefix, sugg) + // We expect the already typed prefix to be removed from the suggestion. + if got == sugg { + t.Fatalf("expected duplicate prefix to be stripped; got unchanged: %q", got) + } + if got != "() *CustData" { + t.Fatalf("got %q want %q", got, "() *CustData") + } } func TestStripDuplicateGeneralPrefix_TokenBoundarySuffix(t *testing.T) { - prefix := "db." - sugg := "db.Query()" - got := stripDuplicateGeneralPrefix(prefix, sugg) - if got != "Query()" { - t.Fatalf("got %q want %q", got, "Query()") - } + prefix := "db." + sugg := "db.Query()" + got := stripDuplicateGeneralPrefix(prefix, sugg) + if got != "Query()" { + t.Fatalf("got %q want %q", got, "Query()") + } } func TestStripDuplicateAssignmentPrefix_AssignAndWalrus(t *testing.T) { - // walrus - if out := stripDuplicateAssignmentPrefix("name := ", "name := compute()" ); out != "compute()" { - t.Fatalf(":= expected compute(), got %q", out) - } - // equals - if out := stripDuplicateAssignmentPrefix("x = ", "x = y+1" ); out != "y+1" { - t.Fatalf("= expected y+1, got %q", out) - } + // walrus + if out := stripDuplicateAssignmentPrefix("name := ", "name := compute()"); out != "compute()" { + t.Fatalf(":= expected compute(), got %q", out) + } + // equals + if out := stripDuplicateAssignmentPrefix("x = ", "x = y+1"); out != "y+1" { + t.Fatalf("= expected y+1, got %q", out) + } } func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - s.llmClient = fakeLLM{resp: "() *CustData"} - line := "func fib(i int) " // cursor after space - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - // Simulate manual user invocation (TriggerKind=1) - p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true for manual invoke after whitespace") } - if len(items) == 0 { t.Fatalf("expected at least one completion item") } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + s.llmClient = fakeLLM{resp: "() *CustData"} + line := "func fib(i int) " // cursor after space + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}} + // Simulate manual user invocation (TriggerKind=1) + p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true for manual invoke after whitespace") + } + if len(items) == 0 { + t.Fatalf("expected at least one completion item") + } } func TestTryLLMCompletion_InlineSemicolonPromptAlwaysTriggers(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - s.llmClient = fakeLLM{resp: "replacement"} - line := "prefix ;do something; suffix" - // No trigger char immediately before cursor; place cursor at end - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok || len(items) == 0 { t.Fatalf("expected completion to trigger on inline ;text; prompt") } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + s.llmClient = fakeLLM{resp: "replacement"} + line := "prefix ;do something; suffix" + // No trigger char immediately before cursor; place cursor at end + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok || len(items) == 0 { + t.Fatalf("expected completion to trigger on inline ;text; prompt") + } } func TestTryLLMCompletion_DoubleSemicolonEmpty_DoesNotAutoTrigger(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - fake := &countingLLM{} - s.llmClient = fake - line := ";; " // empty content after ';;' should not force-trigger - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://empty-inline.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true for non-trigger path") } - if len(items) != 0 { t.Fatalf("expected no items when inline ';;' is empty") } - if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + line := ";; " // empty content after ';;' should not force-trigger + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://empty-inline.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true for non-trigger path") + } + if len(items) != 0 { + t.Fatalf("expected no items when inline ';;' is empty") + } + if fake.calls != 0 { + t.Fatalf("LLM should not be called; calls=%d", fake.calls) + } } func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) { - if hasDoubleSemicolonTrigger(";;") { t.Fatalf("bare ';;' should not trigger") } - if hasDoubleSemicolonTrigger(";; ;") { t.Fatalf("';;' followed by space should not trigger") } - if hasDoubleSemicolonTrigger(";;;") { t.Fatalf("';;;' should not trigger (no content)") } - if !hasDoubleSemicolonTrigger(";;x;") { t.Fatalf("expected trigger for ';;x;' pattern") } + if hasDoubleSemicolonTrigger(";;") { + t.Fatalf("bare ';;' should not trigger") + } + if hasDoubleSemicolonTrigger(";; ;") { + t.Fatalf("';;' followed by space should not trigger") + } + if hasDoubleSemicolonTrigger(";;;") { + t.Fatalf("';;;' should not trigger (no content)") + } + if !hasDoubleSemicolonTrigger(";;x;") { + t.Fatalf("expected trigger for ';;x;' pattern") + } } func TestBareDoubleSemicolonPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - fake := &countingLLM{} - s.llmClient = fake - // Place a '.' earlier but also include bare ';;' at end; should not auto-trigger - line := "obj. call ;;" - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds.go"} } - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true (handled), but not auto-triggering") } - if len(items) != 0 { t.Fatalf("expected no items due to bare ';;'") } - if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + // Place a '.' earlier but also include bare ';;' at end; should not auto-trigger + line := "obj. call ;;" + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true (handled), but not auto-triggering") + } + if len(items) != 0 { + t.Fatalf("expected no items due to bare ';;'") + } + if fake.calls != 0 { + t.Fatalf("LLM should not be called; calls=%d", fake.calls) + } } func TestBareDoubleSemicolonOnNextLine_PreventsAutoTrigger(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - fake := &countingLLM{} - s.llmClient = fake - current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" - below := ";;" - p := CompletionParams{ Position: Position{ Line: 0, Character: len(current) }, TextDocument: TextDocumentIdentifier{URI: "file://nextline.go"} } - items, ok := s.tryLLMCompletion(p, "", current, below, "", "", false, "") - if !ok { t.Fatalf("expected ok=true handled") } - if len(items) != 0 { t.Fatalf("expected no items due to bare ';;' on next line") } - if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" + below := ";;" + p := CompletionParams{Position: Position{Line: 0, Character: len(current)}, TextDocument: TextDocumentIdentifier{URI: "file://nextline.go"}} + items, ok := s.tryLLMCompletion(p, "", current, below, "", "", false, "") + if !ok { + t.Fatalf("expected ok=true handled") + } + if len(items) != 0 { + t.Fatalf("expected no items due to bare ';;' on next line") + } + if fake.calls != 0 { + t.Fatalf("LLM should not be called; calls=%d", fake.calls) + } } func TestBareDoubleSemicolonPreventsManualInvoke(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string) } - fake := &countingLLM{} - s.llmClient = fake - line := ";;" - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds-manual.go"} } - // Simulate manual invoke - p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true (handled)") } - if len(items) != 0 { t.Fatalf("expected no items for bare ';;' even with manual invoke") } - if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + line := ";;" + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds-manual.go"}} + // Simulate manual invoke + p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true (handled)") + } + if len(items) != 0 { + t.Fatalf("expected no items for bare ';;' even with manual invoke") + } + if fake.calls != 0 { + t.Fatalf("LLM should not be called; calls=%d", fake.calls) + } } diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 332344a..774a94a 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -3,209 +3,25 @@ package lsp import ( - "context" "encoding/json" "fmt" - "hexai/internal" "hexai/internal/llm" "hexai/internal/logging" - "os" "strings" "time" ) func (s *Server) handle(req Request) { - switch req.Method { - case "initialize": - s.handleInitialize(req) - case "initialized": - s.handleInitialized() - case "shutdown": - s.handleShutdown(req) - case "exit": - s.handleExit() - case "textDocument/didOpen": - s.handleDidOpen(req) - case "textDocument/didChange": - s.handleDidChange(req) - case "textDocument/didClose": - s.handleDidClose(req) - case "textDocument/completion": - s.handleCompletion(req) - case "textDocument/codeAction": - s.handleCodeAction(req) - case "codeAction/resolve": - s.handleCodeActionResolve(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) { - version := internal.Version - if s.llmClient != nil { - version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]" - } - res := InitializeResult{ - Capabilities: ServerCapabilities{ - TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull - CompletionProvider: &CompletionOptions{ - ResolveProvider: false, - TriggerCharacters: s.triggerChars, - }, - CodeActionProvider: CodeActionOptions{ResolveProvider: 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 - } - d := s.getDocument(p.TextDocument.URI) - if d == nil || len(d.lines) == 0 || s.llmClient == nil { - if len(req.ID) != 0 { - s.reply(req.ID, []CodeAction{}, nil) - } + if h, ok := s.handlers[req.Method]; ok { + h(req) return } - sel := extractRangeText(d, p.Range) - if strings.TrimSpace(sel) == "" { - if len(req.ID) != 0 { - s.reply(req.ID, []CodeAction{}, nil) - } - return - } - - actions := make([]CodeAction, 0, 2) - if a := s.buildRewriteCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } - if a := s.buildDiagnosticsCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } if len(req.ID) != 0 { - s.reply(req.ID, actions, nil) - } -} - -func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { - if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" { - payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Instruction string `json:"instruction"` - Selection string `json:"selection"` - }{Type: "rewrite", URI: p.TextDocument.URI, Range: p.Range, Instruction: instr, Selection: cleaned} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw} - return &ca - } - return nil -} - -func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction { - diags := s.diagnosticsInRange(p.Context, p.Range) - if len(diags) == 0 { - return nil + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) } - payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Selection string `json:"selection"` - Diagnostics []Diagnostic `json:"diagnostics"` - }{Type: "diagnostics", URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Data: raw} - return &ca } -func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { - if s.llmClient == nil || len(ca.Data) == 0 { - return ca, false - } - var payload struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Instruction string `json:"instruction,omitempty"` - Selection string `json:"selection"` - Diagnostics []Diagnostic `json:"diagnostics,omitempty"` - } - if err := json.Unmarshal(ca.Data, &payload); err != nil { - return ca, false - } - switch payload.Type { - case "rewrite": - 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", payload.Instruction, payload.Selection) - 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{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - } - } else { - logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) - } - case "diagnostics": - 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 payload.Diagnostics { - 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(payload.Selection) - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - 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 := stripCodeFences(strings.TrimSpace(text)); out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - } - } else { - logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) - } - } - return ca, false -} - -func (s *Server) handleCodeActionResolve(req Request) { - var ca CodeAction - if err := json.Unmarshal(req.Params, &ca); err != nil { - if len(req.ID) != 0 { - s.reply(req.ID, ca, nil) - } - return - } - if resolved, ok := s.resolveCodeAction(ca); ok { - s.reply(req.ID, resolved, nil) - return - } - s.reply(req.ID, ca, nil) -} +// handleInitialize moved to handlers_init.go func (s *Server) llmRequestOpts() []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} @@ -327,59 +143,7 @@ func findStrictSemicolonTag(line string) (string, int, int, bool) { // 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 -} - -// 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 -} - -func lessPos(p, q Position) bool { - 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 -} +// CodeAction-related handlers and helpers moved to handlers_codeaction.go // extractRangeText returns the exact text within the given document range. func extractRangeText(d *document, r Range) string { @@ -426,122 +190,33 @@ func extractRangeText(d *document, r Range) string { return b.String() } -func (s *Server) handleInitialized() { - logging.Logf("lsp ", "client initialized") -} +// handleInitialized moved to handlers_init.go -func (s *Server) handleShutdown(req Request) { - s.reply(req.ID, nil, nil) -} +// handleShutdown moved to handlers_init.go -func (s *Server) handleExit() { - s.exited = true - os.Exit(0) -} +// handleExit moved to handlers_init.go -func (s *Server) handleDidOpen(req Request) { - var p DidOpenTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil { - s.setDocument(p.TextDocument.URI, p.TextDocument.Text) - s.markActivity() - } -} +// handleDidOpen moved to handlers_document.go -func (s *Server) handleDidChange(req Request) { - var p DidChangeTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil { - if len(p.ContentChanges) > 0 { - s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) - } - s.markActivity() - // Detect in-editor chat trigger lines and respond inline. - s.detectAndHandleChat(p.TextDocument.URI) - } -} +// handleDidChange moved to handlers_document.go -func (s *Server) handleDidClose(req Request) { - var p DidCloseTextDocumentParams - if err := json.Unmarshal(req.Params, &p); err == nil { - s.deleteDocument(p.TextDocument.URI) - s.markActivity() - } -} +// handleDidClose moved to handlers_document.go -func (s *Server) handleCompletion(req Request) { - var p CompletionParams - var docStr string - if err := json.Unmarshal(req.Params, &p); err == nil { - // Log trigger information for every completion request from client - tk, tch := extractTriggerInfo(p) - logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d", - tk, tch, p.TextDocument.URI, p.Position.Line, p.Position.Character) - above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position) - docStr = s.buildDocString(p, above, current, below, funcCtx) - if s.logContext { - s.logCompletionContext(p, above, current, below, funcCtx) - } - if s.llmClient != nil { - newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) - extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) - items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) - if ok { - s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) - return - } - } - } - items := s.fallbackCompletionItems(docStr) - s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) -} +// handleCompletion moved to handlers_completion.go func (s *Server) reply(id json.RawMessage, result any, err *RespError) { - resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} - s.writeMessage(resp) + resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} + s.writeMessage(resp) } // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { - d := s.getDocument(uri) - if d == nil { return "", "" } - // Clamp indices - line := pos.Line - if line < 0 { line = 0 } - if line >= len(d.lines) { line = len(d.lines) - 1 } - col := pos.Character - if col < 0 { col = 0 } - if col > len(d.lines[line]) { col = len(d.lines[line]) } - // Build before - var b strings.Builder - for i := 0; i < line; i++ { b.WriteString(d.lines[i]); b.WriteByte('\n') } - b.WriteString(d.lines[line][:col]) - before := b.String() - // Build after - var a strings.Builder - a.WriteString(d.lines[line][col:]) - for i := line + 1; i < len(d.lines); i++ { a.WriteByte('\n'); a.WriteString(d.lines[i]) } - return before, a.String() -} +// docBeforeAfter moved to handlers_document.go // extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter // if provided by the client; when absent it returns zeros. -func extractTriggerInfo(p CompletionParams) (kind int, ch string) { - if p.Context == nil { - return 0, "" - } - var ctx struct { - TriggerKind int `json:"triggerKind"` - TriggerCharacter string `json:"triggerCharacter,omitempty"` - } - if raw, ok := p.Context.(json.RawMessage); ok { - _ = json.Unmarshal(raw, &ctx) - } else { - b, _ := json.Marshal(p.Context) - _ = json.Unmarshal(b, &ctx) - } - return ctx.TriggerKind, ctx.TriggerCharacter -} +// extractTriggerInfo moved to handlers_completion.go // --- in-editor chat (";C ...") --- @@ -550,483 +225,86 @@ func extractTriggerInfo(p CompletionParams) (kind int, ch string) { // and no non-empty answer line yet). If found, it asks the LLM and inserts the // answer below the blank line, leaving exactly one empty line between prompt // and response. -func (s *Server) detectAndHandleChat(uri string) { - if s.llmClient == nil { - return - } - d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 { - return - } - for i, raw := range d.lines { - // Find last non-space character index - j := len(raw) - 1 - for j >= 0 { - if raw[j] == ' ' || raw[j] == '\t' { - j-- - continue - } - break - } - if j < 1 { // need at least two chars for pattern like '?>' - continue - } - pair := raw[j-1 : j+1] - isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" - if !isTrigger { - continue - } - // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { - k++ - } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { - continue - } - // Derive prompt by removing only the trailing '>' - removeCount := 1 - base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) - if prompt == "" { - continue - } - lineIdx := i - lastIdx := j - go func(prompt string, remove int) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - sys := "You are a helpful coding assistant. Answer concisely and clearly." - // Build short conversation history from the document above this line - history := s.buildChatHistory(uri, lineIdx, prompt) - msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) - opts := s.llmRequestOpts() - logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) - text, err := s.llmClient.Chat(ctx, msgs, opts...) - if err != nil { - logging.Logf("lsp ", "chat llm error: %v", err) - return - } - out := strings.TrimSpace(stripCodeFences(text)) - if out == "" { - return - } - s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) - }(prompt, removeCount) - // Only handle one per change tick to avoid flooding - break - } -} +// detectAndHandleChat moved to handlers_document.go // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { - d := s.getDocument(uri) - if d == nil { - return - } - // 1) Delete the trailing punctuation (1 or 2 chars) - delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} - delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} - // 2) Insert two newlines and the response at end-of-line, then one extra - // newline so there is exactly one blank line after the reply - insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} - resp := strings.TrimRight(response, "\n") + "\n" - insert := "\n\n" + resp + "\n" - edits := []TextEdit{ - {Range: Range{Start: delStart, End: delEnd}, NewText: ""}, - {Range: Range{Start: insPos, End: insPos}, NewText: insert}, - } - we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} - s.clientApplyEdit("Hexai: insert chat response", we) -} +// applyChatEdits moved to handlers_document.go // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. It returns messages in chronological order // ending with the current user prompt. Limits to a small number of pairs to control tokens. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { - d := s.getDocument(uri) - if d == nil { - return []llm.Message{{Role: "user", Content: currentPrompt}} - } - type pair struct { - q string - a string - } - pairs := []pair{} - i := lineIdx - 1 - // Collect up to 3 recent pairs - for i >= 0 && len(pairs) < 3 { - // Skip blank lines - for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { - i-- - } - if i < 0 { - break - } - // Expect assistant reply lines starting with ">" - if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { - break - } - // Collect contiguous reply block - var replyLines []string - for i >= 0 { - line := strings.TrimSpace(d.lines[i]) - if strings.HasPrefix(line, ">") { - replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) - i-- - continue - } - break - } - // Skip a single blank line that should separate Q from A - for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { - i-- - } - if i < 0 { - break - } - // Take the question as the non-empty line above - q := strings.TrimSpace(d.lines[i]) - // Remove any lingering trigger pair at end if present - q = stripTrailingTrigger(q) - pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) - i-- - // Continue to find older pairs - } - // Build messages - msgs := make([]llm.Message, 0, len(pairs)*2+1) - for _, p := range pairs { - if strings.TrimSpace(p.q) != "" { - msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) - } - if strings.TrimSpace(p.a) != "" { - msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) - } - } - msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) - return msgs -} +// buildChatHistory moved to handlers_document.go // stripTrailingTrigger removes a single trailing punctuation from the set // [?,!,:] or both semicolons if present at end, mirroring the inline trigger rules. -func stripTrailingTrigger(sx string) string { - s := strings.TrimRight(sx, " \t") - // New chat triggers use a trailing '>' paired with one of ? ! : ; - if len(s) >= 2 && s[len(s)-1] == '>' { - prev := s[len(s)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - return strings.TrimRight(s[:len(s)-1], " \t") - } - } - if strings.HasSuffix(s, ";;") { // keep legacy inline ';;' cleanup used in history building - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - } - if len(s) == 0 { - return sx - } - last := s[len(s)-1] - switch last { // legacy: remove one trailing punctuation for old-chat triggers - case '?', '!', ':': - return strings.TrimRight(s[:len(s)-1], " \t") - default: - return sx - } -} +// stripTrailingTrigger moved to handlers_document.go // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { - params := ApplyWorkspaceEditParams{Label: label, Edit: edit} - // Build a JSON-RPC request with a fresh id - id := s.nextReqID() - req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} - // marshal params separately to avoid changing Request type - b, _ := json.Marshal(params) - req.Params = b - s.writeMessage(req) -} +// clientApplyEdit moved to handlers_document.go // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage { - s.mu.Lock() - s.nextID++ - idNum := s.nextID - s.mu.Unlock() - b, _ := json.Marshal(idNum) - return b -} +// nextReqID moved to handlers_document.go // --- completion helpers --- -func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string { - return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", - p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) -} +// buildDocString moved to handlers_completion.go -func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) { - logging.Logf("lsp ", "completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", - p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) -} +// logCompletionContext moved to handlers_completion.go -func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) - defer cancel() - // Track if we've already acquired the LLM busy lock during this call - locked := false - - // Inline prompt markers (strict ;text; or double-; patterns) explicitly allow triggering. - inlinePrompt := lineHasInlinePrompt(current) - // Only invoke LLM when triggered by our characters, manual invoke, or inline prompt markers. - if !inlinePrompt && !s.isTriggerEvent(p, current) { - logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - } - - // Suppress code completion when an in-editor chat trigger is at EOL. - // New triggers: ?> !> :> ;> (trim trailing whitespace before checking). - if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' { - prev := t[len(t)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return []CompletionItem{}, true - } - } - - inParams := inParamList(current, p.Position.Character) - - // Detect manual invoke so we can relax prefix heuristics when user pressed completion key. - manualInvoke := false - if p.Context != nil { - var c struct { - TriggerKind int `json:"triggerKind"` - } - if raw, ok := p.Context.(json.RawMessage); ok { - _ = json.Unmarshal(raw, &c) - } else { - b, _ := json.Marshal(p.Context) - _ = json.Unmarshal(b, &c) - } - if c.TriggerKind == 1 { // Invoked - manualInvoke = true - } - } +// tryLLMCompletion moved to handlers_completion.go - // Build a cache key for this completion context (ignore trailing whitespace - // before the cursor when forming the key) and try cache before any LLM call. - key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) - if cleaned, ok := s.completionCacheGet(key); ok && strings.TrimSpace(cleaned) != "" { - logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", - p.TextDocument.URI, p.Position.Line, p.Position.Character, - logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) - return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true - } - // If there is a bare ';;' on the current or next line (no valid ';;text;'), - // do not auto-trigger unless it was a manual invoke. - if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke { - logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - } - - // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers, - // but allow immediate completion after structural trigger chars like '.', ':', '/'. - if !inParams { - // Determine the effective cursor index within current line, clamped, and - // skip over trailing spaces/tabs to support cases like "type Matrix| " - // where the cursor is after a space following an identifier. - idx := p.Position.Character - if idx > len(current) { - idx = len(current) - } - // Structural triggers allow no prefix - allowNoPrefix := false - if inlinePrompt { - allowNoPrefix = true - } - if idx > 0 { - ch := current[idx-1] - if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' { - allowNoPrefix = true - } - } - if !allowNoPrefix { - // Walk left over whitespace - j := idx - for j > 0 { - c := current[j-1] - if c == ' ' || c == '\t' { - j-- - continue - } - break - } - start := computeWordStart(current, j) - // For manual invoke, require a configurable minimum prefix length - min := 1 - if manualInvoke && s.manualInvokeMinPrefix >= 0 { - min = s.manualInvokeMinPrefix - } - if j-start < min { // require at least min identifier chars - logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - } - } - } - // Prefer provider-native code completion when available (e.g., Copilot Codex) - if cc, ok := s.llmClient.(llm.CodeCompleter); ok { - before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) - // Construct prompt/suffix similar to helix-gpt - path := strings.TrimPrefix(p.TextDocument.URI, "file://") - prompt := "// Path: " + path + "\n" + before - lang := "" - temp := 0.0 - if s.codingTemperature != nil { temp = *s.codingTemperature } - prov := "" - if s.llmClient != nil { prov = s.llmClient.Name() } - logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) - ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel2() - // Concurrency guard - if s.isLLMBusy() { - return []CompletionItem{s.busyCompletionItem()}, true - } - s.setLLMBusy(true) - defer s.setLLMBusy(false) - locked = true - - suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) - if err == nil && len(suggestions) > 0 { - cleaned := strings.TrimSpace(suggestions[0]) - if cleaned != "" { - cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } - if cleaned != "" && hasDoubleSemicolonTrigger(current) { - indent := leadingIndent(current) - if indent != "" { cleaned = applyIndent(indent, cleaned) } - } - if strings.TrimSpace(cleaned) != "" { - key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) - s.completionCachePut(key, cleaned) - return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true - } - } - } else if err != nil { - logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) - } - // If provider-native path failed, fall back to chat below. - } - - sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) - messages := []llm.Message{ - {Role: "system", Content: sysPrompt}, - {Role: "user", Content: userPrompt}, - } - if hasExtra && extraText != "" { - messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) - } - - // If an inline prompt marker is present, make the instruction stricter: code only. - if inlinePrompt { - messages[0].Content = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only." - } - - // Compute total sent context size (sum of message contents) - var sentSize int - for _, m := range messages { - sentSize += len(m.Content) - } - s.incSentCounters(sentSize) +// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. +// parseManualInvoke moved to handlers_completion.go - opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} - if s.codingTemperature != nil { - opts = append(opts, llm.WithTemperature(*s.codingTemperature)) - } - logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) - // Concurrency guard for chat path as well - if !locked { - if s.isLLMBusy() { - return []CompletionItem{s.busyCompletionItem()}, true - } - s.setLLMBusy(true) - defer s.setLLMBusy(false) - } - - text, err := s.llmClient.Chat(ctx, messages, opts...) - if err != nil { - logging.Logf("lsp ", "llm completion error: %v", err) - // Log updated averages after this request (even if failed) - s.logLLMStats() - return nil, false - } - // Update response counters (received) - s.incRecvCounters(len(text)) - s.logLLMStats() - cleaned := stripCodeFences(strings.TrimSpace(text)) - // For code completion responses, also strip inline single-backtick code spans - // when the model returns prose like: "Use `expr` here". - if cleaned != "" { - if strings.ContainsRune(cleaned, '`') { - inline := stripInlineCodeSpan(cleaned) - if strings.TrimSpace(inline) != "" { - cleaned = inline - } - } - } - if cleaned != "" { - cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - } - if cleaned != "" { - cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) - } - // Preserve the current line's leading indentation only for double-semicolon - // inline prompts (";;text;"), since strict ";text;" replacements already - // occur in-place without affecting leading indentation. - if cleaned != "" && hasDoubleSemicolonTrigger(current) { - indent := leadingIndent(current) - if indent != "" { - cleaned = applyIndent(indent, cleaned) - } - } - if cleaned == "" { - return nil, false - } +// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. +// shouldSuppressForChatTriggerEOL moved to handlers_completion.go - // Store successful completion in cache - s.completionCachePut(key, cleaned) +// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. +// prefixHeuristicAllows moved to handlers_completion.go - return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true -} +// tryProviderNativeCompletion attempts provider-native completion and returns items when successful. +// tryProviderNativeCompletion moved to handlers_completion.go + +// buildCompletionMessages constructs the LLM messages for completion. +// buildCompletionMessages moved to handlers_completion.go + +// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. +// postProcessCompletion moved to handlers_completion.go // busyCompletionItem builds a visible, non-inserting completion item indicating // that an LLM request is already in flight. func (s *Server) busyCompletionItem() CompletionItem { - prov := "" - model := "" - if s.llmClient != nil { - prov = s.llmClient.Name() - model = s.llmClient.DefaultModel() - } - label := "Hexai: LLM busy" - if prov != "" && model != "" { label += " (" + prov + ":" + model + ")" } - return CompletionItem{ - Label: label, - Detail: "Another request is running; only one is allowed concurrently", - InsertText: "", - FilterText: "", - SortText: "~~~~~busy", // float to top - Documentation: "Hexai is processing a previous request. Please retry shortly.", - } + prov := "" + model := "" + if s.llmClient != nil { + prov = s.llmClient.Name() + model = s.llmClient.DefaultModel() + } + label := "Hexai: LLM busy" + if prov != "" && model != "" { + label += " (" + prov + ":" + model + ")" + } + return CompletionItem{ + Label: label, + Detail: "Another request is running; only one is allowed concurrently", + InsertText: "", + FilterText: "", + SortText: "~~~~~busy", // float to top + Documentation: "Hexai is processing a previous request. Please retry shortly.", + } } func (s *Server) isLLMBusy() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.llmBusy + s.mu.Lock() + defer s.mu.Unlock() + return s.llmBusy } func (s *Server) setLLMBusy(v bool) { - s.mu.Lock() - s.llmBusy = v - s.mu.Unlock() + s.mu.Lock() + s.llmBusy = v + s.mu.Unlock() } // --- small completion cache (last ~10 entries) --- diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go new file mode 100644 index 0000000..2599238 --- /dev/null +++ b/internal/lsp/handlers_codeaction.go @@ -0,0 +1,214 @@ +// Summary: Code Action handlers and helpers split from handlers.go for clarity. +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "hexai/internal/llm" + "hexai/internal/logging" + "strings" + "time" +) + +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 + } + d := s.getDocument(p.TextDocument.URI) + if d == nil || len(d.lines) == 0 || s.llmClient == nil { + if len(req.ID) != 0 { + s.reply(req.ID, []CodeAction{}, nil) + } + return + } + sel := extractRangeText(d, p.Range) + if strings.TrimSpace(sel) == "" { + if len(req.ID) != 0 { + s.reply(req.ID, []CodeAction{}, nil) + } + return + } + + actions := make([]CodeAction, 0, 2) + if a := s.buildRewriteCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if a := s.buildDiagnosticsCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if len(req.ID) != 0 { + s.reply(req.ID, actions, nil) + } +} + +func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { + if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" { + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Instruction string `json:"instruction"` + Selection string `json:"selection"` + }{Type: "rewrite", URI: p.TextDocument.URI, Range: p.Range, Instruction: instr, Selection: cleaned} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw} + return &ca + } + return nil +} + +func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction { + diags := s.diagnosticsInRange(p.Context, p.Range) + if len(diags) == 0 { + return nil + } + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "diagnostics", URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Data: raw} + return &ca +} + +func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { + if s.llmClient == nil || len(ca.Data) == 0 { + return ca, false + } + var payload struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Instruction string `json:"instruction,omitempty"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + } + if err := json.Unmarshal(ca.Data, &payload); err != nil { + return ca, false + } + switch payload.Type { + case "rewrite": + 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", payload.Instruction, payload.Selection) + 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{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) + } + case "diagnostics": + 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 payload.Diagnostics { + 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(payload.Selection) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + 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 := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) + } + } + return ca, false +} + +func (s *Server) handleCodeActionResolve(req Request) { + var ca CodeAction + if err := json.Unmarshal(req.Params, &ca); err != nil { + if len(req.ID) != 0 { + s.reply(req.ID, ca, nil) + } + return + } + if resolved, ok := s.resolveCodeAction(ca); ok { + s.reply(req.ID, resolved, nil) + return + } + s.reply(req.ID, ca, nil) +} + +// 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 +} + +// 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 +} + +func lessPos(p, q Position) bool { + 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 +} diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go new file mode 100644 index 0000000..56baeab --- /dev/null +++ b/internal/lsp/handlers_completion.go @@ -0,0 +1,306 @@ +// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "hexai/internal/llm" + "hexai/internal/logging" + "strings" + "time" +) + +func (s *Server) handleCompletion(req Request) { + var p CompletionParams + var docStr string + if err := json.Unmarshal(req.Params, &p); err == nil { + // Log trigger information for every completion request from client + tk, tch := extractTriggerInfo(p) + logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d", + tk, tch, p.TextDocument.URI, p.Position.Line, p.Position.Character) + above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position) + docStr = s.buildDocString(p, above, current, below, funcCtx) + if s.logContext { + s.logCompletionContext(p, above, current, below, funcCtx) + } + if s.llmClient != nil { + newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) + extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) + items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) + if ok { + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) + return + } + } + } + items := s.fallbackCompletionItems(docStr) + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) +} + +// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter +// if provided by the client; when absent it returns zeros. +func extractTriggerInfo(p CompletionParams) (kind int, ch string) { + if p.Context == nil { + return 0, "" + } + var ctx struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter,omitempty"` + } + if raw, ok := p.Context.(json.RawMessage); ok { + _ = json.Unmarshal(raw, &ctx) + } else { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + } + return ctx.TriggerKind, ctx.TriggerCharacter +} + +// --- completion helpers --- + +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string { + return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", + p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +} + +func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) { + logging.Logf("lsp ", "completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", + p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +} + +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + locked := false // track if we've taken the LLM busy lock + + inlinePrompt := lineHasInlinePrompt(current) + if !inlinePrompt && !s.isTriggerEvent(p, current) { + logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + } + if s.shouldSuppressForChatTriggerEOL(current, p) { + return []CompletionItem{}, true + } + + inParams := inParamList(current, p.Position.Character) + manualInvoke := parseManualInvoke(p.Context) + + // Cache fast-path + key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) + if cleaned, ok := s.completionCacheGet(key); ok && strings.TrimSpace(cleaned) != "" { + logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", + p.TextDocument.URI, p.Position.Line, p.Position.Character, + logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + } + if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke { + logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + } + + if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) { + logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + } + + // Provider-native path + if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok { + return items, true + } + + // Chat path + messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx) + // Counters and options + sentSize := 0 + for _, m := range messages { + sentSize += len(m.Content) + } + s.incSentCounters(sentSize) + opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} + if s.codingTemperature != nil { + opts = append(opts, llm.WithTemperature(*s.codingTemperature)) + } + logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) + + // Concurrency guard for chat path as well + if !locked { + if s.isLLMBusy() { + return []CompletionItem{s.busyCompletionItem()}, true + } + s.setLLMBusy(true) + defer s.setLLMBusy(false) + } + + text, err := s.llmClient.Chat(ctx, messages, opts...) + if err != nil { + logging.Logf("lsp ", "llm completion error: %v", err) + s.logLLMStats() + return nil, false + } + s.incRecvCounters(len(text)) + s.logLLMStats() + + cleaned := s.postProcessCompletion(strings.TrimSpace(text), current[:p.Position.Character], current) + if cleaned == "" { + return nil, false + } + s.completionCachePut(key, cleaned) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true +} + +// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. +func parseManualInvoke(ctx any) bool { + if ctx == nil { + return false + } + var c struct { + TriggerKind int `json:"triggerKind"` + } + if raw, ok := ctx.(json.RawMessage); ok { + _ = json.Unmarshal(raw, &c) + } else { + b, _ := json.Marshal(ctx) + _ = json.Unmarshal(b, &c) + } + return c.TriggerKind == 1 +} + +// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { + if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' { + prev := t[len(t)-2] + if prev == '?' || prev == '!' || prev == ':' || prev == ';' { + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + } + } + return false +} + +// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool { + // Determine the effective cursor index within current line, clamped, and + // skip over trailing spaces/tabs to support cases like "type Matrix| ". + idx := p.Position.Character + if idx > len(current) { + idx = len(current) + } + allowNoPrefix := inlinePrompt + if idx > 0 { + ch := current[idx-1] + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' { + allowNoPrefix = true + } + } + if allowNoPrefix { + return true + } + // Walk left over whitespace + j := idx + for j > 0 { + c := current[j-1] + if c == ' ' || c == '\t' { + j-- + continue + } + break + } + start := computeWordStart(current, j) + min := 1 + if manualInvoke && s.manualInvokeMinPrefix >= 0 { + min = s.manualInvokeMinPrefix + } + return j-start >= min +} + +// tryProviderNativeCompletion attempts provider-native completion and returns items when successful. +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { + cc, ok := s.llmClient.(llm.CodeCompleter) + if !ok { + return nil, false + } + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + path := strings.TrimPrefix(p.TextDocument.URI, "file://") + prompt := "// Path: " + path + "\n" + before + lang := "" + temp := 0.0 + if s.codingTemperature != nil { + temp = *s.codingTemperature + } + prov := "" + if s.llmClient != nil { + prov = s.llmClient.Name() + } + logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel2() + if s.isLLMBusy() { + return []CompletionItem{s.busyCompletionItem()}, true + } + s.setLLMBusy(true) + defer s.setLLMBusy(false) + + suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) + if err == nil && len(suggestions) > 0 { + cleaned := strings.TrimSpace(suggestions[0]) + if cleaned != "" { + cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + if cleaned != "" { + cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) + } + if cleaned != "" && hasDoubleSemicolonTrigger(current) { + indent := leadingIndent(current) + if indent != "" { + cleaned = applyIndent(indent, cleaned) + } + } + if strings.TrimSpace(cleaned) != "" { + key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) + s.completionCachePut(key, cleaned) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + } + } + } else if err != nil { + logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) + } + return nil, false +} + +// buildCompletionMessages constructs the LLM messages for completion. +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message { + sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) + messages := []llm.Message{ + {Role: "system", Content: sysPrompt}, + {Role: "user", Content: userPrompt}, + } + if hasExtra && extraText != "" { + messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) + } + if inlinePrompt { + messages[0].Content = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only." + } + return messages +} + +// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string { + cleaned := stripCodeFences(text) + if cleaned != "" && strings.ContainsRune(cleaned, '`') { + if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" { + cleaned = inline + } + } + if cleaned != "" { + cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) + } + if cleaned != "" { + cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) + } + if cleaned != "" && hasDoubleSemicolonTrigger(currentLine) { + if indent := leadingIndent(currentLine); indent != "" { + cleaned = applyIndent(indent, cleaned) + } + } + return cleaned +} diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go new file mode 100644 index 0000000..0dba2f7 --- /dev/null +++ b/internal/lsp/handlers_document.go @@ -0,0 +1,273 @@ +// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. +package lsp + +import ( + "context" + "encoding/json" + "hexai/internal/llm" + "hexai/internal/logging" + "strings" + "time" +) + +func (s *Server) handleDidOpen(req Request) { + var p DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.setDocument(p.TextDocument.URI, p.TextDocument.Text) + s.markActivity() + } +} + +func (s *Server) handleDidChange(req Request) { + var p DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + if len(p.ContentChanges) > 0 { + s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) + } + s.markActivity() + // Detect in-editor chat trigger lines and respond inline. + s.detectAndHandleChat(p.TextDocument.URI) + } +} + +func (s *Server) handleDidClose(req Request) { + var p DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.deleteDocument(p.TextDocument.URI) + s.markActivity() + } +} + +// docBeforeAfter returns the full document text split at the given position. +// The returned strings are the text before the cursor (inclusive of anything +// left of the position) and the text after the cursor. +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { + d := s.getDocument(uri) + if d == nil { + return "", "" + } + // Clamp indices + line := pos.Line + if line < 0 { + line = 0 + } + if line >= len(d.lines) { + line = len(d.lines) - 1 + } + col := pos.Character + if col < 0 { + col = 0 + } + if col > len(d.lines[line]) { + col = len(d.lines[line]) + } + // Build before + var b strings.Builder + for i := 0; i < line; i++ { + b.WriteString(d.lines[i]) + b.WriteByte('\n') + } + b.WriteString(d.lines[line][:col]) + before := b.String() + // Build after + var a strings.Builder + a.WriteString(d.lines[line][col:]) + for i := line + 1; i < len(d.lines); i++ { + a.WriteByte('\n') + a.WriteString(d.lines[i]) + } + return before, a.String() +} + +// --- in-editor chat (";C ...") --- + +// detectAndHandleChat scans the current document for any line that starts with +// a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM +// reply below. +func (s *Server) detectAndHandleChat(uri string) { + if s.llmClient == nil { + return + } + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 { + return + } + for i, raw := range d.lines { + // Find last non-space character index + j := len(raw) - 1 + for j >= 0 { + if raw[j] == ' ' || raw[j] == '\t' { + j-- + continue + } + break + } + if j < 1 { + continue + } // need at least two chars + pair := raw[j-1 : j+1] + isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" + if !isTrigger { + continue + } + // Avoid double-answering: if the next non-empty line starts with '>' we skip. + k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { + k++ + } + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + continue + } + // Derive prompt by removing only the trailing '>' + removeCount := 1 + base := raw[:j+1-removeCount] + prompt := strings.TrimSpace(base) + if prompt == "" { + continue + } + lineIdx := i + lastIdx := j + go func(prompt string, remove int) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + sys := "You are a helpful coding assistant. Answer concisely and clearly." + // Build short conversation history from the document above this line + history := s.buildChatHistory(uri, lineIdx, prompt) + msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) + opts := s.llmRequestOpts() + logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) + text, err := s.llmClient.Chat(ctx, msgs, opts...) + if err != nil { + logging.Logf("lsp ", "chat llm error: %v", err) + return + } + out := strings.TrimSpace(stripCodeFences(text)) + if out == "" { + return + } + s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) + }(prompt, removeCount) + // Only handle one per change tick to avoid flooding + break + } +} + +// applyChatEdits removes the triggering punctuation at end of the line and +// inserts two newlines followed by a new line with the response prefixed. +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { + d := s.getDocument(uri) + if d == nil { + return + } + // 1) Delete the trailing punctuation (1 or 2 chars) + delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} + // 2) Insert two newlines and the response at end-of-line, then one extra blank line + insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} + resp := strings.TrimRight(response, "\n") + "\n" + insert := "\n\n" + resp + "\n" + edits := []TextEdit{ + {Range: Range{Start: delStart, End: delEnd}, NewText: ""}, + {Range: Range{Start: insPos, End: insPos}, NewText: insert}, + } + we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} + s.clientApplyEdit("Hexai: insert chat response", we) +} + +// buildChatHistory walks upwards from the current line to collect the most recent +// Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { + d := s.getDocument(uri) + if d == nil { + return []llm.Message{{Role: "user", Content: currentPrompt}} + } + type pair struct{ q, a string } + pairs := []pair{} + i := lineIdx - 1 + for i >= 0 && len(pairs) < 3 { + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { + i-- + } + if i < 0 { + break + } + if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") { + break + } + var replyLines []string + for i >= 0 { + line := strings.TrimSpace(d.lines[i]) + if strings.HasPrefix(line, ">") { + replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) + i-- + continue + } + break + } + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" { + i-- + } + if i < 0 { + break + } + q := strings.TrimSpace(d.lines[i]) + q = stripTrailingTrigger(q) + pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) + i-- + } + msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs { + if strings.TrimSpace(p.q) != "" { + msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) + } + if strings.TrimSpace(p.a) != "" { + msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) + } + } + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + return msgs +} + +// stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. +func stripTrailingTrigger(sx string) string { + s := strings.TrimRight(sx, " \t") + if len(s) >= 2 && s[len(s)-1] == '>' { // new triggers + prev := s[len(s)-2] + if prev == '?' || prev == '!' || prev == ':' || prev == ';' { + return strings.TrimRight(s[:len(s)-1], " \t") + } + } + if strings.HasSuffix(s, ";;") { // legacy inline cleanup used in history building + return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") + } + if len(s) == 0 { + return sx + } + last := s[len(s)-1] + switch last { // legacy: remove one trailing punctuation + case '?', '!', ':': + return strings.TrimRight(s[:len(s)-1], " \t") + default: + return sx + } +} + +// clientApplyEdit sends a workspace/applyEdit request to the client. +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { + params := ApplyWorkspaceEditParams{Label: label, Edit: edit} + id := s.nextReqID() + req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} + b, _ := json.Marshal(params) + req.Params = b + s.writeMessage(req) +} + +// nextReqID returns a unique json.RawMessage id for server-initiated requests. +func (s *Server) nextReqID() json.RawMessage { + s.mu.Lock() + s.nextID++ + idNum := s.nextID + s.mu.Unlock() + b, _ := json.Marshal(idNum) + return b +} diff --git a/internal/lsp/handlers_helpers_test.go b/internal/lsp/handlers_helpers_test.go index 11fe29f..eb7f273 100644 --- a/internal/lsp/handlers_helpers_test.go +++ b/internal/lsp/handlers_helpers_test.go @@ -1,92 +1,94 @@ package lsp import ( - "strings" - "testing" + "strings" + "testing" ) func TestHasDoubleSemicolonTrigger(t *testing.T) { - cases := []struct{ - line string - want bool - }{ - {";;todo; remove this", true}, - {"prefix ;;x; suffix", true}, - {";; spaced ;", false}, - {"no markers", false}, - {";;x ; space before close", false}, - } - for _, tc := range cases { - got := hasDoubleSemicolonTrigger(tc.line) - if got != tc.want { - t.Fatalf("hasDoubleSemicolonTrigger(%q)=%v want %v", tc.line, got, tc.want) - } - } + cases := []struct { + line string + want bool + }{ + {";;todo; remove this", true}, + {"prefix ;;x; suffix", true}, + {";; spaced ;", false}, + {"no markers", false}, + {";;x ; space before close", false}, + } + for _, tc := range cases { + got := hasDoubleSemicolonTrigger(tc.line) + if got != tc.want { + t.Fatalf("hasDoubleSemicolonTrigger(%q)=%v want %v", tc.line, got, tc.want) + } + } } func TestCollectSemicolonMarkers(t *testing.T) { - line := "keep ;ok; this and ;another; that" - edits := collectSemicolonMarkers(line, 7) - if len(edits) != 2 { - t.Fatalf("expected 2 edits, got %d", len(edits)) - } - // Validate the first edit aligns with ;ok; - start := strings.Index(line, ";ok;") - if start < 0 { t.Fatalf("test setup: missing ;ok;") } - if edits[0].Range.Start.Line != 7 || edits[0].Range.Start.Character != start { - t.Fatalf("first edit start got line=%d char=%d want line=7 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, start) - } + line := "keep ;ok; this and ;another; that" + edits := collectSemicolonMarkers(line, 7) + if len(edits) != 2 { + t.Fatalf("expected 2 edits, got %d", len(edits)) + } + // Validate the first edit aligns with ;ok; + start := strings.Index(line, ";ok;") + if start < 0 { + t.Fatalf("test setup: missing ;ok;") + } + if edits[0].Range.Start.Line != 7 || edits[0].Range.Start.Character != start { + t.Fatalf("first edit start got line=%d char=%d want line=7 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, start) + } } func TestPromptRemovalEditsForLine_WholeLine(t *testing.T) { - line := ";;todo; remove this whole line" - edits := promptRemovalEditsForLine(line, 3) - if len(edits) != 1 { - t.Fatalf("expected 1 whole-line edit, got %d", len(edits)) - } - e := edits[0] - if e.Range.Start.Line != 3 || e.Range.End.Line != 3 || e.Range.Start.Character != 0 || e.Range.End.Character != len(line) { - t.Fatalf("unexpected range for whole-line removal: %+v", e.Range) - } + line := ";;todo; remove this whole line" + edits := promptRemovalEditsForLine(line, 3) + if len(edits) != 1 { + t.Fatalf("expected 1 whole-line edit, got %d", len(edits)) + } + e := edits[0] + if e.Range.Start.Line != 3 || e.Range.End.Line != 3 || e.Range.Start.Character != 0 || e.Range.End.Character != len(line) { + t.Fatalf("unexpected range for whole-line removal: %+v", e.Range) + } } 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) - } - } + 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) + } + } } func TestStripInlineCodeSpan(t *testing.T) { - cases := []struct{ - name string - in string - want string - }{ - {"no backticks", "return x + y", "return x + y"}, - {"single inline", "Use `foo(bar)` here", "foo(bar)"}, - {"just inline", "`x := y()`", "x := y()"}, - {"unmatched start", "use `foo(bar) without end", "use `foo(bar) without end"}, - {"multiple spans picks first", "`a` and also `b`", "a"}, - {"leading/trailing spaces", " text ` z ` ", " z "}, - } - for _, tc := range cases { - got := stripInlineCodeSpan(tc.in) - if got != tc.want { - t.Fatalf("%s: got %q want %q", tc.name, got, tc.want) - } - } + cases := []struct { + name string + in string + want string + }{ + {"no backticks", "return x + y", "return x + y"}, + {"single inline", "Use `foo(bar)` here", "foo(bar)"}, + {"just inline", "`x := y()`", "x := y()"}, + {"unmatched start", "use `foo(bar) without end", "use `foo(bar) without end"}, + {"multiple spans picks first", "`a` and also `b`", "a"}, + {"leading/trailing spaces", " text ` z ` ", " z "}, + } + for _, tc := range cases { + got := stripInlineCodeSpan(tc.in) + if got != tc.want { + t.Fatalf("%s: got %q want %q", tc.name, got, tc.want) + } + } } diff --git a/internal/lsp/handlers_init.go b/internal/lsp/handlers_init.go new file mode 100644 index 0000000..1047e78 --- /dev/null +++ b/internal/lsp/handlers_init.go @@ -0,0 +1,40 @@ +// Summary: Initialization and lifecycle handlers split from handlers.go. +package lsp + +import ( + "hexai/internal" + "hexai/internal/logging" + "os" +) + +func (s *Server) handleInitialize(req Request) { + version := internal.Version + if s.llmClient != nil { + version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]" + } + res := InitializeResult{ + Capabilities: ServerCapabilities{ + TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + TriggerCharacters: s.triggerChars, + }, + CodeActionProvider: CodeActionOptions{ResolveProvider: true}, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: version}, + } + s.reply(req.ID, res, nil) +} + +func (s *Server) handleInitialized() { + logging.Logf("lsp ", "client initialized") +} + +func (s *Server) handleShutdown(req Request) { + s.reply(req.ID, nil, nil) +} + +func (s *Server) handleExit() { + s.exited = true + os.Exit(0) +} diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go index 779a71f..5b84254 100644 --- a/internal/lsp/handlers_test.go +++ b/internal/lsp/handlers_test.go @@ -4,172 +4,172 @@ package lsp import "testing" func TestFindFirstInstructionInLine_NoMarker(t *testing.T) { - line := "fmt.Println(\"hello\")" - instr, cleaned, ok := findFirstInstructionInLine(line) - if ok { - t.Fatalf("expected ok=false; got ok=true with instr=%q cleaned=%q", instr, cleaned) - } - if instr != "" || cleaned != line { - t.Fatalf("unexpected outputs: instr=%q cleaned=%q", instr, cleaned) - } + line := "fmt.Println(\"hello\")" + instr, cleaned, ok := findFirstInstructionInLine(line) + if ok { + t.Fatalf("expected ok=false; got ok=true with instr=%q cleaned=%q", instr, cleaned) + } + if instr != "" || cleaned != line { + t.Fatalf("unexpected outputs: instr=%q cleaned=%q", instr, cleaned) + } } func TestFindFirstInstructionInLine_StrictSemicolon_Basic(t *testing.T) { - line := "prefix ;rename var; suffix" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "rename var" { - t.Fatalf("instr got %q want %q", instr, "rename var") - } - // Removal preserves inner spacing; trailing right spaces trimmed only. - if cleaned != "prefix suffix" { - t.Fatalf("cleaned got %q want %q", cleaned, "prefix suffix") - } + line := "prefix ;rename var; suffix" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "rename var" { + t.Fatalf("instr got %q want %q", instr, "rename var") + } + // Removal preserves inner spacing; trailing right spaces trimmed only. + if cleaned != "prefix suffix" { + t.Fatalf("cleaned got %q want %q", cleaned, "prefix suffix") + } } func TestFindFirstInstructionInLine_StrictSemicolon_TrailingSpacesTrimmed(t *testing.T) { - line := "code;fix; \t\t" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "fix" { - t.Fatalf("instr got %q want %q", instr, "fix") - } - if cleaned != "code" { - t.Fatalf("cleaned got %q want %q", cleaned, "code") - } + line := "code;fix; \t\t" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "fix" { + t.Fatalf("instr got %q want %q", instr, "fix") + } + if cleaned != "code" { + t.Fatalf("cleaned got %q want %q", cleaned, "code") + } } func TestFindFirstInstructionInLine_Semicolon_InvalidPatterns(t *testing.T) { - cases := []string{ - "prefix ; bad; suffix", // space after first ';' ⇒ invalid - "prefix ;bad ; suffix", // space before closing ';' ⇒ invalid - "prefix ; ; suffix", // empty inner ⇒ invalid - } - for _, line := range cases { - if instr, _, ok := findFirstInstructionInLine(line); ok && instr != "" { - t.Fatalf("%q: expected no semicolon instruction; got instr=%q", line, instr) - } - } + cases := []string{ + "prefix ; bad; suffix", // space after first ';' ⇒ invalid + "prefix ;bad ; suffix", // space before closing ';' ⇒ invalid + "prefix ; ; suffix", // empty inner ⇒ invalid + } + for _, line := range cases { + if instr, _, ok := findFirstInstructionInLine(line); ok && instr != "" { + t.Fatalf("%q: expected no semicolon instruction; got instr=%q", line, instr) + } + } } func TestFindFirstInstructionInLine_CBlockComment(t *testing.T) { - line := "foo /* update this part */ bar" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "update this part" { - t.Fatalf("instr got %q want %q", instr, "update this part") - } - if cleaned != "foo bar" { - t.Fatalf("cleaned got %q want %q", cleaned, "foo bar") - } + line := "foo /* update this part */ bar" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "update this part" { + t.Fatalf("instr got %q want %q", instr, "update this part") + } + if cleaned != "foo bar" { + t.Fatalf("cleaned got %q want %q", cleaned, "foo bar") + } } func TestFindFirstInstructionInLine_HTMLComment(t *testing.T) { - line := "foo <!-- do x --> bar" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "do x" { - t.Fatalf("instr got %q want %q", instr, "do x") - } - if cleaned != "foo bar" { - t.Fatalf("cleaned got %q want %q", cleaned, "foo bar") - } + line := "foo <!-- do x --> bar" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "do x" { + t.Fatalf("instr got %q want %q", instr, "do x") + } + if cleaned != "foo bar" { + t.Fatalf("cleaned got %q want %q", cleaned, "foo bar") + } } func TestFindFirstInstructionInLine_SlashSlash(t *testing.T) { - line := "val // do this change" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "do this change" { - t.Fatalf("instr got %q want %q", instr, "do this change") - } - if cleaned != "val" { - t.Fatalf("cleaned got %q want %q", cleaned, "val") - } + line := "val // do this change" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "do this change" { + t.Fatalf("instr got %q want %q", instr, "do this change") + } + if cleaned != "val" { + t.Fatalf("cleaned got %q want %q", cleaned, "val") + } } func TestFindFirstInstructionInLine_Hash(t *testing.T) { - line := "val # do this" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "do this" { - t.Fatalf("instr got %q want %q", instr, "do this") - } - if cleaned != "val" { - t.Fatalf("cleaned got %q want %q", cleaned, "val") - } + line := "val # do this" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "do this" { + t.Fatalf("instr got %q want %q", instr, "do this") + } + if cleaned != "val" { + t.Fatalf("cleaned got %q want %q", cleaned, "val") + } } func TestFindFirstInstructionInLine_DoubleDash(t *testing.T) { - line := "SQL -- fix query" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "fix query" { - t.Fatalf("instr got %q want %q", instr, "fix query") - } - if cleaned != "SQL" { - t.Fatalf("cleaned got %q want %q", cleaned, "SQL") - } + line := "SQL -- fix query" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "fix query" { + t.Fatalf("instr got %q want %q", instr, "fix query") + } + if cleaned != "SQL" { + t.Fatalf("cleaned got %q want %q", cleaned, "SQL") + } } func TestFindFirstInstructionInLine_EarliestWins_CommentOverSemicolon(t *testing.T) { - line := "aa // comment ;not this; trailing" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "comment ;not this; trailing" { - t.Fatalf("instr got %q want %q", instr, "comment ;not this; trailing") - } - if cleaned != "aa" { - t.Fatalf("cleaned got %q want %q", cleaned, "aa") - } + line := "aa // comment ;not this; trailing" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "comment ;not this; trailing" { + t.Fatalf("instr got %q want %q", instr, "comment ;not this; trailing") + } + if cleaned != "aa" { + t.Fatalf("cleaned got %q want %q", cleaned, "aa") + } } func TestFindFirstInstructionInLine_EarliestWins_SemicolonOverComment(t *testing.T) { - line := "aa ;short; // comment" - instr, cleaned, ok := findFirstInstructionInLine(line) - if !ok { - t.Fatalf("expected ok=true") - } - if instr != "short" { - t.Fatalf("instr got %q want %q", instr, "short") - } - // Only the earliest marker is removed; the later comment remains. - if cleaned != "aa // comment" { - t.Fatalf("cleaned got %q want %q", cleaned, "aa // comment") - } + line := "aa ;short; // comment" + instr, cleaned, ok := findFirstInstructionInLine(line) + if !ok { + t.Fatalf("expected ok=true") + } + if instr != "short" { + t.Fatalf("instr got %q want %q", instr, "short") + } + // Only the earliest marker is removed; the later comment remains. + if cleaned != "aa // comment" { + t.Fatalf("cleaned got %q want %q", cleaned, "aa // comment") + } } func TestFindStrictSemicolonTag_Various(t *testing.T) { - // basic - if text, l, r, ok := findStrictSemicolonTag("pre;do it;post"); !ok || text != "do it" || l != 3 || r != 10 { - t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r) - } - // at start - if text, l, r, ok := findStrictSemicolonTag(";x;"); !ok || text != "x" || l != 0 || r != 3 { - t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r) - } - // double opening ';' should still allow a tag starting at the second ';' - if text, _, _, ok := findStrictSemicolonTag("prefix ;;bad; suffix"); !ok || text != "bad" { - t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text) - } - // inner spaces directly after first ';' or before last ';' invalidate the tag - if _, _, _, ok := findStrictSemicolonTag("a; inner ;b"); ok { - t.Fatalf("expected invalid strict tag due to spaces at boundaries") - } + // basic + if text, l, r, ok := findStrictSemicolonTag("pre;do it;post"); !ok || text != "do it" || l != 3 || r != 10 { + t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r) + } + // at start + if text, l, r, ok := findStrictSemicolonTag(";x;"); !ok || text != "x" || l != 0 || r != 3 { + t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r) + } + // double opening ';' should still allow a tag starting at the second ';' + if text, _, _, ok := findStrictSemicolonTag("prefix ;;bad; suffix"); !ok || text != "bad" { + t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text) + } + // inner spaces directly after first ';' or before last ';' invalidate the tag + if _, _, _, ok := findStrictSemicolonTag("a; inner ;b"); ok { + t.Fatalf("expected invalid strict tag due to spaces at boundaries") + } } diff --git a/internal/lsp/llm_busy_test.go b/internal/lsp/llm_busy_test.go index 95123d2..c7cc716 100644 --- a/internal/lsp/llm_busy_test.go +++ b/internal/lsp/llm_busy_test.go @@ -1,25 +1,32 @@ package lsp import ( - "encoding/json" - "testing" + "encoding/json" + "testing" ) // Ensure a visible busy item is returned when a prior LLM request is in flight. func TestLLMBusy_YieldsBusyCompletionItem(t *testing.T) { - s := &Server{ maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string) } - s.llmClient = &countingLLM{} - // Mark busy - s.setLLMBusy(true) - t.Cleanup(func(){ s.setLLMBusy(false) }) - line := "obj." - p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://busy.go"} } - // Simulate manual invoke to bypass min-prefix - p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { t.Fatalf("expected ok=true") } - if len(items) != 1 { t.Fatalf("expected one busy item, got %d", len(items)) } - if items[0].InsertText != "" { t.Fatalf("busy item should not insert text") } - if items[0].Label == "" { t.Fatalf("busy item should have a label") } + s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)} + s.llmClient = &countingLLM{} + // Mark busy + s.setLLMBusy(true) + t.Cleanup(func() { s.setLLMBusy(false) }) + line := "obj." + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://busy.go"}} + // Simulate manual invoke to bypass min-prefix + p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true") + } + if len(items) != 1 { + t.Fatalf("expected one busy item, got %d", len(items)) + } + if items[0].InsertText != "" { + t.Fatalf("busy item should not insert text") + } + if items[0].Label == "" { + t.Fatalf("busy item should have a label") + } } - diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 54efdf7..624c2cd 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -40,13 +40,16 @@ type Server struct { // Small LRU cache for recent code completion outputs (keyed by context) compCache map[string]string compCacheOrder []string // most-recent at end; cap ~10 - // Outgoing JSON-RPC id counter for server-initiated requests - nextID int64 - // Minimum identifier chars required for manual invoke to bypass prefix checks - manualInvokeMinPrefix int + // Outgoing JSON-RPC id counter for server-initiated requests + nextID int64 + // Minimum identifier chars required for manual invoke to bypass prefix checks + manualInvokeMinPrefix int - // LLM concurrency guard: allow at most one in-flight request - llmBusy bool + // LLM concurrency guard: allow at most one in-flight request + llmBusy bool + + // Dispatch table for JSON-RPC methods → handler functions + handlers map[string]func(Request) } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -97,6 +100,19 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.codingTemperature = opts.CodingTemperature s.compCache = make(map[string]string) s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix + // Initialize dispatch table + s.handlers = map[string]func(Request){ + "initialize": s.handleInitialize, + "initialized": func(_ Request) { s.handleInitialized() }, + "shutdown": s.handleShutdown, + "exit": func(_ Request) { s.handleExit() }, + "textDocument/didOpen": s.handleDidOpen, + "textDocument/didChange": s.handleDidChange, + "textDocument/didClose": s.handleDidClose, + "textDocument/completion": s.handleCompletion, + "textDocument/codeAction": s.handleCodeAction, + "codeAction/resolve": s.handleCodeActionResolve, + } return s } diff --git a/internal/lsp/testfakes_test.go b/internal/lsp/testfakes_test.go index bfe536e..7887692 100644 --- a/internal/lsp/testfakes_test.go +++ b/internal/lsp/testfakes_test.go @@ -1,8 +1,8 @@ package lsp import ( - "context" - "hexai/internal/llm" + "context" + "hexai/internal/llm" ) // countingLLM counts Chat calls; minimal implementation for tests that need @@ -10,9 +10,8 @@ import ( type countingLLM struct{ calls int } func (f *countingLLM) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { - f.calls++ - return "x := 1", nil + f.calls++ + return "x := 1", nil } func (f *countingLLM) Name() string { return "fake" } func (f *countingLLM) DefaultModel() string { return "m" } - diff --git a/internal/lsp/types.go b/internal/lsp/types.go index 256139f..5169d44 100644 --- a/internal/lsp/types.go +++ b/internal/lsp/types.go @@ -35,10 +35,10 @@ type ServerInfo struct { } type ServerCapabilities struct { - TextDocumentSync any `json:"textDocumentSync,omitempty"` - CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"` - // bool | CodeActionOptions - CodeActionProvider any `json:"codeActionProvider,omitempty"` + TextDocumentSync any `json:"textDocumentSync,omitempty"` + CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"` + // bool | CodeActionOptions + CodeActionProvider any `json:"codeActionProvider,omitempty"` } type CompletionOptions struct { @@ -66,7 +66,7 @@ type CompletionItem struct { // Code action options type CodeActionOptions struct { - ResolveProvider bool `json:"resolveProvider,omitempty"` + ResolveProvider bool `json:"resolveProvider,omitempty"` } // LSP param types (subset) @@ -124,20 +124,20 @@ type CodeActionParams struct { } type WorkspaceEdit struct { - Changes map[string][]TextEdit `json:"changes,omitempty"` + Changes map[string][]TextEdit `json:"changes,omitempty"` } // ApplyWorkspaceEditParams is the client request payload for workspace/applyEdit. type ApplyWorkspaceEditParams struct { - Label string `json:"label,omitempty"` - Edit WorkspaceEdit `json:"edit"` + Label string `json:"label,omitempty"` + Edit WorkspaceEdit `json:"edit"` } type CodeAction struct { - Title string `json:"title"` - Kind string `json:"kind,omitempty"` - Edit *WorkspaceEdit `json:"edit,omitempty"` - Data json.RawMessage `json:"data,omitempty"` + Title string `json:"title"` + Kind string `json:"kind,omitempty"` + Edit *WorkspaceEdit `json:"edit,omitempty"` + Data json.RawMessage `json:"data,omitempty"` } // Diagnostics (subset needed for code action context) |
