Skip to main content

hypen_engine/ir/
discover.rs

1//! Route discovery — walk an IR tree and enumerate every `Router { Route … }`
2//! block with the element names that live inside each route's body.
3//!
4//! Used by the server SDKs to auto-wire a per-session `ManagedRouter`
5//! without the example having to call `managed.addRoute(...)` for every
6//! route. The SDK cross-references the BFS-ordered `element_names` list
7//! against its `HypenApp` registry to pick the component to mount for
8//! each path — first name that's a registered module wins, so templates
9//! like `Route("/") { Column { HomePage() BottomNav() } }` still work
10//! (HomePage is registered; Column / BottomNav are not).
11//!
12//! This helper walks the IR as-is: it does **not** trigger component
13//! resolution. For nested routers (a Router inside another component's
14//! template), call this again on the nested module's IR when that
15//! module mounts — the walker returns every Router it finds, including
16//! those scoped under `module_scope` markers, so the SDK can key nested
17//! wirings by scope.
18
19use super::{ConditionalBranch, IRNode, RouterRoute};
20use serde::{Deserialize, Serialize};
21
22/// One route inside a discovered Router, with the ordered list of
23/// element names found inside the route body.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct DiscoveredRoute {
26    /// The URL pattern on the Route (e.g. `/`, `/user-profile/:id`).
27    pub path: String,
28    /// BFS-ordered element names inside this route's body. Wrapper
29    /// elements (Column, Row, …) come before the actual component,
30    /// so the SDK should scan until it finds a name that matches a
31    /// registered `HypenApp` module.
32    pub element_names: Vec<String>,
33}
34
35/// A `Router { ... }` block found in the IR.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct DiscoveredRouter {
38    /// The module scope this Router lives under, if any. `None` means
39    /// it was found at the document root (the primary module's
40    /// template). Nested Routers inside `module Foo { ... }` carry
41    /// `Some("foo")`.
42    pub module_scope: Option<String>,
43    pub routes: Vec<DiscoveredRoute>,
44}
45
46/// Walk an IR tree and return every `Router { Route … }` block it contains.
47///
48/// Discovery order is depth-first; nested routers appear after their
49/// enclosing router. Empty routers (no routes) are still included.
50pub fn discover_routers(ir: &IRNode) -> Vec<DiscoveredRouter> {
51    let mut out = Vec::new();
52    walk(ir, &mut out);
53    out
54}
55
56fn walk(ir: &IRNode, out: &mut Vec<DiscoveredRouter>) {
57    match ir {
58        IRNode::Router {
59            routes,
60            fallback,
61            module_scope,
62            ..
63        } => {
64            let mut discovered = DiscoveredRouter {
65                module_scope: module_scope.clone(),
66                routes: Vec::with_capacity(routes.len()),
67            };
68            for route in routes {
69                discovered.routes.push(DiscoveredRoute {
70                    path: route.path.clone(),
71                    element_names: collect_element_names(&route.children),
72                });
73            }
74            out.push(discovered);
75
76            // Recurse — a route body can host another Router block, and
77            // fallback children can too. Nested discoveries need to
78            // appear as separate DiscoveredRouter entries so the SDK
79            // can wire them as nested managed routers.
80            for route in routes {
81                for child in &route.children {
82                    walk(child, out);
83                }
84            }
85            if let Some(fb) = fallback {
86                for child in fb {
87                    walk(child, out);
88                }
89            }
90        }
91        IRNode::Element(el) => {
92            for child in &el.ir_children {
93                walk(child, out);
94            }
95        }
96        IRNode::ForEach { template, .. } => {
97            for child in template {
98                walk(child, out);
99            }
100        }
101        IRNode::Conditional {
102            branches, fallback, ..
103        } => {
104            for branch in branches {
105                for child in &branch.children {
106                    walk(child, out);
107                }
108            }
109            if let Some(fb) = fallback {
110                for child in fb {
111                    walk(child, out);
112                }
113            }
114        }
115    }
116}
117
118/// BFS-ordered collection of every Element name reachable from the
119/// supplied node list, *stopping* at the first match for each branch
120/// so the scan mirrors the DSL's top-down reading order. ForEach /
121/// Conditional / Router children are included at their natural depth.
122fn collect_element_names(children: &[IRNode]) -> Vec<String> {
123    let mut names = Vec::new();
124    let mut queue: std::collections::VecDeque<&IRNode> =
125        children.iter().collect();
126    while let Some(node) = queue.pop_front() {
127        match node {
128            IRNode::Element(el) => {
129                names.push(el.element_type.clone());
130                for child in &el.ir_children {
131                    queue.push_back(child);
132                }
133            }
134            IRNode::ForEach { template, .. } => {
135                for child in template {
136                    queue.push_back(child);
137                }
138            }
139            IRNode::Conditional {
140                branches, fallback, ..
141            } => {
142                for ConditionalBranch { children, .. } in branches {
143                    for child in children {
144                        queue.push_back(child);
145                    }
146                }
147                if let Some(fb) = fallback {
148                    for child in fb {
149                        queue.push_back(child);
150                    }
151                }
152            }
153            IRNode::Router { routes, fallback, .. } => {
154                for RouterRoute { children, .. } in routes {
155                    for child in children {
156                        queue.push_back(child);
157                    }
158                }
159                if let Some(fb) = fallback {
160                    for child in fb {
161                        queue.push_back(child);
162                    }
163                }
164            }
165        }
166    }
167    names
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::ir::ast_to_ir_node;
174
175    fn parse(source: &str) -> IRNode {
176        let doc = hypen_parser::parse_document(source).expect("parse");
177        let component = doc.components.first().expect("has component");
178        ast_to_ir_node(component)
179    }
180
181    #[test]
182    fn discovers_flat_router() {
183        let ir = parse(
184            r#"
185            module App {
186                Router {
187                    Route(path: "/") { HomePage() }
188                    Route(path: "/search") { Search() }
189                }
190            }
191            "#,
192        );
193        let routers = discover_routers(&ir);
194        assert_eq!(routers.len(), 1);
195        let r = &routers[0];
196        assert_eq!(r.module_scope.as_deref(), Some("app"));
197        assert_eq!(r.routes.len(), 2);
198        assert_eq!(r.routes[0].path, "/");
199        assert_eq!(r.routes[0].element_names, vec!["HomePage"]);
200        assert_eq!(r.routes[1].path, "/search");
201        assert_eq!(r.routes[1].element_names, vec!["Search"]);
202    }
203
204    #[test]
205    fn discovers_component_through_wrappers() {
206        // The Social pattern: Column wrapping the real component.
207        let ir = parse(
208            r#"
209            module App {
210                Router {
211                    Route(path: "/") {
212                        Column {
213                            HomePage()
214                            BottomNav()
215                        }
216                    }
217                }
218            }
219            "#,
220        );
221        let routers = discover_routers(&ir);
222        assert_eq!(routers.len(), 1);
223        // BFS: Column first, then HomePage + BottomNav. Consumer picks
224        // the first one registered as a module.
225        assert_eq!(
226            routers[0].routes[0].element_names,
227            vec!["Column", "HomePage", "BottomNav"]
228        );
229    }
230
231    #[test]
232    fn discovers_route_params() {
233        let ir = parse(
234            r#"
235            module App {
236                Router {
237                    Route(path: "/user-profile/:id") { UserProfile() }
238                    Route(path: "/comments/:postId") { Comments() }
239                }
240            }
241            "#,
242        );
243        let routers = discover_routers(&ir);
244        assert_eq!(routers[0].routes[0].path, "/user-profile/:id");
245        assert_eq!(routers[0].routes[1].path, "/comments/:postId");
246    }
247
248    #[test]
249    fn discovers_nested_routers() {
250        // Pattern the user called out: an outer Router with a Route
251        // whose body itself contains a Router. We emit both.
252        let ir = parse(
253            r#"
254            module App {
255                Router {
256                    Route(path: "/") {
257                        Column {
258                            Home()
259                            Router {
260                                Route(path: "/") { Feed() }
261                                Route(path: "/explore") { Explore() }
262                            }
263                        }
264                    }
265                }
266            }
267            "#,
268        );
269        let routers = discover_routers(&ir);
270        assert_eq!(routers.len(), 2);
271        assert_eq!(routers[0].routes[0].path, "/");
272        assert_eq!(routers[1].routes.len(), 2);
273        assert_eq!(routers[1].routes[0].path, "/");
274        assert_eq!(routers[1].routes[1].path, "/explore");
275    }
276
277    #[test]
278    fn no_routers_returns_empty() {
279        let ir = parse(
280            r#"
281            module App {
282                Column {
283                    Text("No routes here")
284                    Button("Click me")
285                }
286            }
287            "#,
288        );
289        assert!(discover_routers(&ir).is_empty());
290    }
291}