hypen_engine/reactive/
graph.rs

1use super::Binding;
2use crate::ir::NodeId;
3use indexmap::{IndexMap, IndexSet};
4use std::collections::BTreeMap;
5
6/// Tracks which nodes depend on which state paths
7pub struct DependencyGraph {
8    /// Maps state path → set of NodeIds that depend on it
9    dependencies: IndexMap<String, IndexSet<NodeId>>,
10
11    /// Maps NodeId → set of state paths it depends on
12    node_bindings: IndexMap<NodeId, IndexSet<String>>,
13
14    /// Prefix index for efficient path lookups
15    /// Maps path prefix → set of full paths that start with this prefix
16    /// This enables O(log n) lookup instead of O(n) scan
17    prefix_index: BTreeMap<String, IndexSet<String>>,
18}
19
20impl DependencyGraph {
21    pub fn new() -> Self {
22        Self {
23            dependencies: IndexMap::new(),
24            node_bindings: IndexMap::new(),
25            prefix_index: BTreeMap::new(),
26        }
27    }
28
29    /// Record that a node depends on a binding
30    pub fn add_dependency(&mut self, node_id: NodeId, binding: &Binding) {
31        let path = binding.full_path();
32
33        // Add to dependencies map
34        self.dependencies
35            .entry(path.clone())
36            .or_insert_with(IndexSet::new)
37            .insert(node_id);
38
39        // Add to node_bindings map
40        self.node_bindings
41            .entry(node_id)
42            .or_insert_with(IndexSet::new)
43            .insert(path.clone());
44
45        // Update prefix index for efficient lookups
46        // Add all prefixes of this path to the index
47        self.add_path_to_prefix_index(&path);
48    }
49
50    /// Add a path and all its prefixes to the prefix index
51    fn add_path_to_prefix_index(&mut self, path: &str) {
52        // Add the full path to its own prefix
53        self.prefix_index
54            .entry(path.to_string())
55            .or_insert_with(IndexSet::new)
56            .insert(path.to_string());
57
58        // Add all parent prefixes
59        let mut current = path;
60        while let Some(dot_pos) = current.rfind('.') {
61            current = &current[..dot_pos];
62            self.prefix_index
63                .entry(current.to_string())
64                .or_insert_with(IndexSet::new)
65                .insert(path.to_string());
66        }
67    }
68
69    /// Remove all dependencies for a node (when unmounting)
70    pub fn remove_node(&mut self, node_id: NodeId) {
71        if let Some(paths) = self.node_bindings.shift_remove(&node_id) {
72            for path in paths {
73                if let Some(nodes) = self.dependencies.get_mut(&path) {
74                    nodes.shift_remove(&node_id);
75                }
76            }
77        }
78    }
79
80    /// Get all nodes that depend on a specific state path
81    pub fn get_dependent_nodes(&self, path: &str) -> Option<&IndexSet<NodeId>> {
82        self.dependencies.get(path)
83    }
84
85    /// Get all nodes affected by a state patch (considers nested paths)
86    /// E.g., if "user" changes, both "user" and "user.name" subscribers are affected
87    /// Uses prefix index for O(log n) lookup instead of O(n) scan
88    pub fn get_affected_nodes(&self, changed_path: &str) -> IndexSet<NodeId> {
89        let mut affected = IndexSet::new();
90        let mut affected_paths = IndexSet::new();
91
92        // Strategy: Find all paths that could be affected by this change
93        // 1. Exact match: changed_path itself
94        if self.dependencies.contains_key(changed_path) {
95            affected_paths.insert(changed_path.to_string());
96        }
97
98        // 2. Parent paths: all prefixes of changed_path (e.g., "user.name" affects "user")
99        let mut current = changed_path;
100        while let Some(dot_pos) = current.rfind('.') {
101            current = &current[..dot_pos];
102            if self.dependencies.contains_key(current) {
103                affected_paths.insert(current.to_string());
104            }
105        }
106
107        // 3. Child paths: all paths that start with changed_path (e.g., "user" affects "user.name")
108        // Use prefix index for efficient lookup
109        if let Some(child_paths) = self.prefix_index.get(changed_path) {
110            affected_paths.extend(child_paths.iter().cloned());
111        }
112
113        // Collect nodes from all affected paths
114        for path in affected_paths {
115            if let Some(nodes) = self.dependencies.get(&path) {
116                affected.extend(nodes.iter().copied());
117            }
118        }
119
120        affected
121    }
122
123    /// Clear all dependencies
124    pub fn clear(&mut self) {
125        self.dependencies.clear();
126        self.node_bindings.clear();
127        self.prefix_index.clear();
128    }
129}
130
131impl Default for DependencyGraph {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137/// Check if a dependency path is affected by a state change
138/// Returns true if:
139/// - dep_path == changed_path (exact match)
140/// - dep_path starts with changed_path (parent changed)
141/// - changed_path starts with dep_path (child changed, affects parent)
142fn is_path_affected(dep_path: &str, changed_path: &str) -> bool {
143    if dep_path == changed_path {
144        return true;
145    }
146
147    // If a parent object changed, all its children are affected
148    // E.g., "user" change affects "user.name"
149    if dep_path.starts_with(changed_path) {
150        let remainder = &dep_path[changed_path.len()..];
151        return remainder.starts_with('.');
152    }
153
154    // If a child changed, the parent is affected too
155    // E.g., "user.name" change affects "user"
156    if changed_path.starts_with(dep_path) {
157        let remainder = &changed_path[dep_path.len()..];
158        return remainder.starts_with('.');
159    }
160
161    false
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_path_affected() {
170        assert!(is_path_affected("user", "user"));
171        assert!(is_path_affected("user.name", "user"));
172        assert!(is_path_affected("user", "user.name"));
173        assert!(!is_path_affected("user", "username"));
174        assert!(!is_path_affected("user.name", "user.email"));
175    }
176
177    #[test]
178    fn test_prefix_index_basic() {
179        use super::Binding;
180        use crate::reconcile::tree::InstanceTree;
181        use crate::ir::Element;
182
183        let mut graph = DependencyGraph::new();
184
185        // Create real NodeIds using InstanceTree
186        let mut tree = InstanceTree::new();
187        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
188        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
189        let node3 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
190
191        // Add dependencies
192        let binding1 = Binding::state(vec!["user".to_string()]);
193        let binding2 = Binding::state(vec!["user".to_string(), "name".to_string()]);
194        let binding3 = Binding::state(vec!["user".to_string(), "email".to_string()]);
195
196        graph.add_dependency(node1, &binding1);
197        graph.add_dependency(node2, &binding2);
198        graph.add_dependency(node3, &binding3);
199
200        // Verify prefix index was built correctly
201        assert!(graph.prefix_index.contains_key("user"));
202        assert!(graph.prefix_index.contains_key("user.name"));
203        assert!(graph.prefix_index.contains_key("user.email"));
204
205        // user prefix should contain all three paths
206        let user_paths = graph.prefix_index.get("user").unwrap();
207        assert_eq!(user_paths.len(), 3);
208        assert!(user_paths.contains("user"));
209        assert!(user_paths.contains("user.name"));
210        assert!(user_paths.contains("user.email"));
211    }
212
213    #[test]
214    fn test_get_affected_nodes_with_prefix_index() {
215        use super::Binding;
216        use crate::reconcile::tree::InstanceTree;
217        use crate::ir::Element;
218
219        let mut graph = DependencyGraph::new();
220
221        // Create real NodeIds using InstanceTree
222        let mut tree = InstanceTree::new();
223        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
224        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
225        let node3 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
226        let node4 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
227
228        // Add dependencies
229        graph.add_dependency(node1, &Binding::state(vec!["user".to_string()]));
230        graph.add_dependency(node2, &Binding::state(vec!["user".to_string(), "name".to_string()]));
231        graph.add_dependency(node3, &Binding::state(vec!["user".to_string(), "email".to_string()]));
232        graph.add_dependency(node4, &Binding::state(vec!["posts".to_string()]));
233
234        // When "user" changes, should affect node1, node2, and node3 (but not node4)
235        let affected = graph.get_affected_nodes("user");
236        assert_eq!(affected.len(), 3);
237        assert!(affected.contains(&node1));
238        assert!(affected.contains(&node2));
239        assert!(affected.contains(&node3));
240        assert!(!affected.contains(&node4));
241    }
242
243    #[test]
244    fn test_get_affected_nodes_child_change() {
245        use super::Binding;
246        use crate::reconcile::tree::InstanceTree;
247        use crate::ir::Element;
248
249        let mut graph = DependencyGraph::new();
250
251        // Create real NodeIds using InstanceTree
252        let mut tree = InstanceTree::new();
253        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
254        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
255
256        graph.add_dependency(node1, &Binding::state(vec!["user".to_string()]));
257        graph.add_dependency(node2, &Binding::state(vec!["user".to_string(), "name".to_string()]));
258
259        // When "user.name" changes, should affect both parent "user" and child "user.name"
260        let affected = graph.get_affected_nodes("user.name");
261        assert_eq!(affected.len(), 2);
262        assert!(affected.contains(&node1)); // parent "user" affected
263        assert!(affected.contains(&node2)); // exact match "user.name" affected
264    }
265
266    #[test]
267    fn test_get_affected_nodes_exact_match_only() {
268        use super::Binding;
269        use crate::reconcile::tree::InstanceTree;
270        use crate::ir::Element;
271
272        let mut graph = DependencyGraph::new();
273
274        // Create real NodeId using InstanceTree
275        let mut tree = InstanceTree::new();
276        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
277
278        graph.add_dependency(node1, &Binding::state(vec!["user".to_string(), "name".to_string()]));
279
280        // When "user.email" changes, should not affect "user.name"
281        let affected = graph.get_affected_nodes("user.email");
282        assert_eq!(affected.len(), 0);
283    }
284
285    #[test]
286    fn test_prefix_index_performance() {
287        use super::Binding;
288        use crate::reconcile::tree::InstanceTree;
289        use crate::ir::Element;
290
291        let mut graph = DependencyGraph::new();
292        let mut tree = InstanceTree::new();
293
294        // Add many dependencies with nested paths
295        for i in 0..100 {
296            let node_id = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
297            let path = vec![
298                "user".to_string(),
299                "profile".to_string(),
300                "details".to_string(),
301                format!("field{}", i),
302            ];
303            let binding = Binding::state(path);
304            graph.add_dependency(node_id, &binding);
305        }
306
307        // Should efficiently find all affected nodes when parent changes
308        let affected = graph.get_affected_nodes("user");
309        assert_eq!(affected.len(), 100);
310
311        let affected = graph.get_affected_nodes("user.profile");
312        assert_eq!(affected.len(), 100);
313
314        let affected = graph.get_affected_nodes("user.profile.details");
315        assert_eq!(affected.len(), 100);
316    }
317
318    #[test]
319    fn test_clear_clears_prefix_index() {
320        use super::Binding;
321        use crate::reconcile::tree::InstanceTree;
322        use crate::ir::Element;
323
324        let mut graph = DependencyGraph::new();
325
326        // Create real NodeId using InstanceTree
327        let mut tree = InstanceTree::new();
328        let node = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
329
330        graph.add_dependency(node, &Binding::state(vec!["user".to_string(), "name".to_string()]));
331        assert!(!graph.prefix_index.is_empty());
332
333        graph.clear();
334        assert!(graph.prefix_index.is_empty());
335        assert!(graph.dependencies.is_empty());
336        assert!(graph.node_bindings.is_empty());
337    }
338}