summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-29 00:22:39 +0300
committerPaul Buetow <paul@buetow.org>2025-08-29 00:22:39 +0300
commit0c2994f0065090a4884b28dc27eb760db2dfaab3 (patch)
tree687ecd00584feb634a5853f5964028621f0fa1d5 /internal
parentd35aaa0227334ab0269b0907491c0682841b9cd5 (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')
-rw-r--r--internal/lsp/chat_trigger_suppression_test.go23
-rw-r--r--internal/lsp/codeaction_test.go129
-rw-r--r--internal/lsp/completion_cache_test.go64
-rw-r--r--internal/lsp/completion_codex_path_test.go92
-rw-r--r--internal/lsp/completion_prefix_strip_test.go210
-rw-r--r--internal/lsp/handlers.go848
-rw-r--r--internal/lsp/handlers_codeaction.go214
-rw-r--r--internal/lsp/handlers_completion.go306
-rw-r--r--internal/lsp/handlers_document.go273
-rw-r--r--internal/lsp/handlers_helpers_test.go148
-rw-r--r--internal/lsp/handlers_init.go40
-rw-r--r--internal/lsp/handlers_test.go270
-rw-r--r--internal/lsp/llm_busy_test.go41
-rw-r--r--internal/lsp/server.go28
-rw-r--r--internal/lsp/testfakes_test.go9
-rw-r--r--internal/lsp/types.go24
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 err