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