Skip to main content

gruel_lsp/
workspace.rs

1//! Workspace file discovery and @import closure construction (ADR-0091).
2//!
3//! Each open editor buffer is analyzed as its own compilation root: the LSP
4//! parses it, walks for `@import("...")` calls, transitively loads every
5//! reachable file (open buffer first, on-disk fallback), and hands the closure
6//! to the frontend as a single `CompilationUnit`. Unrelated files in the
7//! workspace are never merged together — that's what kept opening this very
8//! repo from producing thousands of `fn main()` duplicate-definition errors
9//! before ADR-0091's per-root revision (2026-05-19).
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use gruel_air::ModulePath;
15use gruel_compiler::{FileId, PreviewFeatures, SourceFile, parse_all_files_with_preview};
16use gruel_parser::ast::{Ast, Expr, IntrinsicArg, Item};
17use ignore::WalkBuilder;
18use lasso::ThreadedRodeo;
19
20use crate::analysis::WorkspaceFile;
21
22/// Enumerate every `*.gruel` file under `root`, respecting `.gitignore`
23/// and skipping `.git`/`target`.
24///
25/// Used to populate the candidate path list that `@import` resolution searches
26/// against — NOT to build a compilation unit. The LSP analyzes each open file
27/// as its own root and only pulls in files reachable through its `@import`
28/// graph.
29pub fn enumerate_gruel_files(root: &Path) -> Vec<PathBuf> {
30    let mut out = Vec::new();
31    let walker = WalkBuilder::new(root)
32        .standard_filters(true)
33        .filter_entry(|entry| {
34            let name = entry.file_name();
35            name != ".git" && name != "target"
36        })
37        .build();
38    for entry in walker.flatten() {
39        let path = entry.path();
40        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("gruel") {
41            out.push(path.to_path_buf());
42        }
43    }
44    out
45}
46
47/// Walk a parsed AST and collect the string-literal argument of every
48/// `@import("...")` intrinsic call we can find. Imports whose path is a
49/// non-literal (e.g. `@import(comptime { ... })`) are skipped — the sema layer
50/// resolves those during the analysis pass and will surface diagnostics if
51/// resolution fails.
52fn collect_imports_in_expr(expr: &Expr, interner: &ThreadedRodeo, out: &mut Vec<String>) {
53    if let Expr::IntrinsicCall(call) = expr
54        && interner.resolve(&call.name.name) == "import"
55        && let Some(IntrinsicArg::Expr(Expr::String(s))) = call.args.first()
56    {
57        out.push(interner.resolve(&s.value).to_string());
58    }
59}
60
61fn collect_imports_in_ast(ast: &Ast, interner: &ThreadedRodeo, out: &mut Vec<String>) {
62    // Only walk const initializers — that's where the compiler actually
63    // resolves `@import` (see crates/gruel-air/src/sema/imports.rs). Imports
64    // appearing elsewhere are illegal and would already be flagged by sema.
65    for item in &ast.items {
66        if let Item::Const(c) = item {
67            collect_imports_in_expr(&c.init, interner, out);
68        }
69    }
70}
71
72/// Parse one file's source, walk its AST for `@import("...")` paths, and
73/// return them. Returns `Vec::new()` on parse failure — the analysis pass will
74/// surface the syntax error itself; we just can't follow imports through a
75/// broken file.
76fn discover_imports(text: &str, path: &str, preview_features: &PreviewFeatures) -> Vec<String> {
77    let source = SourceFile::new(path, text, FileId::new(1));
78    let parsed = match parse_all_files_with_preview(&[source], preview_features) {
79        Ok(p) => p,
80        Err(_) => return Vec::new(),
81    };
82    let Some(file) = parsed.files.first() else {
83        return Vec::new();
84    };
85    let mut paths = Vec::new();
86    collect_imports_in_ast(&file.ast, &parsed.interner, &mut paths);
87    paths
88}
89
90/// Resolve an `@import("foo")` path against the available files, mirroring
91/// `gruel-air`'s [`ModulePath`] rules. The candidate list contains every
92/// known file path (open + on-disk under the workspace root, plus any extras
93/// supplied by the caller).
94fn resolve_import(import_path: &str, candidates: &[String]) -> Option<String> {
95    let owned: Vec<String> = candidates.to_vec();
96    ModulePath::parse(import_path).resolve(owned.iter())
97}
98
99/// Build a (root + transitively-`@import`-reachable) `WorkspaceFile` set for
100/// one compilation root.
101///
102/// File text comes from `open_text` when the file is currently open in the
103/// editor, otherwise from disk. Already-visited paths are deduped so cyclic
104/// import graphs terminate.
105pub fn build_root_closure<F>(
106    root: WorkspaceFile,
107    workspace_root: Option<&Path>,
108    preview_features: &PreviewFeatures,
109    mut open_text: F,
110) -> Vec<WorkspaceFile>
111where
112    F: FnMut(&Path) -> Option<String>,
113{
114    // All known candidate paths the @import resolver can match against.
115    // Strings, since ModulePath::resolve works on String iterators.
116    let workspace_files: Vec<PathBuf> = match workspace_root {
117        Some(root) => enumerate_gruel_files(root),
118        None => Vec::new(),
119    };
120    let mut candidate_strings: Vec<String> = workspace_files
121        .iter()
122        .map(|p| p.to_string_lossy().into_owned())
123        .collect();
124    let root_str = root.path.to_string_lossy().into_owned();
125    if !candidate_strings.iter().any(|s| s == &root_str) {
126        candidate_strings.push(root_str);
127    }
128
129    let mut closure: Vec<WorkspaceFile> = Vec::new();
130    let mut seen: HashSet<PathBuf> = HashSet::new();
131    let mut next_id: u32 = root.file_id.index().saturating_add(1).max(2);
132    let mut worklist: Vec<WorkspaceFile> = vec![root];
133
134    while let Some(file) = worklist.pop() {
135        if !seen.insert(file.path.clone()) {
136            continue;
137        }
138        let path_str = file.path.to_string_lossy().into_owned();
139        let import_paths = discover_imports(&file.text, &path_str, preview_features);
140        closure.push(file);
141
142        for import_path in import_paths {
143            let Some(resolved_str) = resolve_import(&import_path, &candidate_strings) else {
144                // Resolution failure is the analysis pass's problem (it will
145                // emit ModuleNotFound). We just don't follow what we can't
146                // find here.
147                continue;
148            };
149            let resolved_path = PathBuf::from(&resolved_str);
150            if seen.contains(&resolved_path) {
151                continue;
152            }
153            let text = match open_text(&resolved_path) {
154                Some(t) => t,
155                None => match std::fs::read_to_string(&resolved_path) {
156                    Ok(t) => t,
157                    Err(_) => continue,
158                },
159            };
160            worklist.push(WorkspaceFile {
161                path: resolved_path,
162                text,
163                file_id: FileId::new(next_id),
164            });
165            next_id = next_id.saturating_add(1);
166        }
167    }
168
169    closure
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::fs;
176    use tempfile::tempdir;
177
178    fn wsf(path: PathBuf, text: &str, id: u32) -> WorkspaceFile {
179        WorkspaceFile {
180            path,
181            text: text.to_string(),
182            file_id: FileId::new(id),
183        }
184    }
185
186    #[test]
187    fn finds_gruel_files() {
188        let dir = tempdir().unwrap();
189        let root = dir.path();
190        fs::write(root.join("a.gruel"), "fn main() -> i32 { 0 }").unwrap();
191        fs::create_dir_all(root.join("sub")).unwrap();
192        fs::write(root.join("sub/b.gruel"), "fn helper() -> i32 { 1 }").unwrap();
193        fs::write(root.join("notes.txt"), "not gruel").unwrap();
194        let files = enumerate_gruel_files(root);
195        let names: Vec<_> = files
196            .iter()
197            .filter_map(|p| p.file_name().and_then(|s| s.to_str()))
198            .collect();
199        assert!(names.contains(&"a.gruel"));
200        assert!(names.contains(&"b.gruel"));
201        assert!(!names.contains(&"notes.txt"));
202    }
203
204    #[test]
205    fn discovers_no_imports_for_plain_file() {
206        let imports = discover_imports(
207            "fn main() -> i32 { 0 }",
208            "main.gruel",
209            &PreviewFeatures::default(),
210        );
211        assert!(imports.is_empty());
212    }
213
214    #[test]
215    fn discovers_import_in_const_init() {
216        let text = r#"const math = @import("math.gruel");
217fn main() -> i32 { 0 }
218"#;
219        let imports = discover_imports(text, "main.gruel", &PreviewFeatures::default());
220        assert_eq!(imports, vec!["math.gruel".to_string()]);
221    }
222
223    #[test]
224    fn closure_includes_only_root_when_no_imports() {
225        let dir = tempdir().unwrap();
226        let root_path = dir.path().join("main.gruel");
227        fs::write(&root_path, "fn main() -> i32 { 0 }").unwrap();
228        let root = wsf(root_path.clone(), "fn main() -> i32 { 0 }", 1);
229        let closure =
230            build_root_closure(root, Some(dir.path()), &PreviewFeatures::default(), |_| {
231                None
232            });
233        assert_eq!(closure.len(), 1);
234        assert_eq!(closure[0].path, root_path);
235    }
236
237    #[test]
238    fn closure_follows_one_import() {
239        let dir = tempdir().unwrap();
240        let main_path = dir.path().join("main.gruel");
241        let math_path = dir.path().join("math.gruel");
242        let main_src = r#"const math = @import("math.gruel");
243fn main() -> i32 { 0 }
244"#;
245        let math_src = r#"pub fn pi() -> i32 { 3 }
246"#;
247        fs::write(&main_path, main_src).unwrap();
248        fs::write(&math_path, math_src).unwrap();
249        let root = wsf(main_path.clone(), main_src, 1);
250        let closure =
251            build_root_closure(root, Some(dir.path()), &PreviewFeatures::default(), |_| {
252                None
253            });
254        let paths: Vec<_> = closure.iter().map(|f| f.path.clone()).collect();
255        assert!(paths.contains(&main_path), "closure should include root");
256        assert!(
257            paths.contains(&math_path),
258            "closure should include math.gruel"
259        );
260        assert_eq!(closure.len(), 2);
261    }
262
263    #[test]
264    fn closure_does_not_include_sibling_with_no_import_relation() {
265        // The bug fix: two unrelated `fn main()` files don't get merged.
266        let dir = tempdir().unwrap();
267        let a_path = dir.path().join("a.gruel");
268        let b_path = dir.path().join("b.gruel");
269        let a_src = "fn main() -> i32 { 1 }";
270        let b_src = "fn main() -> i32 { 2 }";
271        fs::write(&a_path, a_src).unwrap();
272        fs::write(&b_path, b_src).unwrap();
273        let root = wsf(a_path.clone(), a_src, 1);
274        let closure =
275            build_root_closure(root, Some(dir.path()), &PreviewFeatures::default(), |_| {
276                None
277            });
278        let paths: Vec<_> = closure.iter().map(|f| f.path.clone()).collect();
279        assert!(paths.contains(&a_path));
280        assert!(
281            !paths.contains(&b_path),
282            "unrelated file must not be pulled in"
283        );
284    }
285
286    #[test]
287    fn closure_terminates_on_import_cycle() {
288        let dir = tempdir().unwrap();
289        let a_path = dir.path().join("a.gruel");
290        let b_path = dir.path().join("b.gruel");
291        let a_src = r#"const b = @import("b.gruel");
292pub fn from_a() -> i32 { 1 }
293"#;
294        let b_src = r#"const a = @import("a.gruel");
295pub fn from_b() -> i32 { 2 }
296"#;
297        fs::write(&a_path, a_src).unwrap();
298        fs::write(&b_path, b_src).unwrap();
299        let root = wsf(a_path.clone(), a_src, 1);
300        let closure =
301            build_root_closure(root, Some(dir.path()), &PreviewFeatures::default(), |_| {
302                None
303            });
304        let paths: Vec<_> = closure.iter().map(|f| f.path.clone()).collect();
305        assert!(paths.contains(&a_path));
306        assert!(paths.contains(&b_path));
307        assert_eq!(closure.len(), 2);
308    }
309
310    #[test]
311    fn closure_prefers_open_text_over_disk() {
312        let dir = tempdir().unwrap();
313        let main_path = dir.path().join("main.gruel");
314        fs::write(&main_path, "fn main() -> i32 { 0 }").unwrap();
315        let root = wsf(main_path.clone(), "fn main() -> i32 { 999 }", 1);
316        let in_memory = "fn main() -> i32 { 999 }".to_string();
317        let closure =
318            build_root_closure(root, Some(dir.path()), &PreviewFeatures::default(), |_| {
319                Some(in_memory.clone())
320            });
321        assert_eq!(closure[0].text, "fn main() -> i32 { 999 }");
322    }
323}