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 parsed module with its source and AST.
29#[derive(Debug)]
30pub struct ParsedModule {
31    /// The module's path (e.g., `["agents", "researcher"]`).
32    pub path: ModulePath,
33    /// The file path on disk.
34    pub file_path: PathBuf,
35    /// The source code.
36    pub source: Arc<str>,
37    /// The parsed AST.
38    pub program: Program,
39}
40
41/// Load a single .sg file (no project structure).
42pub fn load_single_file(path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
43    let source = std::fs::read_to_string(path).map_err(|e| {
44        vec![LoadError::IoError {
45            path: path.to_path_buf(),
46            source: e,
47        }]
48    })?;
49
50    let source_arc: Arc<str> = Arc::from(source.as_str());
51    let lex_result = sage_lexer::lex(&source).map_err(|e| {
52        vec![LoadError::ParseError {
53            file: path.to_path_buf(),
54            errors: vec![format!("{e}")],
55        }]
56    })?;
57
58    let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
59
60    if !parse_errors.is_empty() {
61        return Err(vec![LoadError::ParseError {
62            file: path.to_path_buf(),
63            errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
64        }]);
65    }
66
67    let program = program.ok_or_else(|| {
68        vec![LoadError::ParseError {
69            file: path.to_path_buf(),
70            errors: vec!["failed to parse program".to_string()],
71        }]
72    })?;
73
74    let root_path = vec![];
75    let mut modules = HashMap::new();
76    modules.insert(
77        root_path.clone(),
78        ParsedModule {
79            path: root_path.clone(),
80            file_path: path.to_path_buf(),
81            source: source_arc,
82            program,
83        },
84    );
85
86    Ok(ModuleTree {
87        modules,
88        root: root_path,
89        project_root: path
90            .parent()
91            .map(Path::to_path_buf)
92            .unwrap_or_else(|| PathBuf::from(".")),
93        external_roots: HashMap::new(),
94    })
95}
96
97/// Load a project from a sage.toml or project directory.
98///
99/// This does NOT resolve external dependencies. For that, use `load_project_with_packages`.
100pub fn load_project(project_path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
101    // Find the manifest
102    let manifest_path = if project_path.is_file() && project_path.ends_with("sage.toml") {
103        project_path.to_path_buf()
104    } else if project_path.is_dir() {
105        project_path.join("sage.toml")
106    } else {
107        // It's a .sg file - treat as single file
108        return load_single_file(project_path);
109    };
110
111    if !manifest_path.exists() {
112        // No manifest - treat as single file if it's a .sg
113        if project_path.extension().is_some_and(|e| e == "sg") {
114            return load_single_file(project_path);
115        }
116        return Err(vec![LoadError::NoManifest {
117            dir: project_path.to_path_buf(),
118        }]);
119    }
120
121    let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
122    let project_root = manifest_path.parent().unwrap().to_path_buf();
123    let entry_path = project_root.join(&manifest.project.entry);
124
125    if !entry_path.exists() {
126        return Err(vec![LoadError::MissingEntry { path: entry_path }]);
127    }
128
129    // Load the module tree starting from the entry point
130    let mut loader = ModuleLoader::new(project_root.clone());
131    let root_path: ModulePath = vec![];
132    loader.load_module(&root_path, &entry_path)?;
133
134    Ok(ModuleTree {
135        modules: loader.modules,
136        root: vec![],
137        project_root,
138        external_roots: HashMap::new(),
139    })
140}
141
142/// Load a project with external package resolution.
143///
144/// This function will:
145/// 1. Load the project manifest
146/// 2. Check for dependencies
147/// 3. If lock file exists and is fresh, use it; otherwise resolve dependencies
148/// 4. Load all external packages into the module tree
149pub fn load_project_with_packages(
150    project_path: &Path,
151) -> Result<(ModuleTree, bool), Vec<LoadError>> {
152    use sage_package::{check_lock_freshness, install_from_lock, resolve_dependencies, LockFile};
153
154    // First, do the basic project loading to check if it's a valid project
155    let manifest_path = if project_path.is_file() && project_path.ends_with("sage.toml") {
156        project_path.to_path_buf()
157    } else if project_path.is_dir() {
158        project_path.join("sage.toml")
159    } else {
160        // Single file - no packages
161        let tree = load_single_file(project_path)?;
162        return Ok((tree, false));
163    };
164
165    if !manifest_path.exists() {
166        if project_path.extension().is_some_and(|e| e == "sg") {
167            let tree = load_single_file(project_path)?;
168            return Ok((tree, false));
169        }
170        return Err(vec![LoadError::NoManifest {
171            dir: project_path.to_path_buf(),
172        }]);
173    }
174
175    let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
176    let project_root = manifest_path.parent().unwrap().to_path_buf();
177
178    // Parse dependencies
179    let deps = manifest.parse_dependencies().map_err(|e| vec![e])?;
180
181    // Resolve external packages
182    let external_roots = if deps.is_empty() {
183        HashMap::new()
184    } else {
185        let lock_path = project_root.join("sage.lock");
186        let packages = if lock_path.exists() {
187            let lock = LockFile::load(&lock_path)
188                .map_err(|e| vec![LoadError::PackageError { source: e }])?;
189            if check_lock_freshness(&deps, &lock) {
190                // Lock file is fresh - install from lock
191                install_from_lock(&lock).map_err(|e| vec![LoadError::PackageError { source: e }])?
192            } else {
193                // Lock file is stale - re-resolve
194                let resolved = resolve_dependencies(&project_root, &deps, Some(&lock))
195                    .map_err(|e| vec![LoadError::PackageError { source: e }])?;
196                resolved.packages
197            }
198        } else {
199            // No lock file - resolve fresh
200            let resolved = resolve_dependencies(&project_root, &deps, None)
201                .map_err(|e| vec![LoadError::PackageError { source: e }])?;
202            resolved.packages
203        };
204
205        packages
206            .into_iter()
207            .map(|(name, pkg)| (name, pkg.path))
208            .collect()
209    };
210
211    // Load the main project
212    let entry_path = project_root.join(&manifest.project.entry);
213    if !entry_path.exists() {
214        return Err(vec![LoadError::MissingEntry { path: entry_path }]);
215    }
216
217    let mut loader = ModuleLoader::new(project_root.clone());
218    let root_path: ModulePath = vec![];
219    loader.load_module(&root_path, &entry_path)?;
220
221    let installed = !external_roots.is_empty();
222
223    Ok((
224        ModuleTree {
225            modules: loader.modules,
226            root: vec![],
227            project_root,
228            external_roots,
229        },
230        installed,
231    ))
232}
233
234/// Internal loader that tracks state during recursive loading.
235struct ModuleLoader {
236    #[allow(dead_code)]
237    project_root: PathBuf,
238    modules: HashMap<ModulePath, ParsedModule>,
239    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
240}
241
242impl ModuleLoader {
243    fn new(project_root: PathBuf) -> Self {
244        Self {
245            project_root,
246            modules: HashMap::new(),
247            loading: HashSet::new(),
248        }
249    }
250
251    fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
252        let canonical = file_path
253            .canonicalize()
254            .unwrap_or_else(|_| file_path.to_path_buf());
255
256        // Check for cycles
257        if self.loading.contains(&canonical) {
258            let cycle: Vec<String> = self
259                .loading
260                .iter()
261                .map(|p| p.display().to_string())
262                .collect();
263            return Err(vec![LoadError::CircularDependency { cycle }]);
264        }
265
266        // Already loaded?
267        if self.modules.contains_key(path) {
268            return Ok(());
269        }
270
271        self.loading.insert(canonical.clone());
272
273        // Read and parse
274        let source = std::fs::read_to_string(file_path).map_err(|e| {
275            vec![LoadError::IoError {
276                path: file_path.to_path_buf(),
277                source: e,
278            }]
279        })?;
280
281        let source_arc: Arc<str> = Arc::from(source.as_str());
282        let lex_result = sage_lexer::lex(&source).map_err(|e| {
283            vec![LoadError::ParseError {
284                file: file_path.to_path_buf(),
285                errors: vec![format!("{e}")],
286            }]
287        })?;
288
289        let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
290
291        if !parse_errors.is_empty() {
292            return Err(vec![LoadError::ParseError {
293                file: file_path.to_path_buf(),
294                errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
295            }]);
296        }
297
298        let program = program.ok_or_else(|| {
299            vec![LoadError::ParseError {
300                file: file_path.to_path_buf(),
301                errors: vec!["failed to parse program".to_string()],
302            }]
303        })?;
304
305        // Process mod declarations to find child modules
306        let parent_dir = file_path.parent().unwrap();
307        let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
308        let is_mod_file = file_stem == "mod";
309
310        for mod_decl in &program.mod_decls {
311            let child_name = &mod_decl.name.name;
312            let mut child_path = path.clone();
313            child_path.push(child_name.clone());
314
315            // Find the child module file
316            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
317
318            // Recursively load
319            self.load_module(&child_path, &child_file)?;
320        }
321
322        self.loading.remove(&canonical);
323
324        // Store the module
325        self.modules.insert(
326            path.clone(),
327            ParsedModule {
328                path: path.clone(),
329                file_path: file_path.to_path_buf(),
330                source: source_arc,
331                program,
332            },
333        );
334
335        Ok(())
336    }
337
338    fn find_module_file(
339        &self,
340        parent_dir: &Path,
341        mod_name: &str,
342        _parent_is_mod_file: bool,
343    ) -> Result<PathBuf, Vec<LoadError>> {
344        // Try two locations:
345        // 1. mod_name.sg (sibling file)
346        // 2. mod_name/mod.sg (directory with mod.sg)
347        let sibling = parent_dir.join(format!("{mod_name}.sg"));
348        let nested = parent_dir.join(mod_name).join("mod.sg");
349
350        let sibling_exists = sibling.exists();
351        let nested_exists = nested.exists();
352
353        match (sibling_exists, nested_exists) {
354            (true, true) => Err(vec![LoadError::AmbiguousModule {
355                mod_name: mod_name.to_string(),
356                candidates: vec![sibling, nested],
357            }]),
358            (true, false) => Ok(sibling),
359            (false, true) => Ok(nested),
360            (false, false) => Err(vec![LoadError::FileNotFound {
361                mod_name: mod_name.to_string(),
362                searched: vec![sibling, nested],
363                span: (0, 0).into(),
364                source_code: String::new(),
365            }]),
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::fs;
374    use tempfile::TempDir;
375
376    #[test]
377    fn load_single_file_works() {
378        let dir = TempDir::new().unwrap();
379        let file = dir.path().join("test.sg");
380        fs::write(
381            &file,
382            r#"
383agent Main {
384    on start {
385        emit(42);
386    }
387}
388run Main;
389"#,
390        )
391        .unwrap();
392
393        let tree = load_single_file(&file).unwrap();
394        assert_eq!(tree.modules.len(), 1);
395        assert!(tree.modules.contains_key(&vec![]));
396    }
397
398    #[test]
399    fn load_project_with_manifest() {
400        let dir = TempDir::new().unwrap();
401
402        // Create sage.toml
403        fs::write(
404            dir.path().join("sage.toml"),
405            r#"
406[project]
407name = "test"
408entry = "src/main.sg"
409"#,
410        )
411        .unwrap();
412
413        // Create src/main.sg
414        fs::create_dir_all(dir.path().join("src")).unwrap();
415        fs::write(
416            dir.path().join("src/main.sg"),
417            r#"
418agent Main {
419    on start {
420        emit(0);
421    }
422}
423run Main;
424"#,
425        )
426        .unwrap();
427
428        let tree = load_project(dir.path()).unwrap();
429        assert_eq!(tree.modules.len(), 1);
430    }
431
432    #[test]
433    fn load_project_with_submodule() {
434        let dir = TempDir::new().unwrap();
435
436        // Create sage.toml
437        fs::write(
438            dir.path().join("sage.toml"),
439            r#"
440[project]
441name = "test"
442entry = "src/main.sg"
443"#,
444        )
445        .unwrap();
446
447        // Create src/main.sg with mod declaration
448        fs::create_dir_all(dir.path().join("src")).unwrap();
449        fs::write(
450            dir.path().join("src/main.sg"),
451            r#"
452mod agents;
453
454agent Main {
455    on start {
456        emit(0);
457    }
458}
459run Main;
460"#,
461        )
462        .unwrap();
463
464        // Create src/agents.sg
465        fs::write(
466            dir.path().join("src/agents.sg"),
467            r#"
468pub agent Worker {
469    on start {
470        emit(1);
471    }
472}
473"#,
474        )
475        .unwrap();
476
477        let tree = load_project(dir.path()).unwrap();
478        assert_eq!(tree.modules.len(), 2);
479        assert!(tree.modules.contains_key(&vec![]));
480        assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
481    }
482}