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 grove.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("grove.toml") {
114        project_path.to_path_buf()
115    } else if project_path.is_dir() {
116        // Try grove.toml first, fall back to sage.toml with deprecation warning
117        let grove_path = project_path.join("grove.toml");
118        let sage_path = project_path.join("sage.toml");
119        if grove_path.exists() {
120            grove_path
121        } else if sage_path.exists() {
122            eprintln!("warning: sage.toml is deprecated, rename to grove.toml");
123            sage_path
124        } else {
125            project_path.join("grove.toml") // Will fail with proper error
126        }
127    } else {
128        // It's a .sg file - treat as single file
129        return load_single_file(project_path);
130    };
131
132    if !manifest_path.exists() {
133        // No manifest - treat as single file if it's a .sg
134        if project_path.extension().is_some_and(|e| e == "sg") {
135            return load_single_file(project_path);
136        }
137        return Err(vec![LoadError::NoManifest {
138            dir: project_path.to_path_buf(),
139        }]);
140    }
141
142    let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
143    let project_root = manifest_path.parent().unwrap().to_path_buf();
144    let entry_path = project_root.join(&manifest.project.entry);
145
146    if !entry_path.exists() {
147        return Err(vec![LoadError::MissingEntry { path: entry_path }]);
148    }
149
150    // Load the module tree starting from the entry point
151    let mut loader = ModuleLoader::new(project_root.clone());
152    let root_path: ModulePath = vec![];
153    loader.load_module(&root_path, &entry_path)?;
154
155    Ok(ModuleTree {
156        modules: loader.modules,
157        root: vec![],
158        project_root,
159        external_roots: HashMap::new(),
160    })
161}
162
163/// Load a project with external package resolution.
164///
165/// This function will:
166/// 1. Load the project manifest
167/// 2. Check for dependencies
168/// 3. If lock file exists and is fresh, use it; otherwise resolve dependencies
169/// 4. Load all external packages into the module tree
170pub fn load_project_with_packages(
171    project_path: &Path,
172) -> Result<(ModuleTree, bool), Vec<LoadError>> {
173    use sage_package::{check_lock_freshness, install_from_lock, resolve_dependencies, LockFile};
174
175    // First, do the basic project loading to check if it's a valid project
176    let manifest_path = if project_path.is_file() && project_path.ends_with("grove.toml") {
177        project_path.to_path_buf()
178    } else if project_path.is_dir() {
179        // Try grove.toml first, fall back to sage.toml with deprecation warning
180        let grove_path = project_path.join("grove.toml");
181        let sage_path = project_path.join("sage.toml");
182        if grove_path.exists() {
183            grove_path
184        } else if sage_path.exists() {
185            eprintln!("warning: sage.toml is deprecated, rename to grove.toml");
186            sage_path
187        } else {
188            project_path.join("grove.toml") // Will fail with proper error
189        }
190    } else {
191        // Single file - no packages
192        let tree = load_single_file(project_path)?;
193        return Ok((tree, false));
194    };
195
196    if !manifest_path.exists() {
197        if project_path.extension().is_some_and(|e| e == "sg") {
198            let tree = load_single_file(project_path)?;
199            return Ok((tree, false));
200        }
201        return Err(vec![LoadError::NoManifest {
202            dir: project_path.to_path_buf(),
203        }]);
204    }
205
206    let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
207    let project_root = manifest_path.parent().unwrap().to_path_buf();
208
209    // Parse dependencies
210    let deps = manifest.parse_dependencies().map_err(|e| vec![e])?;
211
212    // Resolve external packages
213    let external_roots = if deps.is_empty() {
214        HashMap::new()
215    } else {
216        let lock_path = project_root.join("grove.lock");
217        let packages = if lock_path.exists() {
218            let lock = LockFile::load(&lock_path)
219                .map_err(|e| vec![LoadError::PackageError { source: e }])?;
220            if check_lock_freshness(&deps, &lock) {
221                // Lock file is fresh - install from lock
222                install_from_lock(&project_root, &lock)
223                    .map_err(|e| vec![LoadError::PackageError { source: e }])?
224            } else {
225                // Lock file is stale - re-resolve
226                let resolved = resolve_dependencies(&project_root, &deps, Some(&lock))
227                    .map_err(|e| vec![LoadError::PackageError { source: e }])?;
228                resolved.packages
229            }
230        } else {
231            // No lock file - resolve fresh
232            let resolved = resolve_dependencies(&project_root, &deps, None)
233                .map_err(|e| vec![LoadError::PackageError { source: e }])?;
234            resolved.packages
235        };
236
237        packages
238            .into_iter()
239            .map(|(name, pkg)| (name, pkg.path))
240            .collect()
241    };
242
243    // Load the main project
244    let entry_path = project_root.join(&manifest.project.entry);
245    if !entry_path.exists() {
246        return Err(vec![LoadError::MissingEntry { path: entry_path }]);
247    }
248
249    let mut loader = ModuleLoader::new(project_root.clone());
250    let root_path: ModulePath = vec![];
251    loader.load_module(&root_path, &entry_path)?;
252
253    let installed = !external_roots.is_empty();
254
255    Ok((
256        ModuleTree {
257            modules: loader.modules,
258            root: vec![],
259            project_root,
260            external_roots,
261        },
262        installed,
263    ))
264}
265
266/// Discover all `*_test.sg` files in a project.
267///
268/// Walks the source directory and collects all files ending in `_test.sg`.
269/// Files in `hearth/` (build output) are excluded.
270pub fn discover_test_files(project_path: &Path) -> Result<Vec<PathBuf>, Vec<LoadError>> {
271    let project_root = if project_path.is_file() {
272        project_path
273            .parent()
274            .unwrap_or(Path::new("."))
275            .to_path_buf()
276    } else {
277        project_path.to_path_buf()
278    };
279
280    let src_dir = project_root.join("src");
281    let search_dir = if src_dir.exists() {
282        src_dir
283    } else {
284        project_root
285    };
286
287    let mut test_files = Vec::new();
288    collect_test_files(&search_dir, &mut test_files)?;
289
290    // Sort for deterministic ordering
291    test_files.sort();
292
293    Ok(test_files)
294}
295
296fn collect_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Vec<LoadError>> {
297    let entries = std::fs::read_dir(dir).map_err(|e| {
298        vec![LoadError::IoError {
299            path: dir.to_path_buf(),
300            source: e,
301        }]
302    })?;
303
304    for entry in entries {
305        let entry = entry.map_err(|e| {
306            vec![LoadError::IoError {
307                path: dir.to_path_buf(),
308                source: e,
309            }]
310        })?;
311
312        let path = entry.path();
313
314        // Skip hearth (build output directory)
315        if path.file_name().is_some_and(|n| n == "hearth") {
316            continue;
317        }
318
319        if path.is_dir() {
320            collect_test_files(&path, out)?;
321        } else if path.is_file() {
322            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
323                if name.ends_with("_test.sg") {
324                    out.push(path);
325                }
326            }
327        }
328    }
329
330    Ok(())
331}
332
333/// Load all test files in a project.
334///
335/// Returns a vector of parsed test files. Each test file is parsed independently.
336pub fn load_test_files(project_path: &Path) -> Result<Vec<TestFile>, Vec<LoadError>> {
337    let test_paths = discover_test_files(project_path)?;
338    let mut test_files = Vec::new();
339    let mut errors = Vec::new();
340
341    for path in test_paths {
342        match load_test_file(&path) {
343            Ok(tf) => test_files.push(tf),
344            Err(mut errs) => errors.append(&mut errs),
345        }
346    }
347
348    if errors.is_empty() {
349        Ok(test_files)
350    } else {
351        Err(errors)
352    }
353}
354
355/// Load a single test file.
356fn load_test_file(path: &Path) -> Result<TestFile, Vec<LoadError>> {
357    let source = std::fs::read_to_string(path).map_err(|e| {
358        vec![LoadError::IoError {
359            path: path.to_path_buf(),
360            source: e,
361        }]
362    })?;
363
364    let source_arc: Arc<str> = Arc::from(source.as_str());
365    let lex_result = sage_parser::lex(&source).map_err(|e| {
366        vec![LoadError::ParseError {
367            file: path.to_path_buf(),
368            errors: vec![format!("{e}")],
369        }]
370    })?;
371
372    let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
373
374    if !parse_errors.is_empty() {
375        return Err(vec![LoadError::ParseError {
376            file: path.to_path_buf(),
377            errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
378        }]);
379    }
380
381    let program = program.ok_or_else(|| {
382        vec![LoadError::ParseError {
383            file: path.to_path_buf(),
384            errors: vec!["failed to parse program".to_string()],
385        }]
386    })?;
387
388    Ok(TestFile {
389        file_path: path.to_path_buf(),
390        source: source_arc,
391        program,
392    })
393}
394
395/// Internal loader that tracks state during recursive loading.
396struct ModuleLoader {
397    #[allow(dead_code)]
398    project_root: PathBuf,
399    modules: HashMap<ModulePath, ParsedModule>,
400    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
401}
402
403impl ModuleLoader {
404    fn new(project_root: PathBuf) -> Self {
405        Self {
406            project_root,
407            modules: HashMap::new(),
408            loading: HashSet::new(),
409        }
410    }
411
412    fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
413        let canonical = file_path
414            .canonicalize()
415            .unwrap_or_else(|_| file_path.to_path_buf());
416
417        // Check for cycles
418        if self.loading.contains(&canonical) {
419            let cycle: Vec<String> = self
420                .loading
421                .iter()
422                .map(|p| p.display().to_string())
423                .collect();
424            return Err(vec![LoadError::CircularDependency { cycle }]);
425        }
426
427        // Already loaded?
428        if self.modules.contains_key(path) {
429            return Ok(());
430        }
431
432        self.loading.insert(canonical.clone());
433
434        // Read and parse
435        let source = std::fs::read_to_string(file_path).map_err(|e| {
436            vec![LoadError::IoError {
437                path: file_path.to_path_buf(),
438                source: e,
439            }]
440        })?;
441
442        let source_arc: Arc<str> = Arc::from(source.as_str());
443        let lex_result = sage_parser::lex(&source).map_err(|e| {
444            vec![LoadError::ParseError {
445                file: file_path.to_path_buf(),
446                errors: vec![format!("{e}")],
447            }]
448        })?;
449
450        let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
451
452        if !parse_errors.is_empty() {
453            return Err(vec![LoadError::ParseError {
454                file: file_path.to_path_buf(),
455                errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
456            }]);
457        }
458
459        let program = program.ok_or_else(|| {
460            vec![LoadError::ParseError {
461                file: file_path.to_path_buf(),
462                errors: vec!["failed to parse program".to_string()],
463            }]
464        })?;
465
466        // Process mod declarations to find child modules
467        let parent_dir = file_path.parent().unwrap();
468        let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
469        let is_mod_file = file_stem == "mod";
470
471        for mod_decl in &program.mod_decls {
472            let child_name = &mod_decl.name.name;
473            let mut child_path = path.clone();
474            child_path.push(child_name.clone());
475
476            // Find the child module file
477            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
478
479            // Recursively load
480            self.load_module(&child_path, &child_file)?;
481        }
482
483        self.loading.remove(&canonical);
484
485        // Store the module
486        self.modules.insert(
487            path.clone(),
488            ParsedModule {
489                path: path.clone(),
490                file_path: file_path.to_path_buf(),
491                source: source_arc,
492                program,
493            },
494        );
495
496        Ok(())
497    }
498
499    fn find_module_file(
500        &self,
501        parent_dir: &Path,
502        mod_name: &str,
503        _parent_is_mod_file: bool,
504    ) -> Result<PathBuf, Vec<LoadError>> {
505        // Try two locations:
506        // 1. mod_name.sg (sibling file)
507        // 2. mod_name/mod.sg (directory with mod.sg)
508        let sibling = parent_dir.join(format!("{mod_name}.sg"));
509        let nested = parent_dir.join(mod_name).join("mod.sg");
510
511        let sibling_exists = sibling.exists();
512        let nested_exists = nested.exists();
513
514        match (sibling_exists, nested_exists) {
515            (true, true) => Err(vec![LoadError::AmbiguousModule {
516                mod_name: mod_name.to_string(),
517                candidates: vec![sibling, nested],
518            }]),
519            (true, false) => Ok(sibling),
520            (false, true) => Ok(nested),
521            (false, false) => Err(vec![LoadError::FileNotFound {
522                mod_name: mod_name.to_string(),
523                searched: vec![sibling, nested],
524                span: (0, 0).into(),
525                source_code: String::new(),
526            }]),
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use std::fs;
535    use tempfile::TempDir;
536
537    #[test]
538    fn load_single_file_works() {
539        let dir = TempDir::new().unwrap();
540        let file = dir.path().join("test.sg");
541        fs::write(
542            &file,
543            r#"
544agent Main {
545    on start {
546        yield(42);
547    }
548}
549run Main;
550"#,
551        )
552        .unwrap();
553
554        let tree = load_single_file(&file).unwrap();
555        assert_eq!(tree.modules.len(), 1);
556        assert!(tree.modules.contains_key(&vec![]));
557    }
558
559    #[test]
560    fn load_project_with_manifest() {
561        let dir = TempDir::new().unwrap();
562
563        // Create grove.toml
564        fs::write(
565            dir.path().join("grove.toml"),
566            r#"
567[project]
568name = "test"
569entry = "src/main.sg"
570"#,
571        )
572        .unwrap();
573
574        // Create src/main.sg
575        fs::create_dir_all(dir.path().join("src")).unwrap();
576        fs::write(
577            dir.path().join("src/main.sg"),
578            r#"
579agent Main {
580    on start {
581        yield(0);
582    }
583}
584run Main;
585"#,
586        )
587        .unwrap();
588
589        let tree = load_project(dir.path()).unwrap();
590        assert_eq!(tree.modules.len(), 1);
591    }
592
593    #[test]
594    fn load_project_with_submodule() {
595        let dir = TempDir::new().unwrap();
596
597        // Create grove.toml
598        fs::write(
599            dir.path().join("grove.toml"),
600            r#"
601[project]
602name = "test"
603entry = "src/main.sg"
604"#,
605        )
606        .unwrap();
607
608        // Create src/main.sg with mod declaration
609        fs::create_dir_all(dir.path().join("src")).unwrap();
610        fs::write(
611            dir.path().join("src/main.sg"),
612            r#"
613mod agents;
614
615agent Main {
616    on start {
617        yield(0);
618    }
619}
620run Main;
621"#,
622        )
623        .unwrap();
624
625        // Create src/agents.sg
626        fs::write(
627            dir.path().join("src/agents.sg"),
628            r#"
629pub agent Worker {
630    on start {
631        yield(1);
632    }
633}
634"#,
635        )
636        .unwrap();
637
638        let tree = load_project(dir.path()).unwrap();
639        assert_eq!(tree.modules.len(), 2);
640        assert!(tree.modules.contains_key(&vec![]));
641        assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
642    }
643
644    #[test]
645    fn discover_test_files_finds_all() {
646        let dir = TempDir::new().unwrap();
647        fs::create_dir_all(dir.path().join("src")).unwrap();
648
649        // Create main file and test files
650        fs::write(
651            dir.path().join("src/main.sg"),
652            "agent Main { on start { yield(0); } } run Main;",
653        )
654        .unwrap();
655        fs::write(
656            dir.path().join("src/counter_test.sg"),
657            "test \"counter works\" { assert(true); }",
658        )
659        .unwrap();
660        fs::write(
661            dir.path().join("src/worker_test.sg"),
662            "test \"worker works\" { assert(true); }",
663        )
664        .unwrap();
665
666        let test_files = discover_test_files(dir.path()).unwrap();
667        assert_eq!(test_files.len(), 2);
668        assert!(test_files.iter().any(|p| p.ends_with("counter_test.sg")));
669        assert!(test_files.iter().any(|p| p.ends_with("worker_test.sg")));
670    }
671
672    #[test]
673    fn discover_test_files_skips_hearth() {
674        let dir = TempDir::new().unwrap();
675        fs::create_dir_all(dir.path().join("src")).unwrap();
676        fs::create_dir_all(dir.path().join("hearth")).unwrap();
677
678        fs::write(
679            dir.path().join("src/main.sg"),
680            "agent Main { on start { yield(0); } } run Main;",
681        )
682        .unwrap();
683        fs::write(
684            dir.path().join("src/counter_test.sg"),
685            "test \"counter\" { assert(true); }",
686        )
687        .unwrap();
688        // This should be skipped
689        fs::write(
690            dir.path().join("hearth/generated_test.sg"),
691            "test \"gen\" { assert(true); }",
692        )
693        .unwrap();
694
695        let test_files = discover_test_files(dir.path()).unwrap();
696        assert_eq!(test_files.len(), 1);
697        assert!(test_files[0].ends_with("counter_test.sg"));
698    }
699
700    #[test]
701    fn load_test_files_parses_all() {
702        let dir = TempDir::new().unwrap();
703        fs::create_dir_all(dir.path().join("src")).unwrap();
704
705        fs::write(
706            dir.path().join("src/main.sg"),
707            "agent Main { on start { yield(0); } } run Main;",
708        )
709        .unwrap();
710        fs::write(
711            dir.path().join("src/math_test.sg"),
712            r#"
713test "addition works" {
714    let x = 1 + 2;
715    assert(x == 3);
716}
717
718test "subtraction works" {
719    let y = 5 - 3;
720    assert(y == 2);
721}
722"#,
723        )
724        .unwrap();
725
726        let test_files = load_test_files(dir.path()).unwrap();
727        assert_eq!(test_files.len(), 1);
728        assert_eq!(test_files[0].program.tests.len(), 2);
729    }
730}