Skip to main content

mz_deploy/lsp/
hover.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Hover information for SQL identifiers and variable references.
11//!
12//! ## Variable Hover
13//!
14//! When the cursor is on a psql-style variable reference (`:foo`, `:'foo'`,
15//! `:"foo"`), [`resolve_variable_hover`] returns a tooltip showing the variable
16//! name, its resolved value (or "undefined"), and the active profile name.
17//!
18//! ## Object Hover
19//!
20//! When the cursor hovers over an identifier that references a database object,
21//! [`resolve_hover`] resolves the identifier to its output schema (column names
22//! and types) using the build artifact database and `types.lock` data. The result is
23//! formatted as a Markdown table for display in the editor.
24//!
25//! ## Function Hover
26//!
27//! If the identifier is not a project object, [`resolve_hover`] falls back to
28//! the function registry ([`functions::lookup`]), which is derived from
29//! `mz_sql::func`. Single unqualified names that match a built-in show the
30//! function kind and one line per overload signature.
31//!
32//! ### Resolution
33//!
34//! 1. Identifier parts are resolved to an `ObjectId` using the same
35//!    1/2/3-part convention as [`goto_definition::resolve_reference()`].
36//! 2. The object is looked up in the `ProjectCache` (SQLite) to confirm
37//!    existence and determine its kind (view, materialized view, table, etc.).
38//! 3. Column schemas are retrieved via two-tier lookup: `ProjectCache`
39//!    (SQLite) first, then `Types` (types.lock) as fallback.
40//!
41//! ### Output
42//!
43//! - **Object with cached columns** — Shows the object kind, fully-qualified
44//!   name, and a column table (name, type, nullable). If the object has a
45//!   `COMMENT ON` description, it appears as a paragraph after the header.
46//!   If any column has a `COMMENT ON COLUMN` description, a `Description`
47//!   column is added to the table.
48//! - **Object without cached columns** — Shows just the object kind, name,
49//!   and source file path.
50//! - **Unknown identifier** — Returns `None`.
51
52use 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
60/// Resolve hover information for a variable reference at the given byte offset.
61///
62/// If `offset` falls inside a resolved variable reference (`:name`, `:'name'`,
63/// or `:"name"`), returns a [`Hover`] showing the variable name, its value,
64/// and the active profile. Undefined variables return `None` — their diagnostic
65/// already covers the error.
66///
67/// Returns `None` if the offset is not inside a variable reference, or if the
68/// variable is undefined.
69pub 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
86/// Resolve hover information for an identifier.
87///
88/// Takes the dot-qualified identifier `parts` (from
89/// [`goto_definition::find_reference_at_position()`]), resolves it against the
90/// `ProjectCache` (SQLite), and formats the output schema as Markdown.
91///
92/// Column schemas are retrieved via two-tier lookup: `ProjectCache` first
93/// (typecheck columns), then `Types` (types.lock) as fallback.
94///
95/// # Returns
96/// `Some(Hover)` with Markdown content if the identifier resolves to a known
97/// object, `None` otherwise.
98pub 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 not a project object, try function lookup (single unqualified name)
110    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    // Fall back to types.lock column comments when no project cache comments exist
142    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
195/// Resolve hover for a SQL function name.
196///
197/// Matches single unqualified names against the function registry. Returns a
198/// Markdown tooltip showing the function kind and every overload signature,
199/// one per line in a code block.
200fn 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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
262    #[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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
283    #[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        // Should not contain a table header
302        assert!(!text.contains("| Column |"));
303    }
304
305    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
306    #[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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
323    #[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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
343    #[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        // name has no column comment — empty description cell
367        assert!(text.contains("| name | text |  |"));
368    }
369
370    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
371    #[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        // No Description column since no column comments
391        assert!(!text.contains("| Description |"));
392        assert!(text.contains("| Column | Type |"));
393    }
394
395    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
396    #[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        // No description paragraph
412        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        // No Description column
417        assert!(!text.contains("Description"));
418    }
419
420    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
421    #[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        // name has no comment — empty description cell
478        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    /// Compile a project and open a ProjectCache from its SQLite DB.
489    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    /// Build project cache with a types.lock providing column schemas for foo.
516    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}