diff --git a/CHANGELOG.md b/CHANGELOG.md index b19cf000..de5a9760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,17 @@ acceptance, CLI refusals), so read **Behavior changes** before upgrading. the gosqlx.dev playground uses it for editor highlighting. See [docs/HIGHLIGHTING.md](docs/HIGHLIGHTING.md). +- **Dialect-aware LSP server**: the dialect from `initializationOptions.dialect` + now drives every feature, not only semantic tokens. Diagnostics, formatting + and document symbols tokenize and parse with the dialect's rules (MySQL + `LIMIT 5, 10` is valid under `mysql`, `ILIKE` is reported under `mysql`); + completion and hover add the dialect's own keywords. The server also handles + `workspace/didChangeConfiguration` with `{"gosqlx": {"dialect": "..."}}`: + it switches dialect without a restart, re-publishes diagnostics for open + documents and, when the client advertises `refreshSupport`, sends + `workspace/semanticTokens/refresh`. Invalid values fall back to `generic`, as + in `initialize`. The default `generic` dialect keeps the previous behaviour + for every feature. See [docs/LSP_GUIDE.md](docs/LSP_GUIDE.md#sql-dialect). - **`--dialect` flag for the `parse`, `format`, `analyze`, and `lint` CLI commands**: dialect-specific SQL (e.g. MySQL `LIMIT offset, count`) can now be parsed by these commands, matching the existing `validate --dialect`. Unknown diff --git a/docs/LSP_GUIDE.md b/docs/LSP_GUIDE.md index 851e9fb0..8f112ede 100644 --- a/docs/LSP_GUIDE.md +++ b/docs/LSP_GUIDE.md @@ -29,6 +29,7 @@ The GoSQLX Language Server Protocol (LSP) implementation provides real-time SQL - **Signature Help**: Function parameter documentation for 15+ SQL functions - **Code Actions**: Quick fixes for common SQL issues - **Semantic Tokens**: Dialect-aware syntax highlighting powered by `pkg/highlight` +- **SQL Dialects**: One configurable dialect drives diagnostics, formatting, symbols, completion, hover and highlighting, and can change at runtime - **Rate Limiting**: Protection against DoS (100 requests/sec) --- @@ -218,9 +219,10 @@ Earlier builds used a non-standard `identifier` token type and reported quoted identifiers as `string`. Custom highlight rules keyed on `identifier` (for example Neovim's `@lsp.type.identifier`) must use `variable` instead. -#### Dialect for Semantic Tokens +### SQL Dialect -Semantic tokens use the SQL dialect from the client's `initializationOptions`: +The server uses one SQL dialect for every feature. The client chooses it in +`initializationOptions` and can change it later without a restart: ```json { "dialect": "mysql" } @@ -228,12 +230,56 @@ Semantic tokens use the SQL dialect from the client's `initializationOptions`: - `dialect` is a string and accepts `generic`, `postgresql`, `mysql`, `mariadb`, `sqlserver`, `oracle`, `sqlite`, `snowflake`, `bigquery`, - `redshift` and `clickhouse`. + `redshift` and `clickhouse` (case and surrounding spaces are ignored). - A missing, non-string, empty or unrecognised value uses `generic`. -- The setting is read once, at `initialize`, and affects semantic tokens only. - Changing it requires restarting the language server. The VS Code extension - restarts the server when `gosqlx.dialect` changes. Other clients must restart - the server themselves (in nvim-lspconfig, `:LspRestart`). + +What the dialect changes: + +| Feature | Effect | +|---------|--------| +| Diagnostics | Documents are tokenized and parsed with the dialect's rules, so MySQL `LIMIT 5, 10` is valid under `mysql` but reported under `postgresql`, and `ILIKE` is reported under `mysql` | +| Formatting | Dialect-specific SQL is parsed and reformatted instead of falling back to whitespace-only formatting | +| Document symbols | Statements that only the dialect accepts appear in the outline | +| Completion | The dialect's own keywords (for example MySQL `ZEROFILL`) are offered after the standard keywords | +| Hover | The dialect's own keywords show a short "MySQL-specific keyword" note | +| Semantic tokens | Keywords, quoting and literals are classified for the dialect (for example `"x"` is a string under `mysql`) | + +Signature help and code actions do not depend on the dialect. + +`generic`, the default, keeps the behaviour of earlier releases exactly: the +server parses with the library's default pipeline (PostgreSQL keyword rules, +no parser dialect). That pipeline is more permissive than an explicit dialect, +so, for example, `ILIKE` is accepted under `generic`. + +#### Changing the Dialect at Runtime + +Send `workspace/didChangeConfiguration` with the dialect under a `gosqlx` +section: + +```json +{ + "jsonrpc": "2.0", + "method": "workspace/didChangeConfiguration", + "params": { "settings": { "gosqlx": { "dialect": "postgresql" } } } +} +``` + +- The value is validated exactly as in `initializationOptions`: a non-string, + empty or unrecognised `dialect` switches to `generic`, so a live change and a + restart with the same setting always agree. +- Settings without a `gosqlx.dialect` entry (including `"settings": null`) + leave the dialect unchanged, so unrelated configuration changes are harmless. +- When the dialect changes, the server re-publishes diagnostics for every open + document. If the client advertised + `capabilities.workspace.semanticTokens.refreshSupport` in `initialize`, the + server also sends a `workspace/semanticTokens/refresh` request so the editor + re-requests highlighting. Other features read the dialect on each request. + +The VS Code extension sends `gosqlx.dialect` as `initializationOptions.dialect` +and restarts the server when the setting changes; it does not send +`workspace/didChangeConfiguration`. Other clients can push the setting with +`workspace/didChangeConfiguration` or restart the server (in nvim-lspconfig, +`:LspRestart`). Neovim with nvim-lspconfig, using the `gosqlx` config from [Neovim](#neovim): @@ -241,10 +287,16 @@ Neovim with nvim-lspconfig, using the `gosqlx` config from ```lua require('lspconfig').gosqlx.setup{ init_options = { dialect = 'mysql' }, + settings = { gosqlx = { dialect = 'mysql' } }, } ``` -Any client sends the setting in the `initialize` request: +Neovim's LSP client sends `settings` in `workspace/didChangeConfiguration` +right after initialization, and sending new settings later (for example with +`client.notify('workspace/didChangeConfiguration', { settings = ... })`) +switches the dialect live. + +Any client sends the initial setting in the `initialize` request: ```json { diff --git a/pkg/lsp/dialect.go b/pkg/lsp/dialect.go new file mode 100644 index 00000000..c3ff78f7 --- /dev/null +++ b/pkg/lsp/dialect.go @@ -0,0 +1,269 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lsp + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +// Dialect routing +// +// The configured dialect (Handler.dialect) drives every feature that +// tokenizes or parses: diagnostics, formatting, document symbols and +// semantic tokens. Completion and hover additionally offer the dialect's +// own keywords. +// +// DialectGeneric (the default when the client sends no dialect) keeps the +// exact pipeline the server used before it became dialect-aware: the +// dialect-less gosqlx.Parse / ParseWithRecovery / Format entry points, which +// tokenize with the tokenizer pool's default (PostgreSQL keyword rules) and +// parse with no parser dialect. Passing "generic" explicitly to the +// dialect-aware APIs is NOT equivalent: for example ILIKE parses with the +// default pipeline but is rejected by ParseWithDialect(sql, "generic"). Only +// an explicitly chosen non-generic dialect switches to the dialect-aware +// APIs, so existing users see no change. + +// usesDefaultPipeline reports whether d selects the server's original, +// dialect-less parsing pipeline. +func usesDefaultPipeline(d keywords.SQLDialect) bool { + return d == "" || d == keywords.DialectGeneric || d == keywords.DialectUnknown +} + +// parseWithRecoveryForDialect is gosqlx.ParseWithRecovery with the tokenizer +// and parser configured for d. Errors are wrapped with the same gosqlx +// sentinels so diagnostics look the same for every dialect. +func parseWithRecoveryForDialect(sql string, d keywords.SQLDialect) ([]ast.Statement, []error) { + if usesDefaultPipeline(d) { + return gosqlx.ParseWithRecovery(sql) + } + + // PutTokenizer restores the pool's default dialect. + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + tkz.SetDialect(d) + + tokens, err := tkz.TokenizeString(sql) + if err != nil { + return nil, []error{fmt.Errorf("%w: %w", gosqlx.ErrTokenize, err)} + } + + // PutParser resets the parser's dialect. + p := parser.GetParser() + defer parser.PutParser(p) + p.ApplyOptions(parser.WithDialect(string(d))) + + stmts, recoveryErrs := p.ParseWithRecoveryFromModelTokens(tokens) + if len(recoveryErrs) > 0 { + wrapped := make([]error, len(recoveryErrs)) + for i, e := range recoveryErrs { + wrapped[i] = fmt.Errorf("%w: %w", gosqlx.ErrSyntax, e) + } + return stmts, wrapped + } + return stmts, nil +} + +// parseForDialect parses sql with the dialect-aware API for d, or with +// gosqlx.Parse for the default pipeline. +func parseForDialect(sql string, d keywords.SQLDialect) (*ast.AST, error) { + if usesDefaultPipeline(d) { + return gosqlx.Parse(sql) + } + return gosqlx.ParseWithDialect(sql, d) +} + +// formatForDialect formats sql like gosqlx.Format, but parses it with the +// dialect-aware API for d. The FormatAST options reproduce the +// ast.FormatOptions that gosqlx.Format derives from opts (LineWidth, the only +// field that differs, is not used by the formatter). opts.AddSemicolon is not +// honoured on this path; the LSP formatter never sets it. +func formatForDialect(sql string, opts gosqlx.FormatOptions, d keywords.SQLDialect) (string, error) { + if usesDefaultPipeline(d) { + return gosqlx.Format(sql, opts) + } + parsed, err := gosqlx.ParseWithDialect(sql, d) + if err != nil { + return "", fmt.Errorf("cannot format invalid SQL: %w", err) + } + formatted := gosqlx.FormatAST(parsed, + gosqlx.WithIndent(opts.IndentSize), + gosqlx.WithUppercaseKeywords(opts.UppercaseKeywords), + ) + return formatted, nil +} + +// dialectDisplayNames are the human-readable dialect names used in +// completion details and hover text. +var dialectDisplayNames = map[keywords.SQLDialect]string{ + keywords.DialectPostgreSQL: "PostgreSQL", + keywords.DialectMySQL: "MySQL", + keywords.DialectMariaDB: "MariaDB", + keywords.DialectSQLServer: "SQL Server", + keywords.DialectOracle: "Oracle", + keywords.DialectSQLite: "SQLite", + keywords.DialectSnowflake: "Snowflake", + keywords.DialectBigQuery: "BigQuery", + keywords.DialectRedshift: "Redshift", + keywords.DialectClickHouse: "ClickHouse", +} + +// dialectDisplayName returns the human-readable name of d. +func dialectDisplayName(d keywords.SQLDialect) string { + if name, ok := dialectDisplayNames[d]; ok { + return name + } + return string(d) +} + +// dialectKeywordCompletions holds, per dialect, a completion item for each +// dialect-specific keyword (keywords.DialectKeywords) that the generic +// sqlKeywords list does not already offer. Built once; read-only afterwards. +var dialectKeywordCompletions = buildDialectKeywordCompletions() + +func buildDialectKeywordCompletions() map[keywords.SQLDialect][]CompletionItem { + base := make(map[string]bool, len(sqlKeywords)) + for _, kw := range sqlKeywords { + base[strings.ToUpper(kw.Label)] = true + } + out := make(map[keywords.SQLDialect][]CompletionItem) + for _, d := range keywords.AllDialects() { + seen := make(map[string]bool) + var items []CompletionItem + for _, kw := range keywords.DialectKeywords(d) { + word := strings.ToUpper(kw.Word) + if word == "" || base[word] || seen[word] { + continue + } + seen[word] = true + items = append(items, CompletionItem{ + Label: word, + Kind: KeywordCompletion, + Detail: dialectDisplayName(d) + " keyword", + }) + } + if len(items) > 0 { + out[d] = items + } + } + return out +} + +// isDialectKeyword reports whether upperWord is one of d's dialect-specific +// keywords offered by completion. +func isDialectKeyword(upperWord string, d keywords.SQLDialect) bool { + for _, item := range dialectKeywordCompletions[d] { + if item.Label == upperWord { + return true + } + } + return false +} + +// dialectKeywordHover returns hover markdown for a dialect-specific keyword +// of d that has no entry in the generic keyword documentation, or "". +func dialectKeywordHover(upperWord string, d keywords.SQLDialect) string { + if usesDefaultPipeline(d) || !isDialectKeyword(upperWord, d) { + return "" + } + return fmt.Sprintf("**%s** - %s-specific keyword.", upperWord, dialectDisplayName(d)) +} + +// dialectFromSettings extracts the dialect from workspace/didChangeConfiguration +// settings shaped {"gosqlx": {"dialect": "mysql"}}. ok is false when the +// settings carry no gosqlx.dialect entry at all (for example the null +// settings vscode-languageclient sends without a configurationSection), in +// which case the current dialect is kept. A present but non-string, empty or +// unrecognised value is validated exactly like initializationOptions and +// falls back to DialectGeneric, so a live change and a server restart with +// the same setting always agree. +func dialectFromSettings(settings interface{}) (d keywords.SQLDialect, ok bool) { + root, isMap := settings.(map[string]interface{}) + if !isMap { + return "", false + } + section, isMap := root["gosqlx"].(map[string]interface{}) + if !isMap { + return "", false + } + if _, present := section["dialect"]; !present { + return "", false + } + return dialectFromInitOptions(section), true +} + +// handleDidChangeConfiguration applies a gosqlx.dialect change without a +// server restart. When the dialect actually changes, diagnostics of every +// open document are re-published under the new dialect and, if the client +// advertised workspace.semanticTokens.refreshSupport, the client is asked to +// re-request semantic tokens. Hover, completion, formatting and document +// symbols read the dialect per request and need no refresh. +func (h *Handler) handleDidChangeConfiguration(params json.RawMessage) { + var p DidChangeConfigurationParams + if err := json.Unmarshal(params, &p); err != nil { + h.server.Logger().Printf("workspace/didChangeConfiguration: failed to parse params: %v (raw: %s)", err, truncateForLog(params)) + h.sendParseError("didChangeConfiguration", err) + return + } + + d, ok := dialectFromSettings(p.Settings) + if !ok { + h.server.Logger().Printf("workspace/didChangeConfiguration: no gosqlx.dialect setting; keeping %s", h.currentDialect()) + return + } + if !h.setDialect(d) { + return + } + + h.revalidateOpenDocuments() + + if h.semanticRefresh.Load() { + h.server.sendRequest("workspace/semanticTokens/refresh", nil) + } +} + +// revalidateOpenDocuments re-publishes diagnostics for every open document +// that is within the size limit, using the current dialect. +// +// Each document is re-fetched just before it is validated, so a document +// closed or edited since the snapshot is skipped or validated at its current +// version rather than publishing diagnostics for stale content. A pending +// debounced didChange validation is cancelled: it would only re-validate the +// same content. One that is already running and parsed under the previous +// dialect is dropped by validateDocument's dialect check. +func (h *Handler) revalidateOpenDocuments() { + docs := h.server.Documents() + for _, snap := range docs.snapshots() { + h.debounceMu.Lock() + if t, ok := h.debounceTimers[snap.URI]; ok { + t.Stop() + delete(h.debounceTimers, snap.URI) + } + h.debounceMu.Unlock() + + doc, ok := docs.Get(snap.URI) + if !ok || len(doc.Content) > h.server.MaxDocumentSizeBytes() { + continue + } + h.validateDocument(doc.URI, doc.Content, doc.Version) + } +} diff --git a/pkg/lsp/dialect_test.go b/pkg/lsp/dialect_test.go new file mode 100644 index 00000000..03901b6f --- /dev/null +++ b/pkg/lsp/dialect_test.go @@ -0,0 +1,586 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lsp + +import ( + "encoding/json" + "fmt" + "io" + "log" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// SQL the parser treats differently per dialect: +// - mysqlLimit: LIMIT offset, count is MySQL syntax, rejected by the +// default pipeline and by PostgreSQL. +// - ilikeQuery: ILIKE is accepted by the default pipeline and PostgreSQL +// but rejected by MySQL. +const ( + mysqlLimit = "SELECT id FROM users LIMIT 5, 10" + ilikeQuery = "SELECT id FROM users WHERE name ILIKE 'a%'" +) + +// dialectTestServer is a server whose output can be read while handlers run +// on other goroutines. +type dialectTestServer struct { + *Server + out *countingWriter +} + +// newDialectServer creates a server and sends initialize. initOpts is the raw +// initializationOptions JSON ("" omits it); refresh advertises +// workspace.semanticTokens.refreshSupport. +func newDialectServer(t *testing.T, initOpts string, refresh bool) *dialectTestServer { + t.Helper() + out := &countingWriter{} + server := NewServer(strings.NewReader(""), out, log.New(io.Discard, "", 0)) + params := `{"processId":1,"rootUri":"file:///"` + if initOpts != "" { + params += `,"initializationOptions":` + initOpts + } + if refresh { + params += `,"capabilities":{"workspace":{"semanticTokens":{"refreshSupport":true}}}` + } + params += `}` + if _, err := server.handler.HandleRequest("initialize", json.RawMessage(params)); err != nil { + t.Fatalf("initialize: %v", err) + } + return &dialectTestServer{Server: server, out: out} +} + +func dialectOpts(d string) string { + if d == "" { + return "" + } + return fmt.Sprintf(`{"dialect":%q}`, d) +} + +// frames decodes every message the server has written so far. +func (s *dialectTestServer) frames(t *testing.T) []map[string]json.RawMessage { + t.Helper() + data, _ := s.out.snapshot() + var msgs []map[string]json.RawMessage + for _, f := range parseStrictFrames(t, data) { + var m map[string]json.RawMessage + if err := json.Unmarshal(f, &m); err != nil { + t.Fatalf("unmarshal frame %s: %v", f, err) + } + msgs = append(msgs, m) + } + return msgs +} + +// methodFrames returns the messages with the given method, in order. +func (s *dialectTestServer) methodFrames(t *testing.T, method string) []map[string]json.RawMessage { + t.Helper() + var out []map[string]json.RawMessage + for _, m := range s.frames(t) { + var got string + if raw, ok := m["method"]; ok && json.Unmarshal(raw, &got) == nil && got == method { + out = append(out, m) + } + } + return out +} + +// lastDiagnostics returns the diagnostics of the last publishDiagnostics +// notification for uri. +func (s *dialectTestServer) lastDiagnostics(t *testing.T, uri string) []Diagnostic { + t.Helper() + var last []Diagnostic + found := false + for _, m := range s.methodFrames(t, "textDocument/publishDiagnostics") { + var p PublishDiagnosticsParams + if err := json.Unmarshal(m["params"], &p); err != nil { + t.Fatalf("unmarshal diagnostics: %v", err) + } + if p.URI == uri { + last, found = p.Diagnostics, true + } + } + if !found { + t.Fatalf("no publishDiagnostics for %s", uri) + } + return last +} + +func (s *dialectTestServer) open(t *testing.T, uri, text string) { + t.Helper() + raw, err := json.Marshal(DidOpenTextDocumentParams{ + TextDocument: TextDocumentItem{URI: uri, LanguageID: "sql", Version: 1, Text: text}, + }) + if err != nil { + t.Fatalf("marshal didOpen: %v", err) + } + s.handler.HandleNotification("textDocument/didOpen", raw) +} + +func (s *dialectTestServer) changeConfig(settings string) { + s.handler.HandleNotification("workspace/didChangeConfiguration", + json.RawMessage(`{"settings":`+settings+`}`)) +} + +func (s *dialectTestServer) request(t *testing.T, method string, params interface{}) interface{} { + t.Helper() + raw, err := json.Marshal(params) + if err != nil { + t.Fatalf("marshal %s params: %v", method, err) + } + result, err := s.handler.HandleRequest(method, raw) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + return result +} + +func (s *dialectTestServer) formatting(t *testing.T, uri string) []TextEdit { + t.Helper() + result := s.request(t, "textDocument/formatting", DocumentFormattingParams{ + TextDocument: TextDocumentIdentifier{URI: uri}, + Options: FormattingOptions{TabSize: 2, InsertSpaces: true}, + }) + edits, ok := result.([]TextEdit) + if !ok { + t.Fatalf("expected []TextEdit, got %T", result) + } + return edits +} + +func (s *dialectTestServer) symbols(t *testing.T, uri string) []DocumentSymbol { + t.Helper() + result := s.request(t, "textDocument/documentSymbol", DocumentSymbolParams{ + TextDocument: TextDocumentIdentifier{URI: uri}, + }) + syms, ok := result.([]DocumentSymbol) + if !ok { + t.Fatalf("expected []DocumentSymbol, got %T", result) + } + return syms +} + +func (s *dialectTestServer) completionLabels(t *testing.T, uri string, pos Position) map[string]bool { + t.Helper() + result := s.request(t, "textDocument/completion", CompletionParams{ + TextDocumentPositionParams: TextDocumentPositionParams{ + TextDocument: TextDocumentIdentifier{URI: uri}, + Position: pos, + }, + }) + list, ok := result.(*CompletionList) + if !ok { + t.Fatalf("expected *CompletionList, got %T", result) + } + labels := make(map[string]bool, len(list.Items)) + for _, item := range list.Items { + labels[item.Label] = true + } + return labels +} + +func (s *dialectTestServer) hover(t *testing.T, uri string, pos Position) string { + t.Helper() + result := s.request(t, "textDocument/hover", TextDocumentPositionParams{ + TextDocument: TextDocumentIdentifier{URI: uri}, + Position: pos, + }) + h, ok := result.(*Hover) + if !ok { + t.Fatalf("expected *Hover, got %T", result) + } + return h.Contents.Value +} + +func TestDialect_Diagnostics(t *testing.T) { + tests := []struct { + dialect string // "" = no initializationOptions + sql string + wantDiags bool + }{ + {"", mysqlLimit, true}, + {"generic", mysqlLimit, true}, + {"postgresql", mysqlLimit, true}, + {"mysql", mysqlLimit, false}, + {"", ilikeQuery, false}, + {"generic", ilikeQuery, false}, + {"postgresql", ilikeQuery, false}, + {"mysql", ilikeQuery, true}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.dialect, tt.sql), func(t *testing.T) { + s := newDialectServer(t, dialectOpts(tt.dialect), false) + const uri = "file:///diag.sql" + s.open(t, uri, tt.sql) + diags := s.lastDiagnostics(t, uri) + if got := len(diags) > 0; got != tt.wantDiags { + t.Errorf("diagnostics present = %v, want %v (%+v)", got, tt.wantDiags, diags) + } + }) + } +} + +// TestDialect_DefaultMatchesLegacy pins the unset/generic behaviour to the +// dialect-less pipeline the server used before it became dialect-aware. +func TestDialect_DefaultMatchesLegacy(t *testing.T) { + inputs := []string{ + mysqlLimit, + ilikeQuery, + "SELECT * FROM", + "SELECT * FROM; DELETE FROM", + "SELECT `id` FROM `users`", + "SELECT data->>'name' FROM users", + "SELECT TOP 10 id FROM users", + "select id,name from users where id=1", + } + for _, d := range []string{"", "generic", "GENERIC", "fakesql"} { + for _, sql := range inputs { + t.Run(d+"/"+sql, func(t *testing.T) { + s := newDialectServer(t, dialectOpts(d), false) + const uri = "file:///legacy.sql" + s.open(t, uri, sql) + + _, errs := gosqlx.ParseWithRecovery(sql) + wantDiags := buildDiagnostics(sql, errs) + if wantDiags == nil { + wantDiags = []Diagnostic{} + } + if got := s.lastDiagnostics(t, uri); !reflect.DeepEqual(got, wantDiags) { + t.Errorf("diagnostics\n got: %+v\nwant: %+v", got, wantDiags) + } + + wantFormatted, err := gosqlx.Format(sql, gosqlx.DefaultFormatOptions()) + if err != nil { + wantFormatted = formatSQL(sql, FormattingOptions{TabSize: 2, InsertSpaces: true}) + } + edits := s.formatting(t, uri) + gotFormatted := sql + if len(edits) == 1 { + gotFormatted = edits[0].NewText + } + if gotFormatted != wantFormatted { + t.Errorf("formatting = %q, want %q", gotFormatted, wantFormatted) + } + + _, parseErr := gosqlx.Parse(sql) + if got, want := len(s.symbols(t, uri)) > 0, parseErr == nil; got != want { + t.Errorf("symbols present = %v, want %v", got, want) + } + }) + } + } +} + +func TestDialect_Formatting(t *testing.T) { + const sql = "select id from users limit 5, 10" + const uri = "file:///fmt.sql" + fallback := formatSQL(sql, FormattingOptions{TabSize: 2, InsertSpaces: true}) + + parsed, err := gosqlx.ParseWithDialect(sql, keywords.DialectMySQL) + if err != nil { + t.Fatalf("ParseWithDialect: %v", err) + } + wantMySQL := gosqlx.FormatAST(parsed, gosqlx.WithIndent(2)) + if wantMySQL == fallback { + t.Fatalf("test needs AST and fallback formatting to differ; both %q", fallback) + } + + for _, tt := range []struct{ dialect, want string }{ + {"", fallback}, + {"postgresql", fallback}, + {"mysql", wantMySQL}, + } { + t.Run(tt.dialect, func(t *testing.T) { + s := newDialectServer(t, dialectOpts(tt.dialect), false) + s.Documents().Open(uri, "sql", 1, sql) + got := sql + if edits := s.formatting(t, uri); len(edits) == 1 { + got = edits[0].NewText + } + if got != tt.want { + t.Errorf("formatted = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDialect_DocumentSymbols(t *testing.T) { + const uri = "file:///sym.sql" + for _, tt := range []struct { + dialect, sql string + want int + }{ + {"", mysqlLimit, 0}, + {"postgresql", mysqlLimit, 0}, + {"mysql", mysqlLimit, 1}, + {"", ilikeQuery, 1}, + {"mysql", ilikeQuery, 0}, + } { + t.Run(tt.dialect+"/"+tt.sql, func(t *testing.T) { + s := newDialectServer(t, dialectOpts(tt.dialect), false) + s.Documents().Open(uri, "sql", 1, tt.sql) + if got := len(s.symbols(t, uri)); got != tt.want { + t.Errorf("symbols = %d, want %d", got, tt.want) + } + }) + } +} + +func TestDialect_CompletionAndHover(t *testing.T) { + const uri = "file:///kw.sql" + for _, tt := range []struct { + dialect string + wantKW bool + wantText string + }{ + {"", false, ""}, + {"generic", false, ""}, + {"postgresql", false, ""}, + {"mysql", true, "MySQL-specific"}, + {"mariadb", true, "MariaDB-specific"}, + } { + t.Run(tt.dialect, func(t *testing.T) { + s := newDialectServer(t, dialectOpts(tt.dialect), false) + + // GetWordAtPosition needs the cursor inside the word. + s.Documents().Open(uri, "sql", 1, "SELECT ZEROF") + labels := s.completionLabels(t, uri, Position{Line: 0, Character: 10}) + if labels["ZEROFILL"] != tt.wantKW { + t.Errorf("ZEROFILL offered = %v, want %v (%v)", labels["ZEROFILL"], tt.wantKW, labels) + } + + // Generic keywords are still offered first for every dialect. + s.Documents().Open(uri, "sql", 1, "SEL") + if !s.completionLabels(t, uri, Position{Line: 0, Character: 1})["SELECT"] { + t.Error("SELECT missing from completion") + } + + s.Documents().Open(uri, "sql", 1, "CREATE TABLE t (id INT ZEROFILL)") + text := s.hover(t, uri, Position{Line: 0, Character: 26}) + if tt.wantText == "" && text != "" { + t.Errorf("hover = %q, want none", text) + } + if tt.wantText != "" && !strings.Contains(text, tt.wantText) { + t.Errorf("hover = %q, want it to contain %q", text, tt.wantText) + } + + // Documented generic keywords keep their documentation. + if text := s.hover(t, uri, Position{Line: 0, Character: 2}); !strings.Contains(text, "CREATE") { + t.Errorf("CREATE hover = %q", text) + } + }) + } +} + +func TestDialect_DidChangeConfiguration(t *testing.T) { + tests := []struct { + name string + settings string + want keywords.SQLDialect + }{ + {"mysql", `{"gosqlx":{"dialect":"mysql"}}`, keywords.DialectMySQL}, + {"mixed case", `{"gosqlx":{"dialect":" MySQL "}}`, keywords.DialectMySQL}, + {"other settings kept", `{"gosqlx":{"dialect":"sqlserver","format":{"indentSize":4}}}`, keywords.DialectSQLServer}, + {"generic", `{"gosqlx":{"dialect":"generic"}}`, keywords.DialectGeneric}, + {"unknown falls back to generic", `{"gosqlx":{"dialect":"fakesql"}}`, keywords.DialectGeneric}, + {"empty falls back to generic", `{"gosqlx":{"dialect":""}}`, keywords.DialectGeneric}, + {"non-string falls back to generic", `{"gosqlx":{"dialect":42}}`, keywords.DialectGeneric}, + {"null settings keep current", `null`, keywords.DialectPostgreSQL}, + {"no gosqlx section keeps current", `{"editor":{"tabSize":2}}`, keywords.DialectPostgreSQL}, + {"no dialect key keeps current", `{"gosqlx":{"enable":true}}`, keywords.DialectPostgreSQL}, + {"non-object section keeps current", `{"gosqlx":"mysql"}`, keywords.DialectPostgreSQL}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newDialectServer(t, dialectOpts("postgresql"), false) + s.changeConfig(tt.settings) + if got := s.handler.currentDialect(); got != tt.want { + t.Errorf("dialect = %q, want %q", got, tt.want) + } + }) + } + + t.Run("malformed params keep current", func(t *testing.T) { + s := newDialectServer(t, dialectOpts("postgresql"), false) + s.handler.HandleNotification("workspace/didChangeConfiguration", json.RawMessage(`{"settings":`)) + if got := s.handler.currentDialect(); got != keywords.DialectPostgreSQL { + t.Errorf("dialect = %q, want postgresql", got) + } + if len(s.methodFrames(t, "window/showMessage")) != 1 { + t.Error("expected a showMessage warning for malformed params") + } + }) +} + +func TestDialect_DidChangeConfiguration_RepublishesAndRefreshes(t *testing.T) { + const uriA, uriB = "file:///a.sql", "file:///b.sql" + + s := newDialectServer(t, "", true) + s.open(t, uriA, mysqlLimit) + s.open(t, uriB, ilikeQuery) + if len(s.lastDiagnostics(t, uriA)) == 0 || len(s.lastDiagnostics(t, uriB)) != 0 { + t.Fatal("unexpected diagnostics before the dialect change") + } + + s.changeConfig(`{"gosqlx":{"dialect":"mysql"}}`) + if d := s.lastDiagnostics(t, uriA); len(d) != 0 { + t.Errorf("a.sql under mysql: want no diagnostics, got %+v", d) + } + if len(s.lastDiagnostics(t, uriB)) == 0 { + t.Error("b.sql under mysql: want ILIKE diagnosed") + } + refreshes := s.methodFrames(t, "workspace/semanticTokens/refresh") + if len(refreshes) != 1 { + t.Fatalf("expected 1 semantic token refresh request, got %d", len(refreshes)) + } + if _, ok := refreshes[0]["id"]; !ok { + t.Error("refresh must be a request (with an id)") + } + + // Semantic tokens follow the new dialect: MySQL treats "x" as a string. + const semURI = "file:///sem.sql" + s.Documents().Open(semURI, "sql", 1, `SELECT "x" FROM t`) + toks := decodeSemanticTokens(t, requestSemanticTokens(t, s.Server, semURI).Data) + if len(toks) < 2 || toks[1].Type != "string" { + t.Errorf(`"x" under mysql: tokens %+v, want string`, toks) + } + + // Re-sending the same dialect is a no-op: no re-publish, no refresh. + before := len(s.frames(t)) + s.changeConfig(`{"gosqlx":{"dialect":"mysql"}}`) + if after := len(s.frames(t)); after != before { + t.Errorf("unchanged dialect wrote %d messages", after-before) + } + + // The client's response to the refresh request is accepted silently. + before = len(s.frames(t)) + s.handleMessage(json.RawMessage(`{"jsonrpc":"2.0","id":1,"result":null}`)) + if after := len(s.frames(t)); after != before { + t.Errorf("response to server request produced %d messages", after-before) + } +} + +func TestDialect_DidChangeConfiguration_NoRefreshWithoutSupport(t *testing.T) { + s := newDialectServer(t, "", false) + s.open(t, "file:///a.sql", mysqlLimit) + s.changeConfig(`{"gosqlx":{"dialect":"mysql"}}`) + if n := len(s.methodFrames(t, "workspace/semanticTokens/refresh")); n != 0 { + t.Errorf("sent %d refresh requests to a client without refreshSupport", n) + } + if d := s.lastDiagnostics(t, "file:///a.sql"); len(d) != 0 { + t.Errorf("want diagnostics re-published without errors, got %+v", d) + } +} + +// TestDialect_DidChangeConfiguration_PendingChange checks that a dialect +// change re-validates the document's current version and cancels a pending +// debounced didChange validation instead of letting it publish afterwards. +func TestDialect_DidChangeConfiguration_PendingChange(t *testing.T) { + const uri = "file:///pending.sql" + s := newDialectServer(t, "", false) + s.open(t, uri, ilikeQuery) + + raw, err := json.Marshal(DidChangeTextDocumentParams{ + TextDocument: VersionedTextDocumentIdentifier{TextDocumentIdentifier: TextDocumentIdentifier{URI: uri}, Version: 2}, + ContentChanges: []TextDocumentContentChangeEvent{{Text: mysqlLimit}}, + }) + if err != nil { + t.Fatalf("marshal didChange: %v", err) + } + s.handler.HandleNotification("textDocument/didChange", raw) + s.changeConfig(`{"gosqlx":{"dialect":"mysql"}}`) + + s.handler.debounceMu.Lock() + pending := len(s.handler.debounceTimers) + s.handler.debounceMu.Unlock() + if pending != 0 { + t.Errorf("dialect change left %d debounce timers pending", pending) + } + + publishes := func() []PublishDiagnosticsParams { + var out []PublishDiagnosticsParams + for _, m := range s.methodFrames(t, "textDocument/publishDiagnostics") { + var p PublishDiagnosticsParams + if err := json.Unmarshal(m["params"], &p); err != nil { + t.Fatalf("unmarshal diagnostics: %v", err) + } + if p.URI == uri { + out = append(out, p) + } + } + return out + } + got := publishes() + if len(got) == 0 { + t.Fatal("no diagnostics published") + } + if last := got[len(got)-1]; last.Version != 2 || len(last.Diagnostics) != 0 { + t.Errorf("after dialect change: version %d diagnostics %+v, want version 2 with none", last.Version, last.Diagnostics) + } + + // The cancelled debounce (300ms) must not publish again. + time.Sleep(400 * time.Millisecond) + if after := publishes(); len(after) != len(got) { + t.Errorf("cancelled debounce published %d more diagnostics", len(after)-len(got)) + } +} + +// TestDialect_ConcurrentConfigurationChange runs every dialect-aware feature +// while the dialect flips; run with -race. +func TestDialect_ConcurrentConfigurationChange(t *testing.T) { + s := newDialectServer(t, "", true) + const uri = "file:///concurrent.sql" + s.open(t, uri, mysqlLimit+";\n"+ilikeQuery) + + var wg sync.WaitGroup + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + dialects := []string{"mysql", "postgresql", "generic", "sqlserver", "fakesql"} + for i := 0; i < 200; i++ { + s.changeConfig(fmt.Sprintf(`{"gosqlx":{"dialect":%q}}`, dialects[i%len(dialects)])) + } + close(stop) + }() + + worker := func(fn func()) { + defer wg.Done() + for { + select { + case <-stop: + return + default: + fn() + } + } + } + pos := Position{Line: 0, Character: 2} + wg.Add(5) + go worker(func() { s.formatting(t, uri) }) + go worker(func() { s.symbols(t, uri) }) + go worker(func() { s.completionLabels(t, uri, pos) }) + go worker(func() { s.hover(t, uri, pos) }) + go worker(func() { requestSemanticTokens(t, s.Server, uri) }) + wg.Wait() + + // Every message written concurrently is still a well-formed frame. + s.frames(t) +} diff --git a/pkg/lsp/doc.go b/pkg/lsp/doc.go index 50e0676f..429441f8 100644 --- a/pkg/lsp/doc.go +++ b/pkg/lsp/doc.go @@ -73,6 +73,13 @@ Code Actions (textDocument/codeAction): - Keyword case correction suggestions - Context-aware refactoring hints +SQL Dialect (initializationOptions.dialect, workspace/didChangeConfiguration): + - One dialect drives diagnostics, formatting, document symbols, completion, + hover and semantic tokens + - Set at initialize with {"dialect": "mysql"} or changed at runtime with + settings {"gosqlx": {"dialect": "mysql"}} + - The default, generic, keeps the dialect-less parsing pipeline + # Architecture The LSP server consists of three main components: diff --git a/pkg/lsp/documents.go b/pkg/lsp/documents.go index e40ca128..c1c5bb68 100644 --- a/pkg/lsp/documents.go +++ b/pkg/lsp/documents.go @@ -277,6 +277,24 @@ func (dm *DocumentManager) Get(uri string) (*Document, bool) { }, true } +// snapshots returns a snapshot (as returned by Get) of every open document, +// in no particular order. +func (dm *DocumentManager) snapshots() []*Document { + dm.mu.RLock() + defer dm.mu.RUnlock() + docs := make([]*Document, 0, len(dm.documents)) + for _, doc := range dm.documents { + docs = append(docs, &Document{ + URI: doc.URI, + LanguageID: doc.LanguageID, + Version: doc.Version, + Content: doc.Content, + Lines: doc.Lines, + }) + } + return docs +} + // GetContent retrieves a document's content func (dm *DocumentManager) GetContent(uri string) (string, bool) { dm.mu.RLock() diff --git a/pkg/lsp/handler.go b/pkg/lsp/handler.go index 6a100996..7592efe7 100644 --- a/pkg/lsp/handler.go +++ b/pkg/lsp/handler.go @@ -21,6 +21,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "unicode/utf16" "unicode/utf8" @@ -120,9 +121,19 @@ type Handler struct { debounceTimers map[string]*time.Timer // dialectMu guards dialect, which is set from the client's - // initializationOptions and read by semantic token requests. + // initializationOptions or workspace/didChangeConfiguration and read by + // every feature that tokenizes, parses or offers keywords (see dialect.go). dialectMu sync.RWMutex dialect keywords.SQLDialect + + // publishMu serialises the dialect staleness check and the publish in + // validateDocument, so diagnostics computed under a superseded dialect + // can never be published after the ones that replace them. + publishMu sync.Mutex + + // semanticRefresh records whether the client advertised + // workspace.semanticTokens.refreshSupport in initialize. + semanticRefresh atomic.Bool } // NewHandler creates a new LSP request handler. @@ -130,8 +141,10 @@ type Handler struct { // This constructor initializes the handler with a reference to the server // and sets up the SQL keywords database for hover documentation and completion. // -// The handler uses DialectGeneric for maximum SQL compatibility across -// PostgreSQL, MySQL, SQL Server, Oracle, and SQLite dialects. +// The handler starts with DialectGeneric for maximum SQL compatibility across +// PostgreSQL, MySQL, SQL Server, Oracle, and SQLite dialects. A client selects +// another dialect via initializationOptions.dialect or, at runtime, via +// workspace/didChangeConfiguration (see dialect.go). // // Parameters: // - server: The LSP server instance that owns this handler @@ -191,6 +204,8 @@ func (h *Handler) HandleNotification(method string, params json.RawMessage) { h.handleDidClose(params) case "textDocument/didSave": h.handleDidSave(params) + case "workspace/didChangeConfiguration": + h.handleDidChangeConfiguration(params) } } @@ -203,6 +218,9 @@ func (h *Handler) handleInitialize(params json.RawMessage) (*InitializeResult, e h.server.Logger().Printf("Initialize request from client, rootURI: %s", initParams.RootURI) h.setDialect(dialectFromInitOptions(initParams.InitializationOptions)) + if ws := initParams.Capabilities.Workspace; ws != nil && ws.SemanticTokens != nil { + h.semanticRefresh.Store(ws.SemanticTokens.RefreshSupport) + } return &InitializeResult{ Capabilities: ServerCapabilities{ @@ -403,14 +421,23 @@ func (h *Handler) handleDidSave(params json.RawMessage) { func (h *Handler) validateDocument(uri, content string, version int) { // Use ParseWithRecovery for multi-error diagnostics so the user sees // every problem at once instead of fixing them one-by-one. - _, errs := gosqlx.ParseWithRecovery(content) + dialect := h.currentDialect() + _, errs := parseWithRecoveryForDialect(content, dialect) diagnostics := buildDiagnostics(content, errs) if diagnostics == nil { // Publish an empty (non-nil) slice so the JSON stays "diagnostics":[]. diagnostics = []Diagnostic{} } - // Publish diagnostics + // Publish diagnostics, unless the dialect changed while parsing: the + // dialect change re-validates every open document (see + // revalidateOpenDocuments), and that result must not be overwritten. + h.publishMu.Lock() + defer h.publishMu.Unlock() + if h.currentDialect() != dialect { + h.server.Logger().Printf("Dropped diagnostics for %s: dialect changed during validation", uri) + return + } h.server.SendNotification("textDocument/publishDiagnostics", PublishDiagnosticsParams{ URI: uri, Version: version, @@ -642,8 +669,13 @@ func (h *Handler) handleHover(params json.RawMessage) (*Hover, error) { return &Hover{}, nil } - // Look up keyword documentation - doc_text := getKeywordDocumentation(strings.ToUpper(word)) + // Look up keyword documentation, then the configured dialect's own + // keywords (none for the generic dialect). + upper := strings.ToUpper(word) + doc_text := getKeywordDocumentation(upper) + if doc_text == "" { + doc_text = dialectKeywordHover(upper, h.currentDialect()) + } if doc_text == "" { return &Hover{}, nil } @@ -702,6 +734,13 @@ func (h *Handler) handleCompletion(params json.RawMessage) (*CompletionList, err } } + // Then the configured dialect's own keywords (none for generic). + for _, kw := range dialectKeywordCompletions[h.currentDialect()] { + if prefix == "" || strings.HasPrefix(kw.Label, prefix) { + items = append(items, kw) + } + } + // Add snippets (match on lowercase prefix for snippet shortcuts) for _, snippet := range sqlSnippets { if lowerPrefix == "" || strings.HasPrefix(strings.ToLower(snippet.Label), lowerPrefix) { @@ -762,7 +801,7 @@ func (h *Handler) handleFormatting(params json.RawMessage) ([]TextEdit, error) { if p.Options.InsertSpaces { opts.IndentSize = p.Options.TabSize } - formatted, err := gosqlx.Format(content, opts) + formatted, err := formatForDialect(content, opts, h.currentDialect()) if err != nil { // If parsing fails, fall back to basic formatting formatted = formatSQL(content, p.Options) @@ -1220,7 +1259,7 @@ func (h *Handler) handleDocumentSymbol(params json.RawMessage) ([]DocumentSymbol lines := doc.Lines // Parse the SQL to extract symbols - parsed, err := gosqlx.Parse(content) + parsed, err := parseForDialect(content, h.currentDialect()) if err != nil { // Return empty symbols on parse error return []DocumentSymbol{}, nil @@ -2174,15 +2213,18 @@ func dialectFromInitOptions(opts interface{}) keywords.SQLDialect { return keywords.SQLDialect(name) } -// setDialect records the SQL dialect used for semantic highlighting. -func (h *Handler) setDialect(d keywords.SQLDialect) { +// setDialect records the SQL dialect used by all language features and +// reports whether it differs from the previous one. +func (h *Handler) setDialect(d keywords.SQLDialect) bool { h.dialectMu.Lock() + changed := h.dialect != d h.dialect = d h.dialectMu.Unlock() - h.server.Logger().Printf("Semantic highlighting dialect: %s", d) + h.server.Logger().Printf("SQL dialect: %s", d) + return changed } -// currentDialect returns the SQL dialect used for semantic highlighting. +// currentDialect returns the SQL dialect used by all language features. func (h *Handler) currentDialect() keywords.SQLDialect { h.dialectMu.RLock() defer h.dialectMu.RUnlock() diff --git a/pkg/lsp/protocol.go b/pkg/lsp/protocol.go index 095d3922..1fd52c71 100644 --- a/pkg/lsp/protocol.go +++ b/pkg/lsp/protocol.go @@ -173,6 +173,18 @@ type InitializeParams struct { // ClientCapabilities describes the client's capabilities type ClientCapabilities struct { TextDocument TextDocumentClientCapabilities `json:"textDocument,omitempty"` + Workspace *WorkspaceClientCapabilities `json:"workspace,omitempty"` +} + +// WorkspaceClientCapabilities describes workspace-level client capabilities +type WorkspaceClientCapabilities struct { + SemanticTokens *SemanticTokensWorkspaceClientCapabilities `json:"semanticTokens,omitempty"` +} + +// SemanticTokensWorkspaceClientCapabilities describes whether the client +// accepts workspace/semanticTokens/refresh requests from the server +type SemanticTokensWorkspaceClientCapabilities struct { + RefreshSupport bool `json:"refreshSupport,omitempty"` } // TextDocumentClientCapabilities describes text document capabilities @@ -335,6 +347,12 @@ type DidSaveTextDocumentParams struct { Text string `json:"text,omitempty"` } +// DidChangeConfigurationParams is sent when the client's settings change. +// Settings is client-defined; the server reads {"gosqlx": {"dialect": ...}}. +type DidChangeConfigurationParams struct { + Settings interface{} `json:"settings"` +} + // Position in a text document type Position struct { Line int `json:"line"` diff --git a/pkg/lsp/server.go b/pkg/lsp/server.go index 20f11267..8753ba57 100644 --- a/pkg/lsp/server.go +++ b/pkg/lsp/server.go @@ -157,6 +157,10 @@ type Server struct { // strings only to throw them away. Set once in NewServer. verboseLog bool + // nextRequestID numbers server-to-client requests (for example + // workspace/semanticTokens/refresh). + nextRequestID atomic.Int64 + // Rate limiting requestCount int64 lastReset time.Time @@ -371,6 +375,13 @@ func (s *Server) handleMessage(msg json.RawMessage) { // Validate required JSON-RPC fields if req.Method == "" { + // A message with an id and a result or error but no method is the + // client's response to a server-to-client request; there is nothing + // to answer, so it must not be rejected as an invalid request. + if req.ID != nil && isResponseMessage(msg) { + s.logger.Printf("Received response to server request %v", req.ID) + return + } s.logger.Printf("Invalid request: missing method") if req.ID != nil { s.sendError(req.ID, InvalidRequest, "missing method field") @@ -474,6 +485,38 @@ func (s *Server) SendNotification(method string, params interface{}) { s.sendMessage(notif) } +// sendRequest sends a server-to-client request. Responses are not awaited: +// handleMessage recognises and drops them, which suits fire-and-forget +// requests such as workspace/semanticTokens/refresh. +func (s *Server) sendRequest(method string, params interface{}) { + req := Request{ + JSONRPC: "2.0", + ID: s.nextRequestID.Add(1), + Method: method, + } + if params != nil { + data, err := json.Marshal(params) + if err != nil { + s.logger.Printf("Failed to marshal request params for %s: %v", method, err) + return + } + req.Params = json.RawMessage(data) + } + s.sendMessage(req) +} + +// isResponseMessage reports whether msg is a JSON-RPC response, that is an +// object carrying a "result" or "error" member. +func isResponseMessage(msg json.RawMessage) bool { + var fields map[string]json.RawMessage + if err := json.Unmarshal(msg, &fields); err != nil { + return false + } + _, hasResult := fields["result"] + _, hasError := fields["error"] + return hasResult || hasError +} + // msgBufPool recycles the buffers used to frame outgoing messages. A buffer is // Reset before use and returned only after the Write that used it has // completed, so no aliasing escapes (io.Writer implementations must not retain