1use 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
32pub type ImportOverlay = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
39
40#[derive(Debug, Clone)]
43pub struct LoadedFile {
44 pub path: PathBuf,
45 pub text: String,
46 pub file_id: FileId,
47}
48
49#[derive(Debug)]
54pub enum ImportLoadError {
55 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
75pub 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 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 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
156fn 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
180fn 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
218pub 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 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}