summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-22 17:33:36 +0300
committerPaul Buetow <paul@buetow.org>2025-08-22 17:33:36 +0300
commit8aba4a99359b59fa11498a3bd13cb9c6cf0ac354 (patch)
treef3ba0f939d0a6d0784c2f823743cfa6fd276df22 /internal/lsp
parentda350b02d395d0135d9193015f969706c7d257a7 (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.go52
-rw-r--r--internal/lsp/handlers.go44
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.