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_edge_anchor(&cloned.from, namespace);
155        cloned.to = prefix_edge_anchor(&cloned.to, namespace);
156        if let Some(ref mut tc) = cloned.text_child {
157            *tc = prefix_node_id(tc, namespace);
158        }
159        for use_ref in &mut cloned.use_styles {
160            *use_ref = prefix_node_id(use_ref, namespace);
161        }
162        graph.edges.push(cloned);
163    }
164}
165
166/// Prefix a `NodeId` with a namespace: `button` → `ns.button`.
167/// Anonymous IDs (starting with `_anon_`) are still prefixed for uniqueness.
168fn prefix_node_id(id: &NodeId, namespace: &str) -> NodeId {
169    NodeId::intern(&format!("{namespace}.{}", id.as_str()))
170}
171
172/// Prefix an `EdgeAnchor` with a namespace. Node anchors get prefixed;
173/// Point anchors pass through unchanged.
174fn prefix_edge_anchor(
175    anchor: &crate::model::EdgeAnchor,
176    namespace: &str,
177) -> crate::model::EdgeAnchor {
178    match anchor {
179        crate::model::EdgeAnchor::Node(id) => {
180            crate::model::EdgeAnchor::Node(prefix_node_id(id, namespace))
181        }
182        point @ crate::model::EdgeAnchor::Point(_, _) => point.clone(),
183    }
184}
185
186// ─── Tests ───────────────────────────────────────────────────────────────
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use std::collections::HashMap;
192
193    /// Simple in-memory loader for testing.
194    struct MemoryLoader {
195        files: HashMap<String, String>,
196    }
197
198    impl ImportLoader for MemoryLoader {
199        fn load(&self, path: &str) -> Result<String, String> {
200            self.files
201                .get(path)
202                .cloned()
203                .ok_or_else(|| format!("File not found: {path}"))
204        }
205    }
206
207    #[test]
208    fn resolve_namespace_prefixing() {
209        let imported_source = r#"
210style accent { fill: #6C5CE7 }
211rect @button { w: 100 h: 40; fill: #FF0000 }
212"#;
213
214        let main_source = r#"
215import "buttons.fd" as btn
216rect @hero { w: 200 h: 100 }
217"#;
218
219        let mut graph = parse_document(main_source).unwrap();
220        let loader = MemoryLoader {
221            files: HashMap::from([("buttons.fd".to_string(), imported_source.to_string())]),
222        };
223
224        resolve_imports(&mut graph, &loader).unwrap();
225
226        // Main node still exists
227        assert!(graph.get_by_id(NodeId::intern("hero")).is_some());
228
229        // Imported node has namespace prefix
230        assert!(graph.get_by_id(NodeId::intern("btn.button")).is_some());
231
232        // Imported style has namespace prefix
233        assert!(graph.styles.contains_key(&NodeId::intern("btn.accent")));
234    }
235
236    #[test]
237    fn resolve_circular_import_error() {
238        let file_a = "import \"b.fd\" as b\n";
239        let file_b = "import \"a.fd\" as a\n";
240
241        let mut graph = parse_document(file_a).unwrap();
242        let loader = MemoryLoader {
243            files: HashMap::from([
244                ("b.fd".to_string(), file_b.to_string()),
245                ("a.fd".to_string(), file_a.to_string()),
246            ]),
247        };
248
249        let result = resolve_imports(&mut graph, &loader);
250        assert!(result.is_err());
251        assert!(result.unwrap_err().contains("Circular import"));
252    }
253
254    #[test]
255    fn resolve_file_not_found_error() {
256        let main_source = "import \"missing.fd\" as m\n";
257        let mut graph = parse_document(main_source).unwrap();
258        let loader = MemoryLoader {
259            files: HashMap::new(),
260        };
261
262        let result = resolve_imports(&mut graph, &loader);
263        assert!(result.is_err());
264        assert!(result.unwrap_err().contains("File not found"));
265    }
266
267    #[test]
268    fn resolve_nested_imports() {
269        let tokens = "style primary { fill: #3B82F6 }\n";
270        let buttons = "import \"tokens.fd\" as tok\nrect @btn { w: 80 h: 32 }\n";
271        let main_source = "import \"buttons.fd\" as ui\n";
272
273        let mut graph = parse_document(main_source).unwrap();
274        let loader = MemoryLoader {
275            files: HashMap::from([
276                ("buttons.fd".to_string(), buttons.to_string()),
277                ("tokens.fd".to_string(), tokens.to_string()),
278            ]),
279        };
280
281        resolve_imports(&mut graph, &loader).unwrap();
282
283        // Button node gets ui. prefix
284        assert!(graph.get_by_id(NodeId::intern("ui.btn")).is_some());
285
286        // Nested token style gets ui.tok. prefix
287        assert!(graph.styles.contains_key(&NodeId::intern("ui.tok.primary")));
288    }
289
290    #[test]
291    fn resolve_imported_edges() {
292        let imported = r#"
293rect @a { w: 10 h: 10 }
294rect @b { w: 10 h: 10 }
295edge @link { from: @a; to: @b; arrow: end }
296"#;
297        let main_source = "import \"flow.fd\" as flow\n";
298
299        let mut graph = parse_document(main_source).unwrap();
300        let loader = MemoryLoader {
301            files: HashMap::from([("flow.fd".to_string(), imported.to_string())]),
302        };
303
304        resolve_imports(&mut graph, &loader).unwrap();
305
306        assert_eq!(graph.edges.len(), 1);
307        let edge = &graph.edges[0];
308        assert_eq!(edge.id.as_str(), "flow.link");
309        assert_eq!(
310            edge.from,
311            crate::model::EdgeAnchor::Node(NodeId::intern("flow.a"))
312        );
313        assert_eq!(
314            edge.to,
315            crate::model::EdgeAnchor::Node(NodeId::intern("flow.b"))
316        );
317    }
318}