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_lexer::tokenize;
8use sql_dialect_fmt_syntax::SyntaxKind;
9use std::ops::Range;
10
11pub const CREATE_PROCEDURE_DOCS: &str =
12    "https://docs.snowflake.com/en/sql-reference/sql/create-procedure";
13pub const CREATE_TASK_DOCS: &str = "https://docs.snowflake.com/en/sql-reference/sql/create-task";
14pub const DATA_TYPES_DOCS: &str = "https://docs.snowflake.com/en/sql-reference/data-types";
15
16const ROUTINE_OPTION_STOPS: &[&str] = &[
17    "AS",
18    "ARTIFACT_REPOSITORY",
19    "CALLED",
20    "COMMENT",
21    "COPY",
22    "EXECUTE",
23    "EXTERNAL_ACCESS_INTEGRATIONS",
24    "HANDLER",
25    "IMMUTABLE",
26    "IMPORTS",
27    "LANGUAGE",
28    "MEMOIZABLE",
29    "NULL",
30    "PACKAGES",
31    "RETURNS",
32    "RUNTIME_VERSION",
33    "SECRETS",
34    "SECURE",
35    "STRICT",
36    "TARGET_PATH",
37    "VOLATILE",
38];
39
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Hover {
42    pub kind: HoverKind,
43    pub title: String,
44    pub body: String,
45    pub range: Range<usize>,
46    pub docs_url: Option<&'static str>,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum HoverKind {
51    Keyword,
52    Type,
53    Procedure,
54    Task,
55    Language,
56    Property,
57}
58
59#[derive(Clone, Debug)]
60struct SpannedToken<'a> {
61    kind: SyntaxKind,
62    text: &'a str,
63    range: Range<usize>,
64}
65
66#[derive(Clone, Copy)]
67struct StaticHover {
68    kind: HoverKind,
69    title: &'static str,
70    body: &'static str,
71    docs_url: Option<&'static str>,
72}
73
74/// Return hover information for the token at `offset`.
75///
76/// Offsets are byte offsets, matching LSP's UTF-8 internal representation after
77/// the caller converts from line/column. Trivia currently has no hover.
78pub fn hover_at(source: &str, offset: usize) -> Option<Hover> {
79    let tokens = spanned_tokens(source);
80    let index = token_at(&tokens, offset)?;
81    let token = tokens[index].clone();
82
83    if let Some(hover) = procedure_symbol_hover(source, &tokens, index) {
84        return Some(hover);
85    }
86    if let Some(hover) = task_symbol_hover(source, &tokens, index) {
87        return Some(hover);
88    }
89    if let Some(hover) = type_hover(&token) {
90        return Some(hover);
91    }
92    if let Some(hover) = language_hover(&token) {
93        return Some(hover);
94    }
95    if let Some(hover) = property_hover(&token) {
96        return Some(hover);
97    }
98    keyword_hover(&token)
99}
100
101fn spanned_tokens(source: &str) -> Vec<SpannedToken<'_>> {
102    let mut offset = 0usize;
103    tokenize(source)
104        .tokens
105        .into_iter()
106        .filter_map(|token| {
107            let start = offset;
108            offset += token.text.len();
109            (!token.kind.is_trivia()).then_some(SpannedToken {
110                kind: token.kind,
111                text: token.text,
112                range: start..offset,
113            })
114        })
115        .collect()
116}
117
118fn token_at(tokens: &[SpannedToken<'_>], offset: usize) -> Option<usize> {
119    tokens
120        .iter()
121        .position(|token| token.range.start <= offset && offset < token.range.end)
122        .or_else(|| {
123            offset.checked_sub(1).and_then(|previous| {
124                tokens
125                    .iter()
126                    .position(|token| token.range.start <= previous && previous < token.range.end)
127            })
128        })
129}
130
131fn procedure_symbol_hover(
132    source: &str,
133    tokens: &[SpannedToken<'_>],
134    index: usize,
135) -> Option<Hover> {
136    let object = object_declaration(tokens, index, "PROCEDURE")?;
137    if word(tokens.get(object.keyword + 1)?, "SCOPED") {
138        return None;
139    }
140    let name_range = procedure_name_range(tokens, object.keyword, object.end)?;
141    if !name_range.contains(&index) {
142        return None;
143    }
144
145    let name = compact_token_text(tokens, name_range.clone());
146    let args = procedure_args(source, tokens, name_range.end, object.end)
147        .unwrap_or_else(|| String::from(""));
148    let returns = clause_after_keyword(
149        source,
150        tokens,
151        object.keyword,
152        object.end,
153        "RETURNS",
154        &[
155            "LANGUAGE",
156            "RUNTIME_VERSION",
157            "PACKAGES",
158            "IMPORTS",
159            "HANDLER",
160            "AS",
161            "COMMENT",
162            "EXECUTE",
163        ],
164    );
165    let language = value_after_keyword(source, tokens, object.keyword, object.end, "LANGUAGE");
166    let handler =
167        routine_option_after_keyword(source, tokens, object.keyword, object.end, "HANDLER");
168    let runtime = routine_option_after_keyword(
169        source,
170        tokens,
171        object.keyword,
172        object.end,
173        "RUNTIME_VERSION",
174    );
175    let packages =
176        routine_option_after_keyword(source, tokens, object.keyword, object.end, "PACKAGES");
177    let imports =
178        routine_option_after_keyword(source, tokens, object.keyword, object.end, "IMPORTS");
179    let target_path =
180        routine_option_after_keyword(source, tokens, object.keyword, object.end, "TARGET_PATH");
181
182    let mut lines = vec![format!("Stored procedure `{name}`.")];
183    if !args.is_empty() {
184        lines.push(format!("Arguments: `{args}`."));
185    }
186    if let Some(returns) = returns {
187        lines.push(format!("Returns: `{returns}`."));
188    }
189    if let Some(language) = language {
190        lines.push(format!("Language: `{language}`."));
191    }
192    if let Some(handler) = handler {
193        lines.push(format!("Handler: `{handler}`."));
194    }
195    if let Some(runtime) = runtime {
196        lines.push(format!("Runtime: `{runtime}`."));
197    }
198    if let Some(packages) = packages {
199        lines.push(format!("Packages: `{packages}`."));
200    }
201    if let Some(imports) = imports {
202        lines.push(format!("Imports: `{imports}`."));
203    }
204    if let Some(target_path) = target_path {
205        lines.push(format!("Target path: `{target_path}`."));
206    }
207    lines.push(String::from(
208        "Snowflake resolves stored procedures by name plus argument types.",
209    ));
210    lines.push(String::from(
211        "External-language procedures usually pair LANGUAGE with HANDLER, PACKAGES, IMPORTS, and RUNTIME_VERSION.",
212    ));
213
214    Some(Hover {
215        kind: HoverKind::Procedure,
216        title: format!("Stored procedure `{name}`"),
217        body: lines.join("\n"),
218        range: combined_range(tokens, name_range),
219        docs_url: Some(CREATE_PROCEDURE_DOCS),
220    })
221}
222
223fn task_symbol_hover(source: &str, tokens: &[SpannedToken<'_>], index: usize) -> Option<Hover> {
224    let object = object_declaration(tokens, index, "TASK")?;
225    let name_range = task_name_range(tokens, object.keyword, object.end)?;
226    if !name_range.contains(&index) {
227        return None;
228    }
229
230    let name = compact_token_text(tokens, name_range.clone());
231    let warehouse = value_after_keyword(source, tokens, object.keyword, object.end, "WAREHOUSE")
232        .or_else(|| {
233            value_after_keyword(
234                source,
235                tokens,
236                object.keyword,
237                object.end,
238                "USER_TASK_MANAGED_INITIAL_WAREHOUSE_SIZE",
239            )
240        });
241    let schedule = value_after_keyword(source, tokens, object.keyword, object.end, "SCHEDULE");
242    let after = clause_after_keyword(
243        source,
244        tokens,
245        object.keyword,
246        object.end,
247        "AFTER",
248        &["WHEN", "AS", "EXECUTE", "COMMENT", "FINALIZE"],
249    );
250    let when = clause_after_keyword(source, tokens, object.keyword, object.end, "WHEN", &["AS"]);
251
252    let mut lines = vec![format!("Task `{name}`.")];
253    if let Some(warehouse) = warehouse {
254        lines.push(format!("Compute: `{warehouse}`."));
255    }
256    if let Some(schedule) = schedule {
257        lines.push(format!("Schedule: `{schedule}`."));
258    }
259    if let Some(after) = after {
260        lines.push(format!("Predecessors: `{after}`."));
261    }
262    if let Some(when) = when {
263        lines.push(format!("Condition: `{when}`."));
264    }
265    lines.push(String::from(
266        "Tasks run SQL on a schedule or after predecessor tasks; newly created tasks start suspended.",
267    ));
268
269    Some(Hover {
270        kind: HoverKind::Task,
271        title: format!("Task `{name}`"),
272        body: lines.join("\n"),
273        range: combined_range(tokens, name_range),
274        docs_url: Some(CREATE_TASK_DOCS),
275    })
276}
277
278#[derive(Clone, Copy)]
279struct ObjectDeclaration {
280    keyword: usize,
281    end: usize,
282}
283
284fn object_declaration(
285    tokens: &[SpannedToken<'_>],
286    index: usize,
287    object_keyword: &str,
288) -> Option<ObjectDeclaration> {
289    let (start, end) = statement_bounds(tokens, index);
290    for keyword in (start..=index.min(end.saturating_sub(1))).rev() {
291        if !word(&tokens[keyword], object_keyword) {
292            continue;
293        }
294        if tokens[start..keyword]
295            .iter()
296            .any(|token| word(token, "CREATE"))
297        {
298            return Some(ObjectDeclaration { keyword, end });
299        }
300    }
301    None
302}
303
304fn statement_bounds(tokens: &[SpannedToken<'_>], index: usize) -> (usize, usize) {
305    let start = tokens[..index]
306        .iter()
307        .rposition(|token| token.kind == SyntaxKind::SEMICOLON)
308        .map_or(0, |idx| idx + 1);
309    let end = tokens[index..]
310        .iter()
311        .position(|token| token.kind == SyntaxKind::SEMICOLON)
312        .map_or(tokens.len(), |relative| index + relative);
313    (start, end)
314}
315
316fn procedure_name_range(
317    tokens: &[SpannedToken<'_>],
318    procedure_keyword: usize,
319    end: usize,
320) -> Option<Range<usize>> {
321    let start = procedure_keyword + 1;
322    let mut cursor = start;
323    while cursor < end && tokens[cursor].kind != SyntaxKind::L_PAREN {
324        if !is_name_part(&tokens[cursor]) {
325            return None;
326        }
327        cursor += 1;
328    }
329    (start < cursor).then_some(start..cursor)
330}
331
332fn task_name_range(
333    tokens: &[SpannedToken<'_>],
334    task_keyword: usize,
335    end: usize,
336) -> Option<Range<usize>> {
337    let mut start = task_keyword + 1;
338    if word_seq(tokens, start, &["IF", "NOT", "EXISTS"]) {
339        start += 3;
340    }
341
342    let mut cursor = start;
343    while cursor < end && is_name_part(&tokens[cursor]) && !is_clause_boundary(&tokens[cursor]) {
344        cursor += 1;
345    }
346    (start < cursor).then_some(start..cursor)
347}
348
349fn procedure_args(
350    source: &str,
351    tokens: &[SpannedToken<'_>],
352    start: usize,
353    end: usize,
354) -> Option<String> {
355    let open = (start..end).find(|&idx| tokens[idx].kind == SyntaxKind::L_PAREN)?;
356    let close = matching_paren(tokens, open, end)?;
357    let inside = token_slice(source, tokens, open + 1, close);
358    Some(inside)
359}
360
361fn matching_paren(tokens: &[SpannedToken<'_>], open: usize, end: usize) -> Option<usize> {
362    let mut depth = 0usize;
363    for (idx, token) in tokens.iter().enumerate().take(end).skip(open) {
364        match token.kind {
365            SyntaxKind::L_PAREN => depth += 1,
366            SyntaxKind::R_PAREN => {
367                depth = depth.saturating_sub(1);
368                if depth == 0 {
369                    return Some(idx);
370                }
371            }
372            _ => {}
373        }
374    }
375    None
376}
377
378fn value_after_keyword(
379    source: &str,
380    tokens: &[SpannedToken<'_>],
381    start: usize,
382    end: usize,
383    keyword: &str,
384) -> Option<String> {
385    let idx = (start..end).find(|&idx| word(&tokens[idx], keyword))?;
386    let mut value_start = idx + 1;
387    if value_start < end && tokens[value_start].kind == SyntaxKind::EQ {
388        value_start += 1;
389    }
390    let mut value_end = value_start;
391    while value_end < end
392        && !is_clause_boundary(&tokens[value_end])
393        && tokens[value_end].kind != SyntaxKind::COMMA
394    {
395        value_end += 1;
396    }
397    let value = token_slice(source, tokens, value_start, value_end);
398    (!value.is_empty()).then_some(value)
399}
400
401fn routine_option_after_keyword(
402    source: &str,
403    tokens: &[SpannedToken<'_>],
404    start: usize,
405    end: usize,
406    keyword: &str,
407) -> Option<String> {
408    clause_after_keyword(source, tokens, start, end, keyword, ROUTINE_OPTION_STOPS)
409        .map(|value| value.strip_prefix("= ").unwrap_or(&value).to_string())
410}
411
412fn clause_after_keyword(
413    source: &str,
414    tokens: &[SpannedToken<'_>],
415    start: usize,
416    end: usize,
417    keyword: &str,
418    stops: &[&str],
419) -> Option<String> {
420    let idx = (start..end).find(|&idx| word(&tokens[idx], keyword))?;
421    let value_start = idx + 1;
422    let mut depth = 0usize;
423    let mut value_end = value_start;
424    while value_end < end {
425        let token = &tokens[value_end];
426        match token.kind {
427            SyntaxKind::L_PAREN | SyntaxKind::L_BRACKET => depth += 1,
428            SyntaxKind::R_PAREN | SyntaxKind::R_BRACKET => depth = depth.saturating_sub(1),
429            _ => {}
430        }
431        if depth == 0 && stops.iter().any(|stop| word(token, stop)) {
432            break;
433        }
434        value_end += 1;
435    }
436    let value = token_slice(source, tokens, value_start, value_end);
437    (!value.is_empty()).then_some(value)
438}
439
440fn token_slice(source: &str, tokens: &[SpannedToken<'_>], start: usize, end: usize) -> String {
441    if start >= end || start >= tokens.len() {
442        return String::new();
443    }
444    let end = end.min(tokens.len());
445    compact_text(&source[tokens[start].range.start..tokens[end - 1].range.end])
446}
447
448fn compact_text(text: &str) -> String {
449    text.split_whitespace().collect::<Vec<_>>().join(" ")
450}
451
452fn compact_token_text(tokens: &[SpannedToken<'_>], range: Range<usize>) -> String {
453    tokens[range]
454        .iter()
455        .map(|token| token.text)
456        .collect::<Vec<_>>()
457        .join("")
458}
459
460fn combined_range(tokens: &[SpannedToken<'_>], range: Range<usize>) -> Range<usize> {
461    tokens[range.start].range.start..tokens[range.end - 1].range.end
462}
463
464fn is_name_part(token: &SpannedToken<'_>) -> bool {
465    matches!(
466        token.kind,
467        SyntaxKind::IDENT | SyntaxKind::QUOTED_IDENT | SyntaxKind::DOT
468    )
469}
470
471fn is_clause_boundary(token: &SpannedToken<'_>) -> bool {
472    matches!(
473        token.kind,
474        SyntaxKind::SEMICOLON | SyntaxKind::R_PAREN | SyntaxKind::R_BRACKET
475    ) || [
476        "AS",
477        "AFTER",
478        "ARTIFACT_REPOSITORY",
479        "CALLED",
480        "COMMENT",
481        "CONFIG",
482        "COPY",
483        "ERROR_INTEGRATION",
484        "EXECUTE",
485        "EXTERNAL_ACCESS_INTEGRATIONS",
486        "FINALIZE",
487        "HANDLER",
488        "IMMUTABLE",
489        "IMPORTS",
490        "LANGUAGE",
491        "MEMOIZABLE",
492        "NULL",
493        "OVERLAP_POLICY",
494        "PACKAGES",
495        "RETURNS",
496        "RUNTIME_VERSION",
497        "SCHEDULE",
498        "SECRETS",
499        "SECURE",
500        "STRICT",
501        "SUCCESS_INTEGRATION",
502        "TASK_AUTO_RETRY_ATTEMPTS",
503        "TARGET_PATH",
504        "USER_TASK_MANAGED_INITIAL_WAREHOUSE_SIZE",
505        "USER_TASK_TIMEOUT_MS",
506        "VOLATILE",
507        "WAREHOUSE",
508        "WHEN",
509    ]
510    .iter()
511    .any(|boundary| word(token, boundary))
512}
513
514fn word(token: &SpannedToken<'_>, expected: &str) -> bool {
515    (token.kind == SyntaxKind::IDENT || token.kind.is_keyword())
516        && token.text.eq_ignore_ascii_case(expected)
517}
518
519fn word_seq(tokens: &[SpannedToken<'_>], start: usize, words: &[&str]) -> bool {
520    words.iter().enumerate().all(|(offset, expected)| {
521        tokens
522            .get(start + offset)
523            .is_some_and(|t| word(t, expected))
524    })
525}
526
527fn type_hover(token: &SpannedToken<'_>) -> Option<Hover> {
528    let (canonical, body) = type_info(token.text)?;
529    Some(Hover {
530        kind: HoverKind::Type,
531        title: format!("Snowflake type `{canonical}`"),
532        body: body.to_string(),
533        range: token.range.clone(),
534        docs_url: Some(DATA_TYPES_DOCS),
535    })
536}
537
538fn type_info(text: &str) -> Option<(&'static str, &'static str)> {
539    let item = [
540        (
541            &["NUMBER", "DECIMAL", "NUMERIC", "INT", "INTEGER", "BIGINT"][..],
542            "NUMBER",
543            "Exact fixed-point numeric type. Use precision and scale for stable financial or identifier-like values.",
544        ),
545        (
546            &["FLOAT", "DOUBLE", "REAL"][..],
547            "FLOAT",
548            "Approximate floating-point numeric type. Prefer NUMBER when exact decimal behavior matters.",
549        ),
550        (
551            &["VARCHAR", "STRING", "TEXT", "CHAR"][..],
552            "VARCHAR",
553            "Variable-length character data. STRING and TEXT are Snowflake aliases for VARCHAR.",
554        ),
555        (&["BOOLEAN"][..], "BOOLEAN", "TRUE/FALSE logical value."),
556        (
557            &["VARIANT"][..],
558            "VARIANT",
559            "Semi-structured value that can hold JSON-like OBJECT, ARRAY, scalar, or SQL NULL distinctions.",
560        ),
561        (
562            &["OBJECT"][..],
563            "OBJECT",
564            "Semi-structured key/value object. Common with JSON ingestion and colon path access.",
565        ),
566        (
567            &["ARRAY"][..],
568            "ARRAY",
569            "Semi-structured ordered collection. Use bracket indexing and FLATTEN/TABLE for traversal.",
570        ),
571        (
572            &["MAP"][..],
573            "MAP",
574            "Structured key/value collection type. Useful when key and value types are part of the schema.",
575        ),
576        (
577            &["VECTOR"][..],
578            "VECTOR",
579            "Vector type for embeddings and similarity workloads; keep element type and dimension explicit.",
580        ),
581        (
582            &["DATE"][..],
583            "DATE",
584            "Calendar date without time of day.",
585        ),
586        (
587            &["TIME"][..],
588            "TIME",
589            "Time of day without date.",
590        ),
591        (
592            &["TIMESTAMP", "TIMESTAMP_NTZ", "TIMESTAMP_LTZ", "TIMESTAMP_TZ"][..],
593            "TIMESTAMP",
594            "Timestamp family. NTZ has no time zone, LTZ uses the session time zone, and TZ stores an explicit offset.",
595        ),
596        (&["BINARY"][..], "BINARY", "Variable-length binary data."),
597        (
598            &["GEOGRAPHY", "GEOMETRY"][..],
599            "GEOSPATIAL",
600            "Geospatial data for spherical geography or planar geometry operations.",
601        ),
602    ]
603    .into_iter()
604    .find(|(aliases, _, _)| aliases.iter().any(|alias| text.eq_ignore_ascii_case(alias)))?;
605
606    Some((item.1, item.2))
607}
608
609fn language_hover(token: &SpannedToken<'_>) -> Option<Hover> {
610    let template = if token.text.eq_ignore_ascii_case("JAVASCRIPT") {
611        StaticHover {
612            kind: HoverKind::Language,
613            title: "LANGUAGE JAVASCRIPT",
614            body: "JavaScript stored procedure body. The handler is the body itself; SQL argument names can need careful case handling inside JavaScript.",
615            docs_url: Some(CREATE_PROCEDURE_DOCS),
616        }
617    } else if token.text.eq_ignore_ascii_case("PYTHON") {
618        StaticHover {
619            kind: HoverKind::Language,
620            title: "LANGUAGE PYTHON",
621            body: "Snowpark Python stored procedure. HANDLER names the Python function; RUNTIME_VERSION pins Python; PACKAGES, IMPORTS, EXTERNAL_ACCESS_INTEGRATIONS, and SECRETS describe the runtime environment.",
622            docs_url: Some(CREATE_PROCEDURE_DOCS),
623        }
624    } else if token.text.eq_ignore_ascii_case("JAVA") {
625        StaticHover {
626            kind: HoverKind::Language,
627            title: "LANGUAGE JAVA",
628            body: "Java stored procedure. HANDLER names a class and method; RUNTIME_VERSION, PACKAGES, IMPORTS, and TARGET_PATH describe the JVM runtime and staged artifact.",
629            docs_url: Some(CREATE_PROCEDURE_DOCS),
630        }
631    } else if token.text.eq_ignore_ascii_case("SCALA") {
632        StaticHover {
633            kind: HoverKind::Language,
634            title: "LANGUAGE SCALA",
635            body: "Snowpark Scala stored procedure. HANDLER names the Scala entry point; RUNTIME_VERSION, PACKAGES, IMPORTS, and TARGET_PATH describe the JVM runtime and staged artifact.",
636            docs_url: Some(CREATE_PROCEDURE_DOCS),
637        }
638    } else if token.text.eq_ignore_ascii_case("SQL") {
639        StaticHover {
640            kind: HoverKind::Language,
641            title: "LANGUAGE SQL",
642            body: "Snowflake Scripting stored procedure body. Use SQL procedural constructs such as DECLARE, BEGIN, RETURN, loops, and EXCEPTION handlers.",
643            docs_url: Some(CREATE_PROCEDURE_DOCS),
644        }
645    } else {
646        return None;
647    };
648    Some(from_static(token.range.clone(), template))
649}
650
651fn property_hover(token: &SpannedToken<'_>) -> Option<Hover> {
652    let properties = [
653        StaticHover {
654            kind: HoverKind::Property,
655            title: "RETURNS",
656            body: "Declares a procedure result type. It can be a scalar type or RETURNS TABLE(...), depending on the procedure language and support level.",
657            docs_url: Some(CREATE_PROCEDURE_DOCS),
658        },
659        StaticHover {
660            kind: HoverKind::Property,
661            title: "LANGUAGE",
662            body: "Selects the stored procedure handler language: SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA.",
663            docs_url: Some(CREATE_PROCEDURE_DOCS),
664        },
665        StaticHover {
666            kind: HoverKind::Property,
667            title: "HANDLER",
668            body: "Names the external-language entry point, such as a Python function or JVM method.",
669            docs_url: Some(CREATE_PROCEDURE_DOCS),
670        },
671        StaticHover {
672            kind: HoverKind::Property,
673            title: "PACKAGES",
674            body: "Declares runtime packages available to Snowpark Java, Scala, or Python procedure handlers.",
675            docs_url: Some(CREATE_PROCEDURE_DOCS),
676        },
677        StaticHover {
678            kind: HoverKind::Property,
679            title: "IMPORTS",
680            body: "Adds staged files that the stored procedure handler can read at runtime.",
681            docs_url: Some(CREATE_PROCEDURE_DOCS),
682        },
683        StaticHover {
684            kind: HoverKind::Property,
685            title: "RUNTIME_VERSION",
686            body: "Pins the language runtime version for Java, Python, or Scala procedure handlers.",
687            docs_url: Some(CREATE_PROCEDURE_DOCS),
688        },
689        StaticHover {
690            kind: HoverKind::Property,
691            title: "TARGET_PATH",
692            body: "Stage path for the compiled Java or Scala procedure artifact. Use it when Snowflake should write the generated handler artifact to a stage.",
693            docs_url: Some(CREATE_PROCEDURE_DOCS),
694        },
695        StaticHover {
696            kind: HoverKind::Property,
697            title: "ARTIFACT_REPOSITORY",
698            body: "Selects an artifact repository for resolving supported external-language dependencies.",
699            docs_url: Some(CREATE_PROCEDURE_DOCS),
700        },
701        StaticHover {
702            kind: HoverKind::Property,
703            title: "EXTERNAL_ACCESS_INTEGRATIONS",
704            body: "Allows an external-language procedure to use one or more external access integrations for outbound network access.",
705            docs_url: Some(CREATE_PROCEDURE_DOCS),
706        },
707        StaticHover {
708            kind: HoverKind::Property,
709            title: "SECRETS",
710            body: "Maps Snowflake secrets to names the external-language handler can use at runtime.",
711            docs_url: Some(CREATE_PROCEDURE_DOCS),
712        },
713        StaticHover {
714            kind: HoverKind::Property,
715            title: "STRICT",
716            body: "Alias for RETURNS NULL ON NULL INPUT: Snowflake does not call the handler when any input argument is NULL.",
717            docs_url: Some(CREATE_PROCEDURE_DOCS),
718        },
719        StaticHover {
720            kind: HoverKind::Property,
721            title: "CALLED",
722            body: "Part of CALLED ON NULL INPUT: Snowflake calls the procedure even when an input argument is NULL.",
723            docs_url: Some(CREATE_PROCEDURE_DOCS),
724        },
725        StaticHover {
726            kind: HoverKind::Property,
727            title: "IMMUTABLE",
728            body: "Declares that the result depends only on inputs and not on database state or side effects.",
729            docs_url: Some(CREATE_PROCEDURE_DOCS),
730        },
731        StaticHover {
732            kind: HoverKind::Property,
733            title: "VOLATILE",
734            body: "Declares that the procedure can depend on state or side effects; this is the conservative behavior for procedural code.",
735            docs_url: Some(CREATE_PROCEDURE_DOCS),
736        },
737        StaticHover {
738            kind: HoverKind::Property,
739            title: "EXECUTE",
740            body: "Controls whether a procedure or task runs with owner, caller, restricted caller, or user execution context.",
741            docs_url: Some(CREATE_PROCEDURE_DOCS),
742        },
743        StaticHover {
744            kind: HoverKind::Property,
745            title: "WAREHOUSE",
746            body: "Virtual warehouse that supplies compute for a task run. Omit it only when using serverless task sizing.",
747            docs_url: Some(CREATE_TASK_DOCS),
748        },
749        StaticHover {
750            kind: HoverKind::Property,
751            title: "USER_TASK_MANAGED_INITIAL_WAREHOUSE_SIZE",
752            body: "Initial serverless task size. Snowflake can manage task compute after enough run history exists.",
753            docs_url: Some(CREATE_TASK_DOCS),
754        },
755        StaticHover {
756            kind: HoverKind::Property,
757            title: "SCHEDULE",
758            body: "Task schedule. Snowflake accepts interval strings or USING CRON expressions with a time zone.",
759            docs_url: Some(CREATE_TASK_DOCS),
760        },
761        StaticHover {
762            kind: HoverKind::Property,
763            title: "AFTER",
764            body: "Creates a task graph dependency; this task runs after one or more predecessor tasks.",
765            docs_url: Some(CREATE_TASK_DOCS),
766        },
767        StaticHover {
768            kind: HoverKind::Property,
769            title: "WHEN",
770            body: "Boolean task condition evaluated before the task body runs. Common with stream checks such as SYSTEM$STREAM_HAS_DATA.",
771            docs_url: Some(CREATE_TASK_DOCS),
772        },
773        StaticHover {
774            kind: HoverKind::Property,
775            title: "FINALIZE",
776            body: "Marks a task as a finalizer for a task graph root.",
777            docs_url: Some(CREATE_TASK_DOCS),
778        },
779        StaticHover {
780            kind: HoverKind::Property,
781            title: "TASK_AUTO_RETRY_ATTEMPTS",
782            body: "Configures automatic retries for failed task graph runs.",
783            docs_url: Some(CREATE_TASK_DOCS),
784        },
785    ];
786
787    properties
788        .into_iter()
789        .find(|property| token.text.eq_ignore_ascii_case(property.title))
790        .map(|template| from_static(token.range.clone(), template))
791}
792
793fn keyword_hover(token: &SpannedToken<'_>) -> Option<Hover> {
794    let template = if token.text.eq_ignore_ascii_case("PROCEDURE") {
795        StaticHover {
796            kind: HoverKind::Procedure,
797            title: "Stored procedure",
798            body: "Schema object that can be called with CALL. Procedures support SQL, JavaScript, Python, Java, and Scala handlers.",
799            docs_url: Some(CREATE_PROCEDURE_DOCS),
800        }
801    } else if token.text.eq_ignore_ascii_case("TASK") {
802        StaticHover {
803            kind: HoverKind::Task,
804            title: "Task",
805            body: "Schema object that executes SQL on a schedule or as part of a task graph. Newly created tasks start suspended.",
806            docs_url: Some(CREATE_TASK_DOCS),
807        }
808    } else {
809        return None;
810    };
811    Some(from_static(token.range.clone(), template))
812}
813
814fn from_static(range: Range<usize>, template: StaticHover) -> Hover {
815    Hover {
816        kind: template.kind,
817        title: template.title.to_string(),
818        body: template.body.to_string(),
819        range,
820        docs_url: template.docs_url,
821    }
822}