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