diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-22 17:33:36 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-22 17:33:36 +0300 |
| commit | 8aba4a99359b59fa11498a3bd13cb9c6cf0ac354 (patch) | |
| tree | f3ba0f939d0a6d0784c2f823743cfa6fd276df22 /internal/lsp | |
| parent | da350b02d395d0135d9193015f969706c7d257a7 (diff) | |
tests(lsp): add duplicate-prefix and manual-invoke tests; fix cache key to ignore trailing whitespace; guard compCache init
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/completion_prefix_strip_test.go | 52 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 44 |
2 files changed, 91 insertions, 5 deletions
diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go new file mode 100644 index 0000000..c8bd642 --- /dev/null +++ b/internal/lsp/completion_prefix_strip_test.go @@ -0,0 +1,52 @@ +package lsp + +import ( + "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") + } +} + +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()") + } +} + +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) + } +} + +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, busy := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if busy { t.Fatalf("unexpected busy=true") } + if !ok { t.Fatalf("expected ok=true for manual invoke after whitespace") } + if len(items) == 0 { t.Fatalf("expected at least one completion item") } +} diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index cb8b0e4..69ee7ab 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -822,9 +822,8 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun } } } - if cleaned != "" { - cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - } + if cleaned != "" { cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) } + if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } if cleaned == "" { return nil, false, false } @@ -863,7 +862,7 @@ func (s *Server) completionCacheKey(p CompletionParams, above, current, below, f model, temp, p.TextDocument.URI, - fmt.Sprintf("%d:%d", p.Position.Line, p.Position.Character), + fmt.Sprintf("%d:%d", p.Position.Line, len(left)), above, left, right, @@ -889,6 +888,9 @@ func (s *Server) completionCacheGet(key string) (string, bool) { func (s *Server) completionCachePut(key, value string) { s.mu.Lock() defer s.mu.Unlock() + if s.compCache == nil { + s.compCache = make(map[string]string) + } if _, exists := s.compCache[key]; !exists { s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value @@ -1179,7 +1181,7 @@ func computeWordStart(current string, at int) int { } func isIdentChar(ch byte) bool { - return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g., @@ -1225,6 +1227,38 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin return suggestion } +// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated +// at the beginning of its suggestion. It compares the entire text to the left of the +// cursor (prefixBeforeCursor) against the suggestion, trimming whitespace appropriately, +// and strips the longest sensible overlap. This prevents cases like: +// prefix: "func New " +// suggestion:"func New() *Type" +// resulting in duplicates like "func New func New() *Type". +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { + if suggestion == "" { return suggestion } + s := strings.TrimLeft(suggestion, " \t") + p := strings.TrimRight(prefixBeforeCursor, " \t") + // Exact prefix overlap: remove the full typed prefix + if p != "" && strings.HasPrefix(s, p) { + return strings.TrimLeft(s[len(p):], " \t") + } + // Otherwise, try the longest token-aligned suffix of p that prefixes s + // Prefer boundaries where the char before the suffix is not an identifier char + for k := len(p) - 1; k > 0; k-- { + if !isIdentBoundary(p[k-1]) { continue } + suf := strings.TrimLeft(p[k:], " \t") + if suf == "" { continue } + if strings.HasPrefix(s, suf) { + return strings.TrimLeft(s[len(suf):], " \t") + } + } + return suggestion +} + +func isIdentBoundary(ch byte) bool { + return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') +} + // stripCodeFences removes surrounding Markdown code fences from a model // response when the entire output is wrapped, e.g. starting with "```go" or // "```" and ending with "```". It returns the inner content unchanged. |
