diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-29 00:22:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-29 00:22:39 +0300 |
| commit | 0c2994f0065090a4884b28dc27eb760db2dfaab3 (patch) | |
| tree | 687ecd00584feb634a5853f5964028621f0fa1d5 | |
| parent | d35aaa0227334ab0269b0907491c0682841b9cd5 (diff) | |
lsp: refactor dispatch to handler map; split handlers into feature files (completion, codeaction, init, document); decompose completion logic into small helpers; update review checklist
| -rw-r--r-- | REVIEWRESULT.md | 70 | ||||
| -rw-r--r-- | internal/lsp/chat_trigger_suppression_test.go | 23 | ||||
| -rw-r--r-- | internal/lsp/codeaction_test.go | 129 | ||||
| -rw-r--r-- | internal/lsp/completion_cache_test.go | 64 | ||||
| -rw-r--r-- | internal/lsp/completion_codex_path_test.go | 92 | ||||
| -rw-r--r-- | internal/lsp/completion_prefix_strip_test.go | 210 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 848 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 214 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 306 | ||||
| -rw-r--r-- | internal/lsp/handlers_document.go | 273 | ||||
| -rw-r--r-- | internal/lsp/handlers_helpers_test.go | 148 | ||||
| -rw-r--r-- | internal/lsp/handlers_init.go | 40 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 270 | ||||
| -rw-r--r-- | internal/lsp/llm_busy_test.go | 41 | ||||
| -rw-r--r-- | internal/lsp/server.go | 28 | ||||
| -rw-r--r-- | internal/lsp/testfakes_test.go | 9 | ||||
| -rw-r--r-- | internal/lsp/types.go | 24 |
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 |
