summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-14 00:19:44 +0300
committerPaul Buetow <paul@buetow.org>2025-08-14 00:19:44 +0300
commiteb0bb96fd23cae6e92c5f8d77ef29db8b6d50dc1 (patch)
tree8ac7beda60280ab15cb4cfcd73b83d1de95a81b8
parent0098488f4c869c257ae30fe7dea9a5d8fce9894b (diff)
feat(lsp): gate completion by context — require preceding space and >=2s idle since last change; add logging for gating decisions
-rw-r--r--internal/lsp/server.go141
1 files changed, 100 insertions, 41 deletions
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 3949680..32ae7f2 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
)
// JSON-RPC 2.0 structures (minimal)
@@ -78,10 +79,11 @@ type Server struct {
mu sync.RWMutex
docs map[string]*document
logContext bool
+ lastChange map[string]time.Time
}
func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool) *Server {
- return &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
+ return &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext, lastChange: make(map[string]time.Time)}
}
func (s *Server) Run() error {
@@ -133,26 +135,30 @@ func (s *Server) handle(req Request) {
s.exited = true
// No response per spec.
os.Exit(0)
- case "textDocument/didOpen":
- var p DidOpenTextDocumentParams
- if err := json.Unmarshal(req.Params, &p); err == nil {
- s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
- }
- case "textDocument/didChange":
- var p DidChangeTextDocumentParams
- if err := json.Unmarshal(req.Params, &p); err == nil {
- if len(p.ContentChanges) > 0 {
- s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text)
- }
- }
- case "textDocument/didClose":
- var p DidCloseTextDocumentParams
- if err := json.Unmarshal(req.Params, &p); err == nil {
- s.deleteDocument(p.TextDocument.URI)
- }
+ case "textDocument/didOpen":
+ var p DidOpenTextDocumentParams
+ if err := json.Unmarshal(req.Params, &p); err == nil {
+ s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
+ s.setLastChange(p.TextDocument.URI, time.Now())
+ }
+ case "textDocument/didChange":
+ var p DidChangeTextDocumentParams
+ if err := json.Unmarshal(req.Params, &p); err == nil {
+ if len(p.ContentChanges) > 0 {
+ s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text)
+ s.setLastChange(p.TextDocument.URI, time.Now())
+ }
+ }
+ case "textDocument/didClose":
+ var p DidCloseTextDocumentParams
+ if err := json.Unmarshal(req.Params, &p); err == nil {
+ s.deleteDocument(p.TextDocument.URI)
+ s.clearLastChange(p.TextDocument.URI)
+ }
case "textDocument/completion":
var p CompletionParams
var docStr string
+ allowed := true
if err := json.Unmarshal(req.Params, &p); err == nil {
above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position)
docStr = fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx))
@@ -160,15 +166,28 @@ func (s *Server) handle(req Request) {
s.logger.Printf("completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q",
p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx))
}
+ // Apply gating: require space before cursor AND >=2s idle since last change
+ if !s.prevCharIsSpace(p.TextDocument.URI, p.Position) {
+ allowed = false
+ }
+ if since := s.sinceLastChange(p.TextDocument.URI); since >= 0 && since < 2*time.Second {
+ allowed = false
+ }
+ }
+ var items []CompletionItem
+ if allowed {
+ items = []CompletionItem{{
+ Label: "hexai-complete",
+ Kind: 14,
+ Detail: "dummy completion",
+ InsertText: "hexai",
+ SortText: "0000",
+ Documentation: docStr,
+ }}
+ } else if s.logContext {
+ s.logger.Printf("completion gated: uri=%s allowed=%v idle=%v spaceBefore=%v",
+ p.TextDocument.URI, allowed, s.sinceLastChange(p.TextDocument.URI), s.prevCharIsSpace(p.TextDocument.URI, p.Position))
}
- items := []CompletionItem{{
- Label: "hexai-complete",
- Kind: 14,
- Detail: "dummy completion",
- InsertText: "hexai",
- SortText: "0000",
- Documentation: docStr,
- }}
s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
default:
// Unknown method; reply with Method Not Found for requests that have an ID.
@@ -245,9 +264,9 @@ type document struct {
}
func (s *Server) setDocument(uri, text string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)}
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)}
}
func (s *Server) deleteDocument(uri string) {
@@ -263,8 +282,8 @@ func (s *Server) getDocument(uri string) *document {
}
func splitLines(sx string) []string {
- sx = strings.ReplaceAll(sx, "\r\n", "\n")
- return strings.Split(sx, "\n")
+ sx = strings.ReplaceAll(sx, "\r\n", "\n")
+ return strings.Split(sx, "\n")
}
// LSP param types (subset)
@@ -315,10 +334,10 @@ type CompletionParams struct {
}
func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- return "", "", "", ""
- }
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return "", "", "", ""
+ }
idx := pos.Line
if idx < 0 {
idx = 0
@@ -340,7 +359,7 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f
break
}
}
- return
+ return
}
func hasAny(s string, needles []string) bool {
@@ -353,9 +372,49 @@ func hasAny(s string, needles []string) bool {
}
func trimLen(s string) string {
- s = strings.TrimSpace(s)
- if len(s) > 200 {
- return s[:200] + "…"
- }
- return s
+ s = strings.TrimSpace(s)
+ if len(s) > 200 {
+ return s[:200] + "…"
+ }
+ return s
+}
+
+// --- Gating helpers ---
+func (s *Server) setLastChange(uri string, t time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.lastChange[uri] = t
+}
+
+func (s *Server) clearLastChange(uri string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.lastChange, uri)
+}
+
+func (s *Server) sinceLastChange(uri string) time.Duration {
+ s.mu.RLock()
+ t, ok := s.lastChange[uri]
+ s.mu.RUnlock()
+ if !ok {
+ return -1
+ }
+ return time.Since(t)
+}
+
+func (s *Server) prevCharIsSpace(uri string, pos Position) bool {
+ d := s.getDocument(uri)
+ if d == nil {
+ return false
+ }
+ if pos.Line < 0 || pos.Line >= len(d.lines) {
+ return false
+ }
+ line := d.lines[pos.Line]
+ // Convert to runes to be safe with multibyte
+ r := []rune(line)
+ if pos.Character <= 0 || pos.Character > len(r) {
+ return false
+ }
+ return r[pos.Character-1] == ' '
}