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