Skip to main content

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    /// Known data source provider names (registered via `register_data_source_provider`).
20    /// When a `DataSource` binding references an unknown provider, it is tracked here
21    /// so the host can emit warnings. This prevents typos like `${username.field}` from
22    /// silently resolving to null instead of erroring.
23    registered_providers: IndexSet<String>,
24
25    /// Data source providers referenced in bindings but not yet registered.
26    /// The host can query this after a render pass to warn about potential typos.
27    unregistered_provider_refs: IndexSet<String>,
28}
29
30impl DependencyGraph {
31    pub fn new() -> Self {
32        Self {
33            dependencies: IndexMap::new(),
34            node_bindings: IndexMap::new(),
35            prefix_index: BTreeMap::new(),
36            registered_providers: IndexSet::new(),
37            unregistered_provider_refs: IndexSet::new(),
38        }
39    }
40
41    /// Record that a node depends on a binding.
42    /// State bindings use their path directly (e.g., "user.name").
43    /// Data source bindings are namespaced as "ds:provider:path" (e.g., "ds:spacetime:messages").
44    /// Item bindings are skipped (handled by ForEach scope).
45    pub fn add_dependency(&mut self, node_id: NodeId, binding: &Binding) {
46        use super::BindingSource;
47        let path = match &binding.source {
48            BindingSource::State => binding.full_path(),
49            BindingSource::Item => return, // Item deps handled by ForEach scope
50            BindingSource::DataSource(provider) => {
51                // Track references to unregistered providers so the host can warn
52                if !self.registered_providers.contains(provider.as_str()) {
53                    self.unregistered_provider_refs.insert(provider.clone());
54                }
55                format!("ds:{}:{}", provider, binding.full_path())
56            }
57        };
58
59        // Add to dependencies map
60        self.dependencies
61            .entry(path.clone())
62            .or_default()
63            .insert(node_id);
64
65        // Add to node_bindings map
66        self.node_bindings
67            .entry(node_id)
68            .or_default()
69            .insert(path.clone());
70
71        // Update prefix index for efficient lookups
72        // Add all prefixes of this path to the index
73        self.add_path_to_prefix_index(&path);
74    }
75
76    /// Add a path and all its prefixes to the prefix index
77    fn add_path_to_prefix_index(&mut self, path: &str) {
78        // Add the full path to its own prefix
79        self.prefix_index
80            .entry(path.to_string())
81            .or_default()
82            .insert(path.to_string());
83
84        // Add all parent prefixes
85        let mut current = path;
86        while let Some(dot_pos) = current.rfind('.') {
87            current = &current[..dot_pos];
88            self.prefix_index
89                .entry(current.to_string())
90                .or_default()
91                .insert(path.to_string());
92        }
93    }
94
95    /// Remove all dependencies for a node (when unmounting)
96    pub fn remove_node(&mut self, node_id: NodeId) {
97        if let Some(paths) = self.node_bindings.shift_remove(&node_id) {
98            for path in paths {
99                if let Some(nodes) = self.dependencies.get_mut(&path) {
100                    nodes.shift_remove(&node_id);
101                }
102            }
103        }
104    }
105
106    /// Get all nodes that depend on a specific state path
107    pub fn get_dependent_nodes(&self, path: &str) -> Option<&IndexSet<NodeId>> {
108        self.dependencies.get(path)
109    }
110
111    /// Get all nodes affected by a state patch (considers nested paths)
112    /// E.g., if "user" changes, both "user" and "user.name" subscribers are affected
113    /// Uses prefix index for O(log n) lookup instead of O(n) scan
114    pub fn get_affected_nodes(&self, changed_path: &str) -> IndexSet<NodeId> {
115        let mut affected = IndexSet::new();
116        let mut affected_paths = IndexSet::new();
117
118        // Strategy: Find all paths that could be affected by this change
119        // 1. Exact match: changed_path itself
120        if self.dependencies.contains_key(changed_path) {
121            affected_paths.insert(changed_path.to_string());
122        }
123
124        // 2. Parent paths: all prefixes of changed_path (e.g., "user.name" affects "user")
125        let mut current = changed_path;
126        while let Some(dot_pos) = current.rfind('.') {
127            current = &current[..dot_pos];
128            if self.dependencies.contains_key(current) {
129                affected_paths.insert(current.to_string());
130            }
131        }
132
133        // 3. Child paths: all paths that start with changed_path (e.g., "user" affects "user.name")
134        // Use prefix index for efficient lookup
135        if let Some(child_paths) = self.prefix_index.get(changed_path) {
136            affected_paths.extend(child_paths.iter().cloned());
137        }
138
139        // Collect nodes from all affected paths
140        for path in affected_paths {
141            if let Some(nodes) = self.dependencies.get(&path) {
142                affected.extend(nodes.iter().copied());
143            }
144        }
145
146        affected
147    }
148
149    /// Register a data source provider name so that bindings to it
150    /// are treated as intentional (not typos).
151    pub fn register_data_source_provider(&mut self, name: impl Into<String>) {
152        let name = name.into();
153        self.registered_providers.insert(name.clone());
154        // If this provider was previously flagged as unregistered, remove it
155        self.unregistered_provider_refs.shift_remove(&name);
156    }
157
158    /// Get data source providers referenced in bindings but not registered.
159    /// The host should warn about these — they likely represent typos
160    /// (e.g., `${username.field}` when the dev meant `${state.username.field}`).
161    pub fn unregistered_provider_refs(&self) -> &IndexSet<String> {
162        &self.unregistered_provider_refs
163    }
164
165    /// Get all nodes bound to a data source provider.
166    ///
167    /// The prefix index uses `.` separators but data source paths use `:`
168    /// between the `ds`, provider, and root key (e.g., `ds:spacetime:messages`).
169    /// This method handles the `:` boundary correctly by scanning dependency paths.
170    pub fn get_data_source_affected_nodes(&self, provider: &str) -> IndexSet<NodeId> {
171        let prefix = format!("ds:{}:", provider);
172        let root = format!("ds:{}", provider);
173        let mut affected = IndexSet::new();
174
175        for (path, nodes) in &self.dependencies {
176            if path == &root || path.starts_with(&prefix) {
177                affected.extend(nodes.iter().copied());
178            }
179        }
180
181        affected
182    }
183
184    /// Clear all dependencies (preserves registered providers)
185    pub fn clear(&mut self) {
186        self.dependencies.clear();
187        self.node_bindings.clear();
188        self.prefix_index.clear();
189        self.unregistered_provider_refs.clear();
190    }
191}
192
193impl Default for DependencyGraph {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_prefix_index_basic() {
205        use super::Binding;
206        use crate::ir::Element;
207        use crate::reconcile::tree::InstanceTree;
208
209        let mut graph = DependencyGraph::new();
210
211        // Create real NodeIds using InstanceTree
212        let mut tree = InstanceTree::new();
213        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
214        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
215        let node3 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
216
217        // Add dependencies
218        let binding1 = Binding::state(vec!["user".to_string()]);
219        let binding2 = Binding::state(vec!["user".to_string(), "name".to_string()]);
220        let binding3 = Binding::state(vec!["user".to_string(), "email".to_string()]);
221
222        graph.add_dependency(node1, &binding1);
223        graph.add_dependency(node2, &binding2);
224        graph.add_dependency(node3, &binding3);
225
226        // Verify prefix index was built correctly
227        assert!(graph.prefix_index.contains_key("user"));
228        assert!(graph.prefix_index.contains_key("user.name"));
229        assert!(graph.prefix_index.contains_key("user.email"));
230
231        // user prefix should contain all three paths
232        let user_paths = graph.prefix_index.get("user").unwrap();
233        assert_eq!(user_paths.len(), 3);
234        assert!(user_paths.contains("user"));
235        assert!(user_paths.contains("user.name"));
236        assert!(user_paths.contains("user.email"));
237    }
238
239    #[test]
240    fn test_get_affected_nodes_with_prefix_index() {
241        use super::Binding;
242        use crate::ir::Element;
243        use crate::reconcile::tree::InstanceTree;
244
245        let mut graph = DependencyGraph::new();
246
247        // Create real NodeIds using InstanceTree
248        let mut tree = InstanceTree::new();
249        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
250        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
251        let node3 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
252        let node4 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
253
254        // Add dependencies
255        graph.add_dependency(node1, &Binding::state(vec!["user".to_string()]));
256        graph.add_dependency(
257            node2,
258            &Binding::state(vec!["user".to_string(), "name".to_string()]),
259        );
260        graph.add_dependency(
261            node3,
262            &Binding::state(vec!["user".to_string(), "email".to_string()]),
263        );
264        graph.add_dependency(node4, &Binding::state(vec!["posts".to_string()]));
265
266        // When "user" changes, should affect node1, node2, and node3 (but not node4)
267        let affected = graph.get_affected_nodes("user");
268        assert_eq!(affected.len(), 3);
269        assert!(affected.contains(&node1));
270        assert!(affected.contains(&node2));
271        assert!(affected.contains(&node3));
272        assert!(!affected.contains(&node4));
273    }
274
275    #[test]
276    fn test_get_affected_nodes_child_change() {
277        use super::Binding;
278        use crate::ir::Element;
279        use crate::reconcile::tree::InstanceTree;
280
281        let mut graph = DependencyGraph::new();
282
283        // Create real NodeIds using InstanceTree
284        let mut tree = InstanceTree::new();
285        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
286        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
287
288        graph.add_dependency(node1, &Binding::state(vec!["user".to_string()]));
289        graph.add_dependency(
290            node2,
291            &Binding::state(vec!["user".to_string(), "name".to_string()]),
292        );
293
294        // When "user.name" changes, should affect both parent "user" and child "user.name"
295        let affected = graph.get_affected_nodes("user.name");
296        assert_eq!(affected.len(), 2);
297        assert!(affected.contains(&node1)); // parent "user" affected
298        assert!(affected.contains(&node2)); // exact match "user.name" affected
299    }
300
301    #[test]
302    fn test_get_affected_nodes_exact_match_only() {
303        use super::Binding;
304        use crate::ir::Element;
305        use crate::reconcile::tree::InstanceTree;
306
307        let mut graph = DependencyGraph::new();
308
309        // Create real NodeId using InstanceTree
310        let mut tree = InstanceTree::new();
311        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
312
313        graph.add_dependency(
314            node1,
315            &Binding::state(vec!["user".to_string(), "name".to_string()]),
316        );
317
318        // When "user.email" changes, should not affect "user.name"
319        let affected = graph.get_affected_nodes("user.email");
320        assert_eq!(affected.len(), 0);
321    }
322
323    #[test]
324    fn test_prefix_index_performance() {
325        use super::Binding;
326        use crate::ir::Element;
327        use crate::reconcile::tree::InstanceTree;
328
329        let mut graph = DependencyGraph::new();
330        let mut tree = InstanceTree::new();
331
332        // Add many dependencies with nested paths
333        for i in 0..100 {
334            let node_id = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
335            let path = vec![
336                "user".to_string(),
337                "profile".to_string(),
338                "details".to_string(),
339                format!("field{}", i),
340            ];
341            let binding = Binding::state(path);
342            graph.add_dependency(node_id, &binding);
343        }
344
345        // Should efficiently find all affected nodes when parent changes
346        let affected = graph.get_affected_nodes("user");
347        assert_eq!(affected.len(), 100);
348
349        let affected = graph.get_affected_nodes("user.profile");
350        assert_eq!(affected.len(), 100);
351
352        let affected = graph.get_affected_nodes("user.profile.details");
353        assert_eq!(affected.len(), 100);
354    }
355
356    #[test]
357    fn test_clear_clears_prefix_index() {
358        use super::Binding;
359        use crate::ir::Element;
360        use crate::reconcile::tree::InstanceTree;
361
362        let mut graph = DependencyGraph::new();
363
364        // Create real NodeId using InstanceTree
365        let mut tree = InstanceTree::new();
366        let node = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
367
368        graph.add_dependency(
369            node,
370            &Binding::state(vec!["user".to_string(), "name".to_string()]),
371        );
372        assert!(!graph.prefix_index.is_empty());
373
374        graph.clear();
375        assert!(graph.prefix_index.is_empty());
376        assert!(graph.dependencies.is_empty());
377        assert!(graph.node_bindings.is_empty());
378    }
379
380    #[test]
381    fn test_get_data_source_affected_nodes() {
382        use super::Binding;
383        use crate::ir::Element;
384        use crate::reconcile::tree::InstanceTree;
385
386        let mut graph = DependencyGraph::new();
387        let mut tree = InstanceTree::new();
388
389        let node1 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
390        let node2 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
391        let node3 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
392        let node4 = tree.create_node(&Element::new("Text"), &serde_json::json!({}));
393
394        // Bind to various data source paths
395        graph.add_dependency(
396            node1,
397            &Binding::data_source("spacetime", vec!["messages".to_string()]),
398        );
399        graph.add_dependency(
400            node2,
401            &Binding::data_source(
402                "spacetime",
403                vec!["users".to_string(), "name".to_string()],
404            ),
405        );
406        graph.add_dependency(
407            node3,
408            &Binding::data_source("firebase", vec!["docs".to_string()]),
409        );
410        // node4 bound to state (not data source)
411        graph.add_dependency(node4, &Binding::state(vec!["count".to_string()]));
412
413        // get_data_source_affected_nodes should find all spacetime nodes
414        let affected = graph.get_data_source_affected_nodes("spacetime");
415        assert_eq!(affected.len(), 2);
416        assert!(affected.contains(&node1));
417        assert!(affected.contains(&node2));
418        assert!(!affected.contains(&node3)); // firebase, not spacetime
419        assert!(!affected.contains(&node4)); // state, not data source
420
421        // Verify the regular get_affected_nodes misses ds:spacetime children
422        // (because : separators aren't in the prefix index)
423        let affected_old = graph.get_affected_nodes("ds:spacetime");
424        assert_eq!(
425            affected_old.len(),
426            0,
427            "get_affected_nodes should miss colon-separated children"
428        );
429    }
430}