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}
24
25/// A parsed module with its source and AST.
26#[derive(Debug)]
27pub struct ParsedModule {
28    /// The module's path (e.g., `["agents", "researcher"]`).
29    pub path: ModulePath,
30    /// The file path on disk.
31    pub file_path: PathBuf,
32    /// The source code.
33    pub source: Arc<str>,
34    /// The parsed AST.
35    pub program: Program,
36}
37
38/// Load a single .sg file (no project structure).
39pub fn load_single_file(path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
40    let source = std::fs::read_to_string(path).map_err(|e| {
41        vec![LoadError::IoError {
42            path: path.to_path_buf(),
43            source: e,
44        }]
45    })?;
46
47    let source_arc: Arc<str> = Arc::from(source.as_str());
48    let lex_result = sage_lexer::lex(&source).map_err(|e| {
49        vec![LoadError::ParseError {
50            file: path.to_path_buf(),
51            errors: vec![format!("{e}")],
52        }]
53    })?;
54
55    let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
56
57    if !parse_errors.is_empty() {
58        return Err(vec![LoadError::ParseError {
59            file: path.to_path_buf(),
60            errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
61        }]);
62    }
63
64    let program = program.ok_or_else(|| {
65        vec![LoadError::ParseError {
66            file: path.to_path_buf(),
67            errors: vec!["failed to parse program".to_string()],
68        }]
69    })?;
70
71    let root_path = vec![];
72    let mut modules = HashMap::new();
73    modules.insert(
74        root_path.clone(),
75        ParsedModule {
76            path: root_path.clone(),
77            file_path: path.to_path_buf(),
78            source: source_arc,
79            program,
80        },
81    );
82
83    Ok(ModuleTree {
84        modules,
85        root: root_path,
86        project_root: path
87            .parent()
88            .map(Path::to_path_buf)
89            .unwrap_or_else(|| PathBuf::from(".")),
90    })
91}
92
93/// Load a project from a sage.toml or project directory.
94pub fn load_project(project_path: &Path) -> Result<ModuleTree, Vec<LoadError>> {
95    // Find the manifest
96    let manifest_path = if project_path.is_file() && project_path.ends_with("sage.toml") {
97        project_path.to_path_buf()
98    } else if project_path.is_dir() {
99        project_path.join("sage.toml")
100    } else {
101        // It's a .sg file - treat as single file
102        return load_single_file(project_path);
103    };
104
105    if !manifest_path.exists() {
106        // No manifest - treat as single file if it's a .sg
107        if project_path.extension().is_some_and(|e| e == "sg") {
108            return load_single_file(project_path);
109        }
110        return Err(vec![LoadError::NoManifest {
111            dir: project_path.to_path_buf(),
112        }]);
113    }
114
115    let manifest = ProjectManifest::load(&manifest_path).map_err(|e| vec![e])?;
116    let project_root = manifest_path.parent().unwrap().to_path_buf();
117    let entry_path = project_root.join(&manifest.project.entry);
118
119    if !entry_path.exists() {
120        return Err(vec![LoadError::MissingEntry { path: entry_path }]);
121    }
122
123    // Load the module tree starting from the entry point
124    let mut loader = ModuleLoader::new(project_root.clone());
125    let root_path: ModulePath = vec![];
126    loader.load_module(&root_path, &entry_path)?;
127
128    Ok(ModuleTree {
129        modules: loader.modules,
130        root: vec![],
131        project_root,
132    })
133}
134
135/// Internal loader that tracks state during recursive loading.
136struct ModuleLoader {
137    #[allow(dead_code)]
138    project_root: PathBuf,
139    modules: HashMap<ModulePath, ParsedModule>,
140    loading: HashSet<PathBuf>, // Currently loading (for cycle detection)
141}
142
143impl ModuleLoader {
144    fn new(project_root: PathBuf) -> Self {
145        Self {
146            project_root,
147            modules: HashMap::new(),
148            loading: HashSet::new(),
149        }
150    }
151
152    fn load_module(&mut self, path: &ModulePath, file_path: &Path) -> Result<(), Vec<LoadError>> {
153        let canonical = file_path
154            .canonicalize()
155            .unwrap_or_else(|_| file_path.to_path_buf());
156
157        // Check for cycles
158        if self.loading.contains(&canonical) {
159            let cycle: Vec<String> = self
160                .loading
161                .iter()
162                .map(|p| p.display().to_string())
163                .collect();
164            return Err(vec![LoadError::CircularDependency { cycle }]);
165        }
166
167        // Already loaded?
168        if self.modules.contains_key(path) {
169            return Ok(());
170        }
171
172        self.loading.insert(canonical.clone());
173
174        // Read and parse
175        let source = std::fs::read_to_string(file_path).map_err(|e| {
176            vec![LoadError::IoError {
177                path: file_path.to_path_buf(),
178                source: e,
179            }]
180        })?;
181
182        let source_arc: Arc<str> = Arc::from(source.as_str());
183        let lex_result = sage_lexer::lex(&source).map_err(|e| {
184            vec![LoadError::ParseError {
185                file: file_path.to_path_buf(),
186                errors: vec![format!("{e}")],
187            }]
188        })?;
189
190        let (program, parse_errors) = parse(lex_result.tokens(), Arc::clone(&source_arc));
191
192        if !parse_errors.is_empty() {
193            return Err(vec![LoadError::ParseError {
194                file: file_path.to_path_buf(),
195                errors: parse_errors.iter().map(|e| format!("{e}")).collect(),
196            }]);
197        }
198
199        let program = program.ok_or_else(|| {
200            vec![LoadError::ParseError {
201                file: file_path.to_path_buf(),
202                errors: vec!["failed to parse program".to_string()],
203            }]
204        })?;
205
206        // Process mod declarations to find child modules
207        let parent_dir = file_path.parent().unwrap();
208        let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
209        let is_mod_file = file_stem == "mod";
210
211        for mod_decl in &program.mod_decls {
212            let child_name = &mod_decl.name.name;
213            let mut child_path = path.clone();
214            child_path.push(child_name.clone());
215
216            // Find the child module file
217            let child_file = self.find_module_file(parent_dir, child_name, is_mod_file)?;
218
219            // Recursively load
220            self.load_module(&child_path, &child_file)?;
221        }
222
223        self.loading.remove(&canonical);
224
225        // Store the module
226        self.modules.insert(
227            path.clone(),
228            ParsedModule {
229                path: path.clone(),
230                file_path: file_path.to_path_buf(),
231                source: source_arc,
232                program,
233            },
234        );
235
236        Ok(())
237    }
238
239    fn find_module_file(
240        &self,
241        parent_dir: &Path,
242        mod_name: &str,
243        _parent_is_mod_file: bool,
244    ) -> Result<PathBuf, Vec<LoadError>> {
245        // Try two locations:
246        // 1. mod_name.sg (sibling file)
247        // 2. mod_name/mod.sg (directory with mod.sg)
248        let sibling = parent_dir.join(format!("{mod_name}.sg"));
249        let nested = parent_dir.join(mod_name).join("mod.sg");
250
251        let sibling_exists = sibling.exists();
252        let nested_exists = nested.exists();
253
254        match (sibling_exists, nested_exists) {
255            (true, true) => Err(vec![LoadError::AmbiguousModule {
256                mod_name: mod_name.to_string(),
257                candidates: vec![sibling, nested],
258            }]),
259            (true, false) => Ok(sibling),
260            (false, true) => Ok(nested),
261            (false, false) => Err(vec![LoadError::FileNotFound {
262                mod_name: mod_name.to_string(),
263                searched: vec![sibling, nested],
264                span: (0, 0).into(),
265                source_code: String::new(),
266            }]),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use std::fs;
275    use tempfile::TempDir;
276
277    #[test]
278    fn load_single_file_works() {
279        let dir = TempDir::new().unwrap();
280        let file = dir.path().join("test.sg");
281        fs::write(
282            &file,
283            r#"
284agent Main {
285    on start {
286        emit(42);
287    }
288}
289run Main;
290"#,
291        )
292        .unwrap();
293
294        let tree = load_single_file(&file).unwrap();
295        assert_eq!(tree.modules.len(), 1);
296        assert!(tree.modules.contains_key(&vec![]));
297    }
298
299    #[test]
300    fn load_project_with_manifest() {
301        let dir = TempDir::new().unwrap();
302
303        // Create sage.toml
304        fs::write(
305            dir.path().join("sage.toml"),
306            r#"
307[project]
308name = "test"
309entry = "src/main.sg"
310"#,
311        )
312        .unwrap();
313
314        // Create src/main.sg
315        fs::create_dir_all(dir.path().join("src")).unwrap();
316        fs::write(
317            dir.path().join("src/main.sg"),
318            r#"
319agent Main {
320    on start {
321        emit(0);
322    }
323}
324run Main;
325"#,
326        )
327        .unwrap();
328
329        let tree = load_project(dir.path()).unwrap();
330        assert_eq!(tree.modules.len(), 1);
331    }
332
333    #[test]
334    fn load_project_with_submodule() {
335        let dir = TempDir::new().unwrap();
336
337        // Create sage.toml
338        fs::write(
339            dir.path().join("sage.toml"),
340            r#"
341[project]
342name = "test"
343entry = "src/main.sg"
344"#,
345        )
346        .unwrap();
347
348        // Create src/main.sg with mod declaration
349        fs::create_dir_all(dir.path().join("src")).unwrap();
350        fs::write(
351            dir.path().join("src/main.sg"),
352            r#"
353mod agents;
354
355agent Main {
356    on start {
357        emit(0);
358    }
359}
360run Main;
361"#,
362        )
363        .unwrap();
364
365        // Create src/agents.sg
366        fs::write(
367            dir.path().join("src/agents.sg"),
368            r#"
369pub agent Worker {
370    on start {
371        emit(1);
372    }
373}
374"#,
375        )
376        .unwrap();
377
378        let tree = load_project(dir.path()).unwrap();
379        assert_eq!(tree.modules.len(), 2);
380        assert!(tree.modules.contains_key(&vec![]));
381        assert!(tree.modules.contains_key(&vec!["agents".to_string()]));
382    }
383}