Skip to main content

fd_core/
resolve.rs

1//! Import resolver: merges imported scene graphs with namespace prefixing.
2//!
3//! The parser stores `Import` declarations on `SceneGraph.imports` but does
4//! NOT read files — file I/O is handled by the caller via the `ImportLoader`
5//! trait. This keeps the parser pure and testable.
6
7use crate::id::NodeId;
8use crate::model::{Import, SceneGraph};
9use crate::parser::parse_document;
10use std::collections::HashSet;
11
12// ─── Import Loader Trait ─────────────────────────────────────────────────
13
14/// Trait for loading `.fd` files by path.
15///
16/// Implemented differently by each host environment:
17/// - WASM: reads from VS Code workspace via message passing
18/// - LSP: reads from the filesystem
19/// - CLI/tests: reads from a HashMap or disk
20pub trait ImportLoader {
21    /// Load the contents of a `.fd` file at the given path.
22    fn load(&self, path: &str) -> Result<String, String>;
23}
24
25// ─── Resolver ────────────────────────────────────────────────────────────
26
27/// Resolve all imports on `graph`, merging imported styles and nodes
28/// with namespace-prefixed IDs.
29///
30/// # Errors
31/// - Circular import detected
32/// - File not found / load error
33/// - Duplicate namespace alias
34pub fn resolve_imports(graph: &mut SceneGraph, loader: &dyn ImportLoader) -> Result<(), String> {
35    let imports = graph.imports.clone();
36    let mut visited = HashSet::new();
37    resolve_imports_recursive(graph, &imports, loader, &mut visited)
38}
39
40fn resolve_imports_recursive(
41    graph: &mut SceneGraph,
42    imports: &[Import],
43    loader: &dyn ImportLoader,
44    visited: &mut HashSet<String>,
45) -> Result<(), String> {
46    for import in imports {
47        // Cycle detection
48        if !visited.insert(import.path.clone()) {
49            return Err(format!(
50                "Circular import detected: \"{}\" was already imported",
51                import.path
52            ));
53        }
54
55        // Load and parse the imported file
56        let source = loader.load(&import.path)?;
57        let mut imported = parse_document(&source)
58            .map_err(|e| format!("Error parsing \"{}\": {e}", import.path))?;
59
60        // Recursively resolve the imported file's own imports
61        let nested_imports = imported.imports.clone();
62        if !nested_imports.is_empty() {
63            resolve_imports_recursive(&mut imported, &nested_imports, loader, visited)?;
64        }
65
66        // Merge namespaced styles
67        merge_namespaced_styles(graph, &imported, &import.namespace)?;
68
69        // Merge namespaced nodes (children of imported root)
70        merge_namespaced_nodes(graph, &imported, &import.namespace)?;
71
72        // Merge namespaced edges
73        merge_namespaced_edges(graph, &imported, &import.namespace);
74    }
75
76    Ok(())
77}
78
79/// Merge imported styles with namespace prefix: `accent` → `ns.accent`.
80fn merge_namespaced_styles(
81    graph: &mut SceneGraph,
82    imported: &SceneGraph,
83    namespace: &str,
84) -> Result<(), String> {
85    for (name, style) in &imported.styles {
86        let ns_name = NodeId::intern(&format!("{namespace}.{}", name.as_str()));
87        if graph.styles.contains_key(&ns_name) {
88            return Err(format!(
89                "Style conflict: \"{namespace}.{}\" already exists",
90                name.as_str()
91            ));
92        }
93        graph.define_style(ns_name, style.clone());
94    }
95    Ok(())
96}
97
98/// Merge imported nodes (top-level children) with namespace prefix.
99fn merge_namespaced_nodes(
100    graph: &mut SceneGraph,
101    imported: &SceneGraph,
102    namespace: &str,
103) -> Result<(), String> {
104    let children = imported.children(imported.root);
105    for child_idx in children {
106        merge_node_recursive(graph, graph.root, imported, child_idx, namespace)?;
107    }
108    Ok(())
109}
110
111/// Recursively clone an imported node tree with namespace-prefixed IDs.
112fn merge_node_recursive(
113    graph: &mut SceneGraph,
114    parent: petgraph::graph::NodeIndex,
115    imported: &SceneGraph,
116    source_idx: petgraph::graph::NodeIndex,
117    namespace: &str,
118) -> Result<(), String> {
119    let source_node = &imported.graph[source_idx];
120
121    // Prefix the node ID
122    let ns_id = prefix_node_id(&source_node.id, namespace);
123    if graph.id_index.contains_key(&ns_id) {
124        return Err(format!(
125            "Node ID conflict: \"{}\" already exists",
126            ns_id.as_str()
127        ));
128    }
129
130    let mut cloned = source_node.clone();
131    cloned.id = ns_id;
132
133    // Prefix use_styles references
134    for use_ref in &mut cloned.use_styles {
135        *use_ref = prefix_node_id(use_ref, namespace);
136    }
137
138    let new_idx = graph.add_node(parent, cloned);
139
140    // Recurse into children
141    let children = imported.children(source_idx);
142    for child_idx in children {
143        merge_node_recursive(graph, new_idx, imported, child_idx, namespace)?;
144    }
145
146    Ok(())
147}
148
149/// Merge imported edges with namespace-prefixed from/to IDs.
150fn merge_namespaced_edges(graph: &mut SceneGraph, imported: &SceneGraph, namespace: &str) {
151    for edge in &imported.edges {
152        let mut cloned = edge.clone();
153        cloned.id = prefix_node_id(&cloned.id, namespace);
154        cloned.from = prefix_node_id(&cloned.from, namespace);
155        cloned.to = prefix_node_id(&cloned.to, namespace);
156        for use_ref in &mut cloned.use_styles {
157            *use_ref = prefix_node_id(use_ref, namespace);
158        }
159        graph.edges.push(cloned);
160    }
161}
162
163/// Prefix a `NodeId` with a namespace: `button` → `ns.button`.
164/// Anonymous IDs (starting with `_anon_`) are still prefixed for uniqueness.
165fn prefix_node_id(id: &NodeId, namespace: &str) -> NodeId {
166    NodeId::intern(&format!("{namespace}.{}", id.as_str()))
167}
168
169// ─── Tests ───────────────────────────────────────────────────────────────
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::collections::HashMap;
175
176    /// Simple in-memory loader for testing.
177    struct MemoryLoader {
178        files: HashMap<String, String>,
179    }
180
181    impl ImportLoader for MemoryLoader {
182        fn load(&self, path: &str) -> Result<String, String> {
183            self.files
184                .get(path)
185                .cloned()
186                .ok_or_else(|| format!("File not found: {path}"))
187        }
188    }
189
190    #[test]
191    fn resolve_namespace_prefixing() {
192        let imported_source = r#"
193style accent { fill: #6C5CE7 }
194rect @button { w: 100 h: 40; fill: #FF0000 }
195"#;
196
197        let main_source = r#"
198import "buttons.fd" as btn
199rect @hero { w: 200 h: 100 }
200"#;
201
202        let mut graph = parse_document(main_source).unwrap();
203        let loader = MemoryLoader {
204            files: HashMap::from([("buttons.fd".to_string(), imported_source.to_string())]),
205        };
206
207        resolve_imports(&mut graph, &loader).unwrap();
208
209        // Main node still exists
210        assert!(graph.get_by_id(NodeId::intern("hero")).is_some());
211
212        // Imported node has namespace prefix
213        assert!(graph.get_by_id(NodeId::intern("btn.button")).is_some());
214
215        // Imported style has namespace prefix
216        assert!(graph.styles.contains_key(&NodeId::intern("btn.accent")));
217    }
218
219    #[test]
220    fn resolve_circular_import_error() {
221        let file_a = "import \"b.fd\" as b\n";
222        let file_b = "import \"a.fd\" as a\n";
223
224        let mut graph = parse_document(file_a).unwrap();
225        let loader = MemoryLoader {
226            files: HashMap::from([
227                ("b.fd".to_string(), file_b.to_string()),
228                ("a.fd".to_string(), file_a.to_string()),
229            ]),
230        };
231
232        let result = resolve_imports(&mut graph, &loader);
233        assert!(result.is_err());
234        assert!(result.unwrap_err().contains("Circular import"));
235    }
236
237    #[test]
238    fn resolve_file_not_found_error() {
239        let main_source = "import \"missing.fd\" as m\n";
240        let mut graph = parse_document(main_source).unwrap();
241        let loader = MemoryLoader {
242            files: HashMap::new(),
243        };
244
245        let result = resolve_imports(&mut graph, &loader);
246        assert!(result.is_err());
247        assert!(result.unwrap_err().contains("File not found"));
248    }
249
250    #[test]
251    fn resolve_nested_imports() {
252        let tokens = "style primary { fill: #3B82F6 }\n";
253        let buttons = "import \"tokens.fd\" as tok\nrect @btn { w: 80 h: 32 }\n";
254        let main_source = "import \"buttons.fd\" as ui\n";
255
256        let mut graph = parse_document(main_source).unwrap();
257        let loader = MemoryLoader {
258            files: HashMap::from([
259                ("buttons.fd".to_string(), buttons.to_string()),
260                ("tokens.fd".to_string(), tokens.to_string()),
261            ]),
262        };
263
264        resolve_imports(&mut graph, &loader).unwrap();
265
266        // Button node gets ui. prefix
267        assert!(graph.get_by_id(NodeId::intern("ui.btn")).is_some());
268
269        // Nested token style gets ui.tok. prefix
270        assert!(graph.styles.contains_key(&NodeId::intern("ui.tok.primary")));
271    }
272
273    #[test]
274    fn resolve_imported_edges() {
275        let imported = r#"
276rect @a { w: 10 h: 10 }
277rect @b { w: 10 h: 10 }
278edge @link { from: @a; to: @b; arrow: end }
279"#;
280        let main_source = "import \"flow.fd\" as flow\n";
281
282        let mut graph = parse_document(main_source).unwrap();
283        let loader = MemoryLoader {
284            files: HashMap::from([("flow.fd".to_string(), imported.to_string())]),
285        };
286
287        resolve_imports(&mut graph, &loader).unwrap();
288
289        assert_eq!(graph.edges.len(), 1);
290        let edge = &graph.edges[0];
291        assert_eq!(edge.id.as_str(), "flow.link");
292        assert_eq!(edge.from.as_str(), "flow.a");
293        assert_eq!(edge.to.as_str(), "flow.b");
294    }
295}