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("sage.toml") {
114 project_path.to_path_buf()
115 } else if project_path.is_dir() {
116 project_path.join("sage.toml")
117 } else {
118 return load_single_file(project_path);
120 };
121
122 if !manifest_path.exists() {
123 if project_path.extension().is_some_and(|e| e == "sg") {
125 return load_single_file(project_path);
126 }
127 return Err(vec![LoadError::NoManifest {
128 dir: project_path.to_path_buf(),
129 }]);
130 }
131
132 let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
133 let project_root = manifest_path.parent().unwrap().to_path_buf();
134 let entry_path = project_root.join(&manifest.project.entry);
135
136 if !entry_path.exists() {
137 return Err(vec![LoadError::MissingEntry { path: entry_path }]);
138 }
139
140 let mut loader = ModuleLoader::new(project_root.clone());
142 let root_path: ModulePath = vec![];
143 loader.load_module(&root_path, &entry_path)?;
144
145 Ok(ModuleTree {
146 modules: loader.modules,
147 root: vec![],
148 project_root,
149 external_roots: HashMap::new(),
150 })
151}
152
153pub fn load_project_with_packages(
161 project_path: &Path,
162) -> Result<(ModuleTree, bool), Vec<LoadError>> {
163 use sage_package::{check_lock_freshness, install_from_lock, resolve_dependencies, LockFile};
164
165 let manifest_path = if project_path.is_file() && project_path.ends_with("sage.toml") {
167 project_path.to_path_buf()
168 } else if project_path.is_dir() {
169 project_path.join("sage.toml")
170 } else {
171 let tree = load_single_file(project_path)?;
173 return Ok((tree, false));
174 };
175
176 if !manifest_path.exists() {
177 if project_path.extension().is_some_and(|e| e == "sg") {
178 let tree = load_single_file(project_path)?;
179 return Ok((tree, false));
180 }
181 return Err(vec![LoadError::NoManifest {
182 dir: project_path.to_path_buf(),
183 }]);
184 }
185
186 let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
187 let project_root = manifest_path.parent().unwrap().to_path_buf();
188
189 let deps = manifest.parse_dependencies().map_err(|e| vec![e])?;
191
192 let external_roots = if deps.is_empty() {
194 HashMap::new()
195 } else {
196 let lock_path = project_root.join("sage.lock");
197 let packages = if lock_path.exists() {
198 let lock = LockFile::load(&lock_path)
199 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
200 if check_lock_freshness(&deps, &lock) {
201 install_from_lock(&project_root, &lock)
203 .map_err(|e| vec![LoadError::PackageError { source: e }])?
204 } else {
205 let resolved = resolve_dependencies(&project_root, &deps, Some(&lock))
207 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
208 resolved.packages
209 }
210 } else {
211 let resolved = resolve_dependencies(&project_root, &deps, None)
213 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
214 resolved.packages
215 };
216
217 packages
218 .into_iter()
219 .map(|(name, pkg)| (name, pkg.path))
220 .collect()
221 };
222
223 let entry_path = project_root.join(&manifest.project.entry);
225 if !entry_path.exists() {
226 return Err(vec![LoadError::MissingEntry { path: entry_path }]);
227 }
228
229 let mut loader = ModuleLoader::new(project_root.clone());
230 let root_path: ModulePath = vec![];
231 loader.load_module(&root_path, &entry_path)?;
232
233 let installed = !external_roots.is_empty();
234
235 Ok((
236 ModuleTree {
237 modules: loader.modules,
238 root: vec![],
239 project_root,
240 external_roots,
241 },
242 installed,
243 ))
244}
245
246pub fn discover_test_files(project_path: &Path) -> Result<Vec<PathBuf>, Vec<LoadError>> {
251 let project_root = if project_path.is_file() {
252 project_path
253 .parent()
254 .unwrap_or(Path::new("."))
255 .to_path_buf()
256 } else {
257 project_path.to_path_buf()
258 };
259
260 let src_dir = project_root.join("src");
261 let search_dir = if src_dir.exists() {
262 src_dir
263 } else {
264 project_root
265 };
266
267 let mut test_files = Vec::new();
268 collect_test_files(&search_dir, &mut test_files)?;
269
270 test_files.sort();
272
273 Ok(test_files)
274}
275
276fn collect_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Vec<LoadError>> {
277 let entries = std::fs::read_dir(dir).map_err(|e| {
278 vec![LoadError::IoError {
279 path: dir.to_path_buf(),
280 source: e,
281 }]
282 })?;
283
284 for entry in entries {
285 let entry = entry.map_err(|e| {
286 vec![LoadError::IoError {
287 path: dir.to_path_buf(),
288 source: e,
289 }]
290 })?;
291
292 let path = entry.path();
293
294 if path.file_name().is_some_and(|n| n == "hearth") {
296 continue;
297 }
298
299 if path.is_dir() {
300 collect_test_files(&path, out)?;
301 } else if path.is_file() {
302 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
303 if name.ends_with("_test.sg") {
304 out.push(path);
305 }
306 }
307 }
308 }
309
310 Ok(())
311}
312
313pub fn load_test_files(project_path: &Path) -> Result<Vec<TestFile>, Vec<LoadError>> {
317 let test_paths = discover_test_files(project_path)?;
318 let mut test_files = Vec::new();
319 let mut errors = Vec::new();
320
321 for path in test_paths {
322 match load_test_file(&path) {
323 Ok(tf) => test_files.push(tf),
324 Err(mut errs) => errors.append(&mut errs),
325 }
326 }
327
328 if errors.is_empty() {
329 Ok(test_files)
330 } else {
331 Err(errors)
332 }
333}
334
335fn load_test_file(path: &Path) -> Result<TestFile, Vec<LoadError>> {
337 let source = std::fs::read_to_string(path).map_err(|e| {
338 vec![LoadError::IoError {
339 path: path.to_path_buf(),
340 source: e,
341 }]
342 })?;
343
344 let source_arc: Arc<str> = Arc::from(source.as_str());
345 let lex_result = sage_parser::lex(&source).map_err(|e| {
346 vec![LoadError::ParseError {
347 file: path.to_path_buf(),
348 errors: vec![format!("{e}")],
349 }]
350 })?;
351
352 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
353
354 if !parse_errors.is_empty() {
355 return Err(vec![LoadError::ParseError {
356 file: path.to_path_buf(),
357 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
358 }]);
359 }
360
361 let program = program.ok_or_else(|| {
362 vec![LoadError::ParseError {
363 file: path.to_path_buf(),
364 errors: vec!["failed to parse program".to_string()],
365 }]
366 })?;
367
368 Ok(TestFile {
369 file_path: path.to_path_buf(),
370 source: source_arc,
371 program,
372 })
373}
374
375struct ModuleLoader {
377 #[allow(dead_code)]
378 project_root: PathBuf,
379 modules: HashMap<ModulePath, ParsedModule>,
380 loading: HashSet<PathBuf>, }
382
383impl ModuleLoader {
384 fn new(project_root: PathBuf) -> Self {
385 Self {
386 project_root,
387 modules: HashMap::new(),
388 loading: HashSet::new(),
389 }
390 }
391
392 fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
393 let canonical = file_path
394 .canonicalize()
395 .unwrap_or_else(|_| file_path.to_path_buf());
396
397 if self.loading.contains(&canonical) {
399 let cycle: Vec<String> = self
400 .loading
401 .iter()
402 .map(|p| p.display().to_string())
403 .collect();
404 return Err(vec![LoadError::CircularDependency { cycle }]);
405 }
406
407 if self.modules.contains_key(path) {
409 return Ok(());
410 }
411
412 self.loading.insert(canonical.clone());
413
414 let source = std::fs::read_to_string(file_path).map_err(|e| {
416 vec![LoadError::IoError {
417 path: file_path.to_path_buf(),
418 source: e,
419 }]
420 })?;
421
422 let source_arc: Arc<str> = Arc::from(source.as_str());
423 let lex_result = sage_parser::lex(&source).map_err(|e| {
424 vec![LoadError::ParseError {
425 file: file_path.to_path_buf(),
426 errors: vec![format!("{e}")],
427 }]
428 })?;
429
430 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
431
432 if !parse_errors.is_empty() {
433 return Err(vec![LoadError::ParseError {
434 file: file_path.to_path_buf(),
435 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
436 }]);
437 }
438
439 let program = program.ok_or_else(|| {
440 vec![LoadError::ParseError {
441 file: file_path.to_path_buf(),
442 errors: vec!["failed to parse program".to_string()],
443 }]
444 })?;
445
446 let parent_dir = file_path.parent().unwrap();
448 let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
449 let is_mod_file = file_stem == "mod";
450
451 for mod_decl in &program.mod_decls {
452 let child_name = &mod_decl.name.name;
453 let mut child_path = path.clone();
454 child_path.push(child_name.clone());
455
456 let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
458
459 self.load_module(&child_path, &child_file)?;
461 }
462
463 self.loading.remove(&canonical);
464
465 self.modules.insert(
467 path.clone(),
468 ParsedModule {
469 path: path.clone(),
470 file_path: file_path.to_path_buf(),
471 source: source_arc,
472 program,
473 },
474 );
475
476 Ok(())
477 }
478
479 fn find_module_file(
480 &self,
481 parent_dir: &Path,
482 mod_name: &str,
483 _parent_is_mod_file: bool,
484 ) -> Result<PathBuf, Vec<LoadError>> {
485 let sibling = parent_dir.join(format!("{mod_name}.sg"));
489 let nested = parent_dir.join(mod_name).join("mod.sg");
490
491 let sibling_exists = sibling.exists();
492 let nested_exists = nested.exists();
493
494 match (sibling_exists, nested_exists) {
495 (true, true) => Err(vec![LoadError::AmbiguousModule {
496 mod_name: mod_name.to_string(),
497 candidates: vec![sibling, nested],
498 }]),
499 (true, false) => Ok(sibling),
500 (false, true) => Ok(nested),
501 (false, false) => Err(vec![LoadError::FileNotFound {
502 mod_name: mod_name.to_string(),
503 searched: vec![sibling, nested],
504 span: (0, 0).into(),
505 source_code: String::new(),
506 }]),
507 }
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use std::fs;
515 use tempfile::TempDir;
516
517 #[test]
518 fn load_single_file_works() {
519 let dir = TempDir::new().unwrap();
520 let file = dir.path().join("test.sg");
521 fs::write(
522 &file,
523 r#"
524agent Main {
525 on start {
526 emit(42);
527 }
528}
529run Main;
530"#,
531 )
532 .unwrap();
533
534 let tree = load_single_file(&file).unwrap();
535 assert_eq!(tree.modules.len(), 1);
536 assert!(tree.modules.contains_key(&vec![]));
537 }
538
539 #[test]
540 fn load_project_with_manifest() {
541 let dir = TempDir::new().unwrap();
542
543 fs::write(
545 dir.path().join("sage.toml"),
546 r#"
547[project]
548name = "test"
549entry = "src/main.sg"
550"#,
551 )
552 .unwrap();
553
554 fs::create_dir_all(dir.path().join("src")).unwrap();
556 fs::write(
557 dir.path().join("src/main.sg"),
558 r#"
559agent Main {
560 on start {
561 emit(0);
562 }
563}
564run Main;
565"#,
566 )
567 .unwrap();
568
569 let tree = load_project(dir.path()).unwrap();
570 assert_eq!(tree.modules.len(), 1);
571 }
572
573 #[test]
574 fn load_project_with_submodule() {
575 let dir = TempDir::new().unwrap();
576
577 fs::write(
579 dir.path().join("sage.toml"),
580 r#"
581[project]
582name = "test"
583entry = "src/main.sg"
584"#,
585 )
586 .unwrap();
587
588 fs::create_dir_all(dir.path().join("src")).unwrap();
590 fs::write(
591 dir.path().join("src/main.sg"),
592 r#"
593mod agents;
594
595agent Main {
596 on start {
597 emit(0);
598 }
599}
600run Main;
601"#,
602 )
603 .unwrap();
604
605 fs::write(
607 dir.path().join("src/agents.sg"),
608 r#"
609pub agent Worker {
610 on start {
611 emit(1);
612 }
613}
614"#,
615 )
616 .unwrap();
617
618 let tree = load_project(dir.path()).unwrap();
619 assert_eq!(tree.modules.len(), 2);
620 assert!(tree.modules.contains_key(&vec![]));
621 assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
622 }
623
624 #[test]
625 fn discover_test_files_finds_all() {
626 let dir = TempDir::new().unwrap();
627 fs::create_dir_all(dir.path().join("src")).unwrap();
628
629 fs::write(
631 dir.path().join("src/main.sg"),
632 "agent Main { on start { emit(0); } } run Main;",
633 )
634 .unwrap();
635 fs::write(
636 dir.path().join("src/counter_test.sg"),
637 "test \"counter works\" { assert(true); }",
638 )
639 .unwrap();
640 fs::write(
641 dir.path().join("src/worker_test.sg"),
642 "test \"worker works\" { assert(true); }",
643 )
644 .unwrap();
645
646 let test_files = discover_test_files(dir.path()).unwrap();
647 assert_eq!(test_files.len(), 2);
648 assert!(test_files.iter().any(|p| p.ends_with("counter_test.sg")));
649 assert!(test_files.iter().any(|p| p.ends_with("worker_test.sg")));
650 }
651
652 #[test]
653 fn discover_test_files_skips_hearth() {
654 let dir = TempDir::new().unwrap();
655 fs::create_dir_all(dir.path().join("src")).unwrap();
656 fs::create_dir_all(dir.path().join("hearth")).unwrap();
657
658 fs::write(
659 dir.path().join("src/main.sg"),
660 "agent Main { on start { emit(0); } } run Main;",
661 )
662 .unwrap();
663 fs::write(
664 dir.path().join("src/counter_test.sg"),
665 "test \"counter\" { assert(true); }",
666 )
667 .unwrap();
668 fs::write(
670 dir.path().join("hearth/generated_test.sg"),
671 "test \"gen\" { assert(true); }",
672 )
673 .unwrap();
674
675 let test_files = discover_test_files(dir.path()).unwrap();
676 assert_eq!(test_files.len(), 1);
677 assert!(test_files[0].ends_with("counter_test.sg"));
678 }
679
680 #[test]
681 fn load_test_files_parses_all() {
682 let dir = TempDir::new().unwrap();
683 fs::create_dir_all(dir.path().join("src")).unwrap();
684
685 fs::write(
686 dir.path().join("src/main.sg"),
687 "agent Main { on start { emit(0); } } run Main;",
688 )
689 .unwrap();
690 fs::write(
691 dir.path().join("src/math_test.sg"),
692 r#"
693test "addition works" {
694 let x = 1 + 2;
695 assert(x == 3);
696}
697
698test "subtraction works" {
699 let y = 5 - 3;
700 assert(y == 2);
701}
702"#,
703 )
704 .unwrap();
705
706 let test_files = load_test_files(dir.path()).unwrap();
707 assert_eq!(test_files.len(), 1);
708 assert_eq!(test_files[0].program.tests.len(), 2);
709 }
710}