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_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
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(&lock).map_err(|e| vec![LoadError::PackageError { source: e }])?
203            } else {
204                // Lock file is stale - re-resolve
205                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            // No lock file - resolve fresh
211            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    // Load the main project
223    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
245/// Discover all `*_test.sg` files in a project.
246///
247/// Walks the source directory and collects all files ending in `_test.sg`.
248/// Files in `hearth/` (build output) are excluded.
249pub 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    // Sort for deterministic ordering
263    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        // Skip hearth (build output directory)
287        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
305/// Load all test files in a project.
306///
307/// Returns a vector of parsed test files. Each test file is parsed independently.
308pub 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
327/// Load a single test file.
328fn 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
367/// Internal loader that tracks state during recursive loading.
368struct ModuleLoader {
369    #[allow(dead_code)]
370    project_root: PathBuf,
371    modules: HashMap<ModulePath, ParsedModule>,
372    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
373}
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        // Check for cycles
390        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        // Already loaded?
400        if self.modules.contains_key(path) {
401            return Ok(());
402        }
403
404        self.loading.insert(canonical.clone());
405
406        // Read and parse
407        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        // Process mod declarations to find child modules
439        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            // Find the child module file
449            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
450
451            // Recursively load
452            self.load_module(&child_path, &child_file)?;
453        }
454
455        self.loading.remove(&canonical);
456
457        // Store the module
458        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        // Try two locations:
478        // 1. mod_name.sg (sibling file)
479        // 2. mod_name/mod.sg (directory with mod.sg)
480        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        // Create sage.toml
536        fs::write(
537            dir.path().join("sage.toml"),
538            r#"
539[project]
540name = "test"
541entry = "src/main.sg"
542"#,
543        )
544        .unwrap();
545
546        // Create src/main.sg
547        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        // Create sage.toml
570        fs::write(
571            dir.path().join("sage.toml"),
572            r#"
573[project]
574name = "test"
575entry = "src/main.sg"
576"#,
577        )
578        .unwrap();
579
580        // Create src/main.sg with mod declaration
581        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        // Create src/agents.sg
598        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        // Create main file and test files
622        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        // This should be skipped
641        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}