Skip to main content

sql_dialect_fmt_lsp/
lib.rs

1//! LSP feature logic for Snowflake SQL: formatting, diagnostics, hover, folding, document symbols,
2//! completion, and semantic tokens.
3//!
4//! This module is deliberately transport-free (it never touches stdio or an `lsp-server`
5//! connection), so every feature is a pure `&str -> data` function that is unit-testable. The
6//! binary crate is the thin adapter that wires these into a language server.
7//!
8//! Positions follow the LSP convention: zero-based lines and **UTF-16** column offsets.
9
10use lsp_types::{
11    CompletionItem, CompletionItemKind, Diagnostic, DiagnosticSeverity, DocumentSymbol,
12    FoldingRange, FoldingRangeKind, Hover, HoverContents, InsertTextFormat, MarkupContent,
13    MarkupKind, Position, Range, SemanticToken, SemanticTokenModifier, SemanticTokenType,
14    SymbolKind, TextEdit,
15};
16use sql_dialect_fmt_formatter::{format, FormatOptions};
17use sql_dialect_fmt_highlight::semantic;
18use sql_dialect_fmt_parser::{SyntaxKind, SyntaxNode};
19use sql_dialect_fmt_syntax::{keyword_texts, BUILTIN_TYPE_WORDS};
20use sql_dialect_fmt_text::{LineIndex, Utf16Position, Utf8Position};
21
22mod lint;
23
24pub use lint::{diagnostic_lint_code, LintCode, LintOptions};
25
26/// LSP position encoding negotiated with the client.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum PositionEncoding {
29    /// UTF-16 code units, the LSP default.
30    Utf16,
31    /// UTF-8 byte offsets.
32    Utf8,
33}
34
35/// The semantic-token type legend, mirrored from the single source of truth in
36/// `sql-dialect-fmt-highlight` ([`semantic::SemanticTokenType::LEGEND`]). A token's `token_type` field is
37/// an index into this slice, so the order here is the contract with the editor (declared in the
38/// server's capabilities) and *must* equal the highlighter's legend — otherwise an editor would
39/// decode our token types against the wrong names.
40pub fn token_types() -> Vec<SemanticTokenType> {
41    semantic::SemanticTokenType::LEGEND
42        .iter()
43        .map(|ty| SemanticTokenType::new(ty.name()))
44        .collect()
45}
46
47/// The semantic-token modifier legend, mirrored from
48/// [`semantic::SemanticTokenModifiers::LEGEND`]. A token's `token_modifiers_bitset` is decoded
49/// bit-by-bit against this slice, so it too must match the highlighter.
50pub fn token_modifiers() -> Vec<SemanticTokenModifier> {
51    semantic::SemanticTokenModifiers::LEGEND
52        .iter()
53        .map(|&name| SemanticTokenModifier::new(name))
54        .collect()
55}
56
57fn lsp_position(index: &LineIndex<'_>, offset: usize, encoding: PositionEncoding) -> Position {
58    match encoding {
59        PositionEncoding::Utf16 => {
60            let position = index.utf16_position(offset);
61            Position::new(position.line, position.character)
62        }
63        PositionEncoding::Utf8 => {
64            let position = index.utf8_position(offset);
65            Position::new(position.line, position.character)
66        }
67    }
68}
69
70fn lsp_end_position(index: &LineIndex<'_>, encoding: PositionEncoding) -> Position {
71    match encoding {
72        PositionEncoding::Utf16 => {
73            let position = index.end_utf16_position();
74            Position::new(position.line, position.character)
75        }
76        PositionEncoding::Utf8 => {
77            let position = index.end_utf8_position();
78            Position::new(position.line, position.character)
79        }
80    }
81}
82
83fn lsp_offset(index: &LineIndex<'_>, position: Position, encoding: PositionEncoding) -> usize {
84    match encoding {
85        PositionEncoding::Utf16 => {
86            index.offset_for_utf16_position(Utf16Position::new(position.line, position.character))
87        }
88        PositionEncoding::Utf8 => {
89            index.offset_for_utf8_position(Utf8Position::new(position.line, position.character))
90        }
91    }
92}
93
94/// The edits to apply for `textDocument/formatting`: a single whole-document replacement, or an
95/// empty list when the input is already formatted (so the editor records no change).
96pub fn format_edits(text: &str, options: &FormatOptions) -> Vec<TextEdit> {
97    format_edits_with_encoding(text, options, PositionEncoding::Utf16)
98}
99
100/// Encoding-aware variant of [`format_edits`].
101pub fn format_edits_with_encoding(
102    text: &str,
103    options: &FormatOptions,
104    encoding: PositionEncoding,
105) -> Vec<TextEdit> {
106    let formatted = format(text, options);
107    if formatted == text {
108        return Vec::new();
109    }
110    let index = LineIndex::new(text);
111    vec![TextEdit {
112        range: Range::new(Position::new(0, 0), lsp_end_position(&index, encoding)),
113        new_text: formatted,
114    }]
115}
116
117/// The edits to apply for `textDocument/rangeFormatting`: reformat only the statements the range
118/// touches, as one minimal replacement, or an empty list when nothing changes.
119pub fn format_range_edits(text: &str, range: Range, options: &FormatOptions) -> Vec<TextEdit> {
120    format_range_edits_with_encoding(text, range, options, PositionEncoding::Utf16)
121}
122
123/// Encoding-aware variant of [`format_range_edits`].
124pub fn format_range_edits_with_encoding(
125    text: &str,
126    range: Range,
127    options: &FormatOptions,
128    encoding: PositionEncoding,
129) -> Vec<TextEdit> {
130    let index = LineIndex::new(text);
131    let a = lsp_offset(&index, range.start, encoding);
132    let b = lsp_offset(&index, range.end, encoding);
133    let target = a.min(b)..a.max(b);
134    let Some(edit) = sql_dialect_fmt_formatter::format_range(text, target, options) else {
135        return Vec::new();
136    };
137    vec![TextEdit {
138        range: Range::new(
139            lsp_position(&index, edit.range.start, encoding),
140            lsp_position(&index, edit.range.end, encoding),
141        ),
142        new_text: edit.new_text,
143    }]
144}
145
146/// The edits to apply for `textDocument/onTypeFormatting`: after the user types `;` or a newline,
147/// reformat the statement that just ended.
148pub fn on_type_formatting_edits(
149    text: &str,
150    position: Position,
151    options: &FormatOptions,
152) -> Vec<TextEdit> {
153    on_type_formatting_edits_with_encoding(text, position, options, PositionEncoding::Utf16)
154}
155
156/// Encoding-aware variant of [`on_type_formatting_edits`].
157///
158/// The trigger position is scanned backwards over whitespace to the completed statement's final
159/// non-whitespace byte, so both `;` (cursor right after the terminator) and newline (cursor at the
160/// start of the next line) resolve to the statement the user just finished. An already formatted
161/// statement yields no edits.
162pub fn on_type_formatting_edits_with_encoding(
163    text: &str,
164    position: Position,
165    options: &FormatOptions,
166    encoding: PositionEncoding,
167) -> Vec<TextEdit> {
168    let index = LineIndex::new(text);
169    let cursor = lsp_offset(&index, position, encoding);
170    let Some(last) = text[..cursor].rfind(|c: char| !c.is_whitespace()) else {
171        return Vec::new();
172    };
173    // `last..cursor` starts on a char boundary and covers the statement's final byte, so the
174    // statement intersects the range; the trailing whitespace bytes touch no other statement.
175    let Some(edit) = sql_dialect_fmt_formatter::format_range(text, last..cursor, options) else {
176        return Vec::new();
177    };
178    vec![TextEdit {
179        range: Range::new(
180            lsp_position(&index, edit.range.start, encoding),
181            lsp_position(&index, edit.range.end, encoding),
182        ),
183        new_text: edit.new_text,
184    }]
185}
186
187/// Diagnostics for `textDocument/publishDiagnostics`: both lexer and parser errors. Neither stage
188/// ever fails, so this is the set of *recovered* errors (empty for clean input).
189///
190/// Lexer errors (unterminated literals/comments, stray characters) are surfaced too — they are
191/// reported by the tokenizer before the parser runs, so the parser-only error list would otherwise
192/// miss them. We read them through the highlighter, which already re-exposes the lexer's errors.
193/// Each diagnostic's range covers the whole offending token (via its byte `range()`), not a single
194/// character, so editors underline the real span.
195pub fn diagnostics(text: &str) -> Vec<Diagnostic> {
196    diagnostics_with_options(text, &FormatOptions::default(), PositionEncoding::Utf16)
197}
198
199/// Encoding-aware variant of [`diagnostics`].
200pub fn diagnostics_with_encoding(text: &str, encoding: PositionEncoding) -> Vec<Diagnostic> {
201    diagnostics_with_options(text, &FormatOptions::default(), encoding)
202}
203
204/// Options- and encoding-aware variant of [`diagnostics`].
205pub fn diagnostics_with_options(
206    text: &str,
207    options: &FormatOptions,
208    encoding: PositionEncoding,
209) -> Vec<Diagnostic> {
210    diagnostics_with_lint_options(text, options, LintOptions::default(), encoding)
211}
212
213/// Options-, lint-, and encoding-aware variant of [`diagnostics`].
214pub fn diagnostics_with_lint_options(
215    text: &str,
216    options: &FormatOptions,
217    lint_options: LintOptions,
218    encoding: PositionEncoding,
219) -> Vec<Diagnostic> {
220    let index = LineIndex::new(text);
221    let to_range = |span: std::ops::Range<usize>| {
222        Range::new(
223            lsp_position(&index, span.start, encoding),
224            lsp_position(&index, span.end, encoding),
225        )
226    };
227    let make = |range: Range, message: String| Diagnostic {
228        range,
229        severity: Some(DiagnosticSeverity::ERROR),
230        source: Some("sql-dialect-fmt".to_string()),
231        message,
232        ..Default::default()
233    };
234
235    let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(text, options.dialect);
236    let lex_errors = lexed.errors.clone();
237    let parse = sql_dialect_fmt_parser::parse_lexed(text, options.dialect, lexed);
238    let mut diagnostics: Vec<_> = lex_errors
239        .iter()
240        .map(|err| make(to_range(err.range()), err.message.clone()))
241        .chain(
242            parse
243                .errors()
244                .iter()
245                .map(|err| make(to_range(err.range()), err.message.clone())),
246        )
247        .collect();
248    diagnostics.extend(lint::diagnostics_with_encoding(
249        text,
250        options.dialect,
251        &index,
252        lint_options,
253        encoding,
254    ));
255    diagnostics
256}
257
258/// Hover information for `textDocument/hover`: the keyword/type/symbol description at `position`,
259/// rendered as Markdown with an optional docs link, scoped to the hovered token's range.
260pub fn hover(text: &str, position: Position) -> Option<Hover> {
261    hover_with_encoding(text, position, PositionEncoding::Utf16)
262}
263
264/// Encoding-aware variant of [`hover`].
265pub fn hover_with_encoding(
266    text: &str,
267    position: Position,
268    encoding: PositionEncoding,
269) -> Option<Hover> {
270    let index = LineIndex::new(text);
271    let info = sql_dialect_fmt_hover::hover_at(text, lsp_offset(&index, position, encoding))?;
272    let mut value = format!("**{}**\n\n{}", info.title, info.body);
273    if let Some(url) = info.docs_url {
274        value.push_str(&format!("\n\n[Snowflake docs]({url})"));
275    }
276    Some(Hover {
277        contents: HoverContents::Markup(MarkupContent {
278            kind: MarkupKind::Markdown,
279            value,
280        }),
281        range: Some(Range::new(
282            lsp_position(&index, info.range.start, encoding),
283            lsp_position(&index, info.range.end, encoding),
284        )),
285    })
286}
287
288/// Document symbols for `textDocument/documentSymbol`: one outline item per top-level statement.
289pub fn document_symbols(text: &str, options: &FormatOptions) -> Vec<DocumentSymbol> {
290    document_symbols_with_encoding(text, options, PositionEncoding::Utf16)
291}
292
293/// Encoding-aware variant of [`document_symbols`].
294pub fn document_symbols_with_encoding(
295    text: &str,
296    options: &FormatOptions,
297    encoding: PositionEncoding,
298) -> Vec<DocumentSymbol> {
299    let index = LineIndex::new(text);
300    let root = sql_dialect_fmt_parser::parse_with_dialect(text, options.dialect).syntax();
301    root.children()
302        .filter_map(|stmt| document_symbol_for_statement(&stmt, &index, encoding))
303        .collect()
304}
305
306#[derive(Clone, Debug)]
307struct SymbolToken {
308    kind: SyntaxKind,
309    text: String,
310    range: std::ops::Range<usize>,
311}
312
313fn document_symbol_for_statement(
314    stmt: &SyntaxNode,
315    index: &LineIndex<'_>,
316    encoding: PositionEncoding,
317) -> Option<DocumentSymbol> {
318    let tokens = significant_symbol_tokens(stmt);
319    let first = tokens.first()?;
320    let last = tokens.last().unwrap_or(first);
321    let range = byte_range_to_lsp(index, first.range.start..last.range.end, encoding);
322    let selection_range = statement_selection_range(&tokens)
323        .map(|range| byte_range_to_lsp(index, range, encoding))
324        .unwrap_or(range);
325    let name = statement_symbol_name(stmt.kind(), &tokens);
326    let kind = statement_symbol_kind(stmt.kind(), &tokens);
327    #[allow(deprecated)]
328    let symbol = DocumentSymbol {
329        name,
330        detail: None,
331        kind,
332        tags: None,
333        deprecated: None,
334        range,
335        selection_range,
336        children: None,
337    };
338    Some(symbol)
339}
340
341fn significant_symbol_tokens(node: &SyntaxNode) -> Vec<SymbolToken> {
342    node.descendants_with_tokens()
343        .filter_map(|element| element.into_token())
344        .filter(|token| !token.kind().is_trivia())
345        .map(|token| {
346            let range = token.text_range();
347            let start: usize = range.start().into();
348            let end: usize = range.end().into();
349            SymbolToken {
350                kind: token.kind(),
351                text: token.text().to_string(),
352                range: start..end,
353            }
354        })
355        .collect()
356}
357
358fn byte_range_to_lsp(
359    index: &LineIndex<'_>,
360    range: std::ops::Range<usize>,
361    encoding: PositionEncoding,
362) -> Range {
363    Range::new(
364        lsp_position(index, range.start, encoding),
365        lsp_position(index, range.end, encoding),
366    )
367}
368
369fn statement_selection_range(tokens: &[SymbolToken]) -> Option<std::ops::Range<usize>> {
370    create_name_range(tokens).or_else(|| tokens.first().map(|token| token.range.clone()))
371}
372
373fn statement_symbol_name(kind: SyntaxKind, tokens: &[SymbolToken]) -> String {
374    if let Some(label) = create_statement_label(tokens) {
375        return label;
376    }
377
378    match kind {
379        SyntaxKind::SELECT_STMT => "SELECT".to_string(),
380        SyntaxKind::WITH_QUERY => "WITH".to_string(),
381        SyntaxKind::INSERT_STMT => statement_words_until(tokens, 4, &["VALUES", "SELECT"]),
382        SyntaxKind::UPDATE_STMT => statement_words_until(tokens, 3, &["SET"]),
383        SyntaxKind::DELETE_STMT => statement_words_until(tokens, 4, &["WHERE", "USING"]),
384        SyntaxKind::MERGE_STMT => statement_words_until(tokens, 4, &["USING"]),
385        SyntaxKind::COPY_STMT => statement_words_until(tokens, 4, &["FROM", "FILES"]),
386        SyntaxKind::STAGE_FILE_STMT => statement_words_until(tokens, 1, &[]),
387        SyntaxKind::CALL_STMT => statement_words_until(tokens, 3, &["("]),
388        SyntaxKind::BLOCK_STMT => "BEGIN".to_string(),
389        SyntaxKind::SET_OP | SyntaxKind::FLOW_STMT => statement_words_until(tokens, 3, &[]),
390        _ => statement_words_until(tokens, 4, &[";", "AS", "WHERE"]),
391    }
392}
393
394fn statement_symbol_kind(kind: SyntaxKind, tokens: &[SymbolToken]) -> SymbolKind {
395    if let Some((object_kind, _)) = create_object_kind(tokens) {
396        return match object_kind {
397            "TABLE" | "DYNAMIC TABLE" => SymbolKind::STRUCT,
398            "VIEW" | "SEMANTIC VIEW" => SymbolKind::INTERFACE,
399            "FUNCTION" => SymbolKind::FUNCTION,
400            "PROCEDURE" => SymbolKind::METHOD,
401            "TASK" => SymbolKind::EVENT,
402            "WAREHOUSE" | "STAGE" | "FILE FORMAT" | "STREAM" | "SEQUENCE" => SymbolKind::OBJECT,
403            "SCHEMA" | "DATABASE" => SymbolKind::NAMESPACE,
404            _ => SymbolKind::OBJECT,
405        };
406    }
407
408    match kind {
409        SyntaxKind::SELECT_STMT | SyntaxKind::WITH_QUERY | SyntaxKind::SET_OP => {
410            SymbolKind::FUNCTION
411        }
412        SyntaxKind::INSERT_STMT
413        | SyntaxKind::UPDATE_STMT
414        | SyntaxKind::DELETE_STMT
415        | SyntaxKind::MERGE_STMT
416        | SyntaxKind::COPY_STMT
417        | SyntaxKind::STAGE_FILE_STMT
418        | SyntaxKind::CALL_STMT
419        | SyntaxKind::SET_STMT
420        | SyntaxKind::EXECUTE_STMT => SymbolKind::METHOD,
421        SyntaxKind::BLOCK_STMT | SyntaxKind::IF_STMT | SyntaxKind::LOOP_STMT => SymbolKind::MODULE,
422        _ => SymbolKind::OBJECT,
423    }
424}
425
426fn create_statement_label(tokens: &[SymbolToken]) -> Option<String> {
427    let (object_kind, name_start) = create_object_kind(tokens)?;
428    let name = dotted_name(tokens, skip_if_not_exists(tokens, name_start));
429    Some(match name {
430        Some(name) => format!("CREATE {object_kind} {name}"),
431        None => format!("CREATE {object_kind}"),
432    })
433}
434
435fn create_name_range(tokens: &[SymbolToken]) -> Option<std::ops::Range<usize>> {
436    let (_, name_start) = create_object_kind(tokens)?;
437    tokens
438        .iter()
439        .skip(skip_if_not_exists(tokens, name_start))
440        .find(|token| is_name_token(token))
441        .map(|token| token.range.clone())
442}
443
444fn create_object_kind(tokens: &[SymbolToken]) -> Option<(&'static str, usize)> {
445    let create_index = tokens.iter().position(|token| word(token, "CREATE"))?;
446    for index in create_index + 1..tokens.len() {
447        let token = &tokens[index];
448        if word(token, "DYNAMIC") && tokens.get(index + 1).is_some_and(|t| word(t, "TABLE")) {
449            return Some(("DYNAMIC TABLE", index + 2));
450        }
451        if word(token, "SEMANTIC") && tokens.get(index + 1).is_some_and(|t| word(t, "VIEW")) {
452            return Some(("SEMANTIC VIEW", index + 2));
453        }
454        if word(token, "FILE") && tokens.get(index + 1).is_some_and(|t| word(t, "FORMAT")) {
455            return Some(("FILE FORMAT", index + 2));
456        }
457        if word(token, "MASKING") && tokens.get(index + 1).is_some_and(|t| word(t, "POLICY")) {
458            return Some(("MASKING POLICY", index + 2));
459        }
460        if word(token, "ACCESS") && tokens.get(index + 1).is_some_and(|t| word(t, "POLICY")) {
461            return Some(("ACCESS POLICY", index + 2));
462        }
463
464        for candidate in [
465            "TABLE",
466            "VIEW",
467            "FUNCTION",
468            "PROCEDURE",
469            "TASK",
470            "WAREHOUSE",
471            "STAGE",
472            "SCHEMA",
473            "DATABASE",
474            "SEQUENCE",
475            "STREAM",
476        ] {
477            if word(token, candidate) {
478                return Some((candidate, index + 1));
479            }
480        }
481    }
482    None
483}
484
485fn skip_if_not_exists(tokens: &[SymbolToken], index: usize) -> usize {
486    if tokens.get(index).is_some_and(|token| word(token, "IF"))
487        && tokens
488            .get(index + 1)
489            .is_some_and(|token| word(token, "NOT"))
490        && tokens
491            .get(index + 2)
492            .is_some_and(|token| word(token, "EXISTS"))
493    {
494        index + 3
495    } else {
496        index
497    }
498}
499
500fn dotted_name(tokens: &[SymbolToken], start: usize) -> Option<String> {
501    let mut out = String::new();
502    let mut saw_name = false;
503    let mut allow_dot = false;
504
505    for token in tokens.iter().skip(start) {
506        if token.text == "." && allow_dot {
507            out.push('.');
508            allow_dot = false;
509            continue;
510        }
511        if is_name_token(token) {
512            out.push_str(&token.text);
513            saw_name = true;
514            allow_dot = true;
515            continue;
516        }
517        break;
518    }
519
520    while out.ends_with('.') {
521        out.pop();
522    }
523    saw_name.then_some(out)
524}
525
526fn statement_words_until(tokens: &[SymbolToken], max_words: usize, stops: &[&str]) -> String {
527    let mut words = Vec::new();
528    for token in tokens.iter().filter(|token| symbol_word(token)) {
529        if !words.is_empty() && stops.iter().any(|stop| word(token, stop)) {
530            break;
531        }
532        words.push(display_word(token));
533        if words.len() == max_words {
534            break;
535        }
536    }
537    if words.is_empty() {
538        "SQL".to_string()
539    } else {
540        words.join(" ")
541    }
542}
543
544fn display_word(token: &SymbolToken) -> String {
545    if token.kind.is_keyword() || token.kind == SyntaxKind::CONTEXTUAL_KEYWORD {
546        token.text.to_ascii_uppercase()
547    } else {
548        token.text.clone()
549    }
550}
551
552fn symbol_word(token: &SymbolToken) -> bool {
553    token.kind.is_keyword()
554        || matches!(
555            token.kind,
556            SyntaxKind::IDENT | SyntaxKind::QUOTED_IDENT | SyntaxKind::CONTEXTUAL_KEYWORD
557        )
558}
559
560fn is_name_token(token: &SymbolToken) -> bool {
561    matches!(
562        token.kind,
563        SyntaxKind::IDENT | SyntaxKind::QUOTED_IDENT | SyntaxKind::CONTEXTUAL_KEYWORD
564    )
565}
566
567fn word(token: &SymbolToken, expected: &str) -> bool {
568    symbol_word(token) && token.text.eq_ignore_ascii_case(expected)
569}
570
571/// Static SQL completion items for `textDocument/completion`.
572pub fn completion_items() -> Vec<CompletionItem> {
573    let mut items = Vec::new();
574    items.extend(keyword_texts().map(|keyword| {
575        let label = keyword.to_ascii_uppercase();
576        completion_item(
577            &label,
578            CompletionItemKind::KEYWORD,
579            "SQL keyword",
580            "Keyword recognized by sql-dialect-fmt.",
581            None,
582            "1",
583        )
584    }));
585    items.extend(BUILTIN_TYPE_WORDS.iter().map(|label| {
586        completion_item(
587            label,
588            CompletionItemKind::TYPE_PARAMETER,
589            "SQL type",
590            type_documentation(label),
591            None,
592            "2",
593        )
594    }));
595    items.extend(SQL_SNIPPETS.iter().map(|snippet| {
596        completion_item(
597            snippet.label,
598            CompletionItemKind::SNIPPET,
599            "SQL snippet",
600            snippet.documentation,
601            Some(snippet.insert_text),
602            "3",
603        )
604    }));
605    items
606}
607
608fn type_documentation(label: &str) -> &'static str {
609    match label {
610        "NUMBER" => "Exact fixed-point numeric type.",
611        "DECIMAL" | "NUMERIC" => "Alias for NUMBER.",
612        "INT" | "INTEGER" | "BIGINT" => "Integer numeric alias.",
613        "FLOAT" | "DOUBLE" | "REAL" => "Approximate floating-point numeric type.",
614        "VARCHAR" => "Variable-length character data.",
615        "STRING" | "TEXT" => "Alias for VARCHAR.",
616        "CHAR" => "Character data.",
617        "BOOLEAN" => "TRUE/FALSE logical value.",
618        "VARIANT" => "Semi-structured value.",
619        "OBJECT" => "Semi-structured key/value object.",
620        "ARRAY" => "Semi-structured ordered collection.",
621        "MAP" => "Structured key/value collection.",
622        "VECTOR" => "Vector type for embeddings.",
623        "DATE" => "Calendar date without time of day.",
624        "TIME" => "Time of day without date.",
625        "TIMESTAMP" => "Timestamp family.",
626        "TIMESTAMP_NTZ" => "Timestamp without time zone.",
627        "TIMESTAMP_LTZ" => "Timestamp using the session time zone.",
628        "TIMESTAMP_TZ" => "Timestamp with explicit offset.",
629        "BINARY" => "Variable-length binary data.",
630        "GEOGRAPHY" => "Spherical geospatial data.",
631        "GEOMETRY" => "Planar geospatial data.",
632        _ => "Built-in SQL type.",
633    }
634}
635
636fn completion_item(
637    label: &str,
638    kind: CompletionItemKind,
639    detail: &str,
640    documentation: &str,
641    insert_text: Option<&str>,
642    sort_prefix: &str,
643) -> CompletionItem {
644    CompletionItem {
645        label: label.to_string(),
646        kind: Some(kind),
647        detail: Some(detail.to_string()),
648        documentation: Some(lsp_types::Documentation::MarkupContent(MarkupContent {
649            kind: MarkupKind::Markdown,
650            value: documentation.to_string(),
651        })),
652        insert_text: insert_text.map(str::to_string),
653        insert_text_format: insert_text.map(|_| InsertTextFormat::SNIPPET),
654        sort_text: Some(format!("{sort_prefix}-{label}")),
655        ..CompletionItem::default()
656    }
657}
658
659struct Snippet {
660    label: &'static str,
661    insert_text: &'static str,
662    documentation: &'static str,
663}
664
665const SQL_SNIPPETS: &[Snippet] = &[
666    Snippet {
667        label: "SELECT ... FROM ...",
668        insert_text: "SELECT ${1:columns}\nFROM ${2:table};",
669        documentation: "Scaffold a SELECT statement.",
670    },
671    Snippet {
672        label: "WITH ... AS (...)",
673        insert_text: "WITH ${1:cte} AS (\n    SELECT ${2:*}\n    FROM ${3:source}\n)\nSELECT ${4:*}\nFROM ${1:cte};",
674        documentation: "Scaffold a common table expression.",
675    },
676    Snippet {
677        label: "CREATE TABLE ...",
678        insert_text: "CREATE TABLE ${1:table_name} (\n    ${2:column_name} ${3:NUMBER}\n);",
679        documentation: "Scaffold a CREATE TABLE statement.",
680    },
681    Snippet {
682        label: "INSERT INTO ...",
683        insert_text: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values});",
684        documentation: "Scaffold an INSERT statement.",
685    },
686    Snippet {
687        label: "CREATE PROCEDURE ...",
688        insert_text: "CREATE PROCEDURE ${1:procedure_name}(${2:args})\nRETURNS ${3:STRING}\nLANGUAGE SQL\nAS\n$$\nBEGIN\n    ${4:RETURN 'ok';}\nEND;\n$$;",
689        documentation: "Scaffold a Snowflake SQL procedure.",
690    },
691];
692
693/// Folding ranges for `textDocument/foldingRange`: one region per multi-line top-level statement,
694/// so an editor can collapse each statement in a script. The CST's root children are the statements.
695pub fn folding_ranges(text: &str) -> Vec<FoldingRange> {
696    let index = LineIndex::new(text);
697    let root = sql_dialect_fmt_parser::parse(text).syntax();
698    root.children()
699        .filter_map(|stmt| {
700            // Use the span of the statement's significant tokens, so a leading/trailing blank line
701            // (attached as trivia) doesn't inflate a single-line statement into a foldable region.
702            let mut tokens = stmt
703                .descendants_with_tokens()
704                .filter_map(|el| el.into_token())
705                .filter(|t| !t.kind().is_trivia());
706            let first = tokens.next()?;
707            let last = tokens.last().unwrap_or_else(|| first.clone());
708            let start = lsp_position(
709                &index,
710                first.text_range().start().into(),
711                PositionEncoding::Utf16,
712            )
713            .line;
714            let end = lsp_position(
715                &index,
716                last.text_range().end().into(),
717                PositionEncoding::Utf16,
718            )
719            .line;
720            (end > start).then_some(FoldingRange {
721                start_line: start,
722                end_line: end,
723                kind: Some(FoldingRangeKind::Region),
724                ..FoldingRange::default()
725            })
726        })
727        .collect()
728}
729
730/// Apply one `textDocument/didChange` content change to `text`, returning the new document.
731///
732/// Supports incremental sync: a `Some(range)` splices `new_text` over the byte span the range
733/// covers; a `None` range is a whole-document replacement (the editor sent the full text). The
734/// range is treated as ordered and clamped to the document, so a malformed event can't panic.
735pub fn apply_change(text: &str, range: Option<Range>, new_text: &str) -> String {
736    apply_change_with_encoding(text, range, new_text, PositionEncoding::Utf16)
737}
738
739/// Encoding-aware variant of [`apply_change`].
740pub fn apply_change_with_encoding(
741    text: &str,
742    range: Option<Range>,
743    new_text: &str,
744    encoding: PositionEncoding,
745) -> String {
746    let Some(range) = range else {
747        return new_text.to_string();
748    };
749    let index = LineIndex::new(text);
750    let a = lsp_offset(&index, range.start, encoding);
751    let b = lsp_offset(&index, range.end, encoding);
752    let (start, end) = (a.min(b), a.max(b));
753    let mut out = String::with_capacity(text.len() - (end - start) + new_text.len());
754    out.push_str(&text[..start]);
755    out.push_str(new_text);
756    out.push_str(&text[end..]);
757    out
758}
759
760/// The delta-encoded semantic tokens for `textDocument/semanticTokens/full`.
761///
762/// This is a thin adapter over `sql-dialect-fmt-highlight`'s [`semantic::semantic_tokens_lsp`]: the
763/// highlighter already splits multi-line tokens (block comments, dollar-quoted strings) into one
764/// token per line — the LSP encoding requires each token to stay on a single line — computes UTF-16
765/// columns/lengths, and delta-encodes into `(deltaLine, deltaStartChar, length, tokenType,
766/// tokenModifiers)` quintuples against the same legend the server advertises. We only reshape each
767/// quintuple into an `lsp_types::SemanticToken`, **preserving** the modifier bitset (rather than
768/// hardcoding 0) so `defaultLibrary` / `documentation` modifiers reach the editor.
769pub fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
770    semantic_tokens_with_encoding(text, PositionEncoding::Utf16)
771}
772
773/// Encoding-aware variant of [`semantic_tokens`].
774pub fn semantic_tokens_with_encoding(text: &str, encoding: PositionEncoding) -> Vec<SemanticToken> {
775    let raw = match encoding {
776        PositionEncoding::Utf16 => semantic::semantic_tokens_lsp(text),
777        PositionEncoding::Utf8 => sql_dialect_fmt_highlight::semantic_tokens_lsp_utf8(text),
778    };
779    raw.into_iter()
780        .map(
781            |[delta_line, delta_start, length, token_type, token_modifiers_bitset]| SemanticToken {
782                delta_line,
783                delta_start,
784                length,
785                token_type,
786                token_modifiers_bitset,
787            },
788        )
789        .collect()
790}
791
792/// The delta-encoded semantic tokens that intersect `range`.
793pub fn semantic_tokens_range_with_encoding(
794    text: &str,
795    range: Range,
796    encoding: PositionEncoding,
797) -> Vec<SemanticToken> {
798    let full = semantic_tokens_with_encoding(text, encoding);
799    let mut absolute = Vec::with_capacity(full.len());
800    let mut line = 0u32;
801    let mut start = 0u32;
802    for token in full {
803        line += token.delta_line;
804        start = if token.delta_line == 0 {
805            start + token.delta_start
806        } else {
807            token.delta_start
808        };
809        absolute.push(AbsoluteSemanticToken { line, start, token });
810    }
811
812    let mut previous: Option<(u32, u32)> = None;
813    absolute
814        .into_iter()
815        .filter(|token| semantic_token_intersects_range(token, range))
816        .map(|absolute| {
817            let delta_line = previous.map_or(absolute.line, |(line, _)| absolute.line - line);
818            let delta_start = if delta_line == 0 {
819                previous.map_or(absolute.start, |(_, start)| absolute.start - start)
820            } else {
821                absolute.start
822            };
823            previous = Some((absolute.line, absolute.start));
824            SemanticToken {
825                delta_line,
826                delta_start,
827                length: absolute.token.length,
828                token_type: absolute.token.token_type,
829                token_modifiers_bitset: absolute.token.token_modifiers_bitset,
830            }
831        })
832        .collect()
833}
834
835#[derive(Clone, Debug)]
836struct AbsoluteSemanticToken {
837    line: u32,
838    start: u32,
839    token: SemanticToken,
840}
841
842fn semantic_token_intersects_range(token: &AbsoluteSemanticToken, range: Range) -> bool {
843    let token_start = Position::new(token.line, token.start);
844    let token_end = Position::new(token.line, token.start + token.token.length);
845    position_lt(token_start, range.end) && position_lt(range.start, token_end)
846}
847
848fn position_lt(a: Position, b: Position) -> bool {
849    a.line < b.line || (a.line == b.line && a.character < b.character)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn line_index_maps_offsets_to_utf16_positions() {
858        let text = "SELECT a\nFROM 芋;\n"; // 芋 is one UTF-16 unit but 3 bytes
859        let index = LineIndex::new(text);
860        assert_eq!(
861            lsp_position(&index, 0, PositionEncoding::Utf16),
862            Position::new(0, 0)
863        );
864        assert_eq!(
865            lsp_position(&index, 7, PositionEncoding::Utf16),
866            Position::new(0, 7)
867        ); // the `a`
868        let from = text.find("FROM").unwrap();
869        assert_eq!(
870            lsp_position(&index, from, PositionEncoding::Utf16),
871            Position::new(1, 0)
872        );
873        let semicolon = text.find(';').unwrap();
874        assert_eq!(
875            lsp_position(&index, semicolon, PositionEncoding::Utf16),
876            Position::new(1, 6)
877        ); // FROM<sp>芋 = 6 utf16 units
878    }
879
880    #[test]
881    fn line_index_maps_offsets_to_utf8_positions() {
882        let text = "SELECT a\nFROM 芋;\n";
883        let index = LineIndex::new(text);
884        let semicolon = text.find(';').unwrap();
885        assert_eq!(
886            lsp_position(&index, semicolon, PositionEncoding::Utf8),
887            Position::new(1, 8)
888        ); // FROM<sp>芋 = 8 utf8 bytes
889    }
890
891    #[test]
892    fn formatting_replaces_the_whole_document() {
893        let edits = format_edits("select a,b from t", &FormatOptions::default());
894        assert_eq!(edits.len(), 1);
895        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;\n");
896        assert_eq!(edits[0].range.start, Position::new(0, 0));
897    }
898
899    #[test]
900    fn already_formatted_input_yields_no_edits() {
901        let formatted = "SELECT a, b\nFROM t;\n";
902        assert!(format_edits(formatted, &FormatOptions::default()).is_empty());
903    }
904
905    #[test]
906    fn range_formatting_reformats_only_the_selected_statement() {
907        let text = "select 1;\nselect a,b from t;\n";
908        // A range on line 1 — the second statement.
909        let range = Range::new(Position::new(1, 0), Position::new(1, 3));
910        let edits = format_range_edits(text, range, &FormatOptions::default());
911        assert_eq!(edits.len(), 1);
912        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
913        // The edit is scoped to the second statement, not the top of the document.
914        assert_eq!(edits[0].range.start, Position::new(1, 0));
915    }
916
917    #[test]
918    fn range_formatting_already_formatted_selection_yields_no_edits() {
919        let text = "SELECT 1;\nSELECT a, b\nFROM t;\n";
920        let range = Range::new(Position::new(0, 0), Position::new(0, 8));
921        assert!(format_range_edits(text, range, &FormatOptions::default()).is_empty());
922    }
923
924    #[test]
925    fn on_type_formatting_after_semicolon_reformats_the_finished_statement() {
926        // Cursor right after the `;` the user just typed on line 1.
927        let text = "SELECT 1;\nselect a,b from t;\n";
928        let edits = on_type_formatting_edits(text, Position::new(1, 18), &FormatOptions::default());
929        assert_eq!(edits.len(), 1);
930        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
931        // Only the second statement is touched.
932        assert_eq!(edits[0].range.start, Position::new(1, 0));
933    }
934
935    #[test]
936    fn on_type_formatting_after_newline_reformats_the_previous_statement() {
937        // Cursor at the start of the line after the statement (the user just typed Enter).
938        let text = "select a,b from t;\n";
939        let edits = on_type_formatting_edits(text, Position::new(1, 0), &FormatOptions::default());
940        assert_eq!(edits.len(), 1);
941        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
942    }
943
944    #[test]
945    fn on_type_formatting_already_formatted_statement_yields_no_edits() {
946        let text = "SELECT a, b\nFROM t;\n";
947        assert!(
948            on_type_formatting_edits(text, Position::new(1, 7), &FormatOptions::default())
949                .is_empty()
950        );
951    }
952
953    #[test]
954    fn on_type_formatting_in_leading_whitespace_yields_no_edits() {
955        let text = "\n\nselect 1;\n";
956        assert!(
957            on_type_formatting_edits(text, Position::new(1, 0), &FormatOptions::default())
958                .is_empty()
959        );
960    }
961
962    #[test]
963    fn clean_sql_has_no_diagnostics() {
964        assert!(diagnostics("select 1").is_empty());
965    }
966
967    #[test]
968    fn broken_sql_reports_a_diagnostic() {
969        let diags = diagnostics("select from where");
970        assert!(!diags.is_empty());
971        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
972    }
973
974    #[test]
975    fn lexer_errors_reach_diagnostics() {
976        // An unterminated string is a *lexer* error (the parser never sees it as a token boundary
977        // problem). It must still surface as a diagnostic, and its range must cover the whole
978        // unterminated literal, not a single character.
979        let text = "SELECT 'oops";
980        let diags = diagnostics(text);
981        let lex_diag = diags
982            .iter()
983            .find(|d| d.message.contains("unterminated string"))
984            .expect("an unterminated-string diagnostic");
985        assert_eq!(lex_diag.severity, Some(DiagnosticSeverity::ERROR));
986        let quote = text.find('\'').unwrap() as u32;
987        assert_eq!(lex_diag.range.start, Position::new(0, quote));
988        // Spans to end of the line (the whole literal), so end column > start column.
989        assert!(lex_diag.range.end.character > lex_diag.range.start.character);
990    }
991
992    #[test]
993    fn parser_diagnostic_range_covers_the_token() {
994        // `MERGE tgt ...` (no INTO) reports "expected INTO" at the `tgt` token; the LSP range must
995        // span the whole 3-character identifier, not one character.
996        let text = "MERGE tgt USING src ON a = b";
997        let diags = diagnostics(text);
998        let into = diags
999            .iter()
1000            .find(|d| d.message == "expected INTO")
1001            .expect("an INTO diagnostic");
1002        let col = text.find("tgt").unwrap() as u32;
1003        assert_eq!(into.range.start, Position::new(0, col));
1004        assert_eq!(into.range.end, Position::new(0, col + 3));
1005    }
1006
1007    #[test]
1008    fn clean_sql_still_has_no_lexer_or_parser_diagnostics() {
1009        assert!(diagnostics("SELECT a FROM t").is_empty());
1010    }
1011
1012    #[test]
1013    fn unsupported_embedded_language_is_a_warning() {
1014        let text = "CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;";
1015        let diags = diagnostics(text);
1016        let language = diags
1017            .iter()
1018            .find(|d| d.message.contains("unsupported embedded language RUBY"))
1019            .expect("unsupported-language diagnostic");
1020        assert_eq!(language.severity, Some(DiagnosticSeverity::WARNING));
1021        let col = text.find("RUBY").unwrap() as u32;
1022        assert_eq!(language.range.start, Position::new(0, col));
1023        assert_eq!(language.range.end, Position::new(0, col + 4));
1024    }
1025
1026    #[test]
1027    fn embedded_language_warning_does_not_fire_for_plain_columns_or_dynamic_sql() {
1028        for text in [
1029            "SELECT language FROM t;",
1030            "EXECUTE IMMEDIATE $$ SELECT 1 $$;",
1031        ] {
1032            assert!(
1033                diagnostics(text)
1034                    .iter()
1035                    .all(|diag| !diag.message.contains("unsupported embedded language")),
1036                "{text}"
1037            );
1038        }
1039    }
1040
1041    #[test]
1042    fn select_wildcard_lint_is_a_warning() {
1043        let text = "SELECT * FROM t;";
1044        let diags = diagnostics(text);
1045        let wildcard = diags
1046            .iter()
1047            .find(|d| d.message.contains("avoid SELECT *"))
1048            .expect("SELECT * warning");
1049        assert_eq!(wildcard.severity, Some(DiagnosticSeverity::WARNING));
1050        let col = text.find('*').unwrap() as u32;
1051        assert_eq!(wildcard.range.start, Position::new(0, col));
1052        assert_eq!(wildcard.range.end, Position::new(0, col + 1));
1053    }
1054
1055    #[test]
1056    fn select_wildcard_lint_ignores_function_stars() {
1057        let text = "SELECT count(*) FROM t;";
1058        assert!(
1059            diagnostics(text)
1060                .iter()
1061                .all(|diag| !diag.message.contains("avoid SELECT *")),
1062            "{text}"
1063        );
1064    }
1065
1066    #[test]
1067    fn large_in_list_lint_is_a_warning() {
1068        let values = (0..101)
1069            .map(|n| n.to_string())
1070            .collect::<Vec<_>>()
1071            .join(", ");
1072        let text = format!("SELECT id FROM t WHERE id IN ({values});");
1073        let diags = diagnostics(&text);
1074        let in_list = diags
1075            .iter()
1076            .find(|d| d.message.contains("large IN list"))
1077            .expect("large IN-list warning");
1078        assert_eq!(in_list.severity, Some(DiagnosticSeverity::WARNING));
1079        let col = text.find("IN").unwrap() as u32;
1080        assert_eq!(in_list.range.start, Position::new(0, col));
1081    }
1082
1083    #[test]
1084    fn normal_in_list_and_subquery_have_no_lint() {
1085        for text in [
1086            "SELECT id FROM t WHERE id IN (1, 2, 3);",
1087            "SELECT id FROM t WHERE id IN (SELECT id FROM src);",
1088        ] {
1089            assert!(
1090                diagnostics(text)
1091                    .iter()
1092                    .all(|diag| !diag.message.contains("large IN list")),
1093                "{text}"
1094            );
1095        }
1096    }
1097
1098    #[test]
1099    fn lint_diagnostics_have_codes_and_respect_options() {
1100        let wildcard = diagnostics("SELECT * FROM t;")
1101            .into_iter()
1102            .find(|diag| diag.message.contains("avoid SELECT *"))
1103            .expect("SELECT * lint");
1104        assert_eq!(
1105            wildcard.code,
1106            Some(lsp_types::NumberOrString::String("SDF001".to_string()))
1107        );
1108
1109        let text = "SELECT id FROM t WHERE id IN (1, 2, 3);";
1110        let diags = diagnostics_with_lint_options(
1111            text,
1112            &FormatOptions::default(),
1113            LintOptions {
1114                large_in_list_threshold: 2,
1115                ..LintOptions::default()
1116            },
1117            PositionEncoding::Utf16,
1118        );
1119        assert!(diags.iter().any(|diag| {
1120            diag.code == Some(lsp_types::NumberOrString::String("SDF002".to_string()))
1121        }));
1122
1123        let disabled = diagnostics_with_lint_options(
1124            text,
1125            &FormatOptions::default(),
1126            LintOptions {
1127                large_in_list: false,
1128                large_in_list_threshold: 2,
1129                ..LintOptions::default()
1130            },
1131            PositionEncoding::Utf16,
1132        );
1133        assert!(
1134            disabled
1135                .iter()
1136                .all(|diag| diag.code
1137                    != Some(lsp_types::NumberOrString::String("SDF002".to_string())))
1138        );
1139    }
1140
1141    #[test]
1142    fn unsupported_embedded_language_has_a_code() {
1143        let diags = diagnostics("CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;");
1144        let language = diags
1145            .iter()
1146            .find(|diag| diag.message.contains("unsupported embedded language RUBY"))
1147            .expect("unsupported language lint");
1148        assert_eq!(
1149            language.code,
1150            Some(lsp_types::NumberOrString::String("SDF003".to_string()))
1151        );
1152    }
1153
1154    #[test]
1155    fn offset_is_the_inverse_of_position() {
1156        let text = "SELECT a\nFROM 芋;\n";
1157        let index = LineIndex::new(text);
1158        for offset in [
1159            0usize,
1160            7,
1161            text.find("FROM").unwrap(),
1162            text.find(';').unwrap(),
1163        ] {
1164            assert_eq!(
1165                lsp_offset(
1166                    &index,
1167                    lsp_position(&index, offset, PositionEncoding::Utf16),
1168                    PositionEncoding::Utf16
1169                ),
1170                offset
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn hover_describes_a_type() {
1177        // Hover over the `varchar` cast target should return a Snowflake type description.
1178        let src = "select x::varchar from t";
1179        let col = src.find("varchar").unwrap() as u32;
1180        let hover = hover(src, Position::new(0, col)).expect("hover");
1181        assert!(hover.range.is_some());
1182        match hover.contents {
1183            HoverContents::Markup(m) => assert!(m.value.to_lowercase().contains("varchar")),
1184            _ => panic!("expected markup"),
1185        }
1186    }
1187
1188    #[test]
1189    fn hover_describes_spec_features_with_a_docs_link() {
1190        // Spec-driven hover: keyword features carry syntax plus a docs link.
1191        let src = "select c from t qualify row_number() over (order by c) = 1";
1192        let col = src.find("qualify").unwrap() as u32;
1193        let hover = hover(src, Position::new(0, col)).expect("hover");
1194        match hover.contents {
1195            HoverContents::Markup(m) => {
1196                assert!(m.value.contains("**QUALIFY**"));
1197                assert!(m.value.contains("QUALIFY <expr>"));
1198                assert!(m.value.contains("[Snowflake docs]("));
1199            }
1200            _ => panic!("expected markup"),
1201        }
1202    }
1203
1204    #[test]
1205    fn hover_describes_function_signatures() {
1206        let src = "select dateadd(day, 1, d) from t";
1207        let col = src.find("dateadd").unwrap() as u32;
1208        let hover = hover(src, Position::new(0, col)).expect("hover");
1209        match hover.contents {
1210            HoverContents::Markup(m) => {
1211                assert!(m.value.contains("DATEADD( <date_or_time_part>"));
1212                assert!(m.value.contains("[Snowflake docs]("));
1213            }
1214            _ => panic!("expected markup"),
1215        }
1216    }
1217
1218    #[test]
1219    fn apply_change_splices_an_incremental_edit() {
1220        // Replace "world" (line 1, cols 0..5) with "snow".
1221        let text = "hello\nworld\n";
1222        let range = Range::new(Position::new(1, 0), Position::new(1, 5));
1223        assert_eq!(apply_change(text, Some(range), "snow"), "hello\nsnow\n");
1224    }
1225
1226    #[test]
1227    fn apply_change_splices_utf8_encoded_ranges() {
1228        let text = "SELECT '長芋'\nFROM t\n";
1229        let start = Position::new(0, "SELECT '長".len() as u32);
1230        let end = Position::new(0, "SELECT '長芋".len() as u32);
1231        assert_eq!(
1232            apply_change_with_encoding(
1233                text,
1234                Some(Range::new(start, end)),
1235                "山芋",
1236                PositionEncoding::Utf8
1237            ),
1238            "SELECT '長山芋'\nFROM t\n"
1239        );
1240    }
1241
1242    #[test]
1243    fn apply_change_with_no_range_replaces_whole_document() {
1244        assert_eq!(apply_change("old", None, "new text"), "new text");
1245    }
1246
1247    #[test]
1248    fn folding_ranges_cover_multiline_statements() {
1249        let ranges = folding_ranges("select a,\nb\nfrom t;\n\nselect 1;");
1250        assert_eq!(ranges.len(), 1); // only the first (multi-line) statement folds
1251        assert_eq!(ranges[0].start_line, 0);
1252        assert_eq!(ranges[0].end_line, 2);
1253    }
1254
1255    #[test]
1256    fn document_symbols_name_top_level_statements() {
1257        let symbols = document_symbols(
1258            "CREATE TABLE db.t (id INT);\n\nSELECT id\nFROM db.t;",
1259            &FormatOptions::default(),
1260        );
1261        assert_eq!(symbols.len(), 2);
1262        assert_eq!(symbols[0].name, "CREATE TABLE db.t");
1263        assert_eq!(symbols[0].kind, SymbolKind::STRUCT);
1264        assert_eq!(symbols[0].selection_range.start, Position::new(0, 13));
1265        assert_eq!(symbols[1].name, "SELECT");
1266        assert_eq!(symbols[1].kind, SymbolKind::FUNCTION);
1267    }
1268
1269    #[test]
1270    fn document_symbols_name_stage_file_operations() {
1271        let symbols = document_symbols(
1272            "PUT file:///tmp/x.csv @stage;\nLIST @stage/path;",
1273            &FormatOptions::default(),
1274        );
1275        assert_eq!(symbols.len(), 2);
1276        assert_eq!(symbols[0].name, "PUT");
1277        assert_eq!(symbols[0].kind, SymbolKind::METHOD);
1278        assert_eq!(symbols[1].name, "LIST");
1279        assert_eq!(symbols[1].kind, SymbolKind::METHOD);
1280    }
1281
1282    #[test]
1283    fn completion_items_include_keywords_types_and_snippets() {
1284        let items = completion_items();
1285        assert!(items.iter().any(|item| {
1286            item.label == "SELECT" && item.kind == Some(CompletionItemKind::KEYWORD)
1287        }));
1288        assert!(items
1289            .iter()
1290            .any(|item| item.label == "NUMBER"
1291                && item.kind == Some(CompletionItemKind::TYPE_PARAMETER)));
1292        assert!(items.iter().any(|item| {
1293            item.label == "CREATE TABLE ..."
1294                && item.kind == Some(CompletionItemKind::SNIPPET)
1295                && item.insert_text_format == Some(InsertTextFormat::SNIPPET)
1296        }));
1297    }
1298
1299    #[test]
1300    fn semantic_tokens_tag_keywords() {
1301        let tokens = semantic_tokens("select a from t");
1302        assert!(!tokens.is_empty());
1303        // The first token is `select`, a keyword (legend index 0), at line 0 column 0.
1304        assert_eq!(tokens[0].delta_line, 0);
1305        assert_eq!(tokens[0].delta_start, 0);
1306        assert_eq!(tokens[0].length, 6);
1307        assert_eq!(tokens[0].token_type, 0);
1308        // The keyword carries the `defaultLibrary` modifier — it must not be hardcoded to 0.
1309        assert_eq!(
1310            tokens[0].token_modifiers_bitset,
1311            semantic::SemanticTokenModifiers::DEFAULT_LIBRARY.bits()
1312        );
1313    }
1314
1315    #[test]
1316    fn server_legend_equals_the_highlighter_legend() {
1317        // The advertised type legend must be exactly the highlighter's LEGEND, in order — this is
1318        // the contract an editor decodes `token_type` against.
1319        let advertised: Vec<String> = token_types()
1320            .iter()
1321            .map(|t| t.as_str().to_string())
1322            .collect();
1323        let expected: Vec<String> = semantic::SemanticTokenType::LEGEND
1324            .iter()
1325            .map(|t| t.name().to_string())
1326            .collect();
1327        assert_eq!(advertised, expected);
1328        // It includes `function` (currently the last appended type) for Cortex/AISQL recognition.
1329        assert_eq!(advertised.last().map(String::as_str), Some("function"));
1330
1331        // Likewise the modifier legend mirrors the highlighter's.
1332        let mods: Vec<String> = token_modifiers()
1333            .iter()
1334            .map(|m| m.as_str().to_string())
1335            .collect();
1336        assert_eq!(mods, vec!["documentation", "defaultLibrary"]);
1337    }
1338
1339    #[test]
1340    fn semantic_tokens_are_monotonic_and_never_panic_on_multiline() {
1341        // A multi-line block comment must split into per-line tokens without panicking.
1342        let tokens = semantic_tokens("select 1 /* a\nb */ from t");
1343        // Deltas must be non-negative by construction (u32) and the stream stays consistent.
1344        assert!(tokens.iter().all(|t| t.length > 0));
1345    }
1346
1347    #[test]
1348    fn semantic_tokens_range_filters_and_reencodes_tokens() {
1349        let text = "SELECT a\nFROM t\nWHERE a = 1";
1350        let tokens = semantic_tokens_range_with_encoding(
1351            text,
1352            Range::new(Position::new(1, 0), Position::new(2, 0)),
1353            PositionEncoding::Utf16,
1354        );
1355        assert!(!tokens.is_empty());
1356        assert_eq!(tokens[0].delta_line, 1);
1357
1358        let mut line = 0u32;
1359        let mut start = 0u32;
1360        for token in tokens {
1361            line += token.delta_line;
1362            start = if token.delta_line == 0 {
1363                start + token.delta_start
1364            } else {
1365                token.delta_start
1366            };
1367            assert_eq!(line, 1);
1368            assert!(start + token.length <= 6);
1369        }
1370    }
1371}