Skip to main content

fd_core/
resolve.rs

1//! Import resolver: merges imported scene graphs with frame-based scoping.
2//!
3//! Each imported file becomes an implicit Frame node under root, providing
4//! visual grouping and namespace isolation. The resolver handles:
5//! - Flat frame topology (all frames are siblings under root)
6//! - Diamond dependency dedup (same file imported twice → one frame)
7//! - Three-state cycle detection (unseen / in-progress / resolved)
8//!
9//! The parser stores `Import` declarations on `SceneGraph.imports` but does
10//! NOT read files — file I/O is handled by the caller via the `ImportLoader`
11//! trait. This keeps the parser pure and testable.
12
13use crate::id::NodeId;
14use crate::model::{Import, LayoutMode, NodeKind, SceneGraph, SceneNode};
15use crate::parser::parse_document;
16use std::collections::{HashMap, HashSet};
17
18// ─── Import Loader Trait ─────────────────────────────────────────────────
19
20/// Trait for loading `.fd` files by path.
21///
22/// Implemented differently by each host environment:
23/// - WASM: reads from VS Code workspace via message passing
24/// - LSP: reads from the filesystem
25/// - CLI/tests: reads from a HashMap or disk
26pub trait ImportLoader {
27    /// Load the contents of a `.fd` file at the given path.
28    fn load(&self, path: &str) -> Result<String, String>;
29
30    /// Return a canonical form of the path for dedup comparison.
31    /// Default: returns the path unchanged.
32    fn canonicalize(&self, path: &str) -> String {
33        path.to_string()
34    }
35}
36
37// ─── Import State ────────────────────────────────────────────────────────
38
39/// Three-state tracking for import resolution.
40#[derive(Debug)]
41enum ImportState {
42    /// Currently being resolved (on the call stack) — hitting this = cycle.
43    InProgress,
44    /// Fully resolved — the frame exists in the graph.
45    Resolved,
46}
47
48// ─── Resolver ────────────────────────────────────────────────────────────
49
50/// Resolve all imports on `graph`, wrapping each imported file in an
51/// implicit Frame node under root. Handles dedup and cycle detection.
52///
53/// # Errors
54/// - Circular import detected
55/// - File not found / load error
56/// - Node/style ID conflicts
57pub fn resolve_imports(graph: &mut SceneGraph, loader: &dyn ImportLoader) -> Result<(), String> {
58    let imports = graph.imports.clone();
59    let mut state: HashMap<String, ImportState> = HashMap::new();
60    resolve_recursive(graph, &imports, loader, &mut state)
61}
62
63fn resolve_recursive(
64    graph: &mut SceneGraph,
65    imports: &[Import],
66    loader: &dyn ImportLoader,
67    state: &mut HashMap<String, ImportState>,
68) -> Result<(), String> {
69    for import in imports {
70        let canonical = loader.canonicalize(&import.path);
71
72        match state.get(&canonical) {
73            Some(ImportState::InProgress) => {
74                return Err(format!(
75                    "Circular import detected: \"{}\" was already imported",
76                    import.path
77                ));
78            }
79            Some(ImportState::Resolved) => {
80                // Diamond dedup — file already fully resolved, skip
81                continue;
82            }
83            None => {}
84        }
85
86        // Mark in-progress for cycle detection
87        state.insert(canonical.clone(), ImportState::InProgress);
88
89        // Load and parse the imported file
90        let source = loader.load(&import.path)?;
91        let imported = parse_document(&source)
92            .map_err(|e| format!("Error parsing \"{}\": {e}", import.path))?;
93
94        // Recursively resolve the imported file's own imports FIRST.
95        // This creates sibling frames under root (flat topology).
96        let nested = imported.imports.clone();
97        if !nested.is_empty() {
98            resolve_recursive(graph, &nested, loader, state)?;
99        }
100
101        // Build local scope sets for conditional prefixing.
102        // Only IDs/styles defined directly in this file are "local".
103        let local_styles: HashSet<NodeId> = imported.styles.keys().cloned().collect();
104        let local_nodes: HashSet<NodeId> = imported
105            .id_index
106            .keys()
107            .filter(|id| id.as_str() != "root")
108            .cloned()
109            .collect();
110
111        // Check for frame ID conflict with existing nodes
112        let frame_id = NodeId::intern(&import.namespace);
113        if graph.id_index.contains_key(&frame_id) {
114            return Err(format!(
115                "Import alias \"{}\" conflicts with existing node \"@{}\"",
116                import.namespace, import.namespace
117            ));
118        }
119
120        // Create the implicit frame node
121        let frame_node = SceneNode::new(
122            frame_id,
123            NodeKind::Frame {
124                width: 0.0,
125                height: 0.0,
126                clip: false,
127                layout: LayoutMode::Free { pad: 0.0 },
128            },
129        );
130        let frame_idx = graph.add_node(graph.root, frame_node);
131
132        // Merge content into the frame
133        merge_namespaced_styles(graph, &imported, &import.namespace)?;
134        merge_namespaced_nodes(
135            graph,
136            frame_idx,
137            &imported,
138            &import.namespace,
139            &local_styles,
140            &local_nodes,
141        )?;
142        merge_namespaced_edges(
143            graph,
144            &imported,
145            &import.namespace,
146            &local_styles,
147            &local_nodes,
148        );
149
150        // Mark as resolved
151        state.insert(canonical, ImportState::Resolved);
152    }
153
154    Ok(())
155}
156
157// ─── Merge Helpers ───────────────────────────────────────────────────────
158
159/// Merge imported styles with namespace prefix: `accent` → `ns.accent`.
160fn merge_namespaced_styles(
161    graph: &mut SceneGraph,
162    imported: &SceneGraph,
163    namespace: &str,
164) -> Result<(), String> {
165    for (name, style) in &imported.styles {
166        let ns_name = NodeId::intern(&format!("{namespace}.{}", name.as_str()));
167        if graph.styles.contains_key(&ns_name) {
168            return Err(format!(
169                "Style conflict: \"{namespace}.{}\" already exists",
170                name.as_str()
171            ));
172        }
173        graph.define_style(ns_name, style.clone());
174    }
175    Ok(())
176}
177
178/// Merge imported nodes (top-level children) into a parent frame.
179fn merge_namespaced_nodes(
180    graph: &mut SceneGraph,
181    parent: petgraph::graph::NodeIndex,
182    imported: &SceneGraph,
183    namespace: &str,
184    local_styles: &HashSet<NodeId>,
185    local_nodes: &HashSet<NodeId>,
186) -> Result<(), String> {
187    let children = imported.children(imported.root);
188    for child_idx in children {
189        merge_node_recursive(
190            graph,
191            parent,
192            imported,
193            child_idx,
194            namespace,
195            local_styles,
196            local_nodes,
197        )?;
198    }
199    Ok(())
200}
201
202/// Recursively clone an imported node tree with namespace-prefixed IDs.
203/// Only local references are prefixed; cross-frame refs are kept as-is.
204fn merge_node_recursive(
205    graph: &mut SceneGraph,
206    parent: petgraph::graph::NodeIndex,
207    imported: &SceneGraph,
208    source_idx: petgraph::graph::NodeIndex,
209    namespace: &str,
210    local_styles: &HashSet<NodeId>,
211    local_nodes: &HashSet<NodeId>,
212) -> Result<(), String> {
213    let source_node = &imported.graph[source_idx];
214
215    // Node IDs are always prefixed (they belong to this file)
216    let ns_id = prefix_node_id(&source_node.id, namespace);
217    if graph.id_index.contains_key(&ns_id) {
218        return Err(format!(
219            "Node ID conflict: \"{}\" already exists",
220            ns_id.as_str()
221        ));
222    }
223
224    let mut cloned = source_node.clone();
225    cloned.id = ns_id;
226
227    // Prefix use_styles: local styles get prefixed, cross-frame stay as-is
228    for use_ref in &mut cloned.use_styles {
229        if local_styles.contains(use_ref) {
230            *use_ref = prefix_node_id(use_ref, namespace);
231        }
232    }
233
234    // Prefix constraint NodeId references if local
235    for constraint in &mut cloned.constraints {
236        prefix_constraint_if_local(constraint, namespace, local_nodes);
237    }
238
239    let new_idx = graph.add_node(parent, cloned);
240
241    // Recurse into children
242    let children = imported.children(source_idx);
243    for child_idx in children {
244        merge_node_recursive(
245            graph,
246            new_idx,
247            imported,
248            child_idx,
249            namespace,
250            local_styles,
251            local_nodes,
252        )?;
253    }
254
255    Ok(())
256}
257
258/// Merge imported edges with namespace-prefixed from/to IDs.
259/// Only local references are prefixed; cross-frame refs stay as-is.
260fn merge_namespaced_edges(
261    graph: &mut SceneGraph,
262    imported: &SceneGraph,
263    namespace: &str,
264    local_styles: &HashSet<NodeId>,
265    local_nodes: &HashSet<NodeId>,
266) {
267    for edge in &imported.edges {
268        let mut cloned = edge.clone();
269
270        // Edge ID is always local
271        cloned.id = prefix_node_id(&cloned.id, namespace);
272
273        // from/to: prefix only if local node
274        cloned.from = prefix_edge_anchor_if_local(&cloned.from, namespace, local_nodes);
275        cloned.to = prefix_edge_anchor_if_local(&cloned.to, namespace, local_nodes);
276
277        // Text child is always local to the edge's file
278        if let Some(ref mut tc) = cloned.text_child {
279            *tc = prefix_node_id(tc, namespace);
280        }
281
282        // use_styles: local → prefix, cross-frame → keep
283        for use_ref in &mut cloned.use_styles {
284            if local_styles.contains(use_ref) {
285                *use_ref = prefix_node_id(use_ref, namespace);
286            }
287        }
288
289        graph.edges.push(cloned);
290    }
291}
292
293// ─── Prefixing Helpers ───────────────────────────────────────────────────
294
295/// Prefix a `NodeId` with a namespace: `button` → `ns.button`.
296fn prefix_node_id(id: &NodeId, namespace: &str) -> NodeId {
297    NodeId::intern(&format!("{namespace}.{}", id.as_str()))
298}
299
300/// Prefix an `EdgeAnchor` only if it references a local node.
301fn prefix_edge_anchor_if_local(
302    anchor: &crate::model::EdgeAnchor,
303    namespace: &str,
304    local_nodes: &HashSet<NodeId>,
305) -> crate::model::EdgeAnchor {
306    match anchor {
307        crate::model::EdgeAnchor::Node(id) => {
308            if local_nodes.contains(id) {
309                crate::model::EdgeAnchor::Node(prefix_node_id(id, namespace))
310            } else {
311                anchor.clone()
312            }
313        }
314        point @ crate::model::EdgeAnchor::Point(_, _) => point.clone(),
315    }
316}
317
318/// Prefix constraint NodeId references only if they are local nodes.
319fn prefix_constraint_if_local(
320    constraint: &mut crate::model::Constraint,
321    namespace: &str,
322    local_nodes: &HashSet<NodeId>,
323) {
324    match constraint {
325        crate::model::Constraint::CenterIn(id) => {
326            if local_nodes.contains(id) {
327                *id = prefix_node_id(id, namespace);
328            }
329        }
330        crate::model::Constraint::Offset { from, .. } => {
331            if local_nodes.contains(from) {
332                *from = prefix_node_id(from, namespace);
333            }
334        }
335        crate::model::Constraint::FillParent { .. } | crate::model::Constraint::Position { .. } => {
336        }
337    }
338}
339
340// ─── Tests ───────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::collections::HashMap;
346
347    /// Simple in-memory loader for testing.
348    struct MemoryLoader {
349        files: HashMap<String, String>,
350    }
351
352    impl ImportLoader for MemoryLoader {
353        fn load(&self, path: &str) -> Result<String, String> {
354            self.files
355                .get(path)
356                .cloned()
357                .ok_or_else(|| format!("File not found: {path}"))
358        }
359    }
360
361    #[test]
362    fn resolve_namespace_prefixing() {
363        let imported_source = r#"
364style accent { fill: #6C5CE7 }
365rect @button { w: 100 h: 40; fill: #FF0000 }
366"#;
367
368        let main_source = r#"
369import "buttons.fd" as btn
370rect @hero { w: 200 h: 100 }
371"#;
372
373        let mut graph = parse_document(main_source).unwrap();
374        let loader = MemoryLoader {
375            files: HashMap::from([("buttons.fd".to_string(), imported_source.to_string())]),
376        };
377
378        resolve_imports(&mut graph, &loader).unwrap();
379
380        // Main node still exists at root
381        assert!(graph.get_by_id(NodeId::intern("hero")).is_some());
382
383        // Frame node exists
384        let btn_frame = graph.get_by_id(NodeId::intern("btn"));
385        assert!(btn_frame.is_some());
386        assert!(matches!(btn_frame.unwrap().kind, NodeKind::Frame { .. }));
387
388        // Imported node has namespace prefix and is under the frame
389        assert!(graph.get_by_id(NodeId::intern("btn.button")).is_some());
390        let btn_frame_idx = graph.index_of(NodeId::intern("btn")).unwrap();
391        let btn_button_idx = graph.index_of(NodeId::intern("btn.button")).unwrap();
392        assert_eq!(graph.parent(btn_button_idx), Some(btn_frame_idx));
393
394        // Imported style has namespace prefix
395        assert!(graph.styles.contains_key(&NodeId::intern("btn.accent")));
396    }
397
398    #[test]
399    fn resolve_circular_import_error() {
400        let file_a = "import \"b.fd\" as b\n";
401        let file_b = "import \"a.fd\" as a\n";
402
403        let mut graph = parse_document(file_a).unwrap();
404        let loader = MemoryLoader {
405            files: HashMap::from([
406                ("b.fd".to_string(), file_b.to_string()),
407                ("a.fd".to_string(), file_a.to_string()),
408            ]),
409        };
410
411        let result = resolve_imports(&mut graph, &loader);
412        assert!(result.is_err());
413        assert!(result.unwrap_err().contains("Circular import"));
414    }
415
416    #[test]
417    fn resolve_file_not_found_error() {
418        let main_source = "import \"missing.fd\" as m\n";
419        let mut graph = parse_document(main_source).unwrap();
420        let loader = MemoryLoader {
421            files: HashMap::new(),
422        };
423
424        let result = resolve_imports(&mut graph, &loader);
425        assert!(result.is_err());
426        assert!(result.unwrap_err().contains("File not found"));
427    }
428
429    #[test]
430    fn resolve_nested_imports_flat() {
431        let tokens = "style primary { fill: #3B82F6 }\n";
432        let buttons = "import \"tokens.fd\" as tok\nrect @btn { w: 80 h: 32 }\n";
433        let main_source = "import \"buttons.fd\" as ui\n";
434
435        let mut graph = parse_document(main_source).unwrap();
436        let loader = MemoryLoader {
437            files: HashMap::from([
438                ("buttons.fd".to_string(), buttons.to_string()),
439                ("tokens.fd".to_string(), tokens.to_string()),
440            ]),
441        };
442
443        resolve_imports(&mut graph, &loader).unwrap();
444
445        // Both frames are siblings under root (flat topology)
446        let tok_idx = graph.index_of(NodeId::intern("tok")).unwrap();
447        let ui_idx = graph.index_of(NodeId::intern("ui")).unwrap();
448        assert_eq!(graph.parent(tok_idx), Some(graph.root));
449        assert_eq!(graph.parent(ui_idx), Some(graph.root));
450
451        // Token style is flat: tok.primary (not ui.tok.primary)
452        assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
453        assert!(!graph.styles.contains_key(&NodeId::intern("ui.tok.primary")));
454
455        // Button node is under ui frame
456        let btn_idx = graph.index_of(NodeId::intern("ui.btn")).unwrap();
457        assert_eq!(graph.parent(btn_idx), Some(ui_idx));
458    }
459
460    #[test]
461    fn resolve_imported_edges() {
462        let imported = r#"
463rect @a { w: 10 h: 10 }
464rect @b { w: 10 h: 10 }
465edge @link { from: @a; to: @b; arrow: end }
466"#;
467        let main_source = "import \"flow.fd\" as flow\n";
468
469        let mut graph = parse_document(main_source).unwrap();
470        let loader = MemoryLoader {
471            files: HashMap::from([("flow.fd".to_string(), imported.to_string())]),
472        };
473
474        resolve_imports(&mut graph, &loader).unwrap();
475
476        // Frame exists
477        assert!(graph.get_by_id(NodeId::intern("flow")).is_some());
478
479        assert_eq!(graph.edges.len(), 1);
480        let edge = &graph.edges[0];
481        assert_eq!(edge.id.as_str(), "flow.link");
482        assert_eq!(
483            edge.from,
484            crate::model::EdgeAnchor::Node(NodeId::intern("flow.a"))
485        );
486        assert_eq!(
487            edge.to,
488            crate::model::EdgeAnchor::Node(NodeId::intern("flow.b"))
489        );
490    }
491
492    #[test]
493    fn resolve_diamond_dedup() {
494        let tokens = "style primary { fill: #3B82F6 }\n";
495        let lib_a = "import \"tokens.fd\" as tok\nrect @widget { w: 50 h: 50 }\n";
496        let lib_b = "import \"tokens.fd\" as tok\nrect @gadget { w: 60 h: 60 }\n";
497        let main_source = concat!("import \"a.fd\" as a\n", "import \"b.fd\" as b\n",);
498
499        let mut graph = parse_document(main_source).unwrap();
500        let loader = MemoryLoader {
501            files: HashMap::from([
502                ("tokens.fd".to_string(), tokens.to_string()),
503                ("a.fd".to_string(), lib_a.to_string()),
504                ("b.fd".to_string(), lib_b.to_string()),
505            ]),
506        };
507
508        resolve_imports(&mut graph, &loader).unwrap();
509
510        // Only one tok frame (dedup)
511        let tok_nodes: Vec<_> = graph
512            .id_index
513            .keys()
514            .filter(|id| id.as_str() == "tok")
515            .collect();
516        assert_eq!(tok_nodes.len(), 1);
517
518        // Only one copy of the style
519        assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
520
521        // Both lib frames exist
522        assert!(graph.get_by_id(NodeId::intern("a")).is_some());
523        assert!(graph.get_by_id(NodeId::intern("b")).is_some());
524    }
525
526    #[test]
527    fn resolve_frame_conflict_error() {
528        let imported = "rect @x { w: 10 h: 10 }\n";
529        let main_source = concat!("rect @btn { w: 100 h: 50 }\n", "import \"lib.fd\" as btn\n",);
530
531        let mut graph = parse_document(main_source).unwrap();
532        let loader = MemoryLoader {
533            files: HashMap::from([("lib.fd".to_string(), imported.to_string())]),
534        };
535
536        let result = resolve_imports(&mut graph, &loader);
537        assert!(result.is_err());
538        let err = result.unwrap_err();
539        assert!(
540            err.contains("conflicts with existing node"),
541            "Expected conflict error, got: {err}"
542        );
543    }
544}