1use 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
17pub fn token_types() -> Vec<SemanticTokenType> {
23 semantic::SemanticTokenType::LEGEND
24 .iter()
25 .map(|ty| SemanticTokenType::new(ty.name()))
26 .collect()
27}
28
29pub fn token_modifiers() -> Vec<SemanticTokenModifier> {
33 semantic::SemanticTokenModifiers::LEGEND
34 .iter()
35 .map(|&name| SemanticTokenModifier::new(name))
36 .collect()
37}
38
39pub struct LineIndex<'a> {
41 text: &'a str,
42 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 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 pub fn end(&self) -> Position {
75 self.position(self.text.len())
76 }
77
78 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; 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
99pub 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
113pub 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
376pub 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
397pub 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 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
424pub 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
444pub 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"; 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)); 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)); }
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 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 assert!(lex_diag.range.end.character > lex_diag.range.start.character);
526 }
527
528 #[test]
529 fn parser_diagnostic_range_covers_the_token() {
530 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 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 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); 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 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 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 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 assert_eq!(advertised.last().map(String::as_str), Some("function"));
713
714 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 let tokens = semantic_tokens("select 1 /* a\nb */ from t");
726 assert!(tokens.iter().all(|t| t.length > 0));
728 }
729}