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.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    // Sort for deterministic ordering
264    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        // Skip hearth (build output directory)
288        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
306/// Load all test files in a project.
307///
308/// Returns a vector of parsed test files. Each test file is parsed independently.
309pub 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
328/// Load a single test file.
329fn 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
368/// Internal loader that tracks state during recursive loading.
369struct ModuleLoader {
370    #[allow(dead_code)]
371    project_root: PathBuf,
372    modules: HashMap<ModulePath, ParsedModule>,
373    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
374}
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        // Check for cycles
391        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        // Already loaded?
401        if self.modules.contains_key(path) {
402            return Ok(());
403        }
404
405        self.loading.insert(canonical.clone());
406
407        // Read and parse
408        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        // Process mod declarations to find child modules
440        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            // Find the child module file
450            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
451
452            // Recursively load
453            self.load_module(&child_path, &child_file)?;
454        }
455
456        self.loading.remove(&canonical);
457
458        // Store the module
459        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        // Try two locations:
479        // 1. mod_name.sg (sibling file)
480        // 2. mod_name/mod.sg (directory with mod.sg)
481        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        // Create sage.toml
537        fs::write(
538            dir.path().join("sage.toml"),
539            r#"
540[project]
541name = "test"
542entry = "src/main.sg"
543"#,
544        )
545        .unwrap();
546
547        // Create src/main.sg
548        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        // Create sage.toml
571        fs::write(
572            dir.path().join("sage.toml"),
573            r#"
574[project]
575name = "test"
576entry = "src/main.sg"
577"#,
578        )
579        .unwrap();
580
581        // Create src/main.sg with mod declaration
582        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        // Create src/agents.sg
599        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        // Create main file and test files
623        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        // This should be skipped
642        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}