Skip to main content

mz_deploy/lsp/
document_symbol.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//! Document symbol provider for `.sql` files.
11//!
12//! Returns the structural outline of a `.sql` file: the main CREATE statement
13//! as the root symbol, with supporting statements (indexes, grants, comments,
14//! unit tests) as children. This powers the editor's "Outline" view and
15//! breadcrumb navigation.
16//!
17//! ## Symbol hierarchy
18//!
19//! ```text
20//! CREATE VIEW orders (root)
21//!   ├─ INDEX orders_id_idx
22//!   ├─ GRANT SELECT TO analyst
23//!   ├─ COMMENT ON VIEW orders
24//!   └─ TEST test_no_nulls
25//! ```
26//!
27//! ## Range handling
28//!
29//! The root symbol spans the entire document. Child symbols use zero-width
30//! ranges at the start of the file since exact byte offsets for individual
31//! supporting statements are not tracked in the typed IR. The hierarchy is
32//! the primary value — range precision can be refined later.
33
34use crate::project::compiler::cache::ProjectCache;
35use crate::project::ir::object_id::ObjectId;
36use std::path::Path;
37use tower_lsp::lsp_types::{DocumentSymbol, Range, SymbolKind, Url};
38
39use super::symbol_kind::object_kind_to_symbol_kind;
40
41/// Build the document symbol outline for a `.sql` file.
42///
43/// Returns a single root symbol (the main CREATE statement) with children for
44/// each supporting statement, or an empty vec if the file doesn't correspond
45/// to a known project object.
46#[allow(deprecated)] // DocumentSymbol::deprecated field is deprecated but required
47pub(super) fn document_symbols(
48    file_uri: &Url,
49    root: &Path,
50    project_cache: &ProjectCache,
51) -> Vec<DocumentSymbol> {
52    let (default_db, default_schema) = match ObjectId::default_db_schema_from_uri(file_uri, root) {
53        Some(pair) => pair,
54        None => return Vec::new(),
55    };
56
57    let file_path = match file_uri.to_file_path() {
58        Ok(p) => p,
59        Err(_) => return Vec::new(),
60    };
61    let object_name = match file_path.file_stem().and_then(|s| s.to_str()) {
62        Some(name) => name.to_string(),
63        None => return Vec::new(),
64    };
65
66    let id = ObjectId::new(default_db, default_schema, object_name);
67    let fqn = id.to_string();
68    let cached_obj = match project_cache.get_object(&id) {
69        Some(o) => o,
70        None => return Vec::new(),
71    };
72
73    let kind = object_kind_to_symbol_kind(cached_obj.kind);
74
75    let mut children = Vec::new();
76
77    // Indexes
78    for idx in &cached_obj.indexes {
79        let name = if idx.name.is_empty() {
80            "index".to_string()
81        } else {
82            idx.name.clone()
83        };
84        children.push(child_symbol(format!("INDEX {name}"), SymbolKind::KEY));
85    }
86
87    // Grants
88    for g in &cached_obj.grants {
89        children.push(child_symbol(
90            format!("GRANT {} TO {}", g.privilege, g.grantee),
91            SymbolKind::EVENT,
92        ));
93    }
94
95    // Comments
96    for c in &cached_obj.comments {
97        let label = if let Some(col) = &c.target_column {
98            format!("COMMENT ON COLUMN {col}")
99        } else {
100            "COMMENT".to_string()
101        };
102        children.push(child_symbol(label, SymbolKind::STRING));
103    }
104
105    // Unit tests
106    let tests = project_cache.get_tests(&id);
107    for t in &tests {
108        children.push(child_symbol(format!("TEST {}", t.name), SymbolKind::METHOD));
109    }
110
111    vec![DocumentSymbol {
112        name: fqn,
113        detail: Some(format!("{}", cached_obj.kind)),
114        kind,
115        tags: None,
116        deprecated: None,
117        range: Range::default(),
118        selection_range: Range::default(),
119        children: if children.is_empty() {
120            None
121        } else {
122            Some(children)
123        },
124    }]
125}
126
127/// Create a child symbol with zero-width range.
128#[allow(deprecated)] // DocumentSymbol::deprecated field is deprecated but required
129fn child_symbol(name: String, kind: SymbolKind) -> DocumentSymbol {
130    DocumentSymbol {
131        name,
132        detail: None,
133        kind,
134        tags: None,
135        deprecated: None,
136        range: Range::default(),
137        selection_range: Range::default(),
138        children: None,
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::path::Path;
146
147    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
148    #[mz_ore::test]
149    fn simple_view_single_root_symbol() {
150        let (root, cache) = build_test_cache("CREATE VIEW foo AS SELECT 1 AS id;");
151        let file_uri = Url::from_file_path(root.path().join("models/mydb/public/foo.sql")).unwrap();
152
153        let symbols = document_symbols(&file_uri, root.path(), &cache);
154        assert_eq!(symbols.len(), 1);
155        assert_eq!(symbols[0].name, "mydb.public.foo");
156        assert_eq!(symbols[0].detail.as_deref(), Some("view"));
157        assert!(symbols[0].children.is_none());
158    }
159
160    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
161    #[mz_ore::test]
162    fn view_with_index_and_comment() {
163        let (root, cache) = build_test_cache(
164            "CREATE VIEW foo AS SELECT 1 AS id;\n\
165             CREATE DEFAULT INDEX IN CLUSTER default ON foo;\n\
166             COMMENT ON VIEW foo IS 'A test view';",
167        );
168        let file_uri = Url::from_file_path(root.path().join("models/mydb/public/foo.sql")).unwrap();
169
170        let symbols = document_symbols(&file_uri, root.path(), &cache);
171        assert_eq!(symbols.len(), 1);
172        let children = symbols[0].children.as_ref().unwrap();
173        assert_eq!(children.len(), 2);
174        // Index child
175        assert!(children[0].name.contains("INDEX"));
176        assert_eq!(children[0].kind, SymbolKind::KEY);
177        // Comment child
178        assert_eq!(children[1].name, "COMMENT");
179        assert_eq!(children[1].kind, SymbolKind::STRING);
180    }
181
182    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
183    #[mz_ore::test]
184    fn unknown_file_returns_empty() {
185        let (root, cache) = build_test_cache("CREATE VIEW foo AS SELECT 1 AS id;");
186        let file_uri =
187            Url::from_file_path(root.path().join("models/mydb/public/unknown.sql")).unwrap();
188
189        let symbols = document_symbols(&file_uri, root.path(), &cache);
190        assert!(symbols.is_empty());
191    }
192
193    fn build_test_cache(foo_sql: &str) -> (tempfile::TempDir, ProjectCache) {
194        let root = tempfile::tempdir().unwrap();
195        let models = root.path().join("models/mydb/public");
196        std::fs::create_dir_all(&models).unwrap();
197        std::fs::write(models.join("foo.sql"), foo_sql).unwrap();
198        write_project_toml(root.path());
199
200        let _project = crate::project::plan_sync(
201            &crate::fs::FileSystem::new(),
202            root.path(),
203            None,
204            None,
205            &Default::default(),
206        )
207        .expect("project should compile");
208        let cache = ProjectCache::open(root.path(), "", None, &Default::default())
209            .expect("cache should open")
210            .expect("cache DB should exist");
211        (root, cache)
212    }
213
214    fn write_project_toml(root: &Path) {
215        std::fs::write(root.join("project.toml"), "[project]\nname = \"test\"\n").unwrap();
216    }
217}