Skip to main content

sql_dialect_fmt_lsp/
lib.rs

1//! LSP feature logic for Snowflake SQL: formatting edits, parse diagnostics, and semantic tokens.
2//!
3//! This module is deliberately transport-free (it never touches stdio or an `lsp-server`
4//! connection), so every feature is a pure `&str -> data` function that is unit-testable. The
5//! binary ([`crate::main`]) is the thin adapter that wires these into a language server.
6//!
7//! Positions follow the LSP convention: zero-based lines and **UTF-16** column offsets.
8
9use lsp_types::{
10    Diagnostic, DiagnosticSeverity, FoldingRange, FoldingRangeKind, Hover, HoverContents,
11    MarkupContent, MarkupKind, Position, Range, SemanticToken, SemanticTokenModifier,
12    SemanticTokenType, TextEdit,
13};
14use sql_dialect_fmt_formatter::{format, FormatOptions};
15use sql_dialect_fmt_highlight::{semantic, HighlightKind};
16
17/// The semantic-token type legend, mirrored from the single source of truth in
18/// `sql-dialect-fmt-highlight` ([`semantic::SemanticTokenType::LEGEND`]). A token's `token_type` field is
19/// an index into this slice, so the order here is the contract with the editor (declared in the
20/// server's capabilities) and *must* equal the highlighter's legend — otherwise an editor would
21/// decode our token types against the wrong names.
22pub fn token_types() -> Vec<SemanticTokenType> {
23    semantic::SemanticTokenType::LEGEND
24        .iter()
25        .map(|ty| SemanticTokenType::new(ty.name()))
26        .collect()
27}
28
29/// The semantic-token modifier legend, mirrored from
30/// [`semantic::SemanticTokenModifiers::LEGEND`]. A token's `token_modifiers_bitset` is decoded
31/// bit-by-bit against this slice, so it too must match the highlighter.
32pub fn token_modifiers() -> Vec<SemanticTokenModifier> {
33    semantic::SemanticTokenModifiers::LEGEND
34        .iter()
35        .map(|&name| SemanticTokenModifier::new(name))
36        .collect()
37}
38
39/// Maps byte offsets into a document to LSP [`Position`]s (UTF-16 columns).
40pub struct LineIndex<'a> {
41    text: &'a str,
42    /// Byte offset of the start of each line.
43    line_starts: Vec<usize>,
44}
45
46impl<'a> LineIndex<'a> {
47    pub fn new(text: &'a str) -> Self {
48        let mut line_starts = vec![0];
49        line_starts.extend(
50            text.bytes()
51                .enumerate()
52                .filter(|&(_, b)| b == b'\n')
53                .map(|(i, _)| i + 1),
54        );
55        LineIndex { text, line_starts }
56    }
57
58    /// The LSP position of a byte `offset` (clamped to the document end).
59    pub fn position(&self, offset: usize) -> Position {
60        let offset = offset.min(self.text.len());
61        let line = match self.line_starts.binary_search(&offset) {
62            Ok(line) => line,
63            Err(next) => next - 1,
64        };
65        let line_start = self.line_starts[line];
66        let col: usize = self.text[line_start..offset]
67            .chars()
68            .map(char::len_utf16)
69            .sum();
70        Position::new(line as u32, col as u32)
71    }
72
73    /// The position one past the last character — the end of the document.
74    pub fn end(&self) -> Position {
75        self.position(self.text.len())
76    }
77
78    /// The byte offset of an LSP [`Position`] (the inverse of [`Self::position`]). Out-of-range
79    /// lines/columns clamp to the line or document end.
80    pub fn offset(&self, position: Position) -> usize {
81        let line = position.line as usize;
82        let Some(&line_start) = self.line_starts.get(line) else {
83            return self.text.len();
84        };
85        let mut remaining = position.character as usize; // UTF-16 units to consume
86        let mut offset = line_start;
87        for ch in self.text[line_start..].chars() {
88            let width = ch.len_utf16();
89            if remaining < width || ch == '\n' {
90                break;
91            }
92            remaining -= width;
93            offset += ch.len_utf8();
94        }
95        offset
96    }
97}
98
99/// The edits to apply for `textDocument/formatting`: a single whole-document replacement, or an
100/// empty list when the input is already formatted (so the editor records no change).
101pub fn format_edits(text: &str, options: &FormatOptions) -> Vec<TextEdit> {
102    let formatted = format(text, options);
103    if formatted == text {
104        return Vec::new();
105    }
106    let index = LineIndex::new(text);
107    vec![TextEdit {
108        range: Range::new(Position::new(0, 0), index.end()),
109        new_text: formatted,
110    }]
111}
112
113/// Diagnostics for `textDocument/publishDiagnostics`: both lexer and parser errors. Neither stage
114/// ever fails, so this is the set of *recovered* errors (empty for clean input).
115///
116/// Lexer errors (unterminated literals/comments, stray characters) are surfaced too — they are
117/// reported by the tokenizer before the parser runs, so the parser-only error list would otherwise
118/// miss them. We read them through the highlighter, which already re-exposes the lexer's errors.
119/// Each diagnostic's range covers the whole offending token (via its byte `range()`), not a single
120/// character, so editors underline the real span.
121pub fn diagnostics(text: &str) -> Vec<Diagnostic> {
122    let index = LineIndex::new(text);
123    let to_range = |span: std::ops::Range<usize>| {
124        Range::new(index.position(span.start), index.position(span.end))
125    };
126    let make = |range: Range, message: String| Diagnostic {
127        range,
128        severity: Some(DiagnosticSeverity::ERROR),
129        source: Some("sql-dialect-fmt".to_string()),
130        message,
131        ..Default::default()
132    };
133
134    let lex_errors = sql_dialect_fmt_highlight::highlight(text).errors;
135    let parse = sql_dialect_fmt_parser::parse(text);
136    let mut diagnostics: Vec<_> = lex_errors
137        .into_iter()
138        .map(|err| make(to_range(err.range()), err.message))
139        .chain(
140            parse
141                .errors()
142                .iter()
143                .map(|err| make(to_range(err.range()), err.message.clone())),
144        )
145        .collect();
146    diagnostics.extend(embedded_language_diagnostics(text, &index));
147    diagnostics.extend(lint_diagnostics(text, &index));
148    diagnostics
149}
150
151fn lint_diagnostics(text: &str, index: &LineIndex<'_>) -> Vec<Diagnostic> {
152    let tokens = sql_dialect_fmt_highlight::highlight(text).tokens;
153    let mut diagnostics = Vec::new();
154
155    diagnostics.extend(select_wildcard_diagnostics(&tokens, index));
156    diagnostics.extend(large_in_list_diagnostics(&tokens, index));
157
158    diagnostics
159}
160
161fn lint_warning(index: &LineIndex<'_>, range: std::ops::Range<usize>, message: &str) -> Diagnostic {
162    Diagnostic {
163        range: Range::new(index.position(range.start), index.position(range.end)),
164        severity: Some(DiagnosticSeverity::WARNING),
165        source: Some("sql-dialect-fmt".to_string()),
166        message: message.to_string(),
167        ..Default::default()
168    }
169}
170
171fn select_wildcard_diagnostics(
172    tokens: &[sql_dialect_fmt_highlight::HighlightToken<'_>],
173    index: &LineIndex<'_>,
174) -> Vec<Diagnostic> {
175    let mut diagnostics = Vec::new();
176    let mut in_select_list = false;
177    let mut paren_depth = 0usize;
178    let mut previous_significant: Option<&str> = None;
179
180    for token in tokens.iter().filter(|token| is_significant(token.kind)) {
181        if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("select") {
182            in_select_list = true;
183            paren_depth = 0;
184            previous_significant = Some(token.text);
185            continue;
186        }
187
188        if in_select_list {
189            match token.text {
190                "(" => paren_depth += 1,
191                ")" => paren_depth = paren_depth.saturating_sub(1),
192                ";" if paren_depth == 0 => in_select_list = false,
193                _ => {
194                    if paren_depth == 0
195                        && token.kind == HighlightKind::Keyword
196                        && token.text.eq_ignore_ascii_case("from")
197                    {
198                        in_select_list = false;
199                    } else if paren_depth == 0
200                        && token.text == "*"
201                        && previous_significant.is_some_and(is_wildcard_prefix)
202                    {
203                        diagnostics.push(lint_warning(
204                            index,
205                            token.range.clone(),
206                            "avoid SELECT * in shared SQL; list columns explicitly",
207                        ));
208                    }
209                }
210            }
211        }
212
213        previous_significant = Some(token.text);
214    }
215
216    diagnostics
217}
218
219fn is_wildcard_prefix(text: &str) -> bool {
220    matches!(text, "," | ".")
221        || text.eq_ignore_ascii_case("select")
222        || text.eq_ignore_ascii_case("distinct")
223        || text.eq_ignore_ascii_case("all")
224}
225
226fn large_in_list_diagnostics(
227    tokens: &[sql_dialect_fmt_highlight::HighlightToken<'_>],
228    index: &LineIndex<'_>,
229) -> Vec<Diagnostic> {
230    const LARGE_IN_LIST_THRESHOLD: usize = 100;
231
232    #[derive(Debug)]
233    struct InList {
234        start: usize,
235        depth: usize,
236        commas: usize,
237        saw_top_level_item: bool,
238        possible_subquery: bool,
239    }
240
241    let mut diagnostics = Vec::new();
242    let mut pending_in: Option<usize> = None;
243    let mut list: Option<InList> = None;
244
245    for token in tokens.iter().filter(|token| is_significant(token.kind)) {
246        let mut close_list_at: Option<usize> = None;
247        if let Some(active) = list.as_mut() {
248            match token.text {
249                "(" => active.depth += 1,
250                ")" => {
251                    active.depth = active.depth.saturating_sub(1);
252                    if active.depth == 0 {
253                        close_list_at = Some(token.range.end);
254                    }
255                }
256                "," if active.depth == 1 => active.commas += 1,
257                _ if active.depth == 1 && !active.saw_top_level_item => {
258                    active.saw_top_level_item = true;
259                    if token.kind == HighlightKind::Keyword
260                        && (token.text.eq_ignore_ascii_case("select")
261                            || token.text.eq_ignore_ascii_case("with"))
262                    {
263                        active.possible_subquery = true;
264                    }
265                }
266                _ => {}
267            }
268
269            if let Some(end) = close_list_at {
270                let active = list.take().expect("closing active IN list");
271                let item_count = if active.saw_top_level_item {
272                    active.commas + 1
273                } else {
274                    0
275                };
276                if !active.possible_subquery && item_count > LARGE_IN_LIST_THRESHOLD {
277                    diagnostics.push(lint_warning(
278                        index,
279                        active.start..end,
280                        "large IN list; prefer a temp table, CTE, or semi-join when practical",
281                    ));
282                }
283            }
284            continue;
285        }
286
287        if let Some(start) = pending_in.take() {
288            if token.text == "(" {
289                list = Some(InList {
290                    start,
291                    depth: 1,
292                    commas: 0,
293                    saw_top_level_item: false,
294                    possible_subquery: false,
295                });
296                continue;
297            }
298        }
299
300        if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("in") {
301            pending_in = Some(token.range.start);
302        }
303    }
304
305    diagnostics
306}
307
308fn is_significant(kind: HighlightKind) -> bool {
309    !matches!(kind, HighlightKind::Whitespace | HighlightKind::Comment)
310}
311
312fn embedded_language_diagnostics(text: &str, index: &LineIndex<'_>) -> Vec<Diagnostic> {
313    let mut diagnostics = Vec::new();
314    let mut expect_language_name = false;
315    let mut language_name: Option<(&str, std::ops::Range<usize>)> = None;
316    let mut saw_as_after_language = false;
317
318    for token in sql_dialect_fmt_highlight::highlight(text).tokens {
319        match token.kind {
320            HighlightKind::Whitespace | HighlightKind::Comment => {}
321            HighlightKind::Punctuation if token.text == ";" => {
322                expect_language_name = false;
323                language_name = None;
324                saw_as_after_language = false;
325            }
326            HighlightKind::DollarString => {
327                if saw_as_after_language {
328                    if let Some((word, range)) = language_name.take() {
329                        if !is_supported_embedded_language(word) {
330                            diagnostics.push(Diagnostic {
331                                range: Range::new(index.position(range.start), index.position(range.end)),
332                                severity: Some(DiagnosticSeverity::WARNING),
333                                source: Some("sql-dialect-fmt".to_string()),
334                                message: format!(
335                                    "unsupported embedded language {word}; expected SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA"
336                                ),
337                                ..Default::default()
338                            });
339                        }
340                    }
341                }
342                expect_language_name = false;
343                saw_as_after_language = false;
344            }
345            HighlightKind::Keyword if token.text.eq_ignore_ascii_case("language") => {
346                expect_language_name = true;
347                language_name = None;
348                saw_as_after_language = false;
349            }
350            HighlightKind::Keyword | HighlightKind::Identifier | HighlightKind::Type
351                if expect_language_name =>
352            {
353                language_name = Some((token.text, token.range));
354                expect_language_name = false;
355            }
356            HighlightKind::Keyword
357                if language_name.is_some() && token.text.eq_ignore_ascii_case("as") =>
358            {
359                saw_as_after_language = true;
360            }
361            _ => {
362                expect_language_name = false;
363            }
364        }
365    }
366
367    diagnostics
368}
369
370fn is_supported_embedded_language(word: &str) -> bool {
371    ["SQL", "JAVASCRIPT", "PYTHON", "JAVA", "SCALA"]
372        .iter()
373        .any(|candidate| candidate.eq_ignore_ascii_case(word))
374}
375
376/// Hover information for `textDocument/hover`: the keyword/type/symbol description at `position`,
377/// rendered as Markdown with an optional docs link, scoped to the hovered token's range.
378pub fn hover(text: &str, position: Position) -> Option<Hover> {
379    let index = LineIndex::new(text);
380    let info = sql_dialect_fmt_hover::hover_at(text, index.offset(position))?;
381    let mut value = format!("**{}**\n\n{}", info.title, info.body);
382    if let Some(url) = info.docs_url {
383        value.push_str(&format!("\n\n[Snowflake docs]({url})"));
384    }
385    Some(Hover {
386        contents: HoverContents::Markup(MarkupContent {
387            kind: MarkupKind::Markdown,
388            value,
389        }),
390        range: Some(Range::new(
391            index.position(info.range.start),
392            index.position(info.range.end),
393        )),
394    })
395}
396
397/// Folding ranges for `textDocument/foldingRange`: one region per multi-line top-level statement,
398/// so an editor can collapse each statement in a script. The CST's root children are the statements.
399pub fn folding_ranges(text: &str) -> Vec<FoldingRange> {
400    let index = LineIndex::new(text);
401    let root = sql_dialect_fmt_parser::parse(text).syntax();
402    root.children()
403        .filter_map(|stmt| {
404            // Use the span of the statement's significant tokens, so a leading/trailing blank line
405            // (attached as trivia) doesn't inflate a single-line statement into a foldable region.
406            let mut tokens = stmt
407                .descendants_with_tokens()
408                .filter_map(|el| el.into_token())
409                .filter(|t| !t.kind().is_trivia());
410            let first = tokens.next()?;
411            let last = tokens.last().unwrap_or_else(|| first.clone());
412            let start = index.position(first.text_range().start().into()).line;
413            let end = index.position(last.text_range().end().into()).line;
414            (end > start).then_some(FoldingRange {
415                start_line: start,
416                end_line: end,
417                kind: Some(FoldingRangeKind::Region),
418                ..FoldingRange::default()
419            })
420        })
421        .collect()
422}
423
424/// Apply one `textDocument/didChange` content change to `text`, returning the new document.
425///
426/// Supports incremental sync: a `Some(range)` splices `new_text` over the byte span the range
427/// covers; a `None` range is a whole-document replacement (the editor sent the full text). The
428/// range is treated as ordered and clamped to the document, so a malformed event can't panic.
429pub fn apply_change(text: &str, range: Option<Range>, new_text: &str) -> String {
430    let Some(range) = range else {
431        return new_text.to_string();
432    };
433    let index = LineIndex::new(text);
434    let a = index.offset(range.start);
435    let b = index.offset(range.end);
436    let (start, end) = (a.min(b), a.max(b));
437    let mut out = String::with_capacity(text.len() - (end - start) + new_text.len());
438    out.push_str(&text[..start]);
439    out.push_str(new_text);
440    out.push_str(&text[end..]);
441    out
442}
443
444/// The delta-encoded semantic tokens for `textDocument/semanticTokens/full`.
445///
446/// This is a thin adapter over `sql-dialect-fmt-highlight`'s [`semantic::semantic_tokens_lsp`]: the
447/// highlighter already splits multi-line tokens (block comments, dollar-quoted strings) into one
448/// token per line — the LSP encoding requires each token to stay on a single line — computes UTF-16
449/// columns/lengths, and delta-encodes into `(deltaLine, deltaStartChar, length, tokenType,
450/// tokenModifiers)` quintuples against the same legend the server advertises. We only reshape each
451/// quintuple into an `lsp_types::SemanticToken`, **preserving** the modifier bitset (rather than
452/// hardcoding 0) so `defaultLibrary` / `documentation` modifiers reach the editor.
453pub fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
454    semantic::semantic_tokens_lsp(text)
455        .into_iter()
456        .map(
457            |[delta_line, delta_start, length, token_type, token_modifiers_bitset]| SemanticToken {
458                delta_line,
459                delta_start,
460                length,
461                token_type,
462                token_modifiers_bitset,
463            },
464        )
465        .collect()
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn line_index_maps_offsets_to_utf16_positions() {
474        let text = "SELECT a\nFROM 芋;\n"; // 芋 is one UTF-16 unit but 3 bytes
475        let index = LineIndex::new(text);
476        assert_eq!(index.position(0), Position::new(0, 0));
477        assert_eq!(index.position(7), Position::new(0, 7)); // the `a`
478        let from = text.find("FROM").unwrap();
479        assert_eq!(index.position(from), Position::new(1, 0));
480        let semicolon = text.find(';').unwrap();
481        assert_eq!(index.position(semicolon), Position::new(1, 6)); // FROM<sp>芋 = 6 utf16 units
482    }
483
484    #[test]
485    fn formatting_replaces_the_whole_document() {
486        let edits = format_edits("select a,b from t", &FormatOptions::default());
487        assert_eq!(edits.len(), 1);
488        assert_eq!(edits[0].new_text, "SELECT a, b\nFROM t;\n");
489        assert_eq!(edits[0].range.start, Position::new(0, 0));
490    }
491
492    #[test]
493    fn already_formatted_input_yields_no_edits() {
494        let formatted = "SELECT a, b\nFROM t;\n";
495        assert!(format_edits(formatted, &FormatOptions::default()).is_empty());
496    }
497
498    #[test]
499    fn clean_sql_has_no_diagnostics() {
500        assert!(diagnostics("select 1").is_empty());
501    }
502
503    #[test]
504    fn broken_sql_reports_a_diagnostic() {
505        let diags = diagnostics("select from where");
506        assert!(!diags.is_empty());
507        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
508    }
509
510    #[test]
511    fn lexer_errors_reach_diagnostics() {
512        // An unterminated string is a *lexer* error (the parser never sees it as a token boundary
513        // problem). It must still surface as a diagnostic, and its range must cover the whole
514        // unterminated literal, not a single character.
515        let text = "SELECT 'oops";
516        let diags = diagnostics(text);
517        let lex_diag = diags
518            .iter()
519            .find(|d| d.message.contains("unterminated string"))
520            .expect("an unterminated-string diagnostic");
521        assert_eq!(lex_diag.severity, Some(DiagnosticSeverity::ERROR));
522        let quote = text.find('\'').unwrap() as u32;
523        assert_eq!(lex_diag.range.start, Position::new(0, quote));
524        // Spans to end of the line (the whole literal), so end column > start column.
525        assert!(lex_diag.range.end.character > lex_diag.range.start.character);
526    }
527
528    #[test]
529    fn parser_diagnostic_range_covers_the_token() {
530        // `MERGE tgt ...` (no INTO) reports "expected INTO" at the `tgt` token; the LSP range must
531        // span the whole 3-character identifier, not one character.
532        let text = "MERGE tgt USING src ON a = b";
533        let diags = diagnostics(text);
534        let into = diags
535            .iter()
536            .find(|d| d.message == "expected INTO")
537            .expect("an INTO diagnostic");
538        let col = text.find("tgt").unwrap() as u32;
539        assert_eq!(into.range.start, Position::new(0, col));
540        assert_eq!(into.range.end, Position::new(0, col + 3));
541    }
542
543    #[test]
544    fn clean_sql_still_has_no_lexer_or_parser_diagnostics() {
545        assert!(diagnostics("SELECT a FROM t").is_empty());
546    }
547
548    #[test]
549    fn unsupported_embedded_language_is_a_warning() {
550        let text = "CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;";
551        let diags = diagnostics(text);
552        let language = diags
553            .iter()
554            .find(|d| d.message.contains("unsupported embedded language RUBY"))
555            .expect("unsupported-language diagnostic");
556        assert_eq!(language.severity, Some(DiagnosticSeverity::WARNING));
557        let col = text.find("RUBY").unwrap() as u32;
558        assert_eq!(language.range.start, Position::new(0, col));
559        assert_eq!(language.range.end, Position::new(0, col + 4));
560    }
561
562    #[test]
563    fn embedded_language_warning_does_not_fire_for_plain_columns_or_dynamic_sql() {
564        for text in [
565            "SELECT language FROM t;",
566            "EXECUTE IMMEDIATE $$ SELECT 1 $$;",
567        ] {
568            assert!(
569                diagnostics(text)
570                    .iter()
571                    .all(|diag| !diag.message.contains("unsupported embedded language")),
572                "{text}"
573            );
574        }
575    }
576
577    #[test]
578    fn select_wildcard_lint_is_a_warning() {
579        let text = "SELECT * FROM t;";
580        let diags = diagnostics(text);
581        let wildcard = diags
582            .iter()
583            .find(|d| d.message.contains("avoid SELECT *"))
584            .expect("SELECT * warning");
585        assert_eq!(wildcard.severity, Some(DiagnosticSeverity::WARNING));
586        let col = text.find('*').unwrap() as u32;
587        assert_eq!(wildcard.range.start, Position::new(0, col));
588        assert_eq!(wildcard.range.end, Position::new(0, col + 1));
589    }
590
591    #[test]
592    fn select_wildcard_lint_ignores_function_stars() {
593        let text = "SELECT count(*) FROM t;";
594        assert!(
595            diagnostics(text)
596                .iter()
597                .all(|diag| !diag.message.contains("avoid SELECT *")),
598            "{text}"
599        );
600    }
601
602    #[test]
603    fn large_in_list_lint_is_a_warning() {
604        let values = (0..101)
605            .map(|n| n.to_string())
606            .collect::<Vec<_>>()
607            .join(", ");
608        let text = format!("SELECT id FROM t WHERE id IN ({values});");
609        let diags = diagnostics(&text);
610        let in_list = diags
611            .iter()
612            .find(|d| d.message.contains("large IN list"))
613            .expect("large IN-list warning");
614        assert_eq!(in_list.severity, Some(DiagnosticSeverity::WARNING));
615        let col = text.find("IN").unwrap() as u32;
616        assert_eq!(in_list.range.start, Position::new(0, col));
617    }
618
619    #[test]
620    fn normal_in_list_and_subquery_have_no_lint() {
621        for text in [
622            "SELECT id FROM t WHERE id IN (1, 2, 3);",
623            "SELECT id FROM t WHERE id IN (SELECT id FROM src);",
624        ] {
625            assert!(
626                diagnostics(text)
627                    .iter()
628                    .all(|diag| !diag.message.contains("large IN list")),
629                "{text}"
630            );
631        }
632    }
633
634    #[test]
635    fn offset_is_the_inverse_of_position() {
636        let text = "SELECT a\nFROM 芋;\n";
637        let index = LineIndex::new(text);
638        for offset in [
639            0usize,
640            7,
641            text.find("FROM").unwrap(),
642            text.find(';').unwrap(),
643        ] {
644            assert_eq!(index.offset(index.position(offset)), offset);
645        }
646    }
647
648    #[test]
649    fn hover_describes_a_type() {
650        // Hover over the `varchar` cast target should return a Snowflake type description.
651        let src = "select x::varchar from t";
652        let col = src.find("varchar").unwrap() as u32;
653        let hover = hover(src, Position::new(0, col)).expect("hover");
654        assert!(hover.range.is_some());
655        match hover.contents {
656            HoverContents::Markup(m) => assert!(m.value.to_lowercase().contains("varchar")),
657            _ => panic!("expected markup"),
658        }
659    }
660
661    #[test]
662    fn apply_change_splices_an_incremental_edit() {
663        // Replace "world" (line 1, cols 0..5) with "snow".
664        let text = "hello\nworld\n";
665        let range = Range::new(Position::new(1, 0), Position::new(1, 5));
666        assert_eq!(apply_change(text, Some(range), "snow"), "hello\nsnow\n");
667    }
668
669    #[test]
670    fn apply_change_with_no_range_replaces_whole_document() {
671        assert_eq!(apply_change("old", None, "new text"), "new text");
672    }
673
674    #[test]
675    fn folding_ranges_cover_multiline_statements() {
676        let ranges = folding_ranges("select a,\nb\nfrom t;\n\nselect 1;");
677        assert_eq!(ranges.len(), 1); // only the first (multi-line) statement folds
678        assert_eq!(ranges[0].start_line, 0);
679        assert_eq!(ranges[0].end_line, 2);
680    }
681
682    #[test]
683    fn semantic_tokens_tag_keywords() {
684        let tokens = semantic_tokens("select a from t");
685        assert!(!tokens.is_empty());
686        // The first token is `select`, a keyword (legend index 0), at line 0 column 0.
687        assert_eq!(tokens[0].delta_line, 0);
688        assert_eq!(tokens[0].delta_start, 0);
689        assert_eq!(tokens[0].length, 6);
690        assert_eq!(tokens[0].token_type, 0);
691        // The keyword carries the `defaultLibrary` modifier — it must not be hardcoded to 0.
692        assert_eq!(
693            tokens[0].token_modifiers_bitset,
694            semantic::SemanticTokenModifiers::DEFAULT_LIBRARY.bits()
695        );
696    }
697
698    #[test]
699    fn server_legend_equals_the_highlighter_legend() {
700        // The advertised type legend must be exactly the highlighter's LEGEND, in order — this is
701        // the contract an editor decodes `token_type` against.
702        let advertised: Vec<String> = token_types()
703            .iter()
704            .map(|t| t.as_str().to_string())
705            .collect();
706        let expected: Vec<String> = semantic::SemanticTokenType::LEGEND
707            .iter()
708            .map(|t| t.name().to_string())
709            .collect();
710        assert_eq!(advertised, expected);
711        // It includes `function` (currently the last appended type) for Cortex/AISQL recognition.
712        assert_eq!(advertised.last().map(String::as_str), Some("function"));
713
714        // Likewise the modifier legend mirrors the highlighter's.
715        let mods: Vec<String> = token_modifiers()
716            .iter()
717            .map(|m| m.as_str().to_string())
718            .collect();
719        assert_eq!(mods, vec!["documentation", "defaultLibrary"]);
720    }
721
722    #[test]
723    fn semantic_tokens_are_monotonic_and_never_panic_on_multiline() {
724        // A multi-line block comment must split into per-line tokens without panicking.
725        let tokens = semantic_tokens("select 1 /* a\nb */ from t");
726        // Deltas must be non-negative by construction (u32) and the stream stays consistent.
727        assert!(tokens.iter().all(|t| t.length > 0));
728    }
729}