From 320de746f7a2985b60c8564a0e65bdf231e840b7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 10:56:27 +0300 Subject: use gofumpt --- internal/lsp/build_prompts_table_test.go | 24 +- internal/lsp/chat_history_test.go | 46 +- internal/lsp/chat_no_double_answer_test.go | 29 +- internal/lsp/code_fences_table_test.go | 45 +- internal/lsp/codeaction_more_test.go | 151 +++--- internal/lsp/codeaction_test.go | 11 +- internal/lsp/codegen_helpers_test.go | 19 +- internal/lsp/completion_cache_test.go | 10 +- internal/lsp/completion_codex_path_test.go | 8 +- internal/lsp/completion_helpers_more_test.go | 60 ++- internal/lsp/completion_messages_test.go | 116 +++-- internal/lsp/completion_prefix_strip_test.go | 97 ++-- internal/lsp/completion_provider_fallback_test.go | 59 ++- internal/lsp/compute_textedit_table_test.go | 53 +- internal/lsp/context.go | 3 +- internal/lsp/debounce_throttle_more_test.go | 51 +- internal/lsp/debounce_throttle_test.go | 123 ++--- internal/lsp/diagnostics_action_test.go | 49 +- internal/lsp/document.go | 2 +- internal/lsp/document_handlers_test.go | 102 ++-- internal/lsp/document_test.go | 42 +- internal/lsp/fallback_items_test.go | 11 +- internal/lsp/gotest_append_test.go | 51 +- internal/lsp/handlers.go | 28 +- internal/lsp/handlers_codeaction.go | 575 ++++++++++++---------- internal/lsp/handlers_completion.go | 219 ++++---- internal/lsp/handlers_document.go | 160 +++--- internal/lsp/handlers_end_to_end_test.go | 454 +++++++++-------- internal/lsp/handlers_execute.go | 53 +- internal/lsp/handlers_helpers_test.go | 56 +-- internal/lsp/handlers_init.go | 3 +- internal/lsp/handlers_test.go | 66 +-- internal/lsp/handlers_utils.go | 265 +++++----- internal/lsp/helpers_inline_prompt_test.go | 82 +-- internal/lsp/helpers_more_test.go | 188 ++++--- internal/lsp/init_and_trigger_test.go | 104 ++-- internal/lsp/init_shutdown_test.go | 27 +- internal/lsp/instruction_table_test.go | 36 +- internal/lsp/label_filter_table_test.go | 21 +- internal/lsp/llm_stats_test.go | 9 +- internal/lsp/log_context_test.go | 15 +- internal/lsp/postprocess_indent_test.go | 14 +- internal/lsp/prefix_table_test.go | 35 +- internal/lsp/provider_native_success_test.go | 66 +-- internal/lsp/rewrite_diagnostics_realism_test.go | 113 +++-- internal/lsp/server.go | 163 +++--- internal/lsp/testfakes_test.go | 5 +- internal/lsp/transport.go | 3 +- internal/lsp/transport_test.go | 71 ++- internal/lsp/triggers_config_test.go | 118 ++--- internal/lsp/types.go | 32 +- 51 files changed, 2247 insertions(+), 1896 deletions(-) (limited to 'internal/lsp') diff --git a/internal/lsp/build_prompts_table_test.go b/internal/lsp/build_prompts_table_test.go index b0092e2..7e8e5e7 100644 --- a/internal/lsp/build_prompts_table_test.go +++ b/internal/lsp/build_prompts_table_test.go @@ -3,14 +3,18 @@ package lsp import "testing" func TestBuildPrompts_Table(t *testing.T) { - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line:5, Character:7}} - cases := []struct{ name string; inParams bool }{ - {"generic", false}, - {"in_params", true}, - } - for _, c := range cases { - sys, user := buildPrompts(c.inParams, p, "above", "current", "below", "func ctx") - if sys == "" || user == "" { t.Fatalf("%s: prompts empty", c.name) } - } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 5, Character: 7}} + cases := []struct { + name string + inParams bool + }{ + {"generic", false}, + {"in_params", true}, + } + for _, c := range cases { + sys, user := buildPrompts(c.inParams, p, "above", "current", "below", "func ctx") + if sys == "" || user == "" { + t.Fatalf("%s: prompts empty", c.name) + } + } } - diff --git a/internal/lsp/chat_history_test.go b/internal/lsp/chat_history_test.go index 0e9fed5..b1cae80 100644 --- a/internal/lsp/chat_history_test.go +++ b/internal/lsp/chat_history_test.go @@ -3,25 +3,35 @@ package lsp import "testing" func TestStripTrailingTrigger(t *testing.T) { - if got := stripTrailingTrigger("what?"); got != "what" { t.Fatalf("should remove trailing ?") } - if got := stripTrailingTrigger("what?>"); got != "what?" { t.Fatalf("should drop trailing > when preceded by ?") } - if got := stripTrailingTrigger("ok!>"); got != "ok!" { t.Fatalf("should drop > after !") } - if got := stripTrailingTrigger("note:>"); got != "note:" { t.Fatalf("should drop > after :") } - if got := stripTrailingTrigger("go;>"); got != "go;" { t.Fatalf("should drop > after ;") } + if got := stripTrailingTrigger("what?"); got != "what" { + t.Fatalf("should remove trailing ?") + } + if got := stripTrailingTrigger("what?>"); got != "what?" { + t.Fatalf("should drop trailing > when preceded by ?") + } + if got := stripTrailingTrigger("ok!>"); got != "ok!" { + t.Fatalf("should drop > after !") + } + if got := stripTrailingTrigger("note:>"); got != "note:" { + t.Fatalf("should drop > after :") + } + if got := stripTrailingTrigger("go;>"); got != "go;" { + t.Fatalf("should drop > after ;") + } } func TestBuildChatHistory_OrderAndLimit(t *testing.T) { - s := newTestServer() - uri := "file:///chat.txt" - // Conversation: q1, > a1, blank, q2, > a2 lines, then current prompt - doc := "q1\n> a1\n\nq2\n> a2\n\n" - s.setDocument(uri, doc) - msgs := s.buildChatHistory(uri, 5, "q3") - // Expect: user q1, assistant a1, user q2, assistant a2, user q3 - if len(msgs) != 5 || msgs[0].Role != "user" || msgs[1].Role != "assistant" || msgs[2].Role != "user" || msgs[3].Role != "assistant" || msgs[4].Role != "user" { - t.Fatalf("unexpected roles: %+v", msgs) - } - if msgs[0].Content != "q1" || msgs[1].Content != "a1" || msgs[2].Content != "q2" || msgs[3].Content != "a2" || msgs[4].Content != "q3" { - t.Fatalf("unexpected contents: %+v", msgs) - } + s := newTestServer() + uri := "file:///chat.txt" + // Conversation: q1, > a1, blank, q2, > a2 lines, then current prompt + doc := "q1\n> a1\n\nq2\n> a2\n\n" + s.setDocument(uri, doc) + msgs := s.buildChatHistory(uri, 5, "q3") + // Expect: user q1, assistant a1, user q2, assistant a2, user q3 + if len(msgs) != 5 || msgs[0].Role != "user" || msgs[1].Role != "assistant" || msgs[2].Role != "user" || msgs[3].Role != "assistant" || msgs[4].Role != "user" { + t.Fatalf("unexpected roles: %+v", msgs) + } + if msgs[0].Content != "q1" || msgs[1].Content != "a1" || msgs[2].Content != "q2" || msgs[3].Content != "a2" || msgs[4].Content != "q3" { + t.Fatalf("unexpected contents: %+v", msgs) + } } diff --git a/internal/lsp/chat_no_double_answer_test.go b/internal/lsp/chat_no_double_answer_test.go index 9898ad9..8821cd0 100644 --- a/internal/lsp/chat_no_double_answer_test.go +++ b/internal/lsp/chat_no_double_answer_test.go @@ -1,22 +1,21 @@ package lsp import ( - "bytes" - "io" - "log" - "testing" + "bytes" + "io" + "log" + "testing" ) func TestDetectAndHandleChat_NoDoubleAnswer(t *testing.T) { - var out bytes.Buffer - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} - s.llmClient = fakeLLM{resp: "IGNORED"} - uri := "file:///x.go" - // Question line with trigger, followed by an existing answer line starting with '>' - s.setDocument(uri, "What?>\n> already answered\n") - s.detectAndHandleChat(uri) - if out.Len() != 0 { - t.Fatalf("expected no applyEdit request when answer exists; got %d bytes", out.Len()) - } + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + s.llmClient = fakeLLM{resp: "IGNORED"} + uri := "file:///x.go" + // Question line with trigger, followed by an existing answer line starting with '>' + s.setDocument(uri, "What?>\n> already answered\n") + s.detectAndHandleChat(uri) + if out.Len() != 0 { + t.Fatalf("expected no applyEdit request when answer exists; got %d bytes", out.Len()) + } } - diff --git a/internal/lsp/code_fences_table_test.go b/internal/lsp/code_fences_table_test.go index c217bce..340ed61 100644 --- a/internal/lsp/code_fences_table_test.go +++ b/internal/lsp/code_fences_table_test.go @@ -3,30 +3,29 @@ package lsp import "testing" func TestStripCodeFences_Table(t *testing.T) { - cases := []struct{ name, in, want string }{ - {"no_fence", "return x", "return x"}, - {"plain_fence", "```\nA\nB\n```", "A\nB"}, - {"lang_fence", "```go\nfmt.Println()\n```", "fmt.Println()"}, - {"spaces", " \n```python\nprint('x')\n```\n ", "print('x')"}, - } - for _, c := range cases { - if got := stripCodeFences(c.in); got != c.want { - t.Fatalf("%s: got %q want %q", c.name, got, c.want) - } - } + cases := []struct{ name, in, want string }{ + {"no_fence", "return x", "return x"}, + {"plain_fence", "```\nA\nB\n```", "A\nB"}, + {"lang_fence", "```go\nfmt.Println()\n```", "fmt.Println()"}, + {"spaces", " \n```python\nprint('x')\n```\n ", "print('x')"}, + } + for _, c := range cases { + if got := stripCodeFences(c.in); got != c.want { + t.Fatalf("%s: got %q want %q", c.name, got, c.want) + } + } } func TestStripInlineCodeSpan_Table(t *testing.T) { - cases := []struct{ name, in, want string }{ - {"no_ticks", "text", "text"}, - {"single_span", "Use `foo()` here", "foo()"}, - {"multiple", "`a` + `b`", "a"}, - {"unmatched", "`missing end", "`missing end"}, - } - for _, c := range cases { - if got := stripInlineCodeSpan(c.in); got != c.want { - t.Fatalf("%s: got %q want %q", c.name, got, c.want) - } - } + cases := []struct{ name, in, want string }{ + {"no_ticks", "text", "text"}, + {"single_span", "Use `foo()` here", "foo()"}, + {"multiple", "`a` + `b`", "a"}, + {"unmatched", "`missing end", "`missing end"}, + } + for _, c := range cases { + if got := stripInlineCodeSpan(c.in); got != c.want { + t.Fatalf("%s: got %q want %q", c.name, got, c.want) + } + } } - diff --git a/internal/lsp/codeaction_more_test.go b/internal/lsp/codeaction_more_test.go index 412d988..82972d8 100644 --- a/internal/lsp/codeaction_more_test.go +++ b/internal/lsp/codeaction_more_test.go @@ -1,86 +1,109 @@ package lsp import ( - "encoding/json" - "path/filepath" - "strings" - "testing" - tut "codeberg.org/snonux/hexai/internal/testutil" + "encoding/json" + "path/filepath" + "strings" + "testing" + + tut "codeberg.org/snonux/hexai/internal/testutil" ) func TestBuildDocumentCodeAction_AndResolve(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: tut.MultilineDocBlock()+"\n"+"func add(a,b int) int { return a+b }"} - uri := "file:///doc.go" - s.setDocument(uri, "package x\nfunc add(a,b int) int {return a+b}") - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line:1, Character:0}, End: Position{Line:1, Character:10}}} - sel := "func add(a,b int) int {return a+b}" - ca := s.buildDocumentCodeAction(p, sel) - if ca == nil { t.Fatalf("expected document code action") } - resolved, ok := s.resolveCodeAction(*ca) - if !ok || resolved.Edit == nil { t.Fatalf("expected resolved edit") } - edits := resolved.Edit.Changes[uri] - if len(edits) != 1 || strings.TrimSpace(edits[0].NewText) == "" { t.Fatalf("expected replacement text") } + s := newTestServer() + s.llmClient = fakeLLM{resp: tut.MultilineDocBlock() + "\n" + "func add(a,b int) int { return a+b }"} + uri := "file:///doc.go" + s.setDocument(uri, "package x\nfunc add(a,b int) int {return a+b}") + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1, Character: 0}, End: Position{Line: 1, Character: 10}}} + sel := "func add(a,b int) int {return a+b}" + ca := s.buildDocumentCodeAction(p, sel) + if ca == nil { + t.Fatalf("expected document code action") + } + resolved, ok := s.resolveCodeAction(*ca) + if !ok || resolved.Edit == nil { + t.Fatalf("expected resolved edit") + } + edits := resolved.Edit.Changes[uri] + if len(edits) != 1 || strings.TrimSpace(edits[0].NewText) == "" { + t.Fatalf("expected replacement text") + } } func TestResolveCodeAction_Rewrite(t *testing.T) { - s := newTestServer() - s.llmClient = fakeLLM{resp: "rewritten"} - uri := "file:///x.go" - s.setDocument(uri, "package p\nvar a=1\n") - 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: uri, Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character: 5}}, Instruction: "do it", Selection: "var a"} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: rewrite selection", Data: raw} - if resolved, ok := s.resolveCodeAction(ca); !ok || resolved.Edit == nil { t.Fatalf("expected resolved rewrite edit") } + s := newTestServer() + s.llmClient = fakeLLM{resp: "rewritten"} + uri := "file:///x.go" + s.setDocument(uri, "package p\nvar a=1\n") + 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: uri, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 5}}, Instruction: "do it", Selection: "var a"} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: rewrite selection", Data: raw} + if resolved, ok := s.resolveCodeAction(ca); !ok || resolved.Edit == nil { + t.Fatalf("expected resolved rewrite edit") + } } func TestBuildGoUnitTestCodeAction_AndResolveCreate(t *testing.T) { - s := newTestServer() - // place files under a temp dir to avoid collisions - dir := t.TempDir() - srcPath := filepath.Join(dir, "calc.go") - uri := "file://" + srcPath - src := "package calc\n\nfunc Sum(a, b int) int { return a+b }\n" - s.setDocument(uri, src) - // Offer action (not a _test.go) - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line:2}}} - if a := s.buildGoUnitTestCodeAction(p); a == nil { t.Fatalf("expected go unit test action") } - // Resolve should create new test file with package+import and a test function - we, testURI, _, ok := s.resolveGoTest(uri, Position{Line:2}) - if !ok { t.Fatalf("resolveGoTest failed") } - if len(we.DocumentChanges) != 2 { t.Fatalf("expected create + edits, got %d", len(we.DocumentChanges)) } - if !strings.HasSuffix(testURI, "_test.go") { t.Fatalf("unexpected test URI: %s", testURI) } + s := newTestServer() + // place files under a temp dir to avoid collisions + dir := t.TempDir() + srcPath := filepath.Join(dir, "calc.go") + uri := "file://" + srcPath + src := "package calc\n\nfunc Sum(a, b int) int { return a+b }\n" + s.setDocument(uri, src) + // Offer action (not a _test.go) + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 2}}} + if a := s.buildGoUnitTestCodeAction(p); a == nil { + t.Fatalf("expected go unit test action") + } + // Resolve should create new test file with package+import and a test function + we, testURI, _, ok := s.resolveGoTest(uri, Position{Line: 2}) + if !ok { + t.Fatalf("resolveGoTest failed") + } + if len(we.DocumentChanges) != 2 { + t.Fatalf("expected create + edits, got %d", len(we.DocumentChanges)) + } + if !strings.HasSuffix(testURI, "_test.go") { + t.Fatalf("unexpected test URI: %s", testURI) + } } func TestBuildGoUnitTestCodeAction_SkipOnTestFile(t *testing.T) { - s := newTestServer() - uri := "file:///tmp/x_test.go" - s.setDocument(uri, "package p\nfunc T(){}") - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}} - if a := s.buildGoUnitTestCodeAction(p); a != nil { t.Fatalf("expected no action on _test.go") } + s := newTestServer() + uri := "file:///tmp/x_test.go" + s.setDocument(uri, "package p\nfunc T(){}") + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}} + if a := s.buildGoUnitTestCodeAction(p); a != nil { + t.Fatalf("expected no action on _test.go") + } } func TestDiagnosticsInRange(t *testing.T) { - s := newTestServer() - ctx := CodeActionContext{Diagnostics: []Diagnostic{ - {Range: Range{Start: Position{Line: 3}, End: Position{Line: 3, Character: 5}}, Message: "in"}, - {Range: Range{Start: Position{Line: 10}, End: Position{Line: 11}}, Message: "out"}, - }} - raw, _ := json.Marshal(ctx) - got := s.diagnosticsInRange(json.RawMessage(raw), Range{Start: Position{Line:2}, End: Position{Line:4}}) - if len(got) != 1 || got[0].Message != "in" { t.Fatalf("unexpected diags: %+v", got) } + s := newTestServer() + ctx := CodeActionContext{Diagnostics: []Diagnostic{ + {Range: Range{Start: Position{Line: 3}, End: Position{Line: 3, Character: 5}}, Message: "in"}, + {Range: Range{Start: Position{Line: 10}, End: Position{Line: 11}}, Message: "out"}, + }} + raw, _ := json.Marshal(ctx) + got := s.diagnosticsInRange(json.RawMessage(raw), Range{Start: Position{Line: 2}, End: Position{Line: 4}}) + if len(got) != 1 || got[0].Message != "in" { + t.Fatalf("unexpected diags: %+v", got) + } } func TestDocBeforeAfter(t *testing.T) { - s := newTestServer() - uri := "file:///d.go" - s.setDocument(uri, "ab\ncd\nef") - before, after := s.docBeforeAfter(uri, Position{Line:1, Character:1}) - if before != "ab\nc" || after != "d\nef" { t.Fatalf("before=%q after=%q", before, after) } + s := newTestServer() + uri := "file:///d.go" + s.setDocument(uri, "ab\ncd\nef") + before, after := s.docBeforeAfter(uri, Position{Line: 1, Character: 1}) + if before != "ab\nc" || after != "d\nef" { + t.Fatalf("before=%q after=%q", before, after) + } } diff --git a/internal/lsp/codeaction_test.go b/internal/lsp/codeaction_test.go index 4de0790..29cb416 100644 --- a/internal/lsp/codeaction_test.go +++ b/internal/lsp/codeaction_test.go @@ -1,10 +1,11 @@ package lsp import ( - "context" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "testing" + "context" + "encoding/json" + "testing" + + "codeberg.org/snonux/hexai/internal/llm" ) type fakeLLM struct { @@ -22,7 +23,7 @@ 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" + sel := ">rewrite>\nold code" ca := s.buildRewriteCodeAction(p, sel) if ca == nil { t.Fatalf("expected code action") diff --git a/internal/lsp/codegen_helpers_test.go b/internal/lsp/codegen_helpers_test.go index d897953..de43b7d 100644 --- a/internal/lsp/codegen_helpers_test.go +++ b/internal/lsp/codegen_helpers_test.go @@ -3,13 +3,20 @@ package lsp import "testing" func TestParseGoPackageName(t *testing.T) { - lines := []string{"// comment", "package mypkg // trailing"} - if got := parseGoPackageName(lines); got != "mypkg" { t.Fatalf("got %q", got) } - if got := parseGoPackageName([]string{"no package"}); got != "" { t.Fatalf("expected empty") } + lines := []string{"// comment", "package mypkg // trailing"} + if got := parseGoPackageName(lines); got != "mypkg" { + t.Fatalf("got %q", got) + } + if got := parseGoPackageName([]string{"no package"}); got != "" { + t.Fatalf("expected empty") + } } func TestDeriveGoFuncName(t *testing.T) { - if got := deriveGoFuncName("func Sum(a int) int { return a }"); got != "Sum" { t.Fatalf("got %q", got) } - if got := deriveGoFuncName("func (t *Type) Method(x int) {}"); got != "Method" { t.Fatalf("got %q", got) } + if got := deriveGoFuncName("func Sum(a int) int { return a }"); got != "Sum" { + t.Fatalf("got %q", got) + } + if got := deriveGoFuncName("func (t *Type) Method(x int) {}"); got != "Method" { + t.Fatalf("got %q", got) + } } - diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go index 9ef0f00..65631f9 100644 --- a/internal/lsp/completion_cache_test.go +++ b/internal/lsp/completion_cache_test.go @@ -1,12 +1,12 @@ package lsp import ( - "bytes" - "log" - "strings" - "testing" + "bytes" + "log" + "strings" + "testing" - "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/logging" ) func TestCompletionCache_IgnoresWhitespaceBeforeCursor(t *testing.T) { diff --git a/internal/lsp/completion_codex_path_test.go b/internal/lsp/completion_codex_path_test.go index 6030d92..bd3b3f4 100644 --- a/internal/lsp/completion_codex_path_test.go +++ b/internal/lsp/completion_codex_path_test.go @@ -1,11 +1,11 @@ package lsp import ( - "context" - "errors" - "testing" + "context" + "errors" + "testing" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llm" ) // fakeCodeLLM implements both llm.Client and llm.CodeCompleter. diff --git a/internal/lsp/completion_helpers_more_test.go b/internal/lsp/completion_helpers_more_test.go index 02fe9f3..79d2523 100644 --- a/internal/lsp/completion_helpers_more_test.go +++ b/internal/lsp/completion_helpers_more_test.go @@ -1,35 +1,51 @@ package lsp import ( - "encoding/json" - "testing" + "encoding/json" + "testing" ) func TestExtractTriggerInfo_ParseManualInvoke(t *testing.T) { - // Compose a CompletionParams with a raw JSON context - ctx := struct{ TriggerKind int `json:"triggerKind"`; TriggerCharacter string `json:"triggerCharacter"` }{TriggerKind: 1, TriggerCharacter: "."} - raw, _ := json.Marshal(ctx) - p := CompletionParams{Context: json.RawMessage(raw)} - kind, ch := extractTriggerInfo(p) - if kind != 1 || ch != "." { t.Fatalf("unexpected trigger info: %d %q", kind, ch) } - if !parseManualInvoke(json.RawMessage(raw)) { t.Fatalf("expected manual invoke true") } + // Compose a CompletionParams with a raw JSON context + ctx := struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter"` + }{TriggerKind: 1, TriggerCharacter: "."} + raw, _ := json.Marshal(ctx) + p := CompletionParams{Context: json.RawMessage(raw)} + kind, ch := extractTriggerInfo(p) + if kind != 1 || ch != "." { + t.Fatalf("unexpected trigger info: %d %q", kind, ch) + } + if !parseManualInvoke(json.RawMessage(raw)) { + t.Fatalf("expected manual invoke true") + } } func TestShouldSuppressForChatTriggerEOL(t *testing.T) { - s := newTestServer() - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:10}} - line := "say hi;>" - if !s.shouldSuppressForChatTriggerEOL(line, p) { t.Fatalf("expected suppression when ;> at EOL") } - if s.shouldSuppressForChatTriggerEOL("plain>", p) { t.Fatalf("should not suppress for plain >") } + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 10}} + line := "say hi;>" + if !s.shouldSuppressForChatTriggerEOL(line, p) { + t.Fatalf("expected suppression when ;> at EOL") + } + if s.shouldSuppressForChatTriggerEOL("plain>", p) { + t.Fatalf("should not suppress for plain >") + } } func TestPrefixHeuristicAllows(t *testing.T) { - s := newTestServer() - // inline prompt allows zero prefix - if !s.prefixHeuristicAllows(true, "", CompletionParams{Position: Position{Line:0, Character:0}}, false) { t.Fatalf("inline prompt should allow") } - // structural triggers like '.' allow without prefix - if !s.prefixHeuristicAllows(false, "fmt.", CompletionParams{Position: Position{Line:0, Character:4}}, false) { t.Fatalf("dot trigger should allow") } - // otherwise need at least minimal prefix (default min=1) - if s.prefixHeuristicAllows(false, " ", CompletionParams{Position: Position{Line:0, Character:0}}, false) { t.Fatalf("should not allow with no prefix") } + s := newTestServer() + // inline prompt allows zero prefix + if !s.prefixHeuristicAllows(true, "", CompletionParams{Position: Position{Line: 0, Character: 0}}, false) { + t.Fatalf("inline prompt should allow") + } + // structural triggers like '.' allow without prefix + if !s.prefixHeuristicAllows(false, "fmt.", CompletionParams{Position: Position{Line: 0, Character: 4}}, false) { + t.Fatalf("dot trigger should allow") + } + // otherwise need at least minimal prefix (default min=1) + if s.prefixHeuristicAllows(false, " ", CompletionParams{Position: Position{Line: 0, Character: 0}}, false) { + t.Fatalf("should not allow with no prefix") + } } - diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go index e9ec3e5..28908d5 100644 --- a/internal/lsp/completion_messages_test.go +++ b/internal/lsp/completion_messages_test.go @@ -1,73 +1,99 @@ package lsp import ( - "testing" + "testing" ) func TestBuildCompletionMessages_InlinePromptOverridesSys(t *testing.T) { - s := newTestServer() - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:1}} - msgs := s.buildCompletionMessages(true, false, "", false, p, "above", "current", "below", "func f") - if len(msgs) < 2 { t.Fatalf("expected messages") } - if msgs[0].Role != "system" || msgs[1].Role != "user" { t.Fatalf("unexpected roles") } - if want := "precise code completion/refactoring engine"; !contains(msgs[0].Content, want) { - t.Fatalf("inline sys not applied") - } + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 1}} + msgs := s.buildCompletionMessages(true, false, "", false, p, "above", "current", "below", "func f") + if len(msgs) < 2 { + t.Fatalf("expected messages") + } + if msgs[0].Role != "system" || msgs[1].Role != "user" { + t.Fatalf("unexpected roles") + } + if want := "precise code completion/refactoring engine"; !contains(msgs[0].Content, want) { + t.Fatalf("inline sys not applied") + } } func TestBuildCompletionMessages_ExtraContextIncluded(t *testing.T) { - s := newTestServer() - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:1}} - msgs := s.buildCompletionMessages(false, true, "EXTRA", false, p, "a", "b", "c", "f") - found := false - for _, m := range msgs { if m.Role == "user" && contains(m.Content, "Additional context:") { found = true } } - if !found { t.Fatalf("missing extra context message") } + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 1}} + msgs := s.buildCompletionMessages(false, true, "EXTRA", false, p, "a", "b", "c", "f") + found := false + for _, m := range msgs { + if m.Role == "user" && contains(m.Content, "Additional context:") { + found = true + } + } + if !found { + t.Fatalf("missing extra context message") + } } func TestPrefixHeuristic_AllVariants(t *testing.T) { - s := newTestServer() - // manual invoke requires at least min prefix; set to 2 - s.manualInvokeMinPrefix = 2 - cur := "a" - p := CompletionParams{Position: Position{Line:0, Character:1}} - if s.prefixHeuristicAllows(false, cur, p, true) { t.Fatalf("should require >=2 prefix on manual invoke") } - // structural triggers allow without prefix - if !s.prefixHeuristicAllows(false, "fmt.", CompletionParams{Position: Position{Line:0, Character:4}}, false) { t.Fatalf("dot trigger should allow") } + s := newTestServer() + // manual invoke requires at least min prefix; set to 2 + s.manualInvokeMinPrefix = 2 + cur := "a" + p := CompletionParams{Position: Position{Line: 0, Character: 1}} + if s.prefixHeuristicAllows(false, cur, p, true) { + t.Fatalf("should require >=2 prefix on manual invoke") + } + // structural triggers allow without prefix + if !s.prefixHeuristicAllows(false, "fmt.", CompletionParams{Position: Position{Line: 0, Character: 4}}, false) { + t.Fatalf("dot trigger should allow") + } } func TestBuildDocString_Contents(t *testing.T) { - s := newTestServer() - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:3, Character:7}} - got := s.buildDocString(p, "above", "current", "below", "func ctx") - if !contains(got, "file: file:///x") || !contains(got, "line: 3") || !contains(got, "function: func ctx") { - t.Fatalf("unexpected doc string: %q", got) - } + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 3, Character: 7}} + got := s.buildDocString(p, "above", "current", "below", "func ctx") + if !contains(got, "file: file:///x") || !contains(got, "line: 3") || !contains(got, "function: func ctx") { + t.Fatalf("unexpected doc string: %q", got) + } } func TestBuildPrompts_InParams(t *testing.T) { - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:5}} - sys, user := buildPrompts(true, p, "a", "func f(x)", "c", "func f(x)") - if !contains(sys, "function signatures") || !contains(user, "parameter list") { t.Fatalf("unexpected in-params prompts") } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 5}} + sys, user := buildPrompts(true, p, "a", "func f(x)", "c", "func f(x)") + if !contains(sys, "function signatures") || !contains(user, "parameter list") { + t.Fatalf("unexpected in-params prompts") + } } func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) { - s := newTestServer() - // code fences - cleaned := s.postProcessCompletion("```go\nname := value\n```", "", "") - if cleaned == "" { t.Fatalf("expected non-empty after fence removal") } - // duplicate assignment prefix strip - cleaned2 := s.postProcessCompletion("name := other", "name := ", "name := ") - if cleaned2 == "" || cleaned2 == "name := other" { t.Fatalf("expected duplicate assignment prefix stripped: %q", cleaned2) } + s := newTestServer() + // code fences + cleaned := s.postProcessCompletion("```go\nname := value\n```", "", "") + if cleaned == "" { + t.Fatalf("expected non-empty after fence removal") + } + // duplicate assignment prefix strip + cleaned2 := s.postProcessCompletion("name := other", "name := ", "name := ") + if cleaned2 == "" || cleaned2 == "name := other" { + t.Fatalf("expected duplicate assignment prefix stripped: %q", cleaned2) + } } -func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (stringIndex(s, sub) >= 0))) } -func stringIndex(s, sub string) int { return len([]rune(s[:])) - len([]rune(s[:])) + (func() int { return intIndex(s, sub) })() } +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (stringIndex(s, sub) >= 0))) +} +func stringIndex(s, sub string) int { + return len([]rune(s[:])) - len([]rune(s[:])) + (func() int { return intIndex(s, sub) })() +} func intIndex(s, sub string) int { return Index(s, sub) } // Go's strings.Index is fine; wrapped to avoid extra imports in this small test. func Index(s, sub string) int { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { return i } - } - return -1 + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 } diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go index e8e70f5..6af87a0 100644 --- a/internal/lsp/completion_prefix_strip_test.go +++ b/internal/lsp/completion_prefix_strip_test.go @@ -1,9 +1,10 @@ package lsp import ( - "encoding/json" - "testing" - tut "codeberg.org/snonux/hexai/internal/testutil" + "encoding/json" + "testing" + + tut "codeberg.org/snonux/hexai/internal/testutil" ) func TestStripDuplicateGeneralPrefix_ExactOverlap(t *testing.T) { @@ -41,7 +42,7 @@ func TestStripDuplicateAssignmentPrefix_AssignAndWalrus(t *testing.T) { func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - s.llmClient = fakeLLM{resp: tut.MultilineFunctionSuggestion()} + s.llmClient = fakeLLM{resp: tut.MultilineFunctionSuggestion()} 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) @@ -56,15 +57,15 @@ func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { } func TestTryLLMCompletion_InlinePromptAlwaysTriggers(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_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { @@ -86,63 +87,63 @@ func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { } func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) { - if hasDoubleOpenTrigger(">>") { - t.Fatalf("bare double-open should not trigger") - } - if hasDoubleOpenTrigger(">> ") { - t.Fatalf("double-open followed by space should not trigger") - } - if hasDoubleOpenTrigger(">>>") { - t.Fatalf("';;;' should not trigger (no content)") - } - if !hasDoubleOpenTrigger(">>x>") { - t.Fatalf("expected trigger for ';;x;' pattern") - } + if hasDoubleOpenTrigger(">>") { + t.Fatalf("bare double-open should not trigger") + } + if hasDoubleOpenTrigger(">> ") { + t.Fatalf("double-open followed by space should not trigger") + } + if hasDoubleOpenTrigger(">>>") { + t.Fatalf("';;;' should not trigger (no content)") + } + if !hasDoubleOpenTrigger(">>x>") { + t.Fatalf("expected trigger for ';;x;' pattern") + } } func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(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 double-open at end; should not auto-trigger - line := "obj. call >>" + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + // Place a '.' earlier but also include bare double-open 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 double-open") - } + if len(items) != 0 { + t.Fatalf("expected no items due to bare double-open") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } } func TestBareDoubleOpenOnNextLine_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 := ">>" + 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 double-open on next line") - } + if len(items) != 0 { + t.Fatalf("expected no items due to bare double-open on next line") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } } func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - fake := &countingLLM{} - s.llmClient = fake - line := ">>" + 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}`)) @@ -150,9 +151,9 @@ func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) { if !ok { t.Fatalf("expected ok=true (handled)") } - if len(items) != 0 { - t.Fatalf("expected no items for bare double-open even with manual invoke") - } + if len(items) != 0 { + t.Fatalf("expected no items for bare double-open even with manual invoke") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } diff --git a/internal/lsp/completion_provider_fallback_test.go b/internal/lsp/completion_provider_fallback_test.go index 04ca7a4..67dc78b 100644 --- a/internal/lsp/completion_provider_fallback_test.go +++ b/internal/lsp/completion_provider_fallback_test.go @@ -1,41 +1,50 @@ package lsp import ( - "context" - "encoding/json" - "io" - "testing" + "context" + "encoding/json" + "io" + "testing" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llm" ) // fakeCompleterErr implements both Client and CodeCompleter; CodeCompletion errors, // forcing tryProviderNativeCompletion to take the error path and fall back to chat. type fakeCompleterErr struct{} -func (fakeCompleterErr) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "X", nil } -func (fakeCompleterErr) Name() string { return "prov" } + +func (fakeCompleterErr) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { + return "X", nil +} +func (fakeCompleterErr) Name() string { return "prov" } func (fakeCompleterErr) DefaultModel() string { return "m" } -func (fakeCompleterErr) CodeCompletion(context.Context, string, string, int, string, float64) ([]string, error) { return nil, io.EOF } +func (fakeCompleterErr) CodeCompletion(context.Context, string, string, int, string, float64) ([]string, error) { + return nil, io.EOF +} func TestCompletion_FallbackOnProviderError(t *testing.T) { - s := newTestServer() - s.llmClient = fakeCompleterErr{} - // Provide simple document - uri := "file:///x.go" - s.setDocument(uri, "package p\nfunc f(){\nfmt.\n}\n") - // Position after 'fmt.' to satisfy prefix heuristics - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line:2, Character:4}} - // Build context for trigger character '.' - ctx := struct{ TriggerKind int `json:"triggerKind"`; TriggerCharacter string `json:"triggerCharacter"` }{TriggerKind: 2, TriggerCharacter: "."} - bctx, _ := json.Marshal(ctx) - p.Context = json.RawMessage(bctx) - - // Call handleCompletion and ensure it returns at least one item from chat fallback - var buf nopWriter - s.out = &buf - s.handleCompletion(Request{JSONRPC: "2.0", ID: json.RawMessage("6"), Method: "textDocument/completion", Params: mustJSON(p)}) - // No panic implies path executed; detailed decode not needed here + s := newTestServer() + s.llmClient = fakeCompleterErr{} + // Provide simple document + uri := "file:///x.go" + s.setDocument(uri, "package p\nfunc f(){\nfmt.\n}\n") + // Position after 'fmt.' to satisfy prefix heuristics + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: 2, Character: 4}} + // Build context for trigger character '.' + ctx := struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter"` + }{TriggerKind: 2, TriggerCharacter: "."} + bctx, _ := json.Marshal(ctx) + p.Context = json.RawMessage(bctx) + + // Call handleCompletion and ensure it returns at least one item from chat fallback + var buf nopWriter + s.out = &buf + s.handleCompletion(Request{JSONRPC: "2.0", ID: json.RawMessage("6"), Method: "textDocument/completion", Params: mustJSON(p)}) + // No panic implies path executed; detailed decode not needed here } type nopWriter struct{} + func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/internal/lsp/compute_textedit_table_test.go b/internal/lsp/compute_textedit_table_test.go index d82e91d..6ed5330 100644 --- a/internal/lsp/compute_textedit_table_test.go +++ b/internal/lsp/compute_textedit_table_test.go @@ -3,31 +3,30 @@ package lsp import "testing" func TestComputeTextEditAndFilter_Table(t *testing.T) { - cases := []struct{ - name string - inParams bool - current string - pos Position - cleaned string - }{ - {"ident_replace", false, "ab cd", Position{Line:1, Character:4}, "X"}, - {"params_inside", true, "func add(a int, b string)", Position{Line:0, Character:15}, "c bool"}, - {"params_at_close", true, "func add(a int)", Position{Line:0, Character:len("func add(a int)")}, "b string"}, - } - for _, c := range cases { - te, filter := computeTextEditAndFilter(c.cleaned, c.inParams, c.current, CompletionParams{Position: c.pos}) - if te == nil { - t.Fatalf("%s: expected edit", c.name) - } - if c.inParams && te.Range.Start.Character == 0 { - t.Fatalf("%s: expected param range (non-zero start)", c.name) - } - if filter == "" && c.current != "" { - // For ident_replace, filter may be non-empty; for params, it can be empty when replacing entire segment - } - if te.NewText != c.cleaned { - t.Fatalf("%s: newText got %q want %q", c.name, te.NewText, c.cleaned) - } - } + cases := []struct { + name string + inParams bool + current string + pos Position + cleaned string + }{ + {"ident_replace", false, "ab cd", Position{Line: 1, Character: 4}, "X"}, + {"params_inside", true, "func add(a int, b string)", Position{Line: 0, Character: 15}, "c bool"}, + {"params_at_close", true, "func add(a int)", Position{Line: 0, Character: len("func add(a int)")}, "b string"}, + } + for _, c := range cases { + te, filter := computeTextEditAndFilter(c.cleaned, c.inParams, c.current, CompletionParams{Position: c.pos}) + if te == nil { + t.Fatalf("%s: expected edit", c.name) + } + if c.inParams && te.Range.Start.Character == 0 { + t.Fatalf("%s: expected param range (non-zero start)", c.name) + } + if filter == "" && c.current != "" { + // For ident_replace, filter may be non-empty; for params, it can be empty when replacing entire segment + } + if te.NewText != c.cleaned { + t.Fatalf("%s: newText got %q want %q", c.name, te.NewText, c.cleaned) + } + } } - diff --git a/internal/lsp/context.go b/internal/lsp/context.go index 72331a8..5a4983c 100644 --- a/internal/lsp/context.go +++ b/internal/lsp/context.go @@ -2,8 +2,9 @@ package lsp import ( - "codeberg.org/snonux/hexai/internal/logging" "strings" + + "codeberg.org/snonux/hexai/internal/logging" ) // buildAdditionalContext builds extra context messages based on the configured mode. diff --git a/internal/lsp/debounce_throttle_more_test.go b/internal/lsp/debounce_throttle_more_test.go index cb11ea4..ed61336 100644 --- a/internal/lsp/debounce_throttle_more_test.go +++ b/internal/lsp/debounce_throttle_more_test.go @@ -1,36 +1,35 @@ package lsp import ( - "context" - "testing" - "time" + "context" + "testing" + "time" ) func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) { - s := newTestServer() - s.completionDebounce = 20 * time.Millisecond - s.mu.Lock() - s.lastInput = time.Now() - s.mu.Unlock() - start := time.Now() - s.waitForDebounce(context.Background()) - if elapsed := time.Since(start); elapsed < 15*time.Millisecond { - t.Fatalf("debounce did not wait long enough: %v", elapsed) - } + s := newTestServer() + s.completionDebounce = 20 * time.Millisecond + s.mu.Lock() + s.lastInput = time.Now() + s.mu.Unlock() + start := time.Now() + s.waitForDebounce(context.Background()) + if elapsed := time.Since(start); elapsed < 15*time.Millisecond { + t.Fatalf("debounce did not wait long enough: %v", elapsed) + } } func TestWaitForThrottle_WaitsRoughlyInterval(t *testing.T) { - s := newTestServer() - s.throttleInterval = 20 * time.Millisecond - s.mu.Lock() - s.lastLLMCall = time.Now() - s.mu.Unlock() - start := time.Now() - if !s.waitForThrottle(context.Background()) { - t.Fatalf("waitForThrottle returned false") - } - if elapsed := time.Since(start); elapsed < 15*time.Millisecond { - t.Fatalf("throttle did not wait long enough: %v", elapsed) - } + s := newTestServer() + s.throttleInterval = 20 * time.Millisecond + s.mu.Lock() + s.lastLLMCall = time.Now() + s.mu.Unlock() + start := time.Now() + if !s.waitForThrottle(context.Background()) { + t.Fatalf("waitForThrottle returned false") + } + if elapsed := time.Since(start); elapsed < 15*time.Millisecond { + t.Fatalf("throttle did not wait long enough: %v", elapsed) + } } - diff --git a/internal/lsp/debounce_throttle_test.go b/internal/lsp/debounce_throttle_test.go index 012ec68..0b49b1b 100644 --- a/internal/lsp/debounce_throttle_test.go +++ b/internal/lsp/debounce_throttle_test.go @@ -1,84 +1,85 @@ package lsp import ( - "context" - "encoding/json" - "testing" - "time" - "codeberg.org/snonux/hexai/internal/llm" + "context" + "encoding/json" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/llm" ) // timeLLM records the time when Chat is invoked. type timeLLM struct{ t time.Time } func (t *timeLLM) Chat(ctx context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { - t.t = time.Now() - return "ok", nil + t.t = time.Now() + return "ok", nil } func (t *timeLLM) Name() string { return "fake" } func (t *timeLLM) DefaultModel() string { return "m" } func TestCompletionDebounce_WaitsUntilQuiet(t *testing.T) { - s := newTestServer() - s.compCache = make(map[string]string) - s.triggerChars = []string{".", ":", "/", "_"} - s.maxTokens = 32 - s.completionDebounce = 30 * time.Millisecond - s.markActivity() // simulate recent input + s := newTestServer() + s.compCache = make(map[string]string) + s.triggerChars = []string{".", ":", "/", "_"} + s.maxTokens = 32 + s.completionDebounce = 30 * time.Millisecond + s.markActivity() // simulate recent input - f := &timeLLM{} - s.llmClient = f + f := &timeLLM{} + s.llmClient = f - line := "func f(i int) " - p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://debounce.go"}} - p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) + line := "func f(i int) " + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://debounce.go"}} + p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) - start := time.Now() - _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok { - t.Fatalf("expected ok=true") - } - if f.t.IsZero() { - t.Fatalf("expected LLM to be called") - } - if f.t.Sub(start) < 25*time.Millisecond { // allow minor timing noise - t.Fatalf("expected debounce delay, got %s", f.t.Sub(start)) - } + start := time.Now() + _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok { + t.Fatalf("expected ok=true") + } + if f.t.IsZero() { + t.Fatalf("expected LLM to be called") + } + if f.t.Sub(start) < 25*time.Millisecond { // allow minor timing noise + t.Fatalf("expected debounce delay, got %s", f.t.Sub(start)) + } } func TestCompletionThrottle_SerializesCalls(t *testing.T) { - s := newTestServer() - s.compCache = make(map[string]string) - s.triggerChars = []string{".", ":", "/", "_"} - s.maxTokens = 32 - s.throttleInterval = 25 * time.Millisecond + s := newTestServer() + s.compCache = make(map[string]string) + s.triggerChars = []string{".", ":", "/", "_"} + s.maxTokens = 32 + s.throttleInterval = 25 * time.Millisecond - // first call uses timeLLM to record time - f1 := &timeLLM{} - s.llmClient = f1 - line := "func f(i int) " - p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://throttle.go"}} - p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) - start := time.Now() - if _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, ""); !ok { - t.Fatalf("first call expected ok=true") - } - if f1.t.IsZero() { - t.Fatalf("expected first call time recorded") - } + // first call uses timeLLM to record time + f1 := &timeLLM{} + s.llmClient = f1 + line := "func f(i int) " + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://throttle.go"}} + p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) + start := time.Now() + if _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, ""); !ok { + t.Fatalf("first call expected ok=true") + } + if f1.t.IsZero() { + t.Fatalf("expected first call time recorded") + } - // second call immediately after; should be delayed by ~interval. - // Clear cache to ensure we actually call the LLM again. - s.compCache = make(map[string]string) - f2 := &timeLLM{} - s.llmClient = f2 - if _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, ""); !ok { - t.Fatalf("second call expected ok=true") - } - if f2.t.IsZero() { - t.Fatalf("expected second call time recorded") - } - if f2.t.Sub(start) < s.throttleInterval { - t.Fatalf("expected throttle spacing >= %s, got %s", s.throttleInterval, f2.t.Sub(start)) - } + // second call immediately after; should be delayed by ~interval. + // Clear cache to ensure we actually call the LLM again. + s.compCache = make(map[string]string) + f2 := &timeLLM{} + s.llmClient = f2 + if _, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, ""); !ok { + t.Fatalf("second call expected ok=true") + } + if f2.t.IsZero() { + t.Fatalf("expected second call time recorded") + } + if f2.t.Sub(start) < s.throttleInterval { + t.Fatalf("expected throttle spacing >= %s, got %s", s.throttleInterval, f2.t.Sub(start)) + } } diff --git a/internal/lsp/diagnostics_action_test.go b/internal/lsp/diagnostics_action_test.go index 1a9201f..a607b86 100644 --- a/internal/lsp/diagnostics_action_test.go +++ b/internal/lsp/diagnostics_action_test.go @@ -1,30 +1,33 @@ package lsp import ( - "encoding/json" - "io" - "log" - "testing" + "encoding/json" + "io" + "log" + "testing" ) func TestHandleCodeAction_ListsDiagnosticsActionWhenOverlap(t *testing.T) { - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document)} - s.llmClient = fakeLLM{resp: "fixed"} - uri := "file:///x.go" - s.setDocument(uri, "package p\nvar a=1\n") - // Selection overlaps line 1 - sel := Range{Start: Position{Line:1, Character:0}, End: Position{Line:1, Character:5}} - // Provide diagnostics in the action context with one overlapping - ctx := CodeActionContext{Diagnostics: []Diagnostic{ - {Range: Range{Start: Position{Line:1, Character:0}, End: Position{Line:1, Character:3}}, Message: "in"}, - {Range: Range{Start: Position{Line:0, Character:0}, End: Position{Line:0, Character:1}}, Message: "out"}, - }} - rawCtx, _ := json.Marshal(ctx) - p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: sel, Context: json.RawMessage(rawCtx)} - ca := s.buildDiagnosticsCodeAction(p, "var a=1") - if ca == nil { t.Fatalf("expected diagnostics action") } - // Resolve should produce an edit - resolved, ok := s.resolveCodeAction(*ca) - if !ok || resolved.Edit == nil { t.Fatalf("expected resolved edit from diagnostics") } + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document)} + s.llmClient = fakeLLM{resp: "fixed"} + uri := "file:///x.go" + s.setDocument(uri, "package p\nvar a=1\n") + // Selection overlaps line 1 + sel := Range{Start: Position{Line: 1, Character: 0}, End: Position{Line: 1, Character: 5}} + // Provide diagnostics in the action context with one overlapping + ctx := CodeActionContext{Diagnostics: []Diagnostic{ + {Range: Range{Start: Position{Line: 1, Character: 0}, End: Position{Line: 1, Character: 3}}, Message: "in"}, + {Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 1}}, Message: "out"}, + }} + rawCtx, _ := json.Marshal(ctx) + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: sel, Context: json.RawMessage(rawCtx)} + ca := s.buildDiagnosticsCodeAction(p, "var a=1") + if ca == nil { + t.Fatalf("expected diagnostics action") + } + // Resolve should produce an edit + resolved, ok := s.resolveCodeAction(*ca) + if !ok || resolved.Edit == nil { + t.Fatalf("expected resolved edit from diagnostics") + } } - diff --git a/internal/lsp/document.go b/internal/lsp/document.go index a5ece7e..1ef1a5b 100644 --- a/internal/lsp/document.go +++ b/internal/lsp/document.go @@ -68,7 +68,7 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f break } } - return + return above, current, below, funcCtx } // isDefiningNewFunction returns true when the cursor appears to be within diff --git a/internal/lsp/document_handlers_test.go b/internal/lsp/document_handlers_test.go index bb12dd2..eae5020 100644 --- a/internal/lsp/document_handlers_test.go +++ b/internal/lsp/document_handlers_test.go @@ -1,61 +1,73 @@ package lsp import ( - "bytes" - "encoding/json" - "io" - "log" - "testing" - "time" + "bytes" + "encoding/json" + "io" + "log" + "testing" + "time" ) func TestDidOpenChangeClose_UpdateDocs(t *testing.T) { - s := newTestServer() - uri := "file:///x.go" - // didOpen - open := DidOpenTextDocumentParams{TextDocument: TextDocumentItem{URI: uri, Text: "a\n"}} - s.handleDidOpen(Request{JSONRPC: "2.0", Method: "textDocument/didOpen", Params: mustJSON(open)}) - if s.getDocument(uri) == nil { t.Fatalf("doc not opened") } - // didChange - ch := DidChangeTextDocumentParams{TextDocument: VersionedTextDocumentIdentifier{URI: uri}, ContentChanges: []TextDocumentContentChangeEvent{{Text: "b\n"}}} - s.handleDidChange(Request{JSONRPC: "2.0", Method: "textDocument/didChange", Params: mustJSON(ch)}) - if d := s.getDocument(uri); d == nil || d.text != "b\n" { t.Fatalf("doc not changed") } - // didClose - s.handleDidClose(Request{JSONRPC: "2.0", Method: "textDocument/didClose", Params: mustJSON(DidCloseTextDocumentParams{TextDocument: TextDocumentIdentifier{URI: uri}})}) - if s.getDocument(uri) != nil { t.Fatalf("doc not closed") } + s := newTestServer() + uri := "file:///x.go" + // didOpen + open := DidOpenTextDocumentParams{TextDocument: TextDocumentItem{URI: uri, Text: "a\n"}} + s.handleDidOpen(Request{JSONRPC: "2.0", Method: "textDocument/didOpen", Params: mustJSON(open)}) + if s.getDocument(uri) == nil { + t.Fatalf("doc not opened") + } + // didChange + ch := DidChangeTextDocumentParams{TextDocument: VersionedTextDocumentIdentifier{URI: uri}, ContentChanges: []TextDocumentContentChangeEvent{{Text: "b\n"}}} + s.handleDidChange(Request{JSONRPC: "2.0", Method: "textDocument/didChange", Params: mustJSON(ch)}) + if d := s.getDocument(uri); d == nil || d.text != "b\n" { + t.Fatalf("doc not changed") + } + // didClose + s.handleDidClose(Request{JSONRPC: "2.0", Method: "textDocument/didClose", Params: mustJSON(DidCloseTextDocumentParams{TextDocument: TextDocumentIdentifier{URI: uri}})}) + if s.getDocument(uri) != nil { + t.Fatalf("doc not closed") + } } func TestClientShowDocument_WritesRequest(t *testing.T) { - var out bytes.Buffer - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} - uri := "file:///x.go" - sel := Range{Start: Position{Line: 1}, End: Position{Line: 2}} - out.Reset() - s.clientShowDocument(uri, &sel) - req := captureRequest(t, &out) - if req.Method != "window/showDocument" { t.Fatalf("got %s", req.Method) } + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + sel := Range{Start: Position{Line: 1}, End: Position{Line: 2}} + out.Reset() + s.clientShowDocument(uri, &sel) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { + t.Fatalf("got %s", req.Method) + } } func TestHandleExecuteCommand_ShowDocument(t *testing.T) { - var out bytes.Buffer - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} - uri := "file:///x.go" - r := Range{Start: Position{Line:0}, End: Position{Line:0}} - args := []any{uri, r} - params := ExecuteCommandParams{Command: "hexai.showDocument", Arguments: args} - s.handleExecuteCommand(Request{JSONRPC: "2.0", ID: json.RawMessage("11"), Method: "workspace/executeCommand", Params: mustJSON(params)}) - req := captureRequest(t, &out) - if req.Method != "window/showDocument" { t.Fatalf("expected showDocument after executeCommand, got %s", req.Method) } + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + r := Range{Start: Position{Line: 0}, End: Position{Line: 0}} + args := []any{uri, r} + params := ExecuteCommandParams{Command: "hexai.showDocument", Arguments: args} + s.handleExecuteCommand(Request{JSONRPC: "2.0", ID: json.RawMessage("11"), Method: "workspace/executeCommand", Params: mustJSON(params)}) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { + t.Fatalf("expected showDocument after executeCommand, got %s", req.Method) + } } func TestDeferShowDocument_WritesLater(t *testing.T) { - var out bytes.Buffer - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} - uri := "file:///x.go" - out.Reset() - s.deferShowDocument(uri, Range{Start: Position{Line:0}, End: Position{Line:0}}) - // wait >120ms per implementation - time.Sleep(160 * time.Millisecond) - req := captureRequest(t, &out) - if req.Method != "window/showDocument" { t.Fatalf("expected showDocument, got %s", req.Method) } + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + out.Reset() + s.deferShowDocument(uri, Range{Start: Position{Line: 0}, End: Position{Line: 0}}) + // wait >120ms per implementation + time.Sleep(160 * time.Millisecond) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { + t.Fatalf("expected showDocument, got %s", req.Method) + } } diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index 5fee18b..00e4548 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -9,20 +9,20 @@ import ( ) func newTestServer() *Server { - s := &Server{ - logger: log.New(io.Discard, "", 0), - docs: make(map[string]*document), - inlineOpen: ">", - inlineClose: ">", - chatSuffix: ">", - chatPrefixes: []string{"?","!",":",";"}, - } - // Keep package-level helpers in sync for tests using free functions - inlineOpenChar = '>' - inlineCloseChar = '>' - chatSuffixChar = '>' - chatPrefixSingles = []string{"?","!",":",";"} - return s + s := &Server{ + logger: log.New(io.Discard, "", 0), + docs: make(map[string]*document), + inlineOpen: ">", + inlineClose: ">", + chatSuffix: ">", + chatPrefixes: []string{"?", "!", ":", ";"}, + } + // Keep package-level helpers in sync for tests using free functions + inlineOpenChar = '>' + inlineCloseChar = '>' + chatSuffixChar = '>' + chatPrefixSingles = []string{"?", "!", ":", ";"} + return s } func TestSplitLines(t *testing.T) { @@ -71,12 +71,14 @@ func TestLineContext_EmptyDoc(t *testing.T) { } func TestDocBeforeAfter_ClampsIndices(t *testing.T) { - s := newTestServer() - uri := "file:///clamp.go" - s.setDocument(uri, "abc\nxyz") - // Position beyond document length should be clamped safely - before, after := s.docBeforeAfter(uri, Position{Line: 99, Character: 99}) - if before == "" && after == "" { t.Fatalf("expected some text with clamped indices") } + s := newTestServer() + uri := "file:///clamp.go" + s.setDocument(uri, "abc\nxyz") + // Position beyond document length should be clamped safely + before, after := s.docBeforeAfter(uri, Position{Line: 99, Character: 99}) + if before == "" && after == "" { + t.Fatalf("expected some text with clamped indices") + } } func TestTrimLen(t *testing.T) { diff --git a/internal/lsp/fallback_items_test.go b/internal/lsp/fallback_items_test.go index 0ce3542..0a08a0d 100644 --- a/internal/lsp/fallback_items_test.go +++ b/internal/lsp/fallback_items_test.go @@ -3,10 +3,9 @@ package lsp import "testing" func TestFallbackCompletionItems(t *testing.T) { - s := newTestServer() - items := s.fallbackCompletionItems("doc") - if len(items) != 1 || items[0].Label != "hexai-complete" || items[0].InsertText != "hexai" { - t.Fatalf("unexpected fallback items: %+v", items) - } + s := newTestServer() + items := s.fallbackCompletionItems("doc") + if len(items) != 1 || items[0].Label != "hexai-complete" || items[0].InsertText != "hexai" { + t.Fatalf("unexpected fallback items: %+v", items) + } } - diff --git a/internal/lsp/gotest_append_test.go b/internal/lsp/gotest_append_test.go index 4fff684..7ceb9e6 100644 --- a/internal/lsp/gotest_append_test.go +++ b/internal/lsp/gotest_append_test.go @@ -1,28 +1,37 @@ package lsp import ( - "os" - "path/filepath" - "strings" - "testing" + "os" + "path/filepath" + "strings" + "testing" ) func TestResolveGoTest_AppendsToExisting(t *testing.T) { - s := newTestServer() - dir := t.TempD