Skip to main content

gruel_compiler/
import_overlay.rs

1//! Walks the `@import` graph from an entry `.gruel` file (ADR-0092 Phase 3).
2//!
3//! [`load_import_closure`] is a thin helper that parses an entry source file,
4//! walks every `@import("...")` it contains (transitively), and returns the
5//! corresponding [`SourceFile`]s in a stable order suitable for feeding the
6//! existing multi-file pipeline ([`parse_all_files_with_preview`],
7//! [`merge_symbols`], …).
8//!
9//! Each file's text is sourced from the optional [`ImportOverlay`] callback
10//! first (so the LSP can substitute open-editor buffers) and falls back to
11//! the on-disk file content. The CLI typically passes `None` for the
12//! overlay; the LSP passes a closure backed by its [`DocState`] map.
13//!
14//! This sits *above* sema: sema continues to look up imports against an
15//! already-loaded `file_paths` map. The overlay's job is to populate that
16//! map before sema runs.
17//!
18//! [`SourceFile`]: crate::SourceFile
19//! [`parse_all_files_with_preview`]: crate::parse_all_files_with_preview
20//! [`merge_symbols`]: crate::merge_symbols
21//! [`DocState`]: https://docs.rs/dashmap
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use gruel_air::ModulePath;
27use gruel_parser::ast::{Ast, Expr, IntrinsicArg, Item};
28use lasso::ThreadedRodeo;
29
30use crate::{FileId, Lexer, Parser, PreviewFeatures};
31
32/// Hook that, given an absolute (or workspace-relative) file path, may
33/// return overriding source text for that file.
34///
35/// CLI callers leave this as `None` and the closure walker reads from disk.
36/// The LSP can supply an overlay backed by its open-buffer cache so that
37/// `@import` resolution sees in-flight edits, not stale disk contents.
38pub type ImportOverlay = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
39
40/// One file loaded by [`load_import_closure`]: a path, its text, and an
41/// assigned [`FileId`]. The caller converts these into [`SourceFile`]s.
42#[derive(Debug, Clone)]
43pub struct LoadedFile {
44    pub path: PathBuf,
45    pub text: String,
46    pub file_id: FileId,
47}
48
49/// A non-fatal load failure for one file (the entry, or a transitively
50/// imported file). The walker logs these up to the caller; sema will
51/// surface the eventual `ModuleNotFound` diagnostic for unresolved
52/// `@import`s.
53#[derive(Debug)]
54pub enum ImportLoadError {
55    /// Filesystem error reading a file (no overlay matched and on-disk
56    /// read failed).
57    Io {
58        path: PathBuf,
59        source: std::io::Error,
60    },
61}
62
63impl std::fmt::Display for ImportLoadError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            ImportLoadError::Io { path, source } => {
67                write!(f, "failed to read {}: {}", path.display(), source)
68            }
69        }
70    }
71}
72
73impl std::error::Error for ImportLoadError {}
74
75/// Load the entry file plus every file it transitively `@import`s.
76///
77/// The first returned `LoadedFile` is always the entry file with
78/// `FileId::new(1)`. Subsequent files get monotonically increasing
79/// `FileId`s in the order they're first discovered.
80///
81/// Resolution mirrors sema's existing rules
82/// ([`gruel_air::ModulePath::resolve`]). Imports that can't be resolved
83/// to a known file (yet) are simply skipped — sema will emit
84/// `ModuleNotFound` once the closure is parsed.
85pub fn load_import_closure(
86    entry_path: &Path,
87    preview_features: &PreviewFeatures,
88    overlay: Option<&ImportOverlay>,
89) -> Result<Vec<LoadedFile>, ImportLoadError> {
90    let entry_text = read_text(entry_path, overlay)?;
91    let mut closure: Vec<LoadedFile> = Vec::new();
92    let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
93    let mut worklist: Vec<LoadedFile> = vec![LoadedFile {
94        path: entry_path.to_path_buf(),
95        text: entry_text,
96        file_id: FileId::new(1),
97    }];
98    let mut next_id: u32 = 2;
99
100    // Candidate paths the @import resolver matches against. Grown as we
101    // discover new files so suffix-matching resolution can find them.
102    let mut candidates: Vec<String> = vec![entry_path.to_string_lossy().into_owned()];
103
104    while let Some(file) = worklist.pop() {
105        if !seen.insert(file.path.clone()) {
106            continue;
107        }
108        let imports = discover_imports(&file.text, preview_features);
109        let file_path = file.path.clone();
110        closure.push(file);
111
112        for import in imports {
113            let Some(resolved) = ModulePath::parse(&import).resolve(candidates.iter()) else {
114                // Could not resolve against currently-known paths. The walker
115                // doesn't enumerate the filesystem; we look for `.gruel`
116                // siblings of files we've already loaded instead.
117                if let Some(candidate) = try_neighbor_path(&file_path, &import) {
118                    let path = candidate;
119                    if seen.contains(&path) {
120                        continue;
121                    }
122                    let text = match read_text(&path, overlay) {
123                        Ok(t) => t,
124                        Err(_) => continue,
125                    };
126                    candidates.push(path.to_string_lossy().into_owned());
127                    worklist.push(LoadedFile {
128                        path,
129                        text,
130                        file_id: FileId::new(next_id),
131                    });
132                    next_id = next_id.saturating_add(1);
133                }
134                continue;
135            };
136            let resolved_path = PathBuf::from(&resolved);
137            if seen.contains(&resolved_path) {
138                continue;
139            }
140            let text = match read_text(&resolved_path, overlay) {
141                Ok(t) => t,
142                Err(_) => continue,
143            };
144            worklist.push(LoadedFile {
145                path: resolved_path,
146                text,
147                file_id: FileId::new(next_id),
148            });
149            next_id = next_id.saturating_add(1);
150        }
151    }
152
153    Ok(closure)
154}
155
156/// Heuristic neighbour resolution for `@import("foo.gruel")` when sema's
157/// candidate-list lookup fails: try the import path as a sibling of the
158/// current file. Matches sema's "explicit path" rule.
159fn try_neighbor_path(current: &Path, import: &str) -> Option<PathBuf> {
160    if !import.ends_with(".gruel") {
161        return None;
162    }
163    let dir = current.parent()?;
164    let candidate = dir.join(import);
165    candidate.exists().then_some(candidate)
166}
167
168fn read_text(path: &Path, overlay: Option<&ImportOverlay>) -> Result<String, ImportLoadError> {
169    if let Some(o) = overlay
170        && let Some(text) = o(path)
171    {
172        return Ok(text);
173    }
174    std::fs::read_to_string(path).map_err(|err| ImportLoadError::Io {
175        path: path.to_path_buf(),
176        source: err,
177    })
178}
179
180/// Parse a file and pluck out the `@import("...")` string-literal paths
181/// it references in const initialisers. Best-effort: returns an empty
182/// list on parse failure (sema will report the parse error from the
183/// real pipeline).
184fn discover_imports(text: &str, preview_features: &PreviewFeatures) -> Vec<String> {
185    let interner = ThreadedRodeo::new();
186    let lexer = Lexer::with_interner_and_file_id(text, interner, FileId::new(1));
187    let Ok((tokens, interner)) = lexer.tokenize() else {
188        return Vec::new();
189    };
190    let parser = Parser::new(tokens, interner)
191        .with_preview_features(preview_features.clone())
192        .with_source(text);
193    let Ok((ast, interner)) = parser.parse() else {
194        return Vec::new();
195    };
196    let mut out = Vec::new();
197    collect_imports_in_ast(&ast, &interner, &mut out);
198    out
199}
200
201fn collect_imports_in_ast(ast: &Ast, interner: &ThreadedRodeo, out: &mut Vec<String>) {
202    for item in &ast.items {
203        if let Item::Const(c) = item {
204            collect_imports_in_expr(&c.init, interner, out);
205        }
206    }
207}
208
209fn collect_imports_in_expr(expr: &Expr, interner: &ThreadedRodeo, out: &mut Vec<String>) {
210    if let Expr::IntrinsicCall(call) = expr
211        && interner.resolve(&call.name.name) == "import"
212        && let Some(IntrinsicArg::Expr(Expr::String(s))) = call.args.first()
213    {
214        out.push(interner.resolve(&s.value).to_string());
215    }
216}
217
218/// View the closure as `(path, text, FileId)` triples so the caller can
219/// construct `SourceFile<'_>` borrows.
220pub fn loaded_files_as_view(files: &[LoadedFile]) -> Vec<(String, String, FileId)> {
221    files
222        .iter()
223        .map(|f| {
224            (
225                f.path.to_string_lossy().into_owned(),
226                f.text.clone(),
227                f.file_id,
228            )
229        })
230        .collect()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::fs;
237    use tempfile::TempDir;
238
239    fn write(dir: &Path, rel: &str, body: &str) -> PathBuf {
240        let p = dir.join(rel);
241        if let Some(parent) = p.parent() {
242            fs::create_dir_all(parent).unwrap();
243        }
244        fs::write(&p, body).unwrap();
245        p
246    }
247
248    #[test]
249    fn closure_of_lone_entry_is_just_the_entry() {
250        let tmp = TempDir::new().unwrap();
251        let main = write(tmp.path(), "main.gruel", "fn main() -> i32 { 0 }\n");
252        let files = load_import_closure(&main, &PreviewFeatures::default(), None).unwrap();
253        assert_eq!(files.len(), 1);
254        assert_eq!(files[0].path, main);
255        assert!(files[0].text.contains("fn main"));
256        assert_eq!(files[0].file_id.index(), 1);
257    }
258
259    #[test]
260    fn closure_follows_one_import_from_disk() {
261        let tmp = TempDir::new().unwrap();
262        let main = write(
263            tmp.path(),
264            "main.gruel",
265            "const math = @import(\"math.gruel\");\nfn main() -> i32 { 0 }\n",
266        );
267        let math = write(tmp.path(), "math.gruel", "pub fn pi() -> i32 { 3 }\n");
268        let files = load_import_closure(&main, &PreviewFeatures::default(), None).unwrap();
269        let paths: Vec<_> = files.iter().map(|f| f.path.clone()).collect();
270        assert!(paths.contains(&main));
271        assert!(paths.contains(&math));
272        assert_eq!(files.len(), 2);
273    }
274
275    #[test]
276    fn overlay_substitutes_imported_file_text() {
277        let tmp = TempDir::new().unwrap();
278        let main = write(
279            tmp.path(),
280            "main.gruel",
281            "const math = @import(\"math.gruel\");\nfn main() -> i32 { 0 }\n",
282        );
283        let math = write(tmp.path(), "math.gruel", "pub fn pi() -> i32 { 999 }\n");
284
285        let overlay_target = math.clone();
286        let overlay: ImportOverlay = Arc::new(move |p: &Path| {
287            if p == overlay_target {
288                Some("pub fn pi() -> i32 { 42 }\n".to_string())
289            } else {
290                None
291            }
292        });
293
294        let files =
295            load_import_closure(&main, &PreviewFeatures::default(), Some(&overlay)).unwrap();
296        let math_file = files
297            .iter()
298            .find(|f| f.path == math)
299            .expect("math.gruel should be in closure");
300        assert!(math_file.text.contains("42"), "expected overlay text, got: {}", math_file.text);
301    }
302
303    #[test]
304    fn overlay_substitutes_entry_file_text() {
305        let tmp = TempDir::new().unwrap();
306        let main = write(tmp.path(), "main.gruel", "fn main() -> i32 { 0 }\n");
307
308        let main_clone = main.clone();
309        let overlay: ImportOverlay = Arc::new(move |p: &Path| {
310            if p == main_clone {
311                Some("fn main() -> i32 { 7 }\n".to_string())
312            } else {
313                None
314            }
315        });
316
317        let files =
318            load_import_closure(&main, &PreviewFeatures::default(), Some(&overlay)).unwrap();
319        assert_eq!(files.len(), 1);
320        assert!(files[0].text.contains("7"));
321    }
322
323    #[test]
324    fn unresolvable_import_is_skipped() {
325        let tmp = TempDir::new().unwrap();
326        let main = write(
327            tmp.path(),
328            "main.gruel",
329            "const missing = @import(\"nope.gruel\");\nfn main() -> i32 { 0 }\n",
330        );
331        let files = load_import_closure(&main, &PreviewFeatures::default(), None).unwrap();
332        // Walker doesn't crash; just leaves the import unresolved.
333        assert_eq!(files.len(), 1);
334        assert_eq!(files[0].path, main);
335    }
336
337    #[test]
338    fn cycle_terminates() {
339        let tmp = TempDir::new().unwrap();
340        let a = write(
341            tmp.path(),
342            "a.gruel",
343            "const b = @import(\"b.gruel\");\npub fn from_a() -> i32 { 1 }\n",
344        );
345        let _b = write(
346            tmp.path(),
347            "b.gruel",
348            "const a = @import(\"a.gruel\");\npub fn from_b() -> i32 { 2 }\n",
349        );
350        let files = load_import_closure(&a, &PreviewFeatures::default(), None).unwrap();
351        assert_eq!(files.len(), 2);
352    }
353
354    #[test]
355    fn entry_io_error_returned() {
356        let tmp = TempDir::new().unwrap();
357        let missing = tmp.path().join("absent.gruel");
358        let err = load_import_closure(&missing, &PreviewFeatures::default(), None).unwrap_err();
359        assert!(matches!(err, ImportLoadError::Io { .. }));
360    }
361}