1use 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
22pub 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
47fn 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 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
72fn 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
90fn 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
99pub 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 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 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 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}