summaryrefslogtreecommitdiff
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
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
-rw-r--r--REVIEWRESULT.md70
-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
17 files changed, 1542 insertions, 1247 deletions
diff --git a/REVIEWRESULT.md b/REVIEWRESULT.md
new file mode 100644
index 0000000..f89250e
--- /dev/null
+++ b/REVIEWRESULT.md
@@ -0,0 +1,70 @@
+# Codebase Review Findings
+
+This document outlines the results of a codebase review for the `hexai` project, focusing on readability, maintainability, Go best practices, and adherence to the guidelines in `AGENTS.md`.
+
+## 1. Executive Summary
+
+The `hexai` codebase is well-structured, with a clear separation of concerns between the LSP server, LLM providers, and CLI components. Test coverage appears to be good for the core LLM provider logic.
+
+However, several key areas require attention to improve maintainability and adhere to the project's coding standards. The most critical issues are:
+
+- **Large, complex functions:** Several functions, particularly within the LSP message handling logic, significantly exceed the 50-line limit. This makes them difficult to read, understand, and maintain.
+- **Large source files:** The primary LSP handler file (`internal/lsp/handlers.go`) has grown too large, violating the 1000-line limit.
+- **Centralized request handling:** The main request loop in `internal/lsp/server.go` is a large monolithic function that dispatches all LSP messages.
+
+Addressing these issues by refactoring large functions and splitting up large files will significantly improve the long-term health of the codebase.
+
+## 2. File and Function Size Violations
+
+The following files and functions violate the size constraints defined in `AGENTS.md`.
+
+### 2.1. Files Exceeding 1000 Lines
+
+- **`internal/lsp/handlers.go`**: This file is significantly over the 1000-line limit. It contains the logic for many different LSP requests.
+ - **Recommendation**: Split this file into multiple smaller files, each responsible for a specific set of related LSP features (e.g., `handlers_completion.go`, `handlers_codeaction.go`, `handlers_commands.go`).
+
+### 2.2. Functions Exceeding 50 Lines
+
+- **`internal/lsp/server.go`**:
+ - `Serve()`: This function is the main request loop and is very long.
+ - **Recommendation**: Refactor this method. Instead of a single large `switch` statement, use a map of method names to handler functions (e.g., `map[string]func(*jsonrpc2.Request) error`). This is a common pattern in LSP servers and will make the code much cleaner and more extensible.
+
+- **`internal/lsp/handlers.go`**:
+ - `handleTextDocumentCompletion()`: This function is extremely large and complex. It handles completion requests, interacts with the LLM, manages caching, and formats the response.
+ - `handleCodeAction()`: This function is also very large and contains complex logic for determining available code actions.
+ - `handleExecuteCommand()`: This function has a large `switch` statement for dispatching different commands.
+ - **Recommendation**: Break down these functions into smaller, more focused helper functions. For example, `handleTextDocumentCompletion` could be split into functions for:
+ 1. Checking if a completion should be triggered.
+ 2. Fetching results from the cache.
+ 3. Preparing the request for the LLM.
+ 4. Calling the LLM and handling its response.
+ 5. Formatting the completion items.
+
+- **`Magefile.go`**:
+ - Several build functions are slightly over the 50-line limit.
+ - **Recommendation**: While less critical than the application code, consider breaking down the larger Mage functions into smaller, reusable helper functions.
+
+## 3. Readability and Maintainability
+
+- **Complex Conditionals**: Functions like `handleTextDocumentCompletion` have deeply nested `if` and `switch` statements. This makes the logic flow very difficult to follow. Refactoring into smaller functions will help flatten these conditionals.
+- **Lack of Comments for Complex Logic**: While the code is generally clean, some of the more complex parts of the LSP logic (e.g., position calculations, completion context) could benefit from comments explaining the *why* behind the code.
+
+## 4. Testing
+
+- **Good Coverage for LLM Providers**: The `internal/llm` package has a good set of tests for each provider. This is excellent.
+- **`handlers_test.go` is Large**: Similar to `handlers.go`, the corresponding test file is also very large. Splitting the handlers into smaller files should be mirrored in the tests.
+- **Main Packages Not Tested**: The `main` functions in `cmd/hexai-lsp/main.go` and `cmd/hexai/main.go` contain some logic that is not unit tested.
+ - **Recommendation**: Extract the core application logic from the `main` functions into separate functions (e.g., `run() error`) in a different file/package so that it can be tested. The `internal/hexaicli/run.go` and `internal/hexailsp/run.go` files seem to be a good step in this direction.
+
+## 5. Go Best Practices & Conventions
+
+- **Error Handling**: The project follows Go's error handling conventions well.
+- **Variable Naming**: The code generally uses descriptive variable names, avoiding single-letter identifiers except in idiomatic cases (e.g., loop counters).
+
+## 6. Summary of Recommendations
+
+1. [x] Refactor JSON-RPC dispatch: replace the large `switch` with a handler map. Implemented via `Server.handlers` and `handle` now dispatches through the map.
+2. [x] Split `internal/lsp/handlers.go`: Extracted feature-specific files `internal/lsp/handlers_codeaction.go` and `internal/lsp/handlers_completion.go`.
+3. [x] Refactor large handler functions: `handleTextDocumentCompletion` split into focused helpers (prefix heuristics, cache, provider-native path, chat path, post-processing). `handleCodeAction` already small; no `handleExecuteCommand` present.
+4. [x] Mirror test structure: Feature-specific tests already exist (`codeaction_test.go`, `completion_*_test.go`); no changes needed.
+5. [x] Extract logic from `main`: Entrypoints already delegate to `internal/hexailsp.Run` and `internal/hexaicli.Run`, both tested.
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"
"fm