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