Skip to main content

sage_loader/
tree.rs

1//! Module tree construction and loading.
2
3use 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
11/// A module path like `["agents", "researcher"]`.
12pub type ModulePath = Vec<String>;
13
14/// A complete module tree for a Sage project.
15#[derive(Debug)]
16pub struct ModuleTree {
17    /// All parsed modules, keyed by their module path.
18    pub modules: HashMap<ModulePath, ParsedModule>,
19    /// The root module path (usually empty for the entry module).
20    pub root: ModulePath,
21    /// The project root directory.
22    pub project_root: PathBuf,
23    /// External package roots, keyed by package name.
24    /// Maps package name to its cached path on disk.
25    pub external_roots: HashMap<String, PathBuf>,
26}
27
28/// A discovered test file with its parsed contents.
29#[derive(Debug)]
30pub struct TestFile {
31    /// The file path on disk.
32    pub file_path: PathBuf,
33    /// The source code.
34    pub source: Arc<str>,
35    /// The parsed AST.
36    pub program: Program,
37}
38
39/// A parsed module with its source and AST.
40#[derive(Debug)]
41pub struct ParsedModule {
42    /// The module's path (e.g., `["agents", "researcher"]`).
43    pub path: ModulePath,
44    /// The file path on disk.
45    pub file_path: PathBuf,
46    /// The source code.
47    pub source: Arc<str>,
48    /// The parsed AST.
49    pub program: Program,
50}
51
52/// Load a single .sg file (no project structure).
53pub 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
108/// Load a project from a sage.toml or project directory.
109///
110/// This does NOT resolve external dependencies. For that, use `load_project_with_packages`.
111pub fn load_project(project_path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
112    // Find the manifest
113    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        // It's a .sg file - treat as single file
119        return load_single_file(project_path);
120    };
121
122    if !manifest_path.exists() {
123        // No manifest - treat as single file if it's a .sg
124        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    // Load the module tree starting from the entry point
141    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
153/// Load a project with external package resolution.
154///
155/// This function will:
156/// 1. Load the project manifest
157/// 2. Check for dependencies
158/// 3. If lock file exists and is fresh, use it; otherwise resolve dependencies
159/// 4. Load all external packages into the module tree
160pub 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    // First, do the basic project loading to check if it's a valid project
166    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        // Single file - no packages
172        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    // Parse dependencies
190    let deps = manifest.parse_dependencies().map_err(|e| vec![e])?;
191
192    // Resolve external packages
193    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                // Lock file is fresh - install from lock
202                install_from_lock(&project_root, &lock)
203                    .map_err(|e| vec![LoadError::PackageError { source: e }])?
204            } else {
205                // Lock file is stale - re-resolve
206                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            // No lock file - resolve fresh
212            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    // Load the main project
224    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
246/// Discover all `*_test.sg` files in a project.
247///
248/// Walks the source directory and collects all files ending in `_test.sg`.
249/// Files in `hearth/` (build output) are excluded.
250pub 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    // Sort for deterministic ordering
271    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        // Skip hearth (build output directory)
295        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
313/// Load all test files in a project.
314///
315/// Returns a vector of parsed test files. Each test file is parsed independently.
316pub 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
335/// Load a single test file.
336fn 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
375/// Internal loader that tracks state during recursive loading.
376struct ModuleLoader {
377    #[allow(dead_code)]
378    project_root: PathBuf,
379    modules: HashMap<ModulePath, ParsedModule>,
380    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
381}
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        // Check for cycles
398        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        // Already loaded?
408        if self.modules.contains_key(path) {
409            return Ok(());
410        }
411
412        self.loading.insert(canonical.clone());
413
414        // Read and parse
415        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        // Process mod declarations to find child modules
447        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            // Find the child module file
457            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
458
459            // Recursively load
460            self.load_module(&child_path, &child_file)?;
461        }
462
463        self.loading.remove(&canonical);
464
465        // Store the module
466        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        // Try two locations:
486        // 1. mod_name.sg (sibling file)
487        // 2. mod_name/mod.sg (directory with mod.sg)
488        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        // Create sage.toml
544        fs::write(
545            dir.path().join("sage.toml"),
546            r#"
547[project]
548name = "test"
549entry = "src/main.sg"
550"#,
551        )
552        .unwrap();
553
554        // Create src/main.sg
555        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        // Create sage.toml
578        fs::write(
579            dir.path().join("sage.toml"),
580            r#"
581[project]
582name = "test"
583entry = "src/main.sg"
584"#,
585        )
586        .unwrap();
587
588        // Create src/main.sg with mod declaration
589        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        // Create src/agents.sg
606        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        // Create main file and test files
630        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        // This should be skipped
669        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}