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_lexer::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(&lock).map_err(|e| vec![LoadError::PackageError { source: e }])?
203 } else {
204 let resolved = resolve_dependencies(&project_root, &deps, Some(&lock))
206 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
207 resolved.packages
208 }
209 } else {
210 let resolved = resolve_dependencies(&project_root, &deps, None)
212 .map_err(|e| vec![LoadError::PackageError { source: e }])?;
213 resolved.packages
214 };
215
216 packages
217 .into_iter()
218 .map(|(name, pkg)| (name, pkg.path))
219 .collect()
220 };
221
222 let entry_path = project_root.join(&manifest.project.entry);
224 if !entry_path.exists() {
225 return Err(vec![LoadError::MissingEntry { path: entry_path }]);
226 }
227
228 let mut loader = ModuleLoader::new(project_root.clone());
229 let root_path: ModulePath = vec![];
230 loader.load_module(&root_path, &entry_path)?;
231
232 let installed = !external_roots.is_empty();
233
234 Ok((
235 ModuleTree {
236 modules: loader.modules,
237 root: vec![],
238 project_root,
239 external_roots,
240 },
241 installed,
242 ))
243}
244
245pub fn discover_test_files(project_path: &Path) -> Result<Vec<PathBuf>, Vec<LoadError>> {
250 let project_root = if project_path.is_file() {
251 project_path.parent().unwrap_or(Path::new(".")).to_path_buf()
252 } else {
253 project_path.to_path_buf()
254 };
255
256 let src_dir = project_root.join("src");
257 let search_dir = if src_dir.exists() { src_dir } else { project_root };
258
259 let mut test_files = Vec::new();
260 collect_test_files(&search_dir, &mut test_files)?;
261
262 test_files.sort();
264
265 Ok(test_files)
266}
267
268fn collect_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Vec<LoadError>> {
269 let entries = std::fs::read_dir(dir).map_err(|e| {
270 vec![LoadError::IoError {
271 path: dir.to_path_buf(),
272 source: e,
273 }]
274 })?;
275
276 for entry in entries {
277 let entry = entry.map_err(|e| {
278 vec![LoadError::IoError {
279 path: dir.to_path_buf(),
280 source: e,
281 }]
282 })?;
283
284 let path = entry.path();
285
286 if path.file_name().is_some_and(|n| n == "hearth") {
288 continue;
289 }
290
291 if path.is_dir() {
292 collect_test_files(&path, out)?;
293 } else if path.is_file() {
294 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
295 if name.ends_with("_test.sg") {
296 out.push(path);
297 }
298 }
299 }
300 }
301
302 Ok(())
303}
304
305pub fn load_test_files(project_path: &Path) -> Result<Vec<TestFile>, Vec<LoadError>> {
309 let test_paths = discover_test_files(project_path)?;
310 let mut test_files = Vec::new();
311 let mut errors = Vec::new();
312
313 for path in test_paths {
314 match load_test_file(&path) {
315 Ok(tf) => test_files.push(tf),
316 Err(mut errs) => errors.append(&mut errs),
317 }
318 }
319
320 if errors.is_empty() {
321 Ok(test_files)
322 } else {
323 Err(errors)
324 }
325}
326
327fn load_test_file(path: &Path) -> Result<TestFile, Vec<LoadError>> {
329 let source = std::fs::read_to_string(path).map_err(|e| {
330 vec![LoadError::IoError {
331 path: path.to_path_buf(),
332 source: e,
333 }]
334 })?;
335
336 let source_arc: Arc<str> = Arc::from(source.as_str());
337 let lex_result = sage_lexer::lex(&source).map_err(|e| {
338 vec![LoadError::ParseError {
339 file: path.to_path_buf(),
340 errors: vec![format!("{e}")],
341 }]
342 })?;
343
344 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
345
346 if !parse_errors.is_empty() {
347 return Err(vec![LoadError::ParseError {
348 file: path.to_path_buf(),
349 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
350 }]);
351 }
352
353 let program = program.ok_or_else(|| {
354 vec![LoadError::ParseError {
355 file: path.to_path_buf(),
356 errors: vec!["failed to parse program".to_string()],
357 }]
358 })?;
359
360 Ok(TestFile {
361 file_path: path.to_path_buf(),
362 source: source_arc,
363 program,
364 })
365}
366
367struct ModuleLoader {
369 #[allow(dead_code)]
370 project_root: PathBuf,
371 modules: HashMap<ModulePath, ParsedModule>,
372 loading: HashSet<PathBuf>, }
374
375impl ModuleLoader {
376 fn new(project_root: PathBuf) -> Self {
377 Self {
378 project_root,
379 modules: HashMap::new(),
380 loading: HashSet::new(),
381 }
382 }
383
384 fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
385 let canonical = file_path
386 .canonicalize()
387 .unwrap_or_else(|_| file_path.to_path_buf());
388
389 if self.loading.contains(&canonical) {
391 let cycle: Vec<String> = self
392 .loading
393 .iter()
394 .map(|p| p.display().to_string())
395 .collect();
396 return Err(vec![LoadError::CircularDependency { cycle }]);
397 }
398
399 if self.modules.contains_key(path) {
401 return Ok(());
402 }
403
404 self.loading.insert(canonical.clone());
405
406 let source = std::fs::read_to_string(file_path).map_err(|e| {
408 vec![LoadError::IoError {
409 path: file_path.to_path_buf(),
410 source: e,
411 }]
412 })?;
413
414 let source_arc: Arc<str> = Arc::from(source.as_str());
415 let lex_result = sage_lexer::lex(&source).map_err(|e| {
416 vec![LoadError::ParseError {
417 file: file_path.to_path_buf(),
418 errors: vec![format!("{e}")],
419 }]
420 })?;
421
422 let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
423
424 if !parse_errors.is_empty() {
425 return Err(vec![LoadError::ParseError {
426 file: file_path.to_path_buf(),
427 errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
428 }]);
429 }
430
431 let program = program.ok_or_else(|| {
432 vec![LoadError::ParseError {
433 file: file_path.to_path_buf(),
434 errors: vec!["failed to parse program".to_string()],
435 }]
436 })?;
437
438 let parent_dir = file_path.parent().unwrap();
440 let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
441 let is_mod_file = file_stem == "mod";
442
443 for mod_decl in &program.mod_decls {
444 let child_name = &mod_decl.name.name;
445 let mut child_path = path.clone();
446 child_path.push(child_name.clone());
447
448 let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
450
451 self.load_module(&child_path, &child_file)?;
453 }
454
455 self.loading.remove(&canonical);
456
457 self.modules.insert(
459 path.clone(),
460 ParsedModule {
461 path: path.clone(),
462 file_path: file_path.to_path_buf(),
463 source: source_arc,
464 program,
465 },
466 );
467
468 Ok(())
469 }
470
471 fn find_module_file(
472 &self,
473 parent_dir: &Path,
474 mod_name: &str,
475 _parent_is_mod_file: bool,
476 ) -> Result<PathBuf, Vec<LoadError>> {
477 let sibling = parent_dir.join(format!("{mod_name}.sg"));
481 let nested = parent_dir.join(mod_name).join("mod.sg");
482
483 let sibling_exists = sibling.exists();
484 let nested_exists = nested.exists();
485
486 match (sibling_exists, nested_exists) {
487 (true, true) => Err(vec![LoadError::AmbiguousModule {
488 mod_name: mod_name.to_string(),
489 candidates: vec![sibling, nested],
490 }]),
491 (true, false) => Ok(sibling),
492 (false, true) => Ok(nested),
493 (false, false) => Err(vec![LoadError::FileNotFound {
494 mod_name: mod_name.to_string(),
495 searched: vec![sibling, nested],
496 span: (0, 0).into(),
497 source_code: String::new(),
498 }]),
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::fs;
507 use tempfile::TempDir;
508
509 #[test]
510 fn load_single_file_works() {
511 let dir = TempDir::new().unwrap();
512 let file = dir.path().join("test.sg");
513 fs::write(
514 &file,
515 r#"
516agent Main {
517 on start {
518 emit(42);
519 }
520}
521run Main;
522"#,
523 )
524 .unwrap();
525
526 let tree = load_single_file(&file).unwrap();
527 assert_eq!(tree.modules.len(), 1);
528 assert!(tree.modules.contains_key(&vec![]));
529 }
530
531 #[test]
532 fn load_project_with_manifest() {
533 let dir = TempDir::new().unwrap();
534
535 fs::write(
537 dir.path().join("sage.toml"),
538 r#"
539[project]
540name = "test"
541entry = "src/main.sg"
542"#,
543 )
544 .unwrap();
545
546 fs::create_dir_all(dir.path().join("src")).unwrap();
548 fs::write(
549 dir.path().join("src/main.sg"),
550 r#"
551agent Main {
552 on start {
553 emit(0);
554 }
555}
556run Main;
557"#,
558 )
559 .unwrap();
560
561 let tree = load_project(dir.path()).unwrap();
562 assert_eq!(tree.modules.len(), 1);
563 }
564
565 #[test]
566 fn load_project_with_submodule() {
567 let dir = TempDir::new().unwrap();
568
569 fs::write(
571 dir.path().join("sage.toml"),
572 r#"
573[project]
574name = "test"
575entry = "src/main.sg"
576"#,
577 )
578 .unwrap();
579
580 fs::create_dir_all(dir.path().join("src")).unwrap();
582 fs::write(
583 dir.path().join("src/main.sg"),
584 r#"
585mod agents;
586
587agent Main {
588 on start {
589 emit(0);
590 }
591}
592run Main;
593"#,
594 )
595 .unwrap();
596
597 fs::write(
599 dir.path().join("src/agents.sg"),
600 r#"
601pub agent Worker {
602 on start {
603 emit(1);
604 }
605}
606"#,
607 )
608 .unwrap();
609
610 let tree = load_project(dir.path()).unwrap();
611 assert_eq!(tree.modules.len(), 2);
612 assert!(tree.modules.contains_key(&vec![]));
613 assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
614 }
615
616 #[test]
617 fn discover_test_files_finds_all() {
618 let dir = TempDir::new().unwrap();
619 fs::create_dir_all(dir.path().join("src")).unwrap();
620
621 fs::write(dir.path().join("src/main.sg"), "agent Main { on start { emit(0); } } run Main;").unwrap();
623 fs::write(dir.path().join("src/counter_test.sg"), "test \"counter works\" { assert(true); }").unwrap();
624 fs::write(dir.path().join("src/worker_test.sg"), "test \"worker works\" { assert(true); }").unwrap();
625
626 let test_files = discover_test_files(dir.path()).unwrap();
627 assert_eq!(test_files.len(), 2);
628 assert!(test_files.iter().any(|p| p.ends_with("counter_test.sg")));
629 assert!(test_files.iter().any(|p| p.ends_with("worker_test.sg")));
630 }
631
632 #[test]
633 fn discover_test_files_skips_hearth() {
634 let dir = TempDir::new().unwrap();
635 fs::create_dir_all(dir.path().join("src")).unwrap();
636 fs::create_dir_all(dir.path().join("hearth")).unwrap();
637
638 fs::write(dir.path().join("src/main.sg"), "agent Main { on start { emit(0); } } run Main;").unwrap();
639 fs::write(dir.path().join("src/counter_test.sg"), "test \"counter\" { assert(true); }").unwrap();
640 fs::write(dir.path().join("hearth/generated_test.sg"), "test \"gen\" { assert(true); }").unwrap();
642
643 let test_files = discover_test_files(dir.path()).unwrap();
644 assert_eq!(test_files.len(), 1);
645 assert!(test_files[0].ends_with("counter_test.sg"));
646 }
647
648 #[test]
649 fn load_test_files_parses_all() {
650 let dir = TempDir::new().unwrap();
651 fs::create_dir_all(dir.path().join("src")).unwrap();
652
653 fs::write(dir.path().join("src/main.sg"), "agent Main { on start { emit(0); } } run Main;").unwrap();
654 fs::write(
655 dir.path().join("src/math_test.sg"),
656 r#"
657test "addition works" {
658 let x = 1 + 2;
659 assert(x == 3);
660}
661
662test "subtraction works" {
663 let y = 5 - 3;
664 assert(y == 2);
665}
666"#,
667 ).unwrap();
668
669 let test_files = load_test_files(dir.path()).unwrap();
670 assert_eq!(test_files.len(), 1);
671 assert_eq!(test_files[0].program.tests.len(), 2);
672 }
673}