Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 60 additions & 8 deletions docs/LSP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down Expand Up @@ -218,33 +219,84 @@ 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" }
```

- `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):

```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
{
Expand Down
269 changes: 269 additions & 0 deletions pkg/lsp/dialect.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading