Skip to main content

mz_deploy/lsp/
code_lens.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//! Code lenses for unit tests and explain plans.
11//!
12//! Places clickable links above SQL statements:
13//!
14//! - **"Run Test"** above each `EXECUTE UNIT TEST` statement — dispatches
15//!   `mz-deploy.runTest` with the test filter string.
16//! - **"Explain"** above `CREATE MATERIALIZED VIEW` statements and named
17//!   `CREATE INDEX` statements — dispatches `mz-deploy.runExplain` with the
18//!   fully qualified target (`database.schema.object` or
19//!   `database.schema.object#index_name`).
20
21use crate::project::compiler::cache::ProjectCache;
22use crate::project::ir::object_id::ObjectId;
23use crate::types::ObjectKind;
24use std::path::Path;
25use tower_lsp::lsp_types::*;
26
27/// Build code lenses for tests and explain targets in the file.
28///
29/// Returns an empty vec if the file is not under `models/<db>/<schema>/` or
30/// the object is not found in the project cache.
31pub(super) fn code_lenses(
32    file_uri: &Url,
33    file_text: &str,
34    root: &Path,
35    project_cache: Option<&ProjectCache>,
36) -> Vec<CodeLens> {
37    let project_cache = match project_cache {
38        Some(c) => c,
39        None => return Vec::new(),
40    };
41
42    let (default_db, default_schema) = match ObjectId::default_db_schema_from_uri(file_uri, root) {
43        Some(pair) => pair,
44        None => return Vec::new(),
45    };
46
47    let object_name = file_uri
48        .to_file_path()
49        .ok()
50        .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()));
51    let object_name = match object_name {
52        Some(name) => name,
53        None => return Vec::new(),
54    };
55
56    let file_object_id = ObjectId::new(default_db.clone(), default_schema.clone(), object_name);
57    let fqn = file_object_id.to_string();
58
59    let cached_obj = match project_cache.get_object(&file_object_id) {
60        Some(obj) => obj,
61        None => return Vec::new(),
62    };
63
64    let mut lenses = Vec::new();
65
66    // Explain lens for materialized views
67    if cached_obj.kind == ObjectKind::MaterializedView {
68        if let Some(line) = find_statement_line(file_text, "create materialized view") {
69            lenses.push(CodeLens {
70                range: Range::new(Position::new(line, 0), Position::new(line, 0)),
71                command: Some(Command {
72                    title: "\u{25b6} Explain".to_string(),
73                    command: "mz-deploy.runExplain".to_string(),
74                    arguments: Some(vec![serde_json::Value::String(fqn.clone())]),
75                }),
76                data: None,
77            });
78        }
79    }
80
81    // Explain lenses for named indexes
82    for index in &cached_obj.indexes {
83        if index.name.is_empty() {
84            continue;
85        }
86        let index_name = &index.name;
87        if let Some(line) = find_index_line(file_text, index_name) {
88            let target = format!("{}#{}", fqn, index_name);
89            lenses.push(CodeLens {
90                range: Range::new(Position::new(line, 0), Position::new(line, 0)),
91                command: Some(Command {
92                    title: "\u{25b6} Explain".to_string(),
93                    command: "mz-deploy.runExplain".to_string(),
94                    arguments: Some(vec![serde_json::Value::String(target)]),
95                }),
96                data: None,
97            });
98        }
99    }
100
101    // Test lenses
102    let tests = project_cache.get_tests(&file_object_id);
103    for test in &tests {
104        let test_name = &test.name;
105        if let Some(line) = find_test_line(file_text, test_name) {
106            let filter = format!("{}#{}", fqn, test_name);
107            lenses.push(CodeLens {
108                range: Range::new(Position::new(line, 0), Position::new(line, 0)),
109                command: Some(Command {
110                    title: "\u{25b6} Run Test".to_string(),
111                    command: "mz-deploy.runTest".to_string(),
112                    arguments: Some(vec![serde_json::Value::String(filter)]),
113                }),
114                data: None,
115            });
116        }
117    }
118
119    lenses
120}
121
122/// Find the 0-based line number where a statement keyword appears.
123///
124/// Case-insensitive scan for lines starting with `keyword` (e.g.,
125/// `"create materialized view"`).
126fn find_statement_line(file_text: &str, keyword: &str) -> Option<u32> {
127    for (i, line) in file_text.lines().enumerate() {
128        if line.trim().to_lowercase().starts_with(keyword) {
129            return u32::try_from(i).ok();
130        }
131    }
132    None
133}
134
135/// Find the 0-based line number where `CREATE INDEX <name>` appears.
136fn find_index_line(file_text: &str, index_name: &str) -> Option<u32> {
137    let target = format!("create index {}", index_name);
138    let target_lower = target.to_lowercase();
139
140    for (i, line) in file_text.lines().enumerate() {
141        if line.trim().to_lowercase().starts_with(&target_lower) {
142            return u32::try_from(i).ok();
143        }
144    }
145    None
146}
147
148/// Find the 0-based line number where `EXECUTE UNIT TEST <name>` appears.
149fn find_test_line(file_text: &str, test_name: &str) -> Option<u32> {
150    let target = format!("execute unit test {}", test_name);
151    let target_lower = target.to_lowercase();
152
153    for (i, line) in file_text.lines().enumerate() {
154        let trimmed = line.trim().to_lowercase();
155        if trimmed.starts_with(&target_lower) {
156            return u32::try_from(i).ok();
157        }
158    }
159    None
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn write_project_toml(root: &Path) {
167        std::fs::write(root.join("project.toml"), "[project]\nname = \"test\"\n").unwrap();
168    }
169
170    fn build_cache(root: &tempfile::TempDir) -> ProjectCache {
171        write_project_toml(root.path());
172        let _project = crate::project::plan_sync(
173            &crate::fs::FileSystem::new(),
174            root.path(),
175            None,
176            None,
177            &Default::default(),
178        )
179        .expect("project should compile");
180        ProjectCache::open(root.path(), "", None, &Default::default())
181            .expect("cache should open")
182            .expect("cache DB should exist")
183    }
184
185    #[mz_ore::test]
186    fn test_find_test_line_basic() {
187        let text = "CREATE VIEW foo AS SELECT 1;\n\nEXECUTE UNIT TEST my_test\nAS SELECT 1;\n";
188        assert_eq!(find_test_line(text, "my_test"), Some(2));
189    }
190
191    #[mz_ore::test]
192    fn test_find_test_line_case_insensitive() {
193        let text = "execute unit test my_test\nAS SELECT 1;\n";
194        assert_eq!(find_test_line(text, "my_test"), Some(0));
195    }
196
197    #[mz_ore::test]
198    fn test_find_test_line_not_found() {
199        let text = "CREATE VIEW foo AS SELECT 1;\n";
200        assert_eq!(find_test_line(text, "nonexistent"), None);
201    }
202
203    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
204    #[mz_ore::test]
205    fn test_single_test() {
206        let root = tempfile::tempdir().unwrap();
207        let dir = root.path().join("models/mydb/public");
208        std::fs::create_dir_all(&dir).unwrap();
209        std::fs::write(
210            dir.join("foo.sql"),
211            "CREATE VIEW foo AS SELECT 1 AS id;\n\nEXECUTE UNIT TEST basic_test\nFOR mydb.public.foo\nEXPECTED(id integer) AS (\n    SELECT 1\n);\n",
212        )
213        .unwrap();
214        let cache = build_cache(&root);
215
216        let uri = Url::from_file_path(root.path().join("models/mydb/public/foo.sql")).unwrap();
217        let file_text =
218            std::fs::read_to_string(root.path().join("models/mydb/public/foo.sql")).unwrap();
219        let lenses = code_lenses(&uri, &file_text, root.path(), Some(&cache));
220
221        assert_eq!(lenses.len(), 1);
222        let lens = &lenses[0];
223        assert_eq!(lens.range.start.line, 2);
224        let cmd = lens.command.as_ref().unwrap();
225        assert_eq!(cmd.command, "mz-deploy.runTest");
226        assert_eq!(
227            cmd.arguments.as_ref().unwrap()[0],
228            serde_json::Value::String("mydb.public.foo#basic_test".to_string())
229        );
230    }
231
232    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
233    #[mz_ore::test]
234    fn test_multiple_tests() {
235        let root = tempfile::tempdir().unwrap();
236        let dir = root.path().join("models/mydb/public");
237        std::fs::create_dir_all(&dir).unwrap();
238        std::fs::write(
239            dir.join("foo.sql"),
240            "CREATE VIEW foo AS SELECT 1 AS id;\n\nEXECUTE UNIT TEST test_one\nFOR mydb.public.foo\nEXPECTED(id integer) AS (\n    SELECT 1\n);\n\nEXECUTE UNIT TEST test_two\nFOR mydb.public.foo\nEXPECTED(id integer) AS (\n    SELECT 1\n);\n",
241        )
242        .unwrap();
243        let cache = build_cache(&root);
244
245        let uri = Url::from_file_path(root.path().join("models/mydb/public/foo.sql")).unwrap();
246        let file_text =
247            std::fs::read_to_string(root.path().join("models/mydb/public/foo.sql")).unwrap();
248        let lenses = code_lenses(&uri, &file_text, root.path(), Some(&cache));
249
250        assert_eq!(lenses.len(), 2);
251
252        let filters: Vec<String> = lenses
253            .iter()
254            .filter_map(|l| {
255                l.command
256                    .as_ref()
257                    .and_then(|c| c.arguments.as_ref())
258                    .and_then(|a| a[0].as_str().map(String::from))
259            })
260            .collect();
261        assert!(filters.contains(&"mydb.public.foo#test_one".to_string()));
262        assert!(filters.contains(&"mydb.public.foo#test_two".to_string()));
263
264        // Different lines
265        assert_ne!(lenses[0].range.start.line, lenses[1].range.start.line);
266    }
267
268    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
269    #[mz_ore::test]
270    fn test_no_tests() {
271        let root = tempfile::tempdir().unwrap();
272        let dir = root.path().join("models/mydb/public");
273        std::fs::create_dir_all(&dir).unwrap();
274        std::fs::write(dir.join("foo.sql"), "CREATE VIEW foo AS SELECT 1 AS id;\n").unwrap();
275        let cache = build_cache(&root);
276
277        let uri = Url::from_file_path(root.path().join("models/mydb/public/foo.sql")).unwrap();
278        let file_text =
279            std::fs::read_to_string(root.path().join("models/mydb/public/foo.sql")).unwrap();
280        let lenses = code_lenses(&uri, &file_text, root.path(), Some(&cache));
281
282        assert!(lenses.is_empty());
283    }
284
285    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
286    #[mz_ore::test]
287    fn test_file_not_in_project() {
288        let root = tempfile::tempdir().unwrap();
289        let dir = root.path().join("models/mydb/public");
290        std::fs::create_dir_all(&dir).unwrap();
291        std::fs::write(dir.join("foo.sql"), "CREATE VIEW foo AS SELECT 1 AS id;\n").unwrap();
292        let cache = build_cache(&root);
293
294        // URI points to a file outside models/
295        let outside = root.path().join("random/foo.sql");
296        std::fs::create_dir_all(root.path().join("random")).unwrap();
297        std::fs::write(&outside, "SELECT 1;").unwrap();
298        let uri = Url::from_file_path(&outside).unwrap();
299        let file_text = std::fs::read_to_string(&outside).unwrap();
300        let lenses = code_lenses(&uri, &file_text, root.path(), Some(&cache));
301
302        assert!(lenses.is_empty());
303    }
304}