1use crate::id::NodeId;
14use crate::model::{Import, LayoutMode, NodeKind, SceneGraph, SceneNode};
15use crate::parser::parse_document;
16use std::collections::{HashMap, HashSet};
17
18pub trait ImportLoader {
27 fn load(&self, path: &str) -> Result<String, String>;
29
30 fn canonicalize(&self, path: &str) -> String {
33 path.to_string()
34 }
35}
36
37#[derive(Debug)]
41enum ImportState {
42 InProgress,
44 Resolved,
46}
47
48pub 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 continue;
82 }
83 None => {}
84 }
85
86 state.insert(canonical.clone(), ImportState::InProgress);
88
89 let source = loader.load(&import.path)?;
91 let imported = parse_document(&source)
92 .map_err(|e| format!("Error parsing \"{}\": {e}", import.path))?;
93
94 let nested = imported.imports.clone();
97 if !nested.is_empty() {
98 resolve_recursive(graph, &nested, loader, state)?;
99 }
100
101 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 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 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_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 state.insert(canonical, ImportState::Resolved);
152 }
153
154 Ok(())
155}
156
157fn 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
178fn 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
202fn 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 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 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 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 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
258fn 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 cloned.id = prefix_node_id(&cloned.id, namespace);
272
273 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 if let Some(ref mut tc) = cloned.text_child {
279 *tc = prefix_node_id(tc, namespace);
280 }
281
282 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
293fn prefix_node_id(id: &NodeId, namespace: &str) -> NodeId {
297 NodeId::intern(&format!("{namespace}.{}", id.as_str()))
298}
299
300fn 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
318fn 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#[cfg(test)]
343mod tests {
344 use super::*;
345 use std::collections::HashMap;
346
347 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 assert!(graph.get_by_id(NodeId::intern("hero")).is_some());
382
383 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 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 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 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 assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
453 assert!(!graph.styles.contains_key(&NodeId::intern("ui.tok.primary")));
454
455 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 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 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 assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
520
521 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}