Skip to main content

sql_dialect_fmt_hover/
lib.rs

1//! Hover information for Snowflake SQL editor integrations.
2//!
3//! This crate is intentionally LSP-agnostic. LSP, Tree-sitter adapters, and CLI
4//! diagnostics can all call [`hover_at`] and translate the result into their own
5//! wire format.
6//!
7//! Besides the hand-written type/procedure/task descriptions, hover covers the
8//! Snowflake feature inventory and function signature table maintained in
9//! `spec/seed/*.json`. Those tables are checked in as `generated.rs` and
10//! refreshed with `python3 scripts/generate-hover-tables.py`.
11
12use sql_dialect_fmt_syntax::SyntaxKind;
13use std::ops::Range;
14
15mod data;
16mod generated;
17mod scan;
18mod spec;
19
20use data::{
21    keyword_template, language_template, property_template, type_info, StaticHover,
22    ROUTINE_OPTION_STOPS,
23};
24use scan::{spanned_tokens, token_at, SpannedToken};
25
26pub use data::{CREATE_PROCEDURE_DOCS, CREATE_TASK_DOCS, DATA_TYPES_DOCS};
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Hover {
30    pub kind: HoverKind,
31    pub title: String,
32    pub body: String,
33    pub range: Range<usize>,
34    pub docs_url: Option<&'static str>,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum HoverKind {
39    Keyword,
40    Type,
41    Procedure,
42    Task,
43    Language,
44    Property,
45    /// A spec-tracker feature (statement, clause, predicate, ...) with its
46    /// syntax, Snowflake status, and parser coverage.
47    Feature,
48    /// A function signature from the spec function table.
49    Function,
50}
51
52/// Return hover information for the token at `offset`.
53///
54/// Offsets are byte offsets, matching LSP's UTF-8 internal representation after
55/// the caller converts from line/column. Trivia currently has no hover.
56pub fn hover_at(source: &str, offset: usize) -> Option<Hover> {
57    let tokens = spanned_tokens(source);
58    let index = token_at(&tokens, offset)?;
59    let token = tokens[index].clone();
60
61    if let Some(hover) = procedure_symbol_hover(source, &tokens, index) {
62        return Some(hover);
63    }
64    if let Some(hover) = task_symbol_hover(source, &tokens, index) {
65        return Some(hover);
66    }
67    if let Some(hover) = type_hover(&token) {
68        return Some(hover);
69    }
70    if let Some(hover) = language_hover(&token) {
71        return Some(hover);
72    }
73    if let Some(hover) = property_hover(&token) {
74        return Some(hover);
75    }
76    if let Some(hover) = spec::function_call_hover(&tokens, index) {
77        return Some(hover);
78    }
79    if let Some(hover) = keyword_hover(&token) {
80        return Some(hover);
81    }
82    spec::feature_hover(&tokens, index)
83}
84
85fn procedure_symbol_hover(
86    source: &str,
87    tokens: &[SpannedToken<'_>],
88    index: usize,
89) -> Option<Hover> {
90    let object = object_declaration(tokens, index, "PROCEDURE")?;
91    if word(tokens.get(object.keyword + 1)?, "SCOPED") {
92        return None;
93    }
94    let name_range = procedure_name_range(tokens, object.keyword, object.end)?;
95    if !name_range.contains(&index) {
96        return None;
97    }
98
99    let name = compact_token_text(tokens, name_range.clone());
100    let args = procedure_args(source, tokens, name_range.end, object.end)
101        .unwrap_or_else(|| String::from(""));
102    let returns = clause_after_keyword(
103        source,
104        tokens,
105        object.keyword,
106        object.end,
107        "RETURNS",
108        &[
109            "LANGUAGE",
110            "RUNTIME_VERSION",
111            "PACKAGES",
112            "IMPORTS",
113            "HANDLER",
114            "AS",
115            "COMMENT",
116            "EXECUTE",
117        ],
118    );
119    let language = value_after_keyword(source, tokens, object.keyword, object.end, "LANGUAGE");
120    let handler =
121        routine_option_after_keyword(source, tokens, object.keyword, object.end, "HANDLER");
122    let runtime = routine_option_after_keyword(
123        source,
124        tokens,
125        object.keyword,
126        object.end,
127        "RUNTIME_VERSION",
128    );
129    let packages =
130        routine_option_after_keyword(source, tokens, object.keyword, object.end, "PACKAGES");
131    let imports =
132        routine_option_after_keyword(source, tokens, object.keyword, object.end, "IMPORTS");
133    let target_path =
134        routine_option_after_keyword(source, tokens, object.keyword, object.end, "TARGET_PATH");
135
136    let mut lines = vec![format!("Stored procedure `{name}`.")];
137    if !args.is_empty() {
138        lines.push(format!("Arguments: `{args}`."));
139    }
140    if let Some(returns) = returns {
141        lines.push(format!("Returns: `{returns}`."));
142    }
143    if let Some(language) = language {
144        lines.push(format!("Language: `{language}`."));
145    }
146    if let Some(handler) = handler {
147        lines.push(format!("Handler: `{handler}`."));
148    }
149    if let Some(runtime) = runtime {
150        lines.push(format!("Runtime: `{runtime}`."));
151    }
152    if let Some(packages) = packages {
153        lines.push(format!("Packages: `{packages}`."));
154    }
155    if let Some(imports) = imports {
156        lines.push(format!("Imports: `{imports}`."));
157    }
158    if let Some(target_path) = target_path {
159        lines.push(format!("Target path: `{target_path}`."));
160    }
161    lines.push(String::from(
162        "Snowflake resolves stored procedures by name plus argument types.",
163    ));
164    lines.push(String::from(
165        "External-language procedures usually pair LANGUAGE with HANDLER, PACKAGES, IMPORTS, and RUNTIME_VERSION.",
166    ));
167
168    Some(Hover {
169        kind: HoverKind::Procedure,
170        title: format!("Stored procedure `{name}`"),
171        body: lines.join("\n"),
172        range: combined_range(tokens, name_range),
173        docs_url: Some(CREATE_PROCEDURE_DOCS),
174    })
175}
176
177fn task_symbol_hover(source: &str, tokens: &[SpannedToken<'_>], index: usize) -> Option<Hover> {
178    let object = object_declaration(tokens, index, "TASK")?;
179    let name_range = task_name_range(tokens, object.keyword, object.end)?;
180    if !name_range.contains(&index) {
181        return None;
182    }
183
184    let name = compact_token_text(tokens, name_range.clone());
185    let warehouse = value_after_keyword(source, tokens, object.keyword, object.end, "WAREHOUSE")
186        .or_else(|| {
187            value_after_keyword(
188                source,
189                tokens,
190                object.keyword,
191                object.end,
192                "USER_TASK_MANAGED_INITIAL_WAREHOUSE_SIZE",
193            )
194        });
195    let schedule = value_after_keyword(source, tokens, object.keyword, object.end, "SCHEDULE");
196    let after = clause_after_keyword(
197        source,
198        tokens,
199        object.keyword,
200        object.end,
201        "AFTER",
202        &["WHEN", "AS", "EXECUTE", "COMMENT", "FINALIZE"],
203    );
204    let when = clause_after_keyword(source, tokens, object.keyword, object.end, "WHEN", &["AS"]);
205
206    let mut lines = vec![format!("Task `{name}`.")];
207    if let Some(warehouse) = warehouse {
208        lines.push(format!("Compute: `{warehouse}`."));
209    }
210    if let Some(schedule) = schedule {
211        lines.push(format!("Schedule: `{schedule}`."));
212    }
213    if let Some(after) = after {
214        lines.push(format!("Predecessors: `{after}`."));
215    }
216    if let Some(when) = when {
217        lines.push(format!("Condition: `{when}`."));
218    }
219    lines.push(String::from(
220        "Tasks run SQL on a schedule or after predecessor tasks; newly created tasks start suspended.",
221    ));
222
223    Some(Hover {
224        kind: HoverKind::Task,
225        title: format!("Task `{name}`"),
226        body: lines.join("\n"),
227        range: combined_range(tokens, name_range),
228        docs_url: Some(CREATE_TASK_DOCS),
229    })
230}
231
232#[derive(Clone, Copy)]
233struct ObjectDeclaration {
234    keyword: usize,
235    end: usize,
236}
237
238fn object_declaration(
239    tokens: &[SpannedToken<'_>],
240    index: usize,
241    object_keyword: &str,
242) -> Option<ObjectDeclaration> {
243    let (start, end) = statement_bounds(tokens, index);
244    for keyword in (start..=index.min(end.saturating_sub(1))).rev() {
245        if !word(&tokens[keyword], object_keyword) {
246            continue;
247        }
248        if tokens[start..keyword]
249            .iter()
250            .any(|token| word(token, "CREATE"))
251        {
252            return Some(ObjectDeclaration { keyword, end });
253        }
254    }
255    None
256}
257
258fn statement_bounds(tokens: &[SpannedToken<'_>], index: usize) -> (usize, usize) {
259    let start = tokens[..index]
260        .iter()
261        .rposition(|token| token.kind == SyntaxKind::SEMICOLON)
262        .map_or(0, |idx| idx + 1);
263    let end = tokens[index..]
264        .iter()
265        .position(|token| token.kind == SyntaxKind::SEMICOLON)
266        .map_or(tokens.len(), |relative| index + relative);
267    (start, end)
268}
269
270fn procedure_name_range(
271    tokens: &[SpannedToken<'_>],
272    procedure_keyword: usize,
273    end: usize,
274) -> Option<Range<usize>> {
275    let start = procedure_keyword + 1;
276    let mut cursor = start;
277    while cursor < end && tokens[cursor].kind != SyntaxKind::L_PAREN {
278        if !is_name_part(&tokens[cursor]) {
279            return None;
280        }
281        cursor += 1;
282    }
283    (start < cursor).then_some(start..cursor)
284}
285
286fn task_name_range(
287    tokens: &[SpannedToken<'_>],
288    task_keyword: usize,
289    end: usize,
290) -> Option<Range<usize>> {
291    let mut start = task_keyword + 1;
292    if word_seq(tokens, start, &["IF", "NOT", "EXISTS"]) {
293        start += 3;
294    }
295
296    let mut cursor = start;
297    while cursor < end && is_name_part(&tokens[cursor]) && !is_clause_boundary(&tokens[cursor]) {
298        cursor += 1;
299    }
300    (start < cursor).then_some(start..cursor)
301}
302
303fn procedure_args(
304    source: &str,
305    tokens: &[SpannedToken<'_>],
306    start: usize,
307    end: usize,
308) -> Option<String> {
309    let open = (start..end).find(|&idx| tokens[idx].kind == SyntaxKind::L_PAREN)?;
310    let close = matching_paren(tokens, open, end)?;
311    let inside = token_slice(source, tokens, open + 1, close);
312    Some(inside)
313}
314
315fn matching_paren(tokens: &[SpannedToken<'_>], open: usize, end: usize) -> Option<usize> {
316    let mut depth = 0usize;
317    for (idx, token) in tokens.iter().enumerate().take(end).skip(open) {
318        match token.kind {
319            SyntaxKind::L_PAREN => depth += 1,
320            SyntaxKind::R_PAREN => {
321                depth = depth.saturating_sub(1);
322                if depth == 0 {
323                    return Some(idx);
324                }
325            }
326            _ => {}
327        }
328    }
329    None
330}
331
332fn value_after_keyword(
333    source: &str,
334    tokens: &[SpannedToken<'_>],
335    start: usize,
336    end: usize,
337    keyword: &str,
338) -> Option<String> {
339    let idx = (start..end).find(|&idx| word(&tokens[idx], keyword))?;
340    let mut value_start = idx + 1;
341    if value_start < end && tokens[value_start].kind == SyntaxKind::EQ {
342        value_start += 1;
343    }
344    let mut value_end = value_start;
345    while value_end < end
346        && !is_clause_boundary(&tokens[value_end])
347        && tokens[value_end].kind != SyntaxKind::COMMA
348    {
349        value_end += 1;
350    }
351    let value = token_slice(source, tokens, value_start, value_end);
352    (!value.is_empty()).then_some(value)
353}
354
355fn routine_option_after_keyword(
356    source: &str,
357    tokens: &[SpannedToken<'_>],
358    start: usize,
359    end: usize,
360    keyword: &str,
361) -> Option<String> {
362    clause_after_keyword(source, tokens, start, end, keyword, ROUTINE_OPTION_STOPS)
363        .map(|value| value.strip_prefix("= ").unwrap_or(&value).to_string())
364}
365
366fn clause_after_keyword(
367    source: &str,
368    tokens: &[SpannedToken<'_>],
369    start: usize,
370    end: usize,
371    keyword: &str,
372    stops: &[&str],
373) -> Option<String> {
374    let idx = (start..end).find(|&idx| word(&tokens[idx], keyword))?;
375    let value_start = idx + 1;
376    let mut depth = 0usize;
377    let mut value_end = value_start;
378    while value_end < end {
379        let token = &tokens[value_end];
380        match token.kind {
381            SyntaxKind::L_PAREN | SyntaxKind::L_BRACKET => depth += 1,
382            SyntaxKind::R_PAREN | SyntaxKind::R_BRACKET => depth = depth.saturating_sub(1),
383            _ => {}
384        }
385        if depth == 0 && stops.iter().any(|stop| word(token, stop)) {
386            break;
387        }
388        value_end += 1;
389    }
390    let value = token_slice(source, tokens, value_start, value_end);
391    (!value.is_empty()).then_some(value)
392}
393
394fn token_slice(source: &str, tokens: &[SpannedToken<'_>], start: usize, end: usize) -> String {
395    if start >= end || start >= tokens.len() {
396        return String::new();
397    }
398    let end = end.min(tokens.len());
399    compact_text(&source[tokens[start].range.start..tokens[end - 1].range.end])
400}
401
402fn compact_text(text: &str) -> String {
403    text.split_whitespace().collect::<Vec<_>>().join(" ")
404}
405
406fn compact_token_text(tokens: &[SpannedToken<'_>], range: Range<usize>) -> String {
407    tokens[range]
408        .iter()
409        .map(|token| token.text)
410        .collect::<Vec<_>>()
411        .join("")
412}
413
414fn combined_range(tokens: &[SpannedToken<'_>], range: Range<usize>) -> Range<usize> {
415    tokens[range.start].range.start..tokens[range.end - 1].range.end
416}
417
418fn is_name_part(token: &SpannedToken<'_>) -> bool {
419    matches!(
420        token.kind,
421        SyntaxKind::IDENT | SyntaxKind::QUOTED_IDENT | SyntaxKind::DOT
422    )
423}
424
425fn is_clause_boundary(token: &SpannedToken<'_>) -> bool {
426    matches!(
427        token.kind,
428        SyntaxKind::SEMICOLON | SyntaxKind::R_PAREN | SyntaxKind::R_BRACKET
429    ) || [
430        "AS",
431        "AFTER",
432        "ARTIFACT_REPOSITORY",
433        "CALLED",
434        "COMMENT",
435        "CONFIG",
436        "COPY",
437        "ERROR_INTEGRATION",
438        "EXECUTE",
439        "EXTERNAL_ACCESS_INTEGRATIONS",
440        "FINALIZE",
441        "HANDLER",
442        "IMMUTABLE",
443        "IMPORTS",
444        "LANGUAGE",
445        "MEMOIZABLE",
446        "NULL",
447        "OVERLAP_POLICY",
448        "PACKAGES",
449        "RETURNS",
450        "RUNTIME_VERSION",
451        "SCHEDULE",
452        "SECRETS",
453        "SECURE",
454        "STRICT",
455        "SUCCESS_INTEGRATION",
456        "TASK_AUTO_RETRY_ATTEMPTS",
457        "TARGET_PATH",
458        "USER_TASK_MANAGED_INITIAL_WAREHOUSE_SIZE",
459        "USER_TASK_TIMEOUT_MS",
460        "VOLATILE",
461        "WAREHOUSE",
462        "WHEN",
463    ]
464    .iter()
465    .any(|boundary| word(token, boundary))
466}
467
468fn word(token: &SpannedToken<'_>, expected: &str) -> bool {
469    (token.kind == SyntaxKind::IDENT || token.kind.is_keyword())
470        && token.text.eq_ignore_ascii_case(expected)
471}
472
473fn word_seq(tokens: &[SpannedToken<'_>], start: usize, words: &[&str]) -> bool {
474    words.iter().enumerate().all(|(offset, expected)| {
475        tokens
476            .get(start + offset)
477            .is_some_and(|t| word(t, expected))
478    })
479}
480
481fn type_hover(token: &SpannedToken<'_>) -> Option<Hover> {
482    let (canonical, body) = type_info(token.text)?;
483    Some(Hover {
484        kind: HoverKind::Type,
485        title: format!("Snowflake type `{canonical}`"),
486        body: body.to_string(),
487        range: token.range.clone(),
488        docs_url: Some(DATA_TYPES_DOCS),
489    })
490}
491
492fn language_hover(token: &SpannedToken<'_>) -> Option<Hover> {
493    language_template(token.text).map(|template| from_static(token.range.clone(), template))
494}
495
496fn property_hover(token: &SpannedToken<'_>) -> Option<Hover> {
497    property_template(token.text).map(|template| from_static(token.range.clone(), template))
498}
499
500fn keyword_hover(token: &SpannedToken<'_>) -> Option<Hover> {
501    keyword_template(token.text).map(|template| from_static(token.range.clone(), template))
502}
503
504fn from_static(range: Range<usize>, template: StaticHover) -> Hover {
505    Hover {
506        kind: template.kind,
507        title: template.title.to_string(),
508        body: template.body.to_string(),
509        range,
510        docs_url: template.docs_url,
511    }
512}