use crate::id::NodeId;
use crate::model::{Import, LayoutMode, NodeKind, SceneGraph, SceneNode};
use crate::parser::parse_document;
use std::collections::{HashMap, HashSet};
pub trait ImportLoader {
fn load(&self, path: &str) -> Result<String, String>;
fn canonicalize(&self, path: &str) -> String {
path.to_string()
}
}
#[derive(Debug)]
enum ImportState {
InProgress,
Resolved,
}
pub fn resolve_imports(graph: &mut SceneGraph, loader: &dyn ImportLoader) -> Result<(), String> {
let imports = graph.imports.clone();
let mut state: HashMap<String, ImportState> = HashMap::new();
resolve_recursive(graph, &imports, loader, &mut state)
}
fn resolve_recursive(
graph: &mut SceneGraph,
imports: &[Import],
loader: &dyn ImportLoader,
state: &mut HashMap<String, ImportState>,
) -> Result<(), String> {
for import in imports {
let canonical = loader.canonicalize(&import.path);
match state.get(&canonical) {
Some(ImportState::InProgress) => {
return Err(format!(
"Circular import detected: \"{}\" was already imported",
import.path
));
}
Some(ImportState::Resolved) => {
continue;
}
None => {}
}
state.insert(canonical.clone(), ImportState::InProgress);
let source = loader.load(&import.path)?;
let imported = parse_document(&source)
.map_err(|e| format!("Error parsing \"{}\": {e}", import.path))?;
let nested = imported.imports.clone();
if !nested.is_empty() {
resolve_recursive(graph, &nested, loader, state)?;
}
let local_styles: HashSet<NodeId> = imported.styles.keys().cloned().collect();
let local_nodes: HashSet<NodeId> = imported
.id_index
.keys()
.filter(|id| id.as_str() != "root")
.cloned()
.collect();
let frame_id = NodeId::intern(&import.namespace);
if graph.id_index.contains_key(&frame_id) {
return Err(format!(
"Import alias \"{}\" conflicts with existing node \"@{}\"",
import.namespace, import.namespace
));
}
let frame_node = SceneNode::new(
frame_id,
NodeKind::Frame {
width: 0.0,
height: 0.0,
clip: false,
layout: LayoutMode::Free { pad: 0.0 },
},
);
let frame_idx = graph.add_node(graph.root, frame_node);
merge_namespaced_styles(graph, &imported, &import.namespace)?;
merge_namespaced_nodes(
graph,
frame_idx,
&imported,
&import.namespace,
&local_styles,
&local_nodes,
)?;
merge_namespaced_edges(
graph,
&imported,
&import.namespace,
&local_styles,
&local_nodes,
);
state.insert(canonical, ImportState::Resolved);
}
Ok(())
}
fn merge_namespaced_styles(
graph: &mut SceneGraph,
imported: &SceneGraph,
namespace: &str,
) -> Result<(), String> {
for (name, style) in &imported.styles {
let ns_name = NodeId::intern(&format!("{namespace}.{}", name.as_str()));
if graph.styles.contains_key(&ns_name) {
return Err(format!(
"Style conflict: \"{namespace}.{}\" already exists",
name.as_str()
));
}
graph.define_style(ns_name, style.clone());
}
Ok(())
}
fn merge_namespaced_nodes(
graph: &mut SceneGraph,
parent: petgraph::graph::NodeIndex,
imported: &SceneGraph,
namespace: &str,
local_styles: &HashSet<NodeId>,
local_nodes: &HashSet<NodeId>,
) -> Result<(), String> {
let children = imported.children(imported.root);
for child_idx in children {
merge_node_recursive(
graph,
parent,
imported,
child_idx,
namespace,
local_styles,
local_nodes,
)?;
}
Ok(())
}
fn merge_node_recursive(
graph: &mut SceneGraph,
parent: petgraph::graph::NodeIndex,
imported: &SceneGraph,
source_idx: petgraph::graph::NodeIndex,
namespace: &str,
local_styles: &HashSet<NodeId>,
local_nodes: &HashSet<NodeId>,
) -> Result<(), String> {
let source_node = &imported.graph[source_idx];
let ns_id = prefix_node_id(&source_node.id, namespace);
if graph.id_index.contains_key(&ns_id) {
return Err(format!(
"Node ID conflict: \"{}\" already exists",
ns_id.as_str()
));
}
let mut cloned = source_node.clone();
cloned.id = ns_id;
for use_ref in &mut cloned.use_styles {
if local_styles.contains(use_ref) {
*use_ref = prefix_node_id(use_ref, namespace);
}
}
for constraint in &mut cloned.constraints {
prefix_constraint_if_local(constraint, namespace, local_nodes);
}
let new_idx = graph.add_node(parent, cloned);
let children = imported.children(source_idx);
for child_idx in children {
merge_node_recursive(
graph,
new_idx,
imported,
child_idx,
namespace,
local_styles,
local_nodes,
)?;
}
Ok(())
}
fn merge_namespaced_edges(
graph: &mut SceneGraph,
imported: &SceneGraph,
namespace: &str,
local_styles: &HashSet<NodeId>,
local_nodes: &HashSet<NodeId>,
) {
for edge in &imported.edges {
let mut cloned = edge.clone();
cloned.id = prefix_node_id(&cloned.id, namespace);
cloned.from = prefix_edge_anchor_if_local(&cloned.from, namespace, local_nodes);
cloned.to = prefix_edge_anchor_if_local(&cloned.to, namespace, local_nodes);
if let Some(ref mut tc) = cloned.text_child {
*tc = prefix_node_id(tc, namespace);
}
for use_ref in &mut cloned.use_styles {
if local_styles.contains(use_ref) {
*use_ref = prefix_node_id(use_ref, namespace);
}
}
graph.edges.push(cloned);
}
}
fn prefix_node_id(id: &NodeId, namespace: &str) -> NodeId {
NodeId::intern(&format!("{namespace}.{}", id.as_str()))
}
fn prefix_edge_anchor_if_local(
anchor: &crate::model::EdgeAnchor,
namespace: &str,
local_nodes: &HashSet<NodeId>,
) -> crate::model::EdgeAnchor {
match anchor {
crate::model::EdgeAnchor::Node(id) => {
if local_nodes.contains(id) {
crate::model::EdgeAnchor::Node(prefix_node_id(id, namespace))
} else {
anchor.clone()
}
}
point @ crate::model::EdgeAnchor::Point(_, _) => point.clone(),
}
}
fn prefix_constraint_if_local(
constraint: &mut crate::model::Constraint,
namespace: &str,
local_nodes: &HashSet<NodeId>,
) {
match constraint {
crate::model::Constraint::CenterIn(id) => {
if local_nodes.contains(id) {
*id = prefix_node_id(id, namespace);
}
}
crate::model::Constraint::Offset { from, .. } => {
if local_nodes.contains(from) {
*from = prefix_node_id(from, namespace);
}
}
crate::model::Constraint::FillParent { .. } | crate::model::Constraint::Position { .. } => {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct MemoryLoader {
files: HashMap<String, String>,
}
impl ImportLoader for MemoryLoader {
fn load(&self, path: &str) -> Result<String, String> {
self.files
.get(path)
.cloned()
.ok_or_else(|| format!("File not found: {path}"))
}
}
#[test]
fn resolve_namespace_prefixing() {
let imported_source = r#"
style accent { fill: #6C5CE7 }
rect @button { w: 100 h: 40; fill: #FF0000 }
"#;
let main_source = r#"
import "buttons.fd" as btn
rect @hero { w: 200 h: 100 }
"#;
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::from([("buttons.fd".to_string(), imported_source.to_string())]),
};
resolve_imports(&mut graph, &loader).unwrap();
assert!(graph.get_by_id(NodeId::intern("hero")).is_some());
let btn_frame = graph.get_by_id(NodeId::intern("btn"));
assert!(btn_frame.is_some());
assert!(matches!(btn_frame.unwrap().kind, NodeKind::Frame { .. }));
assert!(graph.get_by_id(NodeId::intern("btn.button")).is_some());
let btn_frame_idx = graph.index_of(NodeId::intern("btn")).unwrap();
let btn_button_idx = graph.index_of(NodeId::intern("btn.button")).unwrap();
assert_eq!(graph.parent(btn_button_idx), Some(btn_frame_idx));
assert!(graph.styles.contains_key(&NodeId::intern("btn.accent")));
}
#[test]
fn resolve_circular_import_error() {
let file_a = "import \"b.fd\" as b\n";
let file_b = "import \"a.fd\" as a\n";
let mut graph = parse_document(file_a).unwrap();
let loader = MemoryLoader {
files: HashMap::from([
("b.fd".to_string(), file_b.to_string()),
("a.fd".to_string(), file_a.to_string()),
]),
};
let result = resolve_imports(&mut graph, &loader);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Circular import"));
}
#[test]
fn resolve_file_not_found_error() {
let main_source = "import \"missing.fd\" as m\n";
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::new(),
};
let result = resolve_imports(&mut graph, &loader);
assert!(result.is_err());
assert!(result.unwrap_err().contains("File not found"));
}
#[test]
fn resolve_nested_imports_flat() {
let tokens = "style primary { fill: #3B82F6 }\n";
let buttons = "import \"tokens.fd\" as tok\nrect @btn { w: 80 h: 32 }\n";
let main_source = "import \"buttons.fd\" as ui\n";
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::from([
("buttons.fd".to_string(), buttons.to_string()),
("tokens.fd".to_string(), tokens.to_string()),
]),
};
resolve_imports(&mut graph, &loader).unwrap();
let tok_idx = graph.index_of(NodeId::intern("tok")).unwrap();
let ui_idx = graph.index_of(NodeId::intern("ui")).unwrap();
assert_eq!(graph.parent(tok_idx), Some(graph.root));
assert_eq!(graph.parent(ui_idx), Some(graph.root));
assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
assert!(!graph.styles.contains_key(&NodeId::intern("ui.tok.primary")));
let btn_idx = graph.index_of(NodeId::intern("ui.btn")).unwrap();
assert_eq!(graph.parent(btn_idx), Some(ui_idx));
}
#[test]
fn resolve_imported_edges() {
let imported = r#"
rect @a { w: 10 h: 10 }
rect @b { w: 10 h: 10 }
edge @link { from: @a; to: @b; arrow: end }
"#;
let main_source = "import \"flow.fd\" as flow\n";
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::from([("flow.fd".to_string(), imported.to_string())]),
};
resolve_imports(&mut graph, &loader).unwrap();
assert!(graph.get_by_id(NodeId::intern("flow")).is_some());
assert_eq!(graph.edges.len(), 1);
let edge = &graph.edges[0];
assert_eq!(edge.id.as_str(), "flow.link");
assert_eq!(
edge.from,
crate::model::EdgeAnchor::Node(NodeId::intern("flow.a"))
);
assert_eq!(
edge.to,
crate::model::EdgeAnchor::Node(NodeId::intern("flow.b"))
);
}
#[test]
fn resolve_diamond_dedup() {
let tokens = "style primary { fill: #3B82F6 }\n";
let lib_a = "import \"tokens.fd\" as tok\nrect @widget { w: 50 h: 50 }\n";
let lib_b = "import \"tokens.fd\" as tok\nrect @gadget { w: 60 h: 60 }\n";
let main_source = concat!("import \"a.fd\" as a\n", "import \"b.fd\" as b\n",);
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::from([
("tokens.fd".to_string(), tokens.to_string()),
("a.fd".to_string(), lib_a.to_string()),
("b.fd".to_string(), lib_b.to_string()),
]),
};
resolve_imports(&mut graph, &loader).unwrap();
let tok_nodes: Vec<_> = graph
.id_index
.keys()
.filter(|id| id.as_str() == "tok")
.collect();
assert_eq!(tok_nodes.len(), 1);
assert!(graph.styles.contains_key(&NodeId::intern("tok.primary")));
assert!(graph.get_by_id(NodeId::intern("a")).is_some());
assert!(graph.get_by_id(NodeId::intern("b")).is_some());
}
#[test]
fn resolve_frame_conflict_error() {
let imported = "rect @x { w: 10 h: 10 }\n";
let main_source = concat!("rect @btn { w: 100 h: 50 }\n", "import \"lib.fd\" as btn\n",);
let mut graph = parse_document(main_source).unwrap();
let loader = MemoryLoader {
files: HashMap::from([("lib.fd".to_string(), imported.to_string())]),
};
let result = resolve_imports(&mut graph, &loader);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("conflicts with existing node"),
"Expected conflict error, got: {err}"
);
}
}