Skip to content

Commit e664751

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(lsp): add diagnostic debouncing and semantic token provider (#417)
- Debounce validateDocument calls with 300ms timer per document URI to avoid N parse calls per rapid-typing burst - Add textDocument/semanticTokens/full handler with 6-type legend (keyword, identifier, number, string, operator, comment) - Clean up debounce timers on textDocument/didClose Closes #272 Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini-2655.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 93bce8a commit e664751

File tree

2 files changed

+209
-18
lines changed

2 files changed

+209
-18
lines changed

pkg/lsp/handler.go

Lines changed: 185 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ import (
2020
"regexp"
2121
"strconv"
2222
"strings"
23+
"sync"
24+
"time"
2325

2426
"github.com/ajitpratap0/GoSQLX/pkg/errors"
2527
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
28+
"github.com/ajitpratap0/GoSQLX/pkg/models"
2629
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
2730
"github.com/ajitpratap0/GoSQLX/pkg/sql/parser"
31+
"github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer"
2832
)
2933

3034
// Handler processes LSP requests and notifications.
@@ -107,8 +111,10 @@ import (
107111
// - Immutable keyword and snippet data structures
108112
// - No shared mutable state between requests
109113
type Handler struct {
110-
server *Server
111-
keywords *keywords.Keywords
114+
server *Server
115+
keywords *keywords.Keywords
116+
debounceMu sync.Mutex
117+
debounceTimers map[string]*time.Timer
112118
}
113119

114120
// NewHandler creates a new LSP request handler.
@@ -125,8 +131,9 @@ type Handler struct {
125131
// Returns a fully initialized Handler ready to process LSP requests.
126132
func NewHandler(server *Server) *Handler {
127133
return &Handler{
128-
server: server,
129-
keywords: keywords.New(keywords.DialectGeneric, true),
134+
server: server,
135+
keywords: keywords.New(keywords.DialectGeneric, true),
136+
debounceTimers: make(map[string]*time.Timer),
130137
}
131138
}
132139

@@ -149,6 +156,12 @@ func (h *Handler) HandleRequest(method string, params json.RawMessage) (interfac
149156
return h.handleSignatureHelp(params)
150157
case "textDocument/codeAction":
151158
return h.handleCodeAction(params)
159+
case "textDocument/semanticTokens/full":
160+
var semParams SemanticTokensParams
161+
if err := json.Unmarshal(params, &semParams); err != nil {
162+
return nil, err
163+
}
164+
return h.handleSemanticTokensFull(semParams)
152165
default:
153166
return nil, fmt.Errorf("method not found: %s", method)
154167
}
@@ -204,6 +217,16 @@ func (h *Handler) handleInitialize(params json.RawMessage) (*InitializeResult, e
204217
CodeActionProvider: &CodeActionOptions{
205218
CodeActionKinds: []CodeActionKind{CodeActionQuickFix},
206219
},
220+
SemanticTokensProvider: &SemanticTokensOptions{
221+
Legend: SemanticTokensLegend{
222+
TokenTypes: []string{
223+
"keyword", "identifier", "number", "string", "operator", "comment",
224+
},
225+
TokenModifiers: []string{},
226+
},
227+
Full: true,
228+
Range: false,
229+
},
207230
},
208231
ServerInfo: &ServerInfo{
209232
Name: "gosqlx-lsp",
@@ -285,21 +308,38 @@ func (h *Handler) handleDidChange(params json.RawMessage) {
285308
p.ContentChanges,
286309
)
287310

288-
// Get updated content and validate
289-
if content, ok := h.server.Documents().GetContent(p.TextDocument.URI); ok {
290-
// Check document size limit after update
291-
if len(content) > h.server.MaxDocumentSizeBytes() {
292-
h.server.Logger().Printf("Document too large after change: %d bytes (max: %d)", len(content), h.server.MaxDocumentSizeBytes())
293-
// Clear diagnostics but don't validate
294-
h.server.SendNotification("textDocument/publishDiagnostics", PublishDiagnosticsParams{
295-
URI: p.TextDocument.URI,
296-
Version: p.TextDocument.Version,
297-
Diagnostics: []Diagnostic{},
298-
})
299-
return
300-
}
301-
h.validateDocument(p.TextDocument.URI, content, p.TextDocument.Version)
311+
// Get updated content for size check and debounced validation
312+
content, ok := h.server.Documents().GetContent(p.TextDocument.URI)
313+
if !ok {
314+
return
302315
}
316+
317+
// Check document size limit after update
318+
if len(content) > h.server.MaxDocumentSizeBytes() {
319+
h.server.Logger().Printf("Document too large after change: %d bytes (max: %d)", len(content), h.server.MaxDocumentSizeBytes())
320+
// Clear diagnostics but don't validate
321+
h.server.SendNotification("textDocument/publishDiagnostics", PublishDiagnosticsParams{
322+
URI: p.TextDocument.URI,
323+
Version: p.TextDocument.Version,
324+
Diagnostics: []Diagnostic{},
325+
})
326+
return
327+
}
328+
329+
// Debounce diagnostics — cancel existing timer and schedule new one
330+
uri := p.TextDocument.URI
331+
version := p.TextDocument.Version
332+
h.debounceMu.Lock()
333+
if t, ok := h.debounceTimers[uri]; ok {
334+
t.Stop()
335+
}
336+
h.debounceTimers[uri] = time.AfterFunc(300*time.Millisecond, func() {
337+
h.debounceMu.Lock()
338+
delete(h.debounceTimers, uri)
339+
h.debounceMu.Unlock()
340+
h.validateDocument(uri, content, version)
341+
})
342+
h.debounceMu.Unlock()
303343
}
304344

305345
// handleDidClose handles document close notifications
@@ -312,6 +352,15 @@ func (h *Handler) handleDidClose(params json.RawMessage) {
312352
}
313353

314354
h.server.Logger().Printf("Document closed: %s", p.TextDocument.URI)
355+
356+
// Cancel any pending debounce timer for this document
357+
h.debounceMu.Lock()
358+
if t, ok := h.debounceTimers[p.TextDocument.URI]; ok {
359+
t.Stop()
360+
delete(h.debounceTimers, p.TextDocument.URI)
361+
}
362+
h.debounceMu.Unlock()
363+
315364
h.server.Documents().Close(p.TextDocument.URI)
316365

317366
// Clear diagnostics for closed document
@@ -1649,3 +1698,121 @@ func truncateForLog(data json.RawMessage) string {
16491698
}
16501699
return s
16511700
}
1701+
1702+
// handleSemanticTokensFull handles textDocument/semanticTokens/full requests.
1703+
// It tokenizes the document and returns LSP-encoded semantic token data.
1704+
func (h *Handler) handleSemanticTokensFull(params SemanticTokensParams) (*SemanticTokens, error) {
1705+
doc, ok := h.server.Documents().Get(params.TextDocument.URI)
1706+
if !ok {
1707+
return &SemanticTokens{Data: []uint32{}}, nil
1708+
}
1709+
1710+
tkz := tokenizer.GetTokenizer()
1711+
defer tokenizer.PutTokenizer(tkz)
1712+
tokens, err := tkz.Tokenize([]byte(doc.Content))
1713+
if err != nil {
1714+
return &SemanticTokens{Data: []uint32{}}, nil
1715+
}
1716+
1717+
return &SemanticTokens{Data: encodeSemanticTokens(tokens)}, nil
1718+
}
1719+
1720+
// encodeSemanticTokens converts token positions to LSP semantic token encoding.
1721+
// Each token is encoded as 5 uint32 values:
1722+
//
1723+
// [deltaLine, deltaStartChar, length, tokenType, tokenModifiers]
1724+
func encodeSemanticTokens(tokens []models.TokenWithSpan) []uint32 {
1725+
data := make([]uint32, 0, len(tokens)*5)
1726+
prevLine := uint32(0)
1727+
prevStartChar := uint32(0)
1728+
1729+
for _, tok := range tokens {
1730+
tokenType := classifyToken(tok)
1731+
if tokenType < 0 {
1732+
continue // skip tokens we don't classify
1733+
}
1734+
1735+
// Location is 1-based; convert to 0-based for LSP
1736+
line := uint32(tok.Start.Line - 1)
1737+
startChar := uint32(tok.Start.Column - 1)
1738+
length := uint32(tok.End.Column - tok.Start.Column)
1739+
if length == 0 {
1740+
// Fallback: use text length
1741+
length = uint32(len(tok.Token.Value))
1742+
}
1743+
1744+
deltaLine := line - prevLine
1745+
deltaStartChar := startChar
1746+
if deltaLine == 0 {
1747+
deltaStartChar = startChar - prevStartChar
1748+
}
1749+
1750+
data = append(data, deltaLine, deltaStartChar, length, uint32(tokenType), 0)
1751+
prevLine = line
1752+
prevStartChar = startChar
1753+
}
1754+
return data
1755+
}
1756+
1757+
// Token type indices matching the legend declared in handleInitialize:
1758+
//
1759+
// "keyword", "identifier", "number", "string", "operator", "comment"
1760+
const (
1761+
semTokKeyword = 0
1762+
semTokIdentifier = 1
1763+
semTokNumber = 2
1764+
semTokString = 3
1765+
semTokOperator = 4
1766+
semTokComment = 5
1767+
)
1768+
1769+
// classifyToken maps a TokenWithSpan to a semantic token type index.
1770+
// Returns -1 for tokens that should not be included in the semantic token stream.
1771+
func classifyToken(tok models.TokenWithSpan) int {
1772+
switch tok.Token.Type {
1773+
case models.TokenTypeKeyword,
1774+
models.TokenTypeSelect, models.TokenTypeFrom, models.TokenTypeWhere,
1775+
models.TokenTypeInsert, models.TokenTypeUpdate, models.TokenTypeDelete,
1776+
models.TokenTypeCreate, models.TokenTypeDrop, models.TokenTypeAlter,
1777+
models.TokenTypeTable, models.TokenTypeIndex,
1778+
models.TokenTypeJoin, models.TokenTypeLeft, models.TokenTypeRight,
1779+
models.TokenTypeInner, models.TokenTypeOuter,
1780+
models.TokenTypeOn, models.TokenTypeAs,
1781+
models.TokenTypeAnd, models.TokenTypeOr, models.TokenTypeNot,
1782+
models.TokenTypeIn, models.TokenTypeIs, models.TokenTypeNull,
1783+
models.TokenTypeLike,
1784+
models.TokenTypeOrder, models.TokenTypeGroup, models.TokenTypeBy,
1785+
models.TokenTypeHaving, models.TokenTypeLimit, models.TokenTypeOffset,
1786+
models.TokenTypeWith, models.TokenTypeUnion,
1787+
models.TokenTypeIntersect, models.TokenTypeExcept,
1788+
models.TokenTypeDistinct,
1789+
models.TokenTypeCase, models.TokenTypeWhen, models.TokenTypeThen,
1790+
models.TokenTypeElse, models.TokenTypeEnd:
1791+
return semTokKeyword
1792+
case models.TokenTypeWord, models.TokenTypeIdentifier:
1793+
return semTokIdentifier
1794+
case models.TokenTypeNumber:
1795+
return semTokNumber
1796+
case models.TokenTypeString,
1797+
models.TokenTypeSingleQuotedString, models.TokenTypeDoubleQuotedString,
1798+
models.TokenTypeDollarQuotedString, models.TokenTypeEscapedStringLiteral,
1799+
models.TokenTypeNationalStringLiteral, models.TokenTypeUnicodeStringLiteral,
1800+
models.TokenTypeHexStringLiteral:
1801+
return semTokString
1802+
case models.TokenTypeWhitespace:
1803+
// Whitespace tokens that are comments
1804+
if tok.Token.Word == nil {
1805+
return -1
1806+
}
1807+
return semTokComment
1808+
case models.TokenTypeEq, models.TokenTypeNeq,
1809+
models.TokenTypeLt, models.TokenTypeGt,
1810+
models.TokenTypeLtEq, models.TokenTypeGtEq,
1811+
models.TokenTypePlus, models.TokenTypeMinus,
1812+
models.TokenTypeMul, models.TokenTypeDiv,
1813+
models.TokenTypeMod:
1814+
return semTokOperator
1815+
default:
1816+
return -1 // don't classify
1817+
}
1818+
}

pkg/lsp/protocol.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,30 @@ type ServerCapabilities struct {
231231
DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"`
232232
SignatureHelpProvider *SignatureHelpOptions `json:"signatureHelpProvider,omitempty"`
233233
CodeActionProvider interface{} `json:"codeActionProvider,omitempty"` // bool or CodeActionOptions
234+
SemanticTokensProvider *SemanticTokensOptions `json:"semanticTokensProvider,omitempty"`
235+
}
236+
237+
// SemanticTokensLegend defines the token type and modifier vocabularies
238+
type SemanticTokensLegend struct {
239+
TokenTypes []string `json:"tokenTypes"`
240+
TokenModifiers []string `json:"tokenModifiers"`
241+
}
242+
243+
// SemanticTokensOptions is the server capability for semantic tokens
244+
type SemanticTokensOptions struct {
245+
Legend SemanticTokensLegend `json:"legend"`
246+
Full bool `json:"full"`
247+
Range bool `json:"range"`
248+
}
249+
250+
// SemanticTokensParams is sent by client to request full semantic tokens
251+
type SemanticTokensParams struct {
252+
TextDocument TextDocumentIdentifier `json:"textDocument"`
253+
}
254+
255+
// SemanticTokens is the response containing encoded token data
256+
type SemanticTokens struct {
257+
Data []uint32 `json:"data"`
234258
}
235259

236260
// TextDocumentSyncOptions describes how documents are synced

0 commit comments

Comments
 (0)