Skip to main content

intent_parser/
resolve.rs

1//! Module resolver: loads, parses, and caches imported `.intent` files.
2//!
3//! Resolution is file-system based: `use Foo` looks for `Foo.intent` in the
4//! same directory as the importing file.
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use crate::ast;
10use crate::parser::ParseError;
11
12/// A resolved module graph: the root file plus all transitively imported modules.
13#[derive(Debug)]
14pub struct ModuleGraph {
15    /// The root file that was resolved.
16    pub root: PathBuf,
17    /// All parsed modules keyed by canonical path.
18    pub modules: HashMap<PathBuf, ast::File>,
19    /// Import order (topological — dependencies before dependents).
20    pub order: Vec<PathBuf>,
21}
22
23/// Errors from module resolution.
24#[derive(Debug, thiserror::Error)]
25pub enum ResolveError {
26    #[error("parse error in {path}: {source}")]
27    Parse {
28        path: PathBuf,
29        #[source]
30        source: ParseError,
31    },
32    #[error("cannot read {path}: {source}")]
33    Io {
34        path: PathBuf,
35        #[source]
36        source: std::io::Error,
37    },
38    #[error("import cycle detected: {cycle}")]
39    Cycle { cycle: String },
40    #[error("module not found: `use {module_name}` — expected file {expected_path}")]
41    ModuleNotFound {
42        module_name: String,
43        expected_path: PathBuf,
44    },
45}
46
47/// Resolve a module and all its transitive imports.
48///
49/// Starting from `root_path`, parses the file, follows `use` declarations,
50/// and builds a `ModuleGraph` with all modules in dependency order.
51pub fn resolve(root_path: &Path) -> Result<ModuleGraph, ResolveError> {
52    let root_path = std::fs::canonicalize(root_path).map_err(|e| ResolveError::Io {
53        path: root_path.to_path_buf(),
54        source: e,
55    })?;
56
57    let mut modules: HashMap<PathBuf, ast::File> = HashMap::new();
58    let mut order: Vec<PathBuf> = Vec::new();
59    let mut visiting: HashSet<PathBuf> = HashSet::new();
60
61    resolve_recursive(&root_path, &mut modules, &mut order, &mut visiting)?;
62
63    Ok(ModuleGraph {
64        root: root_path,
65        modules,
66        order,
67    })
68}
69
70fn resolve_recursive(
71    path: &Path,
72    modules: &mut HashMap<PathBuf, ast::File>,
73    order: &mut Vec<PathBuf>,
74    visiting: &mut HashSet<PathBuf>,
75) -> Result<(), ResolveError> {
76    // Already fully resolved.
77    if modules.contains_key(path) {
78        return Ok(());
79    }
80
81    // Cycle detection.
82    if !visiting.insert(path.to_path_buf()) {
83        let cycle = path.display().to_string();
84        return Err(ResolveError::Cycle { cycle });
85    }
86
87    // Read and parse.
88    let source = std::fs::read_to_string(path).map_err(|e| ResolveError::Io {
89        path: path.to_path_buf(),
90        source: e,
91    })?;
92
93    let file = crate::parse_file(&source).map_err(|e| ResolveError::Parse {
94        path: path.to_path_buf(),
95        source: e,
96    })?;
97
98    // Resolve each import.
99    let dir = path.parent().unwrap_or(Path::new("."));
100    for use_decl in &file.imports {
101        let import_filename = format!("{}.intent", use_decl.module_name);
102        let import_path = dir.join(&import_filename);
103
104        let canonical =
105            std::fs::canonicalize(&import_path).map_err(|_| ResolveError::ModuleNotFound {
106                module_name: use_decl.module_name.clone(),
107                expected_path: import_path.clone(),
108            })?;
109
110        resolve_recursive(&canonical, modules, order, visiting)?;
111    }
112
113    // Done visiting — add to resolved set in topological order.
114    visiting.remove(path);
115    order.push(path.to_path_buf());
116    modules.insert(path.to_path_buf(), file);
117
118    Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::fs;
125
126    fn setup_temp_dir() -> tempfile::TempDir {
127        tempfile::tempdir().unwrap()
128    }
129
130    #[test]
131    fn resolve_single_file_no_imports() {
132        let dir = setup_temp_dir();
133        let root = dir.path().join("main.intent");
134        fs::write(&root, "module Main\n\nentity Foo {\n  id: UUID\n}\n").unwrap();
135
136        let graph = resolve(&root).unwrap();
137        assert_eq!(graph.modules.len(), 1);
138        assert_eq!(graph.order.len(), 1);
139    }
140
141    #[test]
142    fn resolve_two_modules() {
143        let dir = setup_temp_dir();
144
145        fs::write(
146            dir.path().join("Types.intent"),
147            "module Types\n\nentity Account {\n  id: UUID\n  balance: Int\n}\n",
148        )
149        .unwrap();
150
151        fs::write(
152            dir.path().join("Main.intent"),
153            "module Main\n\nuse Types\n\naction Transfer {\n  from: Account\n  to: Account\n}\n",
154        )
155        .unwrap();
156
157        let graph = resolve(&dir.path().join("Main.intent")).unwrap();
158        assert_eq!(graph.modules.len(), 2);
159        // Types should come before Main in topological order
160        let names: Vec<&str> = graph
161            .order
162            .iter()
163            .map(|p| graph.modules[p].module.name.as_str())
164            .collect();
165        assert_eq!(names, vec!["Types", "Main"]);
166    }
167
168    #[test]
169    fn resolve_selective_import() {
170        let dir = setup_temp_dir();
171
172        fs::write(
173            dir.path().join("Types.intent"),
174            "module Types\n\nentity Account {\n  id: UUID\n}\n\nentity User {\n  name: String\n}\n",
175        )
176        .unwrap();
177
178        fs::write(
179            dir.path().join("Main.intent"),
180            "module Main\n\nuse Types.Account\n\naction Foo {\n  a: Account\n}\n",
181        )
182        .unwrap();
183
184        let graph = resolve(&dir.path().join("Main.intent")).unwrap();
185        assert_eq!(graph.modules.len(), 2);
186        // The import is selective — Main only imports Account
187        let main_file =
188            &graph.modules[&std::fs::canonicalize(dir.path().join("Main.intent")).unwrap()];
189        assert_eq!(main_file.imports[0].module_name, "Types");
190        assert_eq!(main_file.imports[0].item.as_deref(), Some("Account"));
191    }
192
193    #[test]
194    fn resolve_cycle_detected() {
195        let dir = setup_temp_dir();
196
197        fs::write(dir.path().join("A.intent"), "module A\n\nuse B\n").unwrap();
198
199        fs::write(dir.path().join("B.intent"), "module B\n\nuse A\n").unwrap();
200
201        let err = resolve(&dir.path().join("A.intent")).unwrap_err();
202        assert!(matches!(err, ResolveError::Cycle { .. }));
203    }
204
205    #[test]
206    fn resolve_module_not_found() {
207        let dir = setup_temp_dir();
208
209        fs::write(
210            dir.path().join("Main.intent"),
211            "module Main\n\nuse NonExistent\n",
212        )
213        .unwrap();
214
215        let err = resolve(&dir.path().join("Main.intent")).unwrap_err();
216        assert!(matches!(err, ResolveError::ModuleNotFound { .. }));
217    }
218
219    #[test]
220    fn resolve_transitive_imports() {
221        let dir = setup_temp_dir();
222
223        fs::write(
224            dir.path().join("Base.intent"),
225            "module Base\n\nentity Id {\n  value: UUID\n}\n",
226        )
227        .unwrap();
228
229        fs::write(
230            dir.path().join("Types.intent"),
231            "module Types\n\nuse Base\n\nentity Account {\n  id: UUID\n}\n",
232        )
233        .unwrap();
234
235        fs::write(
236            dir.path().join("Main.intent"),
237            "module Main\n\nuse Types\n\naction Foo {\n  a: Account\n}\n",
238        )
239        .unwrap();
240
241        let graph = resolve(&dir.path().join("Main.intent")).unwrap();
242        assert_eq!(graph.modules.len(), 3);
243        let names: Vec<&str> = graph
244            .order
245            .iter()
246            .map(|p| graph.modules[p].module.name.as_str())
247            .collect();
248        assert_eq!(names, vec!["Base", "Types", "Main"]);
249    }
250
251    #[test]
252    fn resolve_diamond_dependency() {
253        let dir = setup_temp_dir();
254
255        fs::write(
256            dir.path().join("Base.intent"),
257            "module Base\n\nentity Id {\n  value: UUID\n}\n",
258        )
259        .unwrap();
260
261        fs::write(
262            dir.path().join("Left.intent"),
263            "module Left\n\nuse Base\n\nentity Foo {\n  id: UUID\n}\n",
264        )
265        .unwrap();
266
267        fs::write(
268            dir.path().join("Right.intent"),
269            "module Right\n\nuse Base\n\nentity Bar {\n  id: UUID\n}\n",
270        )
271        .unwrap();
272
273        fs::write(
274            dir.path().join("Main.intent"),
275            "module Main\n\nuse Left\nuse Right\n",
276        )
277        .unwrap();
278
279        let graph = resolve(&dir.path().join("Main.intent")).unwrap();
280        assert_eq!(graph.modules.len(), 4);
281        // Base should appear only once and before Left/Right
282        let names: Vec<&str> = graph
283            .order
284            .iter()
285            .map(|p| graph.modules[p].module.name.as_str())
286            .collect();
287        assert_eq!(names[0], "Base");
288        assert!(names.contains(&"Left"));
289        assert!(names.contains(&"Right"));
290        assert_eq!(names[3], "Main");
291    }
292}