summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
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 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 != "" {