diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
| commit | dc383b4faef881f3bb22816f42c53a79236a4152 (patch) | |
| tree | 7c6a48487fc1d51fed72ea5d15618d133132cdaa /internal/lsp/handlers_test.go | |
| parent | 6a1d48036105e92193aef11a15a77a569eeb1562 (diff) | |
lsp/config: make completion trigger characters configurable
- Add trigger_characters to JSON config and ServerOptions
- Store on server and advertise in initialize
- Update README and example config
- Preserve previous defaults when unset
Diffstat (limited to 'internal/lsp/handlers_test.go')
| -rw-r--r-- | internal/lsp/handlers_test.go | 460 |
1 files changed, 236 insertions, 224 deletions
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go index 613835a..0ba29cf 100644 --- a/internal/lsp/handlers_test.go +++ b/internal/lsp/handlers_test.go @@ -1,286 +1,298 @@ package lsp import ( - "encoding/json" - "strings" - "testing" + "encoding/json" + "strings" + "testing" ) func TestInParamList(t *testing.T) { - line := "func foo(a int, b string) int {" - if !inParamList(line, 15) { // inside params - t.Fatalf("expected inParamList true for cursor inside params") - } - if inParamList(line, 2) { // before 'func' - t.Fatalf("expected inParamList false for cursor before params") - } - if inParamList(line, len(line)) { // after ')' - t.Fatalf("expected inParamList false for cursor after params") - } + line := "func foo(a int, b string) int {" + if !inParamList(line, 15) { // inside params + t.Fatalf("expected inParamList true for cursor inside params") + } + if inParamList(line, 2) { // before 'func' + t.Fatalf("expected inParamList false for cursor before params") + } + if inParamList(line, len(line)) { // after ')' + t.Fatalf("expected inParamList false for cursor after params") + } } func TestComputeWordStart(t *testing.T) { - current := "fmt.Prin" - // Cursor after the word (index 8) - got := computeWordStart(current, 8) - // should stop after the dot at index 4 - if want := 4; got != want { - t.Fatalf("computeWordStart got %d want %d", got, want) - } + current := "fmt.Prin" + // Cursor after the word (index 8) + got := computeWordStart(current, 8) + // should stop after the dot at index 4 + if want := 4; got != want { + t.Fatalf("computeWordStart got %d want %d", got, want) + } } func TestComputeTextEditAndFilter_InParams(t *testing.T) { - current := "func foo(a int, b string) {" // ')' at index 26 - p := CompletionParams{Position: Position{Line: 10, Character: 20}} - te, filter := computeTextEditAndFilter("x int, y string", true, current, p) + current := "func foo(a int, b string) {" // ')' at index 26 + p := CompletionParams{Position: Position{Line: 10, Character: 20}} + te, filter := computeTextEditAndFilter("x int, y string", true, current, p) - if te == nil { - t.Fatalf("expected TextEdit") - } - // left should be after '(' which is at index 8 - if te.Range.Start.Line != 10 || te.Range.Start.Character != 9 { - t.Fatalf("start got line=%d char=%d want line=10 char=9", te.Range.Start.Line, te.Range.Start.Character) - } - // right should clamp to cursor (20) - if te.Range.End.Line != 10 || te.Range.End.Character != 20 { - t.Fatalf("end got line=%d char=%d want line=10 char=20", te.Range.End.Line, te.Range.End.Character) - } - if filter == "" { - t.Fatalf("expected non-empty filter inside params") - } + if te == nil { + t.Fatalf("expected TextEdit") + } + // left should be after '(' which is at index 8 + if te.Range.Start.Line != 10 || te.Range.Start.Character != 9 { + t.Fatalf("start got line=%d char=%d want line=10 char=9", te.Range.Start.Line, te.Range.Start.Character) + } + // right should clamp to cursor (20) + if te.Range.End.Line != 10 || te.Range.End.Character != 20 { + t.Fatalf("end got line=%d char=%d want line=10 char=20", te.Range.End.Line, te.Range.End.Character) + } + if filter == "" { + t.Fatalf("expected non-empty filter inside params") + } } func TestComputeTextEditAndFilter_Word(t *testing.T) { - current := "fmt.Prin" - p := CompletionParams{Position: Position{Line: 2, Character: len(current)}} - te, filter := computeTextEditAndFilter("Println", false, current, p) - if te == nil { - t.Fatalf("expected TextEdit") - } - if te.Range.Start.Character != 4 || te.Range.End.Character != len(current) { - t.Fatalf("range chars got %d..%d want 4..%d", te.Range.Start.Character, te.Range.End.Character, len(current)) - } - if filter != "Prin" { - t.Fatalf("filter got %q want %q", filter, "Prin") - } + current := "fmt.Prin" + p := CompletionParams{Position: Position{Line: 2, Character: len(current)}} + te, filter := computeTextEditAndFilter("Println", false, current, p) + if te == nil { + t.Fatalf("expected TextEdit") + } + if te.Range.Start.Character != 4 || te.Range.End.Character != len(current) { + t.Fatalf("range chars got %d..%d want 4..%d", te.Range.Start.Character, te.Range.End.Character, len(current)) + } + if filter != "Prin" { + t.Fatalf("filter got %q want %q", filter, "Prin") + } } func TestLabelForCompletion(t *testing.T) { - if got := labelForCompletion("Println", "Pri"); got != "Println" { - t.Fatalf("label mismatch got %q want %q", got, "Println") - } - if got := labelForCompletion("Println", "X"); got != "X" { - t.Fatalf("label mismatch with filter got %q want %q", got, "X") - } - if got := labelForCompletion("Println\nmore", ""); got != "Println" { - t.Fatalf("label firstLine got %q want %q", got, "Println") - } + if got := labelForCompletion("Println", "Pri"); got != "Println" { + t.Fatalf("label mismatch got %q want %q", got, "Println") + } + if got := labelForCompletion("Println", "X"); got != "X" { + t.Fatalf("label mismatch with filter got %q want %q", got, "X") + } + if got := labelForCompletion("Println\nmore", ""); got != "Println" { + t.Fatalf("label firstLine got %q want %q", got, "Println") + } } func TestBuildPrompts_InParams(t *testing.T) { - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 12}} - sys, user := buildPrompts(true, p, "above", "func foo(", "below", "func foo(") - if sys == "" || user == "" { - t.Fatalf("expected non-empty prompts") - } - if want := "function signatures"; !contains(sys, want) { - t.Fatalf("system prompt missing %q: %q", want, sys) - } - if want := "parameter list"; !contains(user, want) { - t.Fatalf("user prompt missing %q: %q", want, user) - } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 12}} + sys, user := buildPrompts(true, p, "above", "func foo(", "below", "func foo(") + if sys == "" || user == "" { + t.Fatalf("expected non-empty prompts") + } + if want := "function signatures"; !contains(sys, want) { + t.Fatalf("system prompt missing %q: %q", want, sys) + } + if want := "parameter list"; !contains(user, want) { + t.Fatalf("user prompt missing %q: %q", want, user) + } } func TestBuildPrompts_Outside(t *testing.T) { - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 5}} - sys, user := buildPrompts(false, p, "ab", "cur", "be", "fnctx") - if sys == "" || user == "" { - t.Fatalf("expected non-empty prompts") - } - if want := "completion engine"; !contains(sys, want) { - t.Fatalf("system prompt missing %q: %q", want, sys) - } - if want := "Provide the next likely code"; !contains(user, want) { - t.Fatalf("user prompt missing %q: %q", want, user) - } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 5}} + sys, user := buildPrompts(false, p, "ab", "cur", "be", "fnctx") + if sys == "" || user == "" { + t.Fatalf("expected non-empty prompts") + } + if want := "completion engine"; !contains(sys, want) { + t.Fatalf("system prompt missing %q: %q", want, sys) + } + if want := "Provide the next likely code"; !contains(user, want) { + t.Fatalf("user prompt missing %q: %q", want, user) + } } func TestComputeTextEditAndFilter_NoParensFallback(t *testing.T) { - current := "func foo bar" // no parentheses - cursor := len(current) - p := CompletionParams{Position: Position{Line: 0, Character: cursor}} - te, filter := computeTextEditAndFilter("baz", true, current, p) - if te == nil { - t.Fatalf("expected TextEdit from fallback path") - } - // fallback should behave like word edit; start at last space + 1 - lastSpace := strings.LastIndex(current, " ") - if te.Range.Start.Character != lastSpace+1 || te.Range.End.Character != cursor { - t.Fatalf("range got %d..%d want %d..%d", te.Range.Start.Character, te.Range.End.Character, lastSpace+1, cursor) - } - if filter != "bar" { - t.Fatalf("filter got %q want %q", filter, "bar") - } + current := "func foo bar" // no parentheses + cursor := len(current) + p := CompletionParams{Position: Position{Line: 0, Character: cursor}} + te, filter := computeTextEditAndFilter("baz", true, current, p) + if te == nil { + t.Fatalf("expected TextEdit from fallback path") + } + // fallback should behave like word edit; start at last space + 1 + lastSpace := strings.LastIndex(current, " ") + if te.Range.Start.Character != lastSpace+1 || te.Range.End.Character != cursor { + t.Fatalf("range got %d..%d want %d..%d", te.Range.Start.Character, te.Range.End.Character, lastSpace+1, cursor) + } + if filter != "bar" { + t.Fatalf("filter got %q want %q", filter, "bar") + } } // small helper to avoid importing strings -func contains(s, sub string) bool { return len(s) >= len(sub) && (func() bool { i := 0; for i+len(sub) <= len(s) { if s[i:i+len(sub)] == sub { return true }; i++ }; return false })() } - - - +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + i := 0 + for i+len(sub) <= len(s) { + if s[i:i+len(sub)] == sub { + return true + } + i++ + } + return false + })() +} func TestCollectPromptRemovalEdits(t *testing.T) { - s := newTestServer() - uri := "file:///x.go" - src := `keep ;tag; this and ;another; that + s := newTestServer() + uri := "file:///x.go" + src := `keep ;tag; this and ;another; that no markers here` - s.setDocument(uri, src) - edits := s.collectPromptRemovalEdits(uri) - if len(edits) != 2 { - t.Fatalf("expected 2 edits, got %d", len(edits)) - } - // First occurrence ;tag; - e0 := edits[0] - if e0.Range.Start.Line != 0 { - t.Fatalf("e0 start line=%d want 0", e0.Range.Start.Line) - } - if s.getDocument(uri).lines[0][e0.Range.Start.Character:e0.Range.Start.Character+1] != ";" { - t.Fatalf("e0 start not at ;") - } + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 2 { + t.Fatalf("expected 2 edits, got %d", len(edits)) + } + // First occurrence ;tag; + e0 := edits[0] + if e0.Range.Start.Line != 0 { + t.Fatalf("e0 start line=%d want 0", e0.Range.Start.Line) + } + if s.getDocument(uri).lines[0][e0.Range.Start.Character:e0.Range.Start.Character+1] != ";" { + t.Fatalf("e0 start not at ;") + } } func TestCollectPromptRemovalEdits_SkipSpacedMarkers(t *testing.T) { - s := newTestServer() - uri := "file:///y.go" - // Only ;ok; should be removed; "; spaced ;" must be ignored - src := `prefix ;ok; middle ; spaced ; suffix` - s.setDocument(uri, src) - edits := s.collectPromptRemovalEdits(uri) - if len(edits) != 1 { - t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits)) - } - // Ensure the removed region starts at the first ';' of ;ok; - line := s.getDocument(uri).lines[0] - wantStart := strings.Index(line, ";ok;") - if wantStart < 0 { - t.Fatalf("test setup: could not find ;ok; in %q", line) - } - if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart { - t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart) - } + s := newTestServer() + uri := "file:///y.go" + // Only ;ok; should be removed; "; spaced ;" must be ignored + src := `prefix ;ok; middle ; spaced ; suffix` + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 1 { + t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits)) + } + // Ensure the removed region starts at the first ';' of ;ok; + line := s.getDocument(uri).lines[0] + wantStart := strings.Index(line, ";ok;") + if wantStart < 0 { + t.Fatalf("test setup: could not find ;ok; in %q", line) + } + if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart { + t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart) + } } func TestCollectPromptRemovalEdits_DoubleSemicolonRemovesWholeLine(t *testing.T) { - s := newTestServer() - uri := "file:///z.go" - line0 := "keep" - line1 := ";;todo; remove this whole line" - line2 := "keep ;ok; end" - src := strings.Join([]string{line0, line1, line2}, "\n") - s.setDocument(uri, src) - edits := s.collectPromptRemovalEdits(uri) - if len(edits) != 2 { - t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits)) - } - // Find the whole-line removal for line1 - found := false - for _, e := range edits { - if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) { - found = true - break - } - } - if !found { - t.Fatalf("did not find whole-line removal edit for line 1") - } + s := newTestServer() + uri := "file:///z.go" + line0 := "keep" + line1 := ";;todo; remove this whole line" + line2 := "keep ;ok; end" + src := strings.Join([]string{line0, line1, line2}, "\n") + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 2 { + t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits)) + } + // Find the whole-line removal for line1 + found := false + for _, e := range edits { + if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) { + found = true + break + } + } + if !found { + t.Fatalf("did not find whole-line removal edit for line 1") + } } func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) { - s := newTestServer() - uri := "file:///w.go" - src := "prefix ;; spaced ; suffix" - s.setDocument(uri, src) - edits := s.collectPromptRemovalEdits(uri) - if len(edits) != 0 { - t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits)) - } + s := newTestServer() + uri := "file:///w.go" + src := "prefix ;; spaced ; suffix" + s.setDocument(uri, src) + edits := s.collectPromptRemovalEdits(uri) + if len(edits) != 0 { + t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits)) + } } func TestInstructionFromSelection_OrderPreference(t *testing.T) { - // Earliest wins within a line - line := "code /*block first*/ // later ;tag;" - instr, cleaned := instructionFromSelection(line) - if instr != "block first" { - t.Fatalf("want block comment instr, got %q", instr) - } - if strings.Contains(cleaned, "block first") { - t.Fatalf("cleaned should not contain the block comment") - } + // Earliest wins within a line + line := "code /*block first*/ // later ;tag;" + instr, cleaned := instructionFromSelection(line) + if instr != "block first" { + t.Fatalf("want block comment instr, got %q", instr) + } + if strings.Contains(cleaned, "block first") { + t.Fatalf("cleaned should not contain the block comment") + } } func TestInstructionFromSelection_SemicolonBeatsCommentIfEarlier(t *testing.T) { - line := ";do this;// later" - instr, cleaned := instructionFromSelection(line) - if instr != "do this" { - t.Fatalf("want semicolon instr, got %q", instr) - } - if strings.Contains(cleaned, ";do this;") { - t.Fatalf("cleaned should have semicolon tag removed") - } + line := ";do this;// later" + instr, cleaned := instructionFromSelection(line) + if instr != "do this" { + t.Fatalf("want semicolon instr, got %q", instr) + } + if strings.Contains(cleaned, ";do this;") { + t.Fatalf("cleaned should have semicolon tag removed") + } } func TestInstructionFromSelection_HTMLAndLineComments(t *testing.T) { - line := "prefix <!-- html note --> suffix" - instr, cleaned := instructionFromSelection(line) - if instr != "html note" { - t.Fatalf("want html note, got %q", instr) - } - if strings.Contains(cleaned, "<!--") || strings.Contains(cleaned, "-->") { - t.Fatalf("cleaned should remove html comment markers") - } + line := "prefix <!-- html note --> suffix" + instr, cleaned := instructionFromSelection(line) + if instr != "html note" { + t.Fatalf("want html note, got %q", instr) + } + if strings.Contains(cleaned, "<!--") || strings.Contains(cleaned, "-->") { + t.Fatalf("cleaned should remove html comment markers") + } } func TestStripDuplicateAssignmentPrefix(t *testing.T) { - prefix := "matrix := " - sug := "matrix := NewMatrix(2,2)" - got := stripDuplicateAssignmentPrefix(prefix, sug) - if got != "NewMatrix(2,2)" { - t.Fatalf("dup strip failed: got %q", got) - } - // '=' variant - prefix2 := "x = " - sug2 := "x = y + 1" - got2 := stripDuplicateAssignmentPrefix(prefix2, sug2) - if got2 != "y + 1" { - t.Fatalf("dup strip '=' failed: got %q", got2) - } + prefix := "matrix := " + sug := "matrix := NewMatrix(2,2)" + got := stripDuplicateAssignmentPrefix(prefix, sug) + if got != "NewMatrix(2,2)" { + t.Fatalf("dup strip failed: got %q", got) + } + // '=' variant + prefix2 := "x = " + sug2 := "x = y + 1" + got2 := stripDuplicateAssignmentPrefix(prefix2, sug2) + if got2 != "y + 1" { + t.Fatalf("dup strip '=' failed: got %q", got2) + } } func TestRangesOverlap(t *testing.T) { - a := Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 0}} - b := Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 4, Character: 1}} - if !rangesOverlap(a, b) { t.Fatalf("expected overlap") } - c := Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 5, Character: 0}} - if rangesOverlap(a, c) { t.Fatalf("expected no overlap") } + a := Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 0}} + b := Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 4, Character: 1}} + if !rangesOverlap(a, b) { + t.Fatalf("expected overlap") + } + c := Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 5, Character: 0}} + if rangesOverlap(a, c) { + t.Fatalf("expected no overlap") + } } func TestDiagnosticsInRange_Filtering(t *testing.T) { - s := newTestServer() - sel := Range{Start: Position{Line: 10, Character: 0}, End: Position{Line: 12, Character: 5}} - // Build a fake context payload with three diagnostics: one inside, one outside, one touching boundary - ctx := CodeActionContext{Diagnostics: []Diagnostic{ - {Range: Range{Start: Position{Line: 11, Character: 0}, End: Position{Line: 11, Character: 10}}, Message: "inside"}, - {Range: Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 3, Character: 0}}, Message: "outside"}, - {Range: Range{Start: Position{Line: 12, Character: 5}, End: Position{Line: 12, Character: 8}}, Message: "touch"}, - }} - data, _ := json.Marshal(ctx) - got := s.diagnosticsInRange(json.RawMessage(data), sel) - if len(got) != 2 { - t.Fatalf("expected 2 diagnostics in range, got %d", len(got)) - } - msgs := []string{got[0].Message, got[1].Message} - joined := strings.Join(msgs, ",") - if !strings.Contains(joined, "inside") || !strings.Contains(joined, "touch") { - t.Fatalf("unexpected diagnostics: %v", msgs) - } + s := newTestServer() + sel := Range{Start: Position{Line: 10, Character: 0}, End: Position{Line: 12, Character: 5}} + // Build a fake context payload with three diagnostics: one inside, one outside, one touching boundary + ctx := CodeActionContext{Diagnostics: []Diagnostic{ + {Range: Range{Start: Position{Line: 11, Character: 0}, End: Position{Line: 11, Character: 10}}, Message: "inside"}, + {Range: Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 3, Character: 0}}, Message: "outside"}, + {Range: Range{Start: Position{Line: 12, Character: 5}, End: Position{Line: 12, Character: 8}}, Message: "touch"}, + }} + data, _ := json.Marshal(ctx) + got := s.diagnosticsInRange(json.RawMessage(data), sel) + if len(got) != 2 { + t.Fatalf("expected 2 diagnostics in range, got %d", len(got)) + } + msgs := []string{got[0].Message, got[1].Message} + joined := strings.Join(msgs, ",") + if !strings.Contains(joined, "inside") || !strings.Contains(joined, "touch") { + t.Fatalf("unexpected diagnostics: %v", msgs) + } } |
