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" | "SMALLINT" | "TINYINT" | "BYTEINT" => {
613            "Integer numeric alias."
614        }
615        "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" | "REAL" => {
616            "Approximate floating-point numeric type."
617        }
618        "DECFLOAT" => "Decimal floating-point numeric type.",
619        "VARCHAR" => "Variable-length character data.",
620        "STRING" | "TEXT" | "NVARCHAR" | "NVARCHAR2" => "Alias for VARCHAR.",
621        "CHAR" => "Character data.",
622        "CHARACTER" | "NCHAR" => "Alias for CHAR.",
623        "VARBINARY" => "Alias for BINARY.",
624        "FILE" => "Reference to an unstructured file on a stage.",
625        "UUID" => "Universally unique identifier.",
626        "BOOLEAN" => "TRUE/FALSE logical value.",
627        "VARIANT" => "Semi-structured value.",
628        "OBJECT" => "Semi-structured key/value object.",
629        "ARRAY" => "Semi-structured ordered collection.",
630        "MAP" => "Structured key/value collection.",
631        "VECTOR" => "Vector type for embeddings.",
632        "DATE" => "Calendar date without time of day.",
633        "TIME" => "Time of day without date.",
634        "TIMESTAMP" => "Timestamp family.",
635        "TIMESTAMP_NTZ" => "Timestamp without time zone.",
636        "TIMESTAMP_LTZ" => "Timestamp using the session time zone.",
637        "TIMESTAMP_TZ" => "Timestamp with explicit offset.",
638        "BINARY" => "Variable-length binary data.",
639        "GEOGRAPHY" => "Spherical geospatial data.",
640        "GEOMETRY" => "Planar geospatial data.",
641        _ => "Built-in SQL type.",
642    }
643}
644
645fn completion_item(
646    label: &str,
647    kind: CompletionItemKind,
648    detail: &str,
649    documentation: &str,
650    insert_text: Option<&str>,
651    sort_prefix: &str,
652) -> CompletionItem {
653    CompletionItem {
654        label: label.to_string(),
655        kind: Some(kind),
656        detail: Some(detail.to_string()),
657        documentation: Some(lsp_types::Documentation::MarkupContent(MarkupContent {
658            kind: MarkupKind::Markdown,
659            value: documentation.to_string(),
660        })),
661        insert_text: insert_text.map(str::to_string),
662        insert_text_format: insert_text.map(|_| InsertTextFormat::SNIPPET),
663        sort_text: Some(format!("{sort_prefix}-{label}")),
664        ..CompletionItem::default()
665    }
666}
667
668struct Snippet {
669    label: &'static str,
670    insert_text: &'static str,
671    documentation: &'static str,
672}
673
674const SQL_SNIPPETS: &[Snippet] = &[
675    Snippet {
676        label: "SELECT ... FROM ...",
677        insert_text: "SELECT ${1:columns}\nFROM ${2:table};",
678        documentation: "Scaffold a SELECT statement.",
679    },
680    Snippet {
681        label: "WITH ... AS (...)",
682        insert_text: "WITH ${1:cte} AS (\n    SELECT ${2:*}\n    FROM ${3:source}\n)\nSELECT ${4:*}\nFROM ${1:cte};",
683        documentation: "Scaffold a common table expression.",
684    },
685    Snippet {
686        label: "CREATE TABLE ...",
687        insert_text: "CREATE TABLE ${1:table_name} (\n    ${2:column_name} ${3:NUMBER}\n);",
688        documentation: "Scaffold a CREATE TABLE statement.",
689    },
690    Snippet {
691        label: "INSERT INTO ...",
692        insert_text: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values});",
693        documentation: "Scaffold an INSERT statement.",
694    },
695    Snippet {
696        label: "CREATE PROCEDURE ...",
697        insert_text: "CREATE PROCEDURE ${1:procedure_name}(${2:args})\nRETURNS ${3:STRING}\nLANGUAGE SQL\nAS\n$$\nBEGIN\n    ${4:RETURN 'ok';}\nEND;\n$$;",
698        documentation: "Scaffold a Snowflake SQL procedure.",
699    },
700];
701
702/// Folding ranges for `textDocument/foldingRange`: one region per multi-line top-level statement,
703/// so an editor can collapse each statement in a script. The CST's root children are the statements.
704pub fn folding_ranges(text: &str) -> Vec<FoldingRange> {
705    let index = LineIndex::new(text);
706    let root = sql_dialect_fmt_parser::parse(text).syntax();
707    root.children()
708        .filter_map(|stmt| {
709            // Use the span of the statement's significant tokens, so a leading/trailing blank line
710            // (attached as trivia) doesn't inflate a single-line statement into a foldable region.
711            let mut tokens = stmt
712                .descendants_with_tokens()
713                .filter_map(|el| el.into_token())
714                .filter(|t| !t.kind().is_trivia());
715            let first = tokens.next()?;
716            let last = tokens.last().unwrap_or_else(|| first.clone());
717            let start = lsp_position(
718                &index,
719                first.text_range().start().into(),
720                PositionEncoding::Utf16,
721            )
722            .line;
723            let end = lsp_position(
724                &index,
725                last.text_range().end().into(),
726                PositionEncoding::Utf16,
727            )
728            .line;
729            (end > start).then_some(FoldingRange {
730                start_line: start,
731                end_line: end,
732                kind: Some(FoldingRangeKind::Region),
733                ..FoldingRange::default()
734            })
735        })
736        .collect()
737}
738
739/// Apply one `textDocument/didChange` content change to `text`, returning the new document.
740///
741/// Supports incremental sync: a `Some(range)` splices `new_text` over the byte span the range
742/// covers; a `None` range is a whole-document replacement (the editor sent the full text). The
743/// range is treated as ordered and clamped to the document, so a malformed event can't panic.
744pub fn apply_change(text: &str, range: Option<Range>, new_text: &str) -> String {
745    apply_change_with_encoding(text, range, new_text, PositionEncoding::Utf16)
746}
747
748/// Encoding-aware variant of [`apply_change`].
749pub fn apply_change_with_encoding(
750    text: &str,
751    range: Option<Range>,
752    new_text: &str,
753    encoding: PositionEncoding,
754) -> String {
755    let Some(range) = range else {
756        return new_text.to_string();
757    };
758    let index = LineIndex::new(text);
759    let a = lsp_offset(&index, range.start, encoding);
760    let b = lsp_offset(&index, range.end, encoding);
761    let (start, end) = (a.min(b), a.max(b));
762    let mut out = String::with_capacity(text.len() - (end - start) + new_text.len());
763    out.push_str(&text[..start]);
764    out.push_str(new_text);
765    out.push_str(&text[end..]);
766    out
767}
768
769/// The delta-encoded semantic tokens for `textDocument/semanticTokens/full`.
770///
771/// This is a thin adapter over `sql-dialect-fmt-highlight`'s [`semantic::semantic_tokens_lsp`]: the
772/// highlighter already splits multi-line tokens (block comments, dollar-quoted strings) into one
773/// token per line — the LSP encoding requires each token to stay on a single line — computes UTF-16
774/// columns/lengths, and delta-encodes into `(deltaLine, deltaStartChar, length, tokenType,
775/// tokenModifiers)` quintuples against the same legend the server advertises. We only reshape each
776/// quintuple into an `lsp_types::SemanticToken`, **preserving** the modifier bitset (rather than
777/// hardcoding 0) so `defaultLibrary` / `documentation` modifiers reach the editor.
778pub fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
779    semantic_tokens_with_encoding(text, PositionEncoding::Utf16)
780}
781
782/// Encoding-aware variant of [`semantic_tokens`].
783pub fn semantic_tokens_with_encoding(text: &str, encoding: PositionEncoding) -> Vec<SemanticToken> {
784    let raw = match encoding {
785        PositionEncoding::Utf16 => semantic::semantic_tokens_lsp(text),
786        PositionEncoding::Utf8 => sql_dialect_fmt_highlight::semantic_tokens_lsp_utf8(text),
787    };
788    raw.into_iter()
789        .map(
790            |[delta_line, delta_start, length, token_type, token_modifiers_bitset]| SemanticToken {
791                delta_line,
792                delta_start,
793                length,
794                token_type,
795                token_modifiers_bitset,
796            },
797        )
798        .collect()
799}
800
801/// The delta-encoded semantic tokens that intersect `range`.
802pub fn semantic_tokens_range_with_encoding(
803    text: &str,
804    range: Range,
805    encoding: PositionEncoding,
806) -> Vec<SemanticToken> {
807    let full = semantic_tokens_with_encoding(text, encoding);
808    let mut absolute = Vec::with_capacity(full.len());
809    let mut line = 0u32;
810    let mut start = 0u32;
811    for token in full {
812        line += token.delta_line;
813        start = if token.delta_line == 0 {
814            start + token.delta_start
815        } else {
816            token.delta_start
817        };
818        absolute.push(AbsoluteSemanticToken { line, start, token });
819    }
820
821    let mut previous: Option<(u32, u32)> = None;
822    absolute
823        .into_iter()
824        .filter(|token| semantic_token_intersects_range(token, range))
825        .map(|absolute| {
826            let delta_line = previous.map_or(absolute.line, |(line, _)| absolute.line - line);
827            let delta_start = if delta_line == 0 {
828                previous.map_or(absolute.start, |(_, start)| absolute.start - start)
829            } else {
830                absolute.start
831            };
832            previous = Some((absolute.line, absolute.start));
833            SemanticToken {
834                delta_line,
835                delta_start,
836                length: absolute.token.length,
837                token_type: absolute.token.token_type,
838                token_modifiers_bitset: absolute.token.token_modifiers_bitset,
839            }
840        })
841        .collect()
842}
843
844#[derive(Clone, Debug)]
845struct AbsoluteSemanticToken {
846    line: u32,
847    start: u32,
848    token: SemanticToken,
849}
850
851fn semantic_token_intersects_range(token: &AbsoluteSemanticToken, range: Range) -> bool {
852    let token_start = Position::new(token.line, token.start);
853    let token_end = Position::new(token.line, token.start + token.token.length);
854    position_lt(token_start, range.end) && position_lt(range.start, token_end)
855}
856
857fn position_lt(a: Position, b: Position) -> bool {
858    a.line < b.line || (a.line == b.line && a.character < b.character)
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn line_index_maps_offsets_to_utf16_positions() {
867        let text = "SELECT a\nFROM 芋;\n"; // 芋 is one UTF-16 unit but 3 bytes
868        let index = LineIndex::new(text);
869        assert_eq!(
870            lsp_position(&index, 0, PositionEncoding::Utf16),
871            Position::new(0, 0)
872        );
873        assert_eq!(
874            lsp_position(&index, 7, PositionEncoding::Utf16),
875            Position::new(0, 7)
876        ); // the `a`
877        let from = text.find("FROM").unwrap();
878        assert_eq!(
879            lsp_position(&index, from, PositionEncoding::Utf16),
880            Position::new(1, 0)
881        );
882        let semicolon = text.find(';').unwrap();
883        assert_eq!(
884            lsp_position(&index, semicolon, PositionEncoding::Utf16),
885            Position::new(1, 6)
886        ); // FROM<sp>芋 = 6 utf16 units
887    }
888
889    #[test]
890    fn line_index_maps_offsets_to_utf8_positions() {
891        let text = "SELECT a\nFROM 芋;\n";
892        let index = LineIndex::new(text);
893        let semicolon = text.find(';').unwrap();
894        assert_eq!(
895            lsp_position(&index, semicolon, PositionEncoding::Utf8),
896            Position::new(1, 8)
897        ); // FROM<sp>芋 = 8 utf8 bytes
898    }
899
900    #[test]
901    fn formatting_replaces_the_whole_document() {
902        let edits = format_edits("select a,b from t", &FormatOptions::default());
903        assert_eq!(edits.len(), 1);
904        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;\n");
905        assert_eq!(edits[0].range.start, Position::new(0, 0));
906    }
907
908    #[test]
909    fn already_formatted_input_yields_no_edits() {
910        let formatted = "SELECT a, b\nFROM t;\n";
911        assert!(format_edits(formatted, &FormatOptions::default()).is_empty());
912    }
913
914    #[test]
915    fn range_formatting_reformats_only_the_selected_statement() {
916        let text = "select 1;\nselect a,b from t;\n";
917        // A range on line 1 — the second statement.
918        let range = Range::new(Position::new(1, 0), Position::new(1, 3));
919        let edits = format_range_edits(text, range, &FormatOptions::default());
920        assert_eq!(edits.len(), 1);
921        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
922        // The edit is scoped to the second statement, not the top of the document.
923        assert_eq!(edits[0].range.start, Position::new(1, 0));
924    }
925
926    #[test]
927    fn range_formatting_already_formatted_selection_yields_no_edits() {
928        let text = "SELECT 1;\nSELECT a, b\nFROM t;\n";
929        let range = Range::new(Position::new(0, 0), Position::new(0, 8));
930        assert!(format_range_edits(text, range, &FormatOptions::default()).is_empty());
931    }
932
933    #[test]
934    fn on_type_formatting_after_semicolon_reformats_the_finished_statement() {
935        // Cursor right after the `;` the user just typed on line 1.
936        let text = "SELECT 1;\nselect a,b from t;\n";
937        let edits = on_type_formatting_edits(text, Position::new(1, 18), &FormatOptions::default());
938        assert_eq!(edits.len(), 1);
939        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
940        // Only the second statement is touched.
941        assert_eq!(edits[0].range.start, Position::new(1, 0));
942    }
943
944    #[test]
945    fn on_type_formatting_after_newline_reformats_the_previous_statement() {
946        // Cursor at the start of the line after the statement (the user just typed Enter).
947        let text = "select a,b from t;\n";
948        let edits = on_type_formatting_edits(text, Position::new(1, 0), &FormatOptions::default());
949        assert_eq!(edits.len(), 1);
950        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;");
951    }
952
953    #[test]
954    fn on_type_formatting_already_formatted_statement_yields_no_edits() {
955        let text = "SELECT a, b\nFROM t;\n";
956        assert!(
957            on_type_formatting_edits(text, Position::new(1, 7), &FormatOptions::default())
958                .is_empty()
959        );
960    }
961
962    #[test]
963    fn on_type_formatting_in_leading_whitespace_yields_no_edits() {
964        let text = "\n\nselect 1;\n";
965        assert!(
966            on_type_formatting_edits(text, Position::new(1, 0), &FormatOptions::default())
967                .is_empty()
968        );
969    }
970
971    #[test]
972    fn clean_sql_has_no_diagnostics() {
973        assert!(diagnostics("select 1").is_empty());
974    }
975
976    #[test]
977    fn broken_sql_reports_a_diagnostic() {
978        let diags = diagnostics("select from where");
979        assert!(!diags.is_empty());
980        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
981    }
982
983    #[test]
984    fn lexer_errors_reach_diagnostics() {
985        // An unterminated string is a *lexer* error (the parser never sees it as a token boundary
986        // problem). It must still surface as a diagnostic, and its range must cover the whole
987        // unterminated literal, not a single character.
988        let text = "SELECT 'oops";
989        let diags = diagnostics(text);
990        let lex_diag = diags
991            .iter()
992            .find(|d| d.message.contains("unterminated string"))
993            .expect("an unterminated-string diagnostic");
994        assert_eq!(lex_diag.severity, Some(DiagnosticSeverity::ERROR));
995        let quote = text.find('\'').unwrap() as u32;
996        assert_eq!(lex_diag.range.start, Position::new(0, quote));
997        // Spans to end of the line (the whole literal), so end column > start column.
998        assert!(lex_diag.range.end.character > lex_diag.range.start.character);
999    }
1000
1001    #[test]
1002    fn parser_diagnostic_range_covers_the_token() {
1003        // `MERGE tgt ...` (no INTO) reports "expected INTO" at the `tgt` token; the LSP range must
1004        // span the whole 3-character identifier, not one character.
1005        let text = "MERGE tgt USING src ON a = b";
1006        let diags = diagnostics(text);
1007        let into = diags
1008            .iter()
1009            .find(|d| d.message == "expected INTO")
1010            .expect("an INTO diagnostic");
1011        let col = text.find("tgt").unwrap() as u32;
1012        assert_eq!(into.range.start, Position::new(0, col));
1013        assert_eq!(into.range.end, Position::new(0, col + 3));
1014    }
1015
1016    #[test]
1017    fn clean_sql_still_has_no_lexer_or_parser_diagnostics() {
1018        assert!(diagnostics("SELECT a FROM t").is_empty());
1019    }
1020
1021    #[test]
1022    fn unsupported_embedded_language_is_a_warning() {
1023        let text = "CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;";
1024        let diags = diagnostics(text);
1025        let language = diags
1026            .iter()
1027            .find(|d| d.message.contains("unsupported embedded language RUBY"))
1028            .expect("unsupported-language diagnostic");
1029        assert_eq!(language.severity, Some(DiagnosticSeverity::WARNING));
1030        let col = text.find("RUBY").unwrap() as u32;
1031        assert_eq!(language.range.start, Position::new(0, col));
1032        assert_eq!(language.range.end, Position::new(0, col + 4));
1033    }
1034
1035    #[test]
1036    fn embedded_language_warning_does_not_fire_for_plain_columns_or_dynamic_sql() {
1037        for text in [
1038            "SELECT language FROM t;",
1039            "EXECUTE IMMEDIATE $$ SELECT 1 $$;",
1040        ] {
1041            assert!(
1042                diagnostics(text)
1043                    .iter()
1044                    .all(|diag| !diag.message.contains("unsupported embedded language")),
1045                "{text}"
1046            );
1047        }
1048    }
1049
1050    #[test]
1051    fn select_wildcard_lint_is_a_warning() {
1052        let text = "SELECT * FROM t;";
1053        let diags = diagnostics(text);
1054        let wildcard = diags
1055            .iter()
1056            .find(|d| d.message.contains("avoid SELECT *"))
1057            .expect("SELECT * warning");
1058        assert_eq!(wildcard.severity, Some(DiagnosticSeverity::WARNING));
1059        let col = text.find('*').unwrap() as u32;
1060        assert_eq!(wildcard.range.start, Position::new(0, col));
1061        assert_eq!(wildcard.range.end, Position::new(0, col + 1));
1062    }
1063
1064    #[test]
1065    fn select_wildcard_lint_ignores_function_stars() {
1066        let text = "SELECT count(*) FROM t;";
1067        assert!(
1068            diagnostics(text)
1069                .iter()
1070                .all(|diag| !diag.message.contains("avoid SELECT *")),
1071            "{text}"
1072        );
1073    }
1074
1075    #[test]
1076    fn large_in_list_lint_is_a_warning() {
1077        let values = (0..101)
1078            .map(|n| n.to_string())
1079            .collect::<Vec<_>>()
1080            .join(", ");
1081        let text = format!("SELECT id FROM t WHERE id IN ({values});");
1082        let diags = diagnostics(&text);
1083        let in_list = diags
1084            .iter()
1085            .find(|d| d.message.contains("large IN list"))
1086            .expect("large IN-list warning");
1087        assert_eq!(in_list.severity, Some(DiagnosticSeverity::WARNING));
1088        let col = text.find("IN").unwrap() as u32;
1089        assert_eq!(in_list.range.start, Position::new(0, col));
1090    }
1091
1092    #[test]
1093    fn normal_in_list_and_subquery_have_no_lint() {
1094        for text in [
1095            "SELECT id FROM t WHERE id IN (1, 2, 3);",
1096            "SELECT id FROM t WHERE id IN (SELECT id FROM src);",
1097        ] {
1098            assert!(
1099                diagnostics(text)
1100                    .iter()
1101                    .all(|diag| !diag.message.contains("large IN list")),
1102                "{text}"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn lint_diagnostics_have_codes_and_respect_options() {
1109        let wildcard = diagnostics("SELECT * FROM t;")
1110            .into_iter()
1111            .find(|diag| diag.message.contains("avoid SELECT *"))
1112            .expect("SELECT * lint");
1113        assert_eq!(
1114            wildcard.code,
1115            Some(lsp_types::NumberOrString::String("SDF001".to_string()))
1116        );
1117
1118        let text = "SELECT id FROM t WHERE id IN (1, 2, 3);";
1119        let diags = diagnostics_with_lint_options(
1120            text,
1121            &FormatOptions::default(),
1122            LintOptions {
1123                large_in_list_threshold: 2,
1124                ..LintOptions::default()
1125            },
1126            PositionEncoding::Utf16,
1127        );
1128        assert!(diags.iter().any(|diag| {
1129            diag.code == Some(lsp_types::NumberOrString::String("SDF002".to_string()))
1130        }));
1131
1132        let disabled = diagnostics_with_lint_options(
1133            text,
1134            &FormatOptions::default(),
1135            LintOptions {
1136                large_in_list: false,
1137                large_in_list_threshold: 2,
1138                ..LintOptions::default()
1139            },
1140            PositionEncoding::Utf16,
1141        );
1142        assert!(
1143            disabled
1144                .iter()
1145                .all(|diag| diag.code
1146                    != Some(lsp_types::NumberOrString::String("SDF002".to_string())))
1147        );
1148    }
1149
1150    #[test]
1151    fn unsupported_embedded_language_has_a_code() {
1152        let diags = diagnostics("CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;");
1153        let language = diags
1154            .iter()
1155            .find(|diag| diag.message.contains("unsupported embedded language RUBY"))
1156            .expect("unsupported language lint");
1157        assert_eq!(
1158            language.code,
1159            Some(lsp_types::NumberOrString::String("SDF003".to_string()))
1160        );
1161    }
1162
1163    #[test]
1164    fn offset_is_the_inverse_of_position() {
1165        let text = "SELECT a\nFROM 芋;\n";
1166        let index = LineIndex::new(text);
1167        for offset in [
1168            0usize,
1169            7,
1170            text.find("FROM").unwrap(),
1171            text.find(';').unwrap(),
1172        ] {
1173            assert_eq!(
1174                lsp_offset(
1175                    &index,
1176                    lsp_position(&index, offset, PositionEncoding::Utf16),
1177                    PositionEncoding::Utf16
1178                ),
1179                offset
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn hover_describes_a_type() {
1186        // Hover over the `varchar` cast target should return a Snowflake type description.
1187        let src = "select x::varchar from t";
1188        let col = src.find("varchar").unwrap() as u32;
1189        let hover = hover(src, Position::new(0, col)).expect("hover");
1190        assert!(hover.range.is_some());
1191        match hover.contents {
1192            HoverContents::Markup(m) => assert!(m.value.to_lowercase().contains("varchar")),
1193            _ => panic!("expected markup"),
1194        }
1195    }
1196
1197    #[test]
1198    fn hover_describes_spec_features_with_a_docs_link() {
1199        // Spec-driven hover: keyword features carry syntax plus a docs link.
1200        let src = "select c from t qualify row_number() over (order by c) = 1";
1201        let col = src.find("qualify").unwrap() as u32;
1202        let hover = hover(src, Position::new(0, col)).expect("hover");
1203        match hover.contents {
1204            HoverContents::Markup(m) => {
1205                assert!(m.value.contains("**QUALIFY**"));
1206                assert!(m.value.contains("QUALIFY <expr>"));
1207                assert!(m.value.contains("[Snowflake docs]("));
1208            }
1209            _ => panic!("expected markup"),
1210        }
1211    }
1212
1213    #[test]
1214    fn hover_describes_function_signatures() {
1215        let src = "select dateadd(day, 1, d) from t";
1216        let col = src.find("dateadd").unwrap() as u32;
1217        let hover = hover(src, Position::new(0, col)).expect("hover");
1218        match hover.contents {
1219            HoverContents::Markup(m) => {
1220                assert!(m.value.contains("DATEADD( <date_or_time_part>"));
1221                assert!(m.value.contains("[Snowflake docs]("));
1222            }
1223            _ => panic!("expected markup"),
1224        }
1225    }
1226
1227    #[test]
1228    fn apply_change_splices_an_incremental_edit() {
1229        // Replace "world" (line 1, cols 0..5) with "snow".
1230        let text = "hello\nworld\n";
1231        let range = Range::new(Position::new(1, 0), Position::new(1, 5));
1232        assert_eq!(apply_change(text, Some(range), "snow"), "hello\nsnow\n");
1233    }
1234
1235    #[test]
1236    fn apply_change_splices_utf8_encoded_ranges() {
1237        let text = "SELECT '長芋'\nFROM t\n";
1238        let start = Position::new(0, "SELECT '長".len() as u32);
1239        let end = Position::new(0, "SELECT '長芋".len() as u32);
1240        assert_eq!(
1241            apply_change_with_encoding(
1242                text,
1243                Some(Range::new(start, end)),
1244                "山芋",
1245                PositionEncoding::Utf8
1246            ),
1247            "SELECT '長山芋'\nFROM t\n"
1248        );
1249    }
1250
1251    #[test]
1252    fn apply_change_with_no_range_replaces_whole_document() {
1253        assert_eq!(apply_change("old", None, "new text"), "new text");
1254    }
1255
1256    #[test]
1257    fn folding_ranges_cover_multiline_statements() {
1258        let ranges = folding_ranges("select a,\nb\nfrom t;\n\nselect 1;");
1259        assert_eq!(ranges.len(), 1); // only the first (multi-line) statement folds
1260        assert_eq!(ranges[0].start_line, 0);
1261        assert_eq!(ranges[0].end_line, 2);
1262    }
1263
1264    #[test]
1265    fn document_symbols_name_top_level_statements() {
1266        let symbols = document_symbols(
1267            "CREATE TABLE db.t (id INT);\n\nSELECT id\nFROM db.t;",
1268            &FormatOptions::default(),
1269        );
1270        assert_eq!(symbols.len(), 2);
1271        assert_eq!(symbols[0].name, "CREATE TABLE db.t");
1272        assert_eq!(symbols[0].kind, SymbolKind::STRUCT);
1273        assert_eq!(symbols[0].selection_range.start, Position::new(0, 13));
1274        assert_eq!(symbols[1].name, "SELECT");
1275        assert_eq!(symbols[1].kind, SymbolKind::FUNCTION);
1276    }
1277
1278    #[test]
1279    fn document_symbols_name_stage_file_operations() {
1280        let symbols = document_symbols(
1281            "PUT file:///tmp/x.csv @stage;\nLIST @stage/path;",
1282            &FormatOptions::default(),
1283        );
1284        assert_eq!(symbols.len(), 2);
1285        assert_eq!(symbols[0].name, "PUT");
1286        assert_eq!(symbols[0].kind, SymbolKind::METHOD);
1287        assert_eq!(symbols[1].name, "LIST");
1288        assert_eq!(symbols[1].kind, SymbolKind::METHOD);
1289    }
1290
1291    #[test]
1292    fn completion_items_include_keywords_types_and_snippets() {
1293        let items = completion_items();
1294        assert!(items.iter().any(|item| {
1295            item.label == "SELECT" && item.kind == Some(CompletionItemKind::KEYWORD)
1296        }));
1297        assert!(items
1298            .iter()
1299            .any(|item| item.label == "NUMBER"
1300                && item.kind == Some(CompletionItemKind::TYPE_PARAMETER)));
1301        assert!(items.iter().any(|item| {
1302            item.label == "CREATE TABLE ..."
1303                && item.kind == Some(CompletionItemKind::SNIPPET)
1304                && item.insert_text_format == Some(InsertTextFormat::SNIPPET)
1305        }));
1306    }
1307
1308    #[test]
1309    fn semantic_tokens_tag_keywords() {
1310        let tokens = semantic_tokens("select a from t");
1311        assert!(!tokens.is_empty());
1312        // The first token is `select`, a keyword (legend index 0), at line 0 column 0.
1313        assert_eq!(tokens[0].delta_line, 0);
1314        assert_eq!(tokens[0].delta_start, 0);
1315        assert_eq!(tokens[0].length, 6);
1316        assert_eq!(tokens[0].token_type, 0);
1317        // The keyword carries the `defaultLibrary` modifier — it must not be hardcoded to 0.
1318        assert_eq!(
1319            tokens[0].token_modifiers_bitset,
1320            semantic::SemanticTokenModifiers::DEFAULT_LIBRARY.bits()
1321        );
1322    }
1323
1324    #[test]
1325    fn server_legend_equals_the_highlighter_legend() {
1326        // The advertised type legend must be exactly the highlighter's LEGEND, in order — this is
1327        // the contract an editor decodes `token_type` against.
1328        let advertised: Vec<String> = token_types()
1329            .iter()
1330            .map(|t| t.as_str().to_string())
1331            .collect();
1332        let expected: Vec<String> = semantic::SemanticTokenType::LEGEND
1333            .iter()
1334            .map(|t| t.name().to_string())
1335            .collect();
1336        assert_eq!(advertised, expected);
1337        // It includes `function` (currently the last appended type) for Cortex/AISQL recognition.
1338        assert_eq!(advertised.last().map(String::as_str), Some("function"));
1339
1340        // Likewise the modifier legend mirrors the highlighter's.
1341        let mods: Vec<String> = token_modifiers()
1342            .iter()
1343            .map(|m| m.as_str().to_string())
1344            .collect();
1345        assert_eq!(mods, vec!["documentation", "defaultLibrary"]);
1346    }
1347
1348    #[test]
1349    fn semantic_tokens_are_monotonic_and_never_panic_on_multiline() {
1350        // A multi-line block comment must split into per-line tokens without panicking.
1351        let tokens = semantic_tokens("select 1 /* a\nb */ from t");
1352        // Deltas must be non-negative by construction (u32) and the stream stays consistent.
1353        assert!(tokens.iter().all(|t| t.length > 0));
1354    }
1355
1356    #[test]
1357    fn semantic_tokens_range_filters_and_reencodes_tokens() {
1358        let text = "SELECT a\nFROM t\nWHERE a = 1";
1359        let tokens = semantic_tokens_range_with_encoding(
1360            text,
1361            Range::new(Position::new(1, 0), Position::new(2, 0)),
1362            PositionEncoding::Utf16,
1363        );
1364        assert!(!tokens.is_empty());
1365        assert_eq!(tokens[0].delta_line, 1);
1366
1367        let mut line = 0u32;
1368        let mut start = 0u32;
1369        for token in tokens {
1370            line += token.delta_line;
1371            start = if token.delta_line == 0 {
1372                start + token.delta_start
1373            } else {
1374                token.delta_start
1375            };
1376            assert_eq!(line, 1);
1377            assert!(start + token.length <= 6);
1378        }
1379    }
1380}