1use crate::error::LoadError;
4use crate::manifest::ProjectManifest;
5use sage_parser::ast::Program;
6use sage_parser::parse;
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11pub type ModulePath = Vec<String>;
13
14#[derive(Debug)]
16pub struct ModuleTree {
17 pub modules: HashMap<ModulePath, ParsedModule>,
19 pub root: ModulePath,
21 pub project_root: PathBuf,
23 pub external_roots: HashMap<String, PathBuf>,
26}
27
28#[derive(Debug)]
30pub struct TestFile {
31 pub file_path: PathBuf,
33 pub source: Arc<str>,
35 pub program: Program,
37}
38
39#[derive(Debug)]
41pub struct ParsedModule {
42 pub path: ModulePath,
44 pub file_path: PathBuf,
46 pub source: Arc<str>,
48 pub program: Program,
50}
51
52pub fn load_single_file(path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
54 let source = std::fs::read_to_string(path).map_err(|e| {
55 vec![LoadError::IoError {
56 path: path.to_path_buf(),
57 source: e,
58 }]
59 })?;
60
61 let source_arc: Arc<str> = Arc::from(source.as_str());
62 let lex_result = sage_parser::lex(&source).map_err(|e| {
63 vec![LoadError::ParseError {
64 file: path.to_path_buf(),
65 errors: vec![format!("{e}")],
66 }]
67 })?;
68
69 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
70
71 if !parse_errors.is_empty() {
72 return Err(vec![LoadError::ParseError {
73 file: path.to_path_buf(),
74 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
75 }]);
76 }
77
78 let program = program.ok_or_else(|| {
79 vec![LoadError::ParseError {
80 file: path.to_path_buf(),
81 errors: vec!["failed to parse program".to_string()],
82 }]
83 })?;
84
85 let root_path = vec![];
86 let mut modules = HashMap::new();
87 modules.insert(
88 root_path.clone(),
89 ParsedModule {
90 path: root_path.clone(),
91 file_path: path.to_path_buf(),
92 source: source_arc,
93 program,
94 },
95 );
96
97 Ok(ModuleTree {
98 modules,
99 root: root_path,
100 project_root: path
101 .parent()
102 .map(Path::to_path_buf)
103 .unwrap_or_else(|| PathBuf::from(".")),
104 external_roots: HashMap::new(),
105 })
106}
107
108pub fn load_project(project_path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
112 let manifest_path = if project_path.is_file() && project_path.ends_with("grove.toml") {
114 project_path.to_path_buf()
115 } else if project_path.is_dir() {
116 let grove_path = project_path.join("grove.toml");
118 let sage_path = project_path.join("sage.toml");
119 if grove_path.exists() {
120 grove_path
121 } else if sage_path.exists() {
122 eprintln!("warning: sage.toml is deprecated, rename to grove.toml");
123 sage_path
124 } else {
125 project_path.join("grove.toml") }
127 } else {
128 return load_single_file(project_path);
130 };
131
132 if !manifest_path.exists() {
133 if project_path.extension().is_some_and(|e| e == "sg") {
135 return load_single_file(project_path);
136 }
137 return Err(vec![LoadError::NoManifest {
138 dir: project_path.to_path_buf(),
139 }]);
140 }
141
142 let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![*e])?;
143 let project_root = manifest_path.parent().unwrap().to_path_buf();
144 let entry_path = project_root.join(&manifest.project.entry);
145
146 if !entry_path.exists() {
147 return Err(vec![LoadError::MissingEntry { path: entry_path }]);
148 }
149
150 let mut loader = ModuleLoader::new(project_root.clone());
152 let root_path: ModulePath = vec![];
153 loader.load_module(&root_path, &entry_path)?;
154
155 Ok(ModuleTree {
156 modules: loader.modules,
157 root: vec![],
158 project_root,
159 external_roots: HashMap::new(),
160 })
161}
162
163pub fn load_project_with_packages(
171 project_path: &Path,
172) -> Result<(ModuleTree, bool), Vec<LoadError>> {
173 use sage_package::{check_lock_freshness, install_from_lock, resolve_dependencies, LockFile};
174
175 let manifest_path = if project_path.is_file() && project_path.ends_with("grove.toml") {
177 project_path.to_path_buf()
178 } else if project_path.is_dir() {
179 let grove_path = project_path.join("grove.toml");
181 let sage_path = project_path.join("sage.toml");
182 if grove_path.exists() {
183 grove_path
184 } else if sage_path.exists() {
185 eprintln!("warning: sage.toml is deprecated, rename to grove.toml");
186 sage_path
187 } else {
188 project_path.join("grove.toml") }
190 } else {
191 let tree = load_single_file(project_path)?;
193 return Ok((tree, false));
194 };
195
196 if !manifest_path.exists() {
197 if project_path.extension().is_some_and(|e| e == "sg") {
198 let tree = load_single_file(project_path)?;
199 return Ok((tree, false));
200 }
201 return Err(vec![LoadError::NoManifest {
202 dir: project_path.to_path_buf(),
203 }]);
204 }
205
206 let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![*e])?;
207 let project_root = manifest_path.parent().unwrap().to_path_buf();
208
209 let deps = manifest.parse_dependencies().map_err(|e| vec![*e])?;
211
212 let external_roots = if deps.is_empty() {
214 HashMap::new()
215 } else {
216 let lock_path = project_root.join("grove.lock");
217 let packages = if lock_path.exists() {
218 let lock = LockFile::load(&lock_path)
219 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
220 if check_lock_freshness(&deps, &lock) {
221 install_from_lock(&project_root, &lock)
223 .map_err(|e| vec![LoadError::PackageError { source: e }])?
224 } else {
225 let resolved = resolve_dependencies(&project_root, &deps, Some(&lock))
227 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
228 resolved.packages
229 }
230 } else {
231 let resolved = resolve_dependencies(&project_root, &deps, None)
233 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
234 resolved.packages
235 };
236
237 packages
238 .into_iter()
239 .map(|(name, pkg)| (name, pkg.path))
240 .collect()
241 };
242
243 let entry_path = project_root.join(&manifest.project.entry);
245 if !entry_path.exists() {
246 return Err(vec![LoadError::MissingEntry { path: entry_path }]);
247 }
248
249 let mut loader = ModuleLoader::new(project_root.clone());
250 let root_path: ModulePath = vec![];
251 loader.load_module(&root_path, &entry_path)?;
252
253 let installed = !external_roots.is_empty();
254
255 Ok((
256 ModuleTree {
257 modules: loader.modules,
258 root: vec![],
259 project_root,
260 external_roots,
261 },
262 installed,
263 ))
264}
265
266pub fn discover_test_files(project_path: &Path) -> Result<Vec<PathBuf>, Vec<LoadError>> {
271 let project_root = if project_path.is_file() {
272 project_path
273 .parent()
274 .unwrap_or(Path::new("."))
275 .to_path_buf()
276 } else {
277 project_path.to_path_buf()
278 };
279
280 let src_dir = project_root.join("src");
281 let search_dir = if src_dir.exists() {
282 src_dir
283 } else {
284 project_root
285 };
286
287 let mut test_files = Vec::new();
288 collect_test_files(&search_dir, &mut test_files)?;
289
290 test_files.sort();
292
293 Ok(test_files)
294}
295
296fn collect_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Vec<LoadError>> {
297 let entries = std::fs::read_dir(dir).map_err(|e| {
298 vec![LoadError::IoError {
299 path: dir.to_path_buf(),
300 source: e,
301 }]
302 })?;
303
304 for entry in entries {
305 let entry = entry.map_err(|e| {
306 vec![LoadError::IoError {
307 path: dir.to_path_buf(),
308 source: e,
309 }]
310 })?;
311
312 let path = entry.path();
313
314 if path.file_name().is_some_and(|n| n == "hearth") {
316 continue;
317 }
318
319 if path.is_dir() {
320 collect_test_files(&path, out)?;
321 } else if path.is_file() {
322 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
323 if name.ends_with("_test.sg") {
324 out.push(path);
325 }
326 }
327 }
328 }
329
330 Ok(())
331}
332
333pub fn load_test_files(project_path: &Path) -> Result<Vec<TestFile>, Vec<LoadError>> {
337 let test_paths = discover_test_files(project_path)?;
338 let mut test_files = Vec::new();
339 let mut errors = Vec::new();
340
341 for path in test_paths {
342 match load_test_file(&path) {
343 Ok(tf) => test_files.push(tf),
344 Err(mut errs) => errors.append(&mut errs),
345 }
346 }
347
348 if errors.is_empty() {
349 Ok(test_files)
350 } else {
351 Err(errors)
352 }
353}
354
355fn load_test_file(path: &Path) -> Result<TestFile, Vec<LoadError>> {
357 let source = std::fs::read_to_string(path).map_err(|e| {
358 vec![LoadError::IoError {
359 path: path.to_path_buf(),
360 source: e,
361 }]
362 })?;
363
364 let source_arc: Arc<str> = Arc::from(source.as_str());
365 let lex_result = sage_parser::lex(&source).map_err(|e| {
366 vec![LoadError::ParseError {
367 file: path.to_path_buf(),
368 errors: vec![format!("{e}")],
369 }]
370 })?;
371
372 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
373
374 if !parse_errors.is_empty() {
375 return Err(vec![LoadError::ParseError {
376 file: path.to_path_buf(),
377 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
378 }]);
379 }
380
381 let program = program.ok_or_else(|| {
382 vec![LoadError::ParseError {
383 file: path.to_path_buf(),
384 errors: vec!["failed to parse program".to_string()],
385 }]
386 })?;
387
388 Ok(TestFile {
389 file_path: path.to_path_buf(),
390 source: source_arc,
391 program,
392 })
393}
394
395struct ModuleLoader {
397 #[allow(dead_code)]
398 project_root: PathBuf,
399 modules: HashMap<ModulePath, ParsedModule>,
400 loading: HashSet<PathBuf>, }
402
403impl ModuleLoader {
404 fn new(project_root: PathBuf) -> Self {
405 Self {
406 project_root,
407 modules: HashMap::new(),
408 loading: HashSet::new(),
409 }
410 }
411
412 fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
413 let canonical = file_path
414 .canonicalize()
415 .unwrap_or_else(|_| file_path.to_path_buf());
416
417 if self.loading.contains(&canonical) {
419 let cycle: Vec<String> = self
420 .loading
421 .iter()
422 .map(|p| p.display().to_string())
423 .collect();
424 return Err(vec![LoadError::CircularDependency { cycle }]);
425 }
426
427 if self.modules.contains_key(path) {
429 return Ok(());
430 }
431
432 self.loading.insert(canonical.clone());
433
434 let source = std::fs::read_to_string(file_path).map_err(|e| {
436 vec![LoadError::IoError {
437 path: file_path.to_path_buf(),
438 source: e,
439 }]
440 })?;
441
442 let source_arc: Arc<str> = Arc::from(source.as_str());
443 let lex_result = sage_parser::lex(&source).map_err(|e| {
444 vec![LoadError::ParseError {
445 file: file_path.to_path_buf(),
446 errors: vec![format!("{e}")],
447 }]
448 })?;
449
450 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
451
452 if !parse_errors.is_empty() {
453 return Err(vec![LoadError::ParseError {
454 file: file_path.to_path_buf(),
455 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
456 }]);
457 }
458
459 let program = program.ok_or_else(|| {
460 vec![LoadError::ParseError {
461 file: file_path.to_path_buf(),
462 errors: vec!["failed to parse program".to_string()],
463 }]
464 })?;
465
466 let parent_dir = file_path.parent().unwrap();
468 let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
469 let is_mod_file = file_stem == "mod";
470
471 for mod_decl in &program.mod_decls {
472 let child_name = &mod_decl.name.name;
473 let mut child_path = path.clone();
474 child_path.push(child_name.clone());
475
476 let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
478
479 self.load_module(&child_path, &child_file)?;
481 }
482
483 self.loading.remove(&canonical);
484
485 self.modules.insert(
487 path.clone(),
488 ParsedModule {
489 path: path.clone(),
490 file_path: file_path.to_path_buf(),
491 source: source_arc,
492 program,
493 },
494 );
495
496 Ok(())
497 }
498
499 fn find_module_file(
500 &self,
501 parent_dir: &Path,
502 mod_name: &str,
503 _parent_is_mod_file: bool,
504 ) -> Result<PathBuf, Vec<LoadError>> {
505 let sibling = parent_dir.join(format!("{mod_name}.sg"));
509 let nested = parent_dir.join(mod_name).join("mod.sg");
510
511 let sibling_exists = sibling.exists();
512 let nested_exists = nested.exists();
513
514 match (sibling_exists, nested_exists) {
515 (true, true) => Err(vec![LoadError::AmbiguousModule {
516 mod_name: mod_name.to_string(),
517 candidates: vec![sibling, nested],
518 }]),
519 (true, false) => Ok(sibling),
520 (false, true) => Ok(nested),
521 (false, false) => Err(vec![LoadError::FileNotFound {
522 mod_name: mod_name.to_string(),
523 searched: vec![sibling, nested],
524 span: (0, 0).into(),
525 source_code: String::new(),
526 }]),
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use std::fs;
535 use tempfile::TempDir;
536
537 #[test]
538 fn load_single_file_works() {
539 let dir = TempDir::new().unwrap();
540 let file = dir.path().join("test.sg");
541 fs::write(
542 &file,
543 r#"
544agent Main {
545 on start {
546 yield(42);
547 }
548}
549run Main;
550"#,
551 )
552 .unwrap();
553
554 let tree = load_single_file(&file).unwrap();
555 assert_eq!(tree.modules.len(), 1);
556 assert!(tree.modules.contains_key(&vec![]));
557 }
558
559 #[test]
560 fn load_project_with_manifest() {
561 let dir = TempDir::new().unwrap();
562
563 fs::write(
565 dir.path().join("grove.toml"),
566 r#"
567[project]
568name = "test"
569entry = "src/main.sg"
570"#,
571 )
572 .unwrap();
573
574 fs::create_dir_all(dir.path().join("src")).unwrap();
576 fs::write(
577 dir.path().join("src/main.sg"),
578 r#"
579agent Main {
580 on start {
581 yield(0);
582 }
583}
584run Main;
585"#,
586 )
587 .unwrap();
588
589 let tree = load_project(dir.path()).unwrap();
590 assert_eq!(tree.modules.len(), 1);
591 }
592
593 #[test]
594 fn load_project_with_submodule() {
595 let dir = TempDir::new().unwrap();
596
597 fs::write(
599 dir.path().join("grove.toml"),
600 r#"
601[project]
602name = "test"
603entry = "src/main.sg"
604"#,
605 )
606 .unwrap();
607
608 fs::create_dir_all(dir.path().join("src")).unwrap();
610 fs::write(
611 dir.path().join("src/main.sg"),
612 r#"
613mod agents;
614
615agent Main {
616 on start {
617 yield(0);
618 }
619}
620run Main;
621"#,
622 )
623 .unwrap();
624
625 fs::write(
627 dir.path().join("src/agents.sg"),
628 r#"
629pub agent Worker {
630 on start {
631 yield(1);
632 }
633}
634"#,
635 )
636 .unwrap();
637
638 let tree = load_project(dir.path()).unwrap();
639 assert_eq!(tree.modules.len(), 2);
640 assert!(tree.modules.contains_key(&vec![]));
641 assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
642 }
643
644 #[test]
645 fn discover_test_files_finds_all() {
646 let dir = TempDir::new().unwrap();
647 fs::create_dir_all(dir.path().join("src")).unwrap();
648
649 fs::write(
651 dir.path().join("src/main.sg"),
652 "agent Main { on start { yield(0); } } run Main;",
653 )
654 .unwrap();
655 fs::write(
656 dir.path().join("src/counter_test.sg"),
657 "test \"counter works\" { assert(true); }",
658 )
659 .unwrap();
660 fs::write(
661 dir.path().join("src/worker_test.sg"),
662 "test \"worker works\" { assert(true); }",
663 )
664 .unwrap();
665
666 let test_files = discover_test_files(dir.path()).unwrap();
667 assert_eq!(test_files.len(), 2);
668 assert!(test_files.iter().any(|p| p.ends_with("counter_test.sg")));
669 assert!(test_files.iter().any(|p| p.ends_with("worker_test.sg")));
670 }
671
672 #[test]
673 fn discover_test_files_skips_hearth() {
674 let dir = TempDir::new().unwrap();
675 fs::create_dir_all(dir.path().join("src")).unwrap();
676 fs::create_dir_all(dir.path().join("hearth")).unwrap();
677
678 fs::write(
679 dir.path().join("src/main.sg"),
680 "agent Main { on start { yield(0); } } run Main;",
681 )
682 .unwrap();
683 fs::write(
684 dir.path().join("src/counter_test.sg"),
685 "test \"counter\" { assert(true); }",
686 )
687 .unwrap();
688 fs::write(
690 dir.path().join("hearth/generated_test.sg"),
691 "test \"gen\" { assert(true); }",
692 )
693 .unwrap();
694
695 let test_files = discover_test_files(dir.path()).unwrap();
696 assert_eq!(test_files.len(), 1);
697 assert!(test_files[0].ends_with("counter_test.sg"));
698 }
699
700 #[test]
701 fn load_test_files_parses_all() {
702 let dir = TempDir::new().unwrap();
703 fs::create_dir_all(dir.path().join("src")).unwrap();
704
705 fs::write(
706 dir.path().join("src/main.sg"),
707 "agent Main { on start { yield(0); } } run Main;",
708 )
709 .unwrap();
710 fs::write(
711 dir.path().join("src/math_test.sg"),
712 r#"
713test "addition works" {
714 let x = 1 + 2;
715 assert(x == 3);
716}
717
718test "subtraction works" {
719 let y = 5 - 3;
720 assert(y == 2);
721}
722"#,
723 )
724 .unwrap();
725
726 let test_files = load_test_files(dir.path()).unwrap();
727 assert_eq!(test_files.len(), 1);
728 assert_eq!(test_files[0].program.tests.len(), 2);
729 }
730}