1use 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
40pub 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}