1use super::{functions, goto_definition};
53use crate::project::compiler::cache::ProjectCache;
54use crate::project::syntax::variables::find_variable_at_position;
55use crate::types::Types;
56use std::collections::BTreeMap;
57use std::path::Path;
58use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Url};
59
60pub fn resolve_variable_hover(
70 text: &str,
71 offset: usize,
72 variables: &BTreeMap<String, String>,
73) -> Option<Hover> {
74 let (name, _start, _len) = find_variable_at_position(text, offset)?;
75 let value = variables.get(&name)?.to_string();
76
77 Some(Hover {
78 contents: HoverContents::Markup(MarkupContent {
79 kind: MarkupKind::Markdown,
80 value,
81 }),
82 range: None,
83 })
84}
85
86pub fn resolve_hover(
99 parts: &[String],
100 file_uri: &Url,
101 root: &Path,
102 project_cache: &ProjectCache,
103 types_lock: &Types,
104) -> Option<Hover> {
105 let id = goto_definition::resolve_object_id(parts, file_uri, root);
106
107 let cached_obj = id.as_ref().and_then(|id| project_cache.get_object(id));
108
109 if cached_obj.is_none() {
111 if let Some(func_hover) = resolve_function_hover(parts) {
112 return Some(func_hover);
113 }
114 return None;
115 }
116
117 let id = id.unwrap();
118 let cached_obj = cached_obj.unwrap();
119 let kind = cached_obj.kind;
120 let fqn = id.to_string();
121
122 let comments = &cached_obj.comments;
123 let description = comments
124 .iter()
125 .find(|c| c.target_column.is_none())
126 .map(|c| c.text.clone())
127 .or_else(|| types_lock.comments.get(&id).cloned());
128 let comments = &cached_obj.comments;
129 let mut column_comments: BTreeMap<_, _> = comments
130 .iter()
131 .filter_map(|c| {
132 c.target_column
133 .as_ref()
134 .map(|col| (col.clone(), c.text.clone()))
135 })
136 .collect();
137 let columns = project_cache
138 .get_columns(&id)
139 .or_else(|| types_lock.get_table(&id).cloned());
140
141 if column_comments.is_empty() {
143 if let Some(cols) = &columns {
144 for (name, col_type) in cols {
145 if let Some(comment) = &col_type.comment {
146 column_comments.insert(name.clone(), comment.clone());
147 }
148 }
149 }
150 }
151
152 let markdown = match columns {
153 Some(cols) if !cols.is_empty() => {
154 let mut md = format!("**{kind}** `{fqn}`\n\n");
155 if let Some(desc) = &description {
156 md.push_str(&format!("{desc}\n\n"));
157 }
158 let has_any_comment = cols.keys().any(|name| column_comments.contains_key(name));
159 if has_any_comment {
160 md.push_str("| Column | Type | Description |\n");
161 md.push_str("|--------|------|-------------|\n");
162 for (name, col_type) in &cols {
163 let nullable = if col_type.nullable { "" } else { "not null " };
164 let comment = column_comments.get(name).map(|s| s.as_str()).unwrap_or("");
165 md.push_str(&format!(
166 "| {} | {} {}| {} |\n",
167 name, col_type.r#type, nullable, comment
168 ));
169 }
170 } else {
171 md.push_str("| Column | Type |\n");
172 md.push_str("|--------|------|\n");
173 for (name, col_type) in &cols {
174 let nullable = if col_type.nullable { "" } else { "not null " };
175 md.push_str(&format!("| {} | {} {}|\n", name, col_type.r#type, nullable));
176 }
177 }
178 md
179 }
180 _ => {
181 let file_path = &cached_obj.file_path;
182 format!("**{kind}** `{fqn}`\n\n*{file_path}*")
183 }
184 };
185
186 Some(Hover {
187 contents: HoverContents::Markup(MarkupContent {
188 kind: MarkupKind::Markdown,
189 value: markdown,
190 }),
191 range: None,
192 })
193}
194
195fn resolve_function_hover(parts: &[String]) -> Option<Hover> {
201 if parts.len() != 1 {
202 return None;
203 }
204 let name = &parts[0];
205 let func = functions::lookup(name)?;
206 let kind = match func.kind {
207 functions::FunctionKind::Scalar => "scalar function",
208 functions::FunctionKind::Aggregate => "aggregate function",
209 functions::FunctionKind::Window => "window function",
210 functions::FunctionKind::Table => "table function",
211 };
212
213 let sigs = func.signatures.join("\n");
214 let markdown = format!("**{kind}**\n\n```\n{sigs}\n```");
215
216 Some(Hover {
217 contents: HoverContents::Markup(MarkupContent {
218 kind: MarkupKind::Markdown,
219 value: markdown,
220 }),
221 range: None,
222 })
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::project::compiler::cache::ProjectCache;
229 use crate::project::ir::object_id::ObjectId;
230 use crate::types::ColumnType;
231 use std::collections::BTreeMap;
232
233 fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
234 pairs
235 .iter()
236 .map(|(k, v)| (k.to_string(), v.to_string()))
237 .collect()
238 }
239
240 #[mz_ore::test]
241 fn variable_hover_resolved() {
242 let variables = vars(&[("cluster", "ontology")]);
243 let sql = "IN CLUSTER :cluster AS";
244 let hover = resolve_variable_hover(sql, 11, &variables).unwrap();
245 let text = extract_markdown(&hover);
246 assert_eq!(text, "ontology");
247 }
248
249 #[mz_ore::test]
250 fn variable_hover_unresolved_returns_none() {
251 let sql = "IN CLUSTER :cluster AS";
252 assert!(resolve_variable_hover(sql, 11, &BTreeMap::new()).is_none());
253 }
254
255 #[mz_ore::test]
256 fn variable_hover_not_on_variable() {
257 let sql = "SELECT 1 FROM t";
258 assert!(resolve_variable_hover(sql, 5, &BTreeMap::new()).is_none());
259 }
260
261 #[cfg_attr(miri, ignore)] #[mz_ore::test]
263 fn hover_with_cached_columns() {
264 let (root, cache, types_lock) = build_test_project_with_types_lock();
265 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
266
267 let hover = resolve_hover(
268 &["foo".to_string()],
269 &file_uri,
270 root.path(),
271 &cache,
272 &types_lock,
273 )
274 .unwrap();
275
276 let text = extract_markdown(&hover);
277 assert!(text.contains("**view** `mydb.public.foo`"));
278 assert!(text.contains("| id | integer not null |"));
279 assert!(text.contains("| name | text |"));
280 }
281
282 #[cfg_attr(miri, ignore)] #[mz_ore::test]
284 fn hover_without_cache_shows_kind_and_path() {
285 let (root, cache) = build_test_project_cache();
286 let empty_types = Types::default();
287 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
288
289 let hover = resolve_hover(
290 &["foo".to_string()],
291 &file_uri,
292 root.path(),
293 &cache,
294 &empty_types,
295 )
296 .unwrap();
297
298 let text = extract_markdown(&hover);
299 assert!(text.contains("**view** `mydb.public.foo`"));
300 assert!(text.contains("mydb/public/foo.sql"));
301 assert!(!text.contains("| Column |"));
303 }
304
305 #[cfg_attr(miri, ignore)] #[mz_ore::test]
307 fn hover_unknown_identifier_returns_none() {
308 let (root, cache) = build_test_project_cache();
309 let empty_types = Types::default();
310 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
311
312 let result = resolve_hover(
313 &["nonexistent".to_string()],
314 &file_uri,
315 root.path(),
316 &cache,
317 &empty_types,
318 );
319 assert!(result.is_none());
320 }
321
322 #[cfg_attr(miri, ignore)] #[mz_ore::test]
324 fn hover_cross_schema_reference() {
325 let (root, cache, types_lock) = build_test_project_cross_schema_with_types_lock();
326 let file_uri = Url::from_file_path(root.path().join("models/mydb/other/baz.sql")).unwrap();
327
328 let hover = resolve_hover(
329 &["public".to_string(), "foo".to_string()],
330 &file_uri,
331 root.path(),
332 &cache,
333 &types_lock,
334 )
335 .unwrap();
336
337 let text = extract_markdown(&hover);
338 assert!(text.contains("**table** `mydb.public.foo`"));
339 assert!(text.contains("| id | integer |"));
340 }
341
342 #[cfg_attr(miri, ignore)] #[mz_ore::test]
344 fn hover_with_description_and_column_comments() {
345 let (root, cache, types_lock) = build_test_project_with_comments_and_types_lock(
346 "CREATE VIEW foo AS SELECT 1 AS id, 'x' AS name;\n\
347 COMMENT ON VIEW foo IS 'All incoming customer orders';\n\
348 COMMENT ON COLUMN foo.id IS 'Primary key';",
349 );
350 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
351
352 let hover = resolve_hover(
353 &["foo".to_string()],
354 &file_uri,
355 root.path(),
356 &cache,
357 &types_lock,
358 )
359 .unwrap();
360
361 let text = extract_markdown(&hover);
362 assert!(text.contains("**view** `mydb.public.foo`"));
363 assert!(text.contains("All incoming customer orders"));
364 assert!(text.contains("| Column | Type | Description |"));
365 assert!(text.contains("| id | integer not null | Primary key |"));
366 assert!(text.contains("| name | text | |"));
368 }
369
370 #[cfg_attr(miri, ignore)] #[mz_ore::test]
372 fn hover_with_description_only() {
373 let (root, cache, types_lock) = build_test_project_with_comments_and_types_lock(
374 "CREATE VIEW foo AS SELECT 1 AS id, 'x' AS name;\n\
375 COMMENT ON VIEW foo IS 'A helpful description';",
376 );
377 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
378
379 let hover = resolve_hover(
380 &["foo".to_string()],
381 &file_uri,
382 root.path(),
383 &cache,
384 &types_lock,
385 )
386 .unwrap();
387
388 let text = extract_markdown(&hover);
389 assert!(text.contains("A helpful description"));
390 assert!(!text.contains("| Description |"));
392 assert!(text.contains("| Column | Type |"));
393 }
394
395 #[cfg_attr(miri, ignore)] #[mz_ore::test]
397 fn hover_no_comments_unchanged() {
398 let (root, cache, types_lock) = build_test_project_with_types_lock();
399 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
400
401 let hover = resolve_hover(
402 &["foo".to_string()],
403 &file_uri,
404 root.path(),
405 &cache,
406 &types_lock,
407 )
408 .unwrap();
409
410 let text = extract_markdown(&hover);
411 let lines: Vec<&str> = text.lines().collect();
413 assert_eq!(lines[0], "**view** `mydb.public.foo`");
414 assert_eq!(lines[1], "");
415 assert!(lines[2].starts_with("| Column | Type |"));
416 assert!(!text.contains("Description"));
418 }
419
420 #[cfg_attr(miri, ignore)] #[mz_ore::test]
422 fn hover_types_lock_comments_on_external_dep() {
423 let (root, cache) = build_test_project_cache();
424 let mut types_lock = Types::default();
425
426 let mut columns = BTreeMap::new();
427 columns.insert(
428 "id".to_string(),
429 ColumnType {
430 r#type: "integer".to_string(),
431 nullable: false,
432 position: 0,
433 comment: Some("Primary key".to_string()),
434 },
435 );
436 columns.insert(
437 "name".to_string(),
438 ColumnType {
439 r#type: "text".to_string(),
440 nullable: true,
441 position: 1,
442 comment: None,
443 },
444 );
445 types_lock
446 .tables
447 .insert("mydb.public.foo".parse::<ObjectId>().unwrap(), columns);
448 types_lock.comments.insert(
449 "mydb.public.foo".parse::<ObjectId>().unwrap(),
450 "External orders table".to_string(),
451 );
452
453 let file_uri = Url::from_file_path(root.path().join("models/mydb/public/bar.sql")).unwrap();
454
455 let hover = resolve_hover(
456 &["foo".to_string()],
457 &file_uri,
458 root.path(),
459 &cache,
460 &types_lock,
461 )
462 .unwrap();
463
464 let text = extract_markdown(&hover);
465 assert!(
466 text.contains("External orders table"),
467 "should show object comment from types.lock"
468 );
469 assert!(
470 text.contains("| Column | Type | Description |"),
471 "should have Description column"
472 );
473 assert!(
474 text.contains("Primary key"),
475 "should show column comment from types.lock"
476 );
477 assert!(text.contains("| name | text | |"));
479 }
480
481 fn extract_markdown(hover: &Hover) -> &str {
482 match &hover.contents {
483 HoverContents::Markup(m) => &m.value,
484 _ => panic!("expected markup content"),
485 }
486 }
487
488 fn build_test_project_cache() -> (tempfile::TempDir, ProjectCache) {
490 let root = tempfile::tempdir().unwrap();
491 let models = root.path().join("models/mydb/public");
492 std::fs::create_dir_all(&models).unwrap();
493 std::fs::write(models.join("foo.sql"), "CREATE VIEW foo AS SELECT 1 AS id;").unwrap();
494 std::fs::write(
495 models.join("bar.sql"),
496 "CREATE VIEW bar AS SELECT * FROM foo;",
497 )
498 .unwrap();
499 write_project_toml(root.path());
500
501 let _project = crate::project::plan_sync(
502 &crate::fs::FileSystem::new(),
503 root.path(),
504 None,
505 None,
506 &Default::default(),
507 )
508 .expect("project should compile");
509 let cache = ProjectCache::open(root.path(), "", None, &Default::default())
510 .expect("cache should open")
511 .expect("cache DB should exist");
512 (root, cache)
513 }
514
515 fn build_test_project_with_types_lock() -> (tempfile::TempDir, ProjectCache, Types) {
517 let (root, cache) = build_test_project_cache();
518
519 let mut types_lock = Types::default();
520 let mut columns = BTreeMap::new();
521 columns.insert(
522 "id".to_string(),
523 ColumnType {
524 r#type: "integer".to_string(),
525 nullable: false,
526 position: 0,
527 comment: None,
528 },
529 );
530 columns.insert(
531 "name".to_string(),
532 ColumnType {
533 r#type: "text".to_string(),
534 nullable: true,
535 position: 1,
536 comment: None,
537 },
538 );
539 types_lock
540 .tables
541 .insert("mydb.public.foo".parse::<ObjectId>().unwrap(), columns);
542
543 (root, cache, types_lock)
544 }
545
546 fn build_test_project_cross_schema_with_types_lock() -> (tempfile::TempDir, ProjectCache, Types)
547 {
548 let root = tempfile::tempdir().unwrap();
549
550 let storage = root.path().join("models/mydb/public");
551 std::fs::create_dir_all(&storage).unwrap();
552 std::fs::write(storage.join("foo.sql"), "CREATE TABLE foo (id INT);").unwrap();
553
554 let other = root.path().join("models/mydb/other");
555 std::fs::create_dir_all(&other).unwrap();
556 std::fs::write(
557 other.join("baz.sql"),
558 "CREATE VIEW baz AS SELECT * FROM mydb.public.foo;",
559 )
560 .unwrap();
561 write_project_toml(root.path());
562
563 let _project = crate::project::plan_sync(
564 &crate::fs::FileSystem::new(),
565 root.path(),
566 None,
567 None,
568 &Default::default(),
569 )
570 .expect("project should compile");
571 let cache = ProjectCache::open(root.path(), "", None, &Default::default())
572 .expect("cache should open")
573 .expect("cache DB should exist");
574
575 let mut types_lock = Types::default();
576 let mut columns = BTreeMap::new();
577 columns.insert(
578 "id".to_string(),
579 ColumnType {
580 r#type: "integer".to_string(),
581 nullable: true,
582 position: 0,
583 comment: None,
584 },
585 );
586 types_lock
587 .tables
588 .insert("mydb.public.foo".parse::<ObjectId>().unwrap(), columns);
589
590 (root, cache, types_lock)
591 }
592
593 fn build_test_project_with_comments_and_types_lock(
594 foo_sql: &str,
595 ) -> (tempfile::TempDir, ProjectCache, Types) {
596 let root = tempfile::tempdir().unwrap();
597 let models = root.path().join("models/mydb/public");
598 std::fs::create_dir_all(&models).unwrap();
599 std::fs::write(models.join("foo.sql"), foo_sql).unwrap();
600 std::fs::write(
601 models.join("bar.sql"),
602 "CREATE VIEW bar AS SELECT * FROM foo;",
603 )
604 .unwrap();
605 write_project_toml(root.path());
606
607 let _project = crate::project::plan_sync(
608 &crate::fs::FileSystem::new(),
609 root.path(),
610 None,
611 None,
612 &Default::default(),
613 )
614 .expect("project should compile");
615 let cache = ProjectCache::open(root.path(), "", None, &Default::default())
616 .expect("cache should open")
617 .expect("cache DB should exist");
618
619 let mut types_lock = Types::default();
620 let mut columns = BTreeMap::new();
621 columns.insert(
622 "id".to_string(),
623 ColumnType {
624 r#type: "integer".to_string(),
625 nullable: false,
626 position: 0,
627 comment: None,
628 },
629 );
630 columns.insert(
631 "name".to_string(),
632 ColumnType {
633 r#type: "text".to_string(),
634 nullable: true,
635 position: 1,
636 comment: None,
637 },
638 );
639 types_lock
640 .tables
641 .insert("mydb.public.foo".parse::<ObjectId>().unwrap(), columns);
642
643 (root, cache, types_lock)
644 }
645
646 fn write_project_toml(root: &Path) {
647 std::fs::write(root.join("project.toml"), "[project]\nname = \"test\"\n").unwrap();
648 }
649}