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> = children.iter().collect();
125    while let Some(node) = queue.pop_front() {
126        match node {
127            IRNode::Element(el) => {
128                names.push(el.element_type.clone());
129                for child in &el.ir_children {
130                    queue.push_back(child);
131                }
132            }
133            IRNode::ForEach { template, .. } => {
134                for child in template {
135                    queue.push_back(child);
136                }
137            }
138            IRNode::Conditional {
139                branches, fallback, ..
140            } => {
141                for ConditionalBranch { children, .. } in branches {
142                    for child in children {
143                        queue.push_back(child);
144                    }
145                }
146                if let Some(fb) = fallback {
147                    for child in fb {
148                        queue.push_back(child);
149                    }
150                }
151            }
152            IRNode::Router {
153                routes, fallback, ..
154            } => {
155                for RouterRoute { children, .. } in routes {
156                    for child in children {
157                        queue.push_back(child);
158                    }
159                }
160                if let Some(fb) = fallback {
161                    for child in fb {
162                        queue.push_back(child);
163                    }
164                }
165            }
166        }
167    }
168    names
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::ir::ast_to_ir_node;
175
176    fn parse(source: &str) -> IRNode {
177        let doc = hypen_parser::parse_document(source).expect("parse");
178        let component = doc.components.first().expect("has component");
179        ast_to_ir_node(component)
180    }
181
182    #[test]
183    fn discovers_flat_router() {
184        let ir = parse(
185            r#"
186            module App {
187                Router {
188                    Route(path: "/") { HomePage() }
189                    Route(path: "/search") { Search() }
190                }
191            }
192            "#,
193        );
194        let routers = discover_routers(&ir);
195        assert_eq!(routers.len(), 1);
196        let r = &routers[0];
197        assert_eq!(r.module_scope.as_deref(), Some("app"));
198        assert_eq!(r.routes.len(), 2);
199        assert_eq!(r.routes[0].path, "/");
200        assert_eq!(r.routes[0].element_names, vec!["HomePage"]);
201        assert_eq!(r.routes[1].path, "/search");
202        assert_eq!(r.routes[1].element_names, vec!["Search"]);
203    }
204
205    #[test]
206    fn discovers_component_through_wrappers() {
207        // The Social pattern: Column wrapping the real component.
208        let ir = parse(
209            r#"
210            module App {
211                Router {
212                    Route(path: "/") {
213                        Column {
214                            HomePage()
215                            BottomNav()
216                        }
217                    }
218                }
219            }
220            "#,
221        );
222        let routers = discover_routers(&ir);
223        assert_eq!(routers.len(), 1);
224        // BFS: Column first, then HomePage + BottomNav. Consumer picks
225        // the first one registered as a module.
226        assert_eq!(
227            routers[0].routes[0].element_names,
228            vec!["Column", "HomePage", "BottomNav"]
229        );
230    }
231
232    #[test]
233    fn discovers_route_params() {
234        let ir = parse(
235            r#"
236            module App {
237                Router {
238                    Route(path: "/user-profile/:id") { UserProfile() }
239                    Route(path: "/comments/:postId") { Comments() }
240                }
241            }
242            "#,
243        );
244        let routers = discover_routers(&ir);
245        assert_eq!(routers[0].routes[0].path, "/user-profile/:id");
246        assert_eq!(routers[0].routes[1].path, "/comments/:postId");
247    }
248
249    #[test]
250    fn discovers_nested_routers() {
251        // Pattern the user called out: an outer Router with a Route
252        // whose body itself contains a Router. We emit both.
253        let ir = parse(
254            r#"
255            module App {
256                Router {
257                    Route(path: "/") {
258                        Column {
259                            Home()
260                            Router {
261                                Route(path: "/") { Feed() }
262                                Route(path: "/explore") { Explore() }
263                            }
264                        }
265                    }
266                }
267            }
268            "#,
269        );
270        let routers = discover_routers(&ir);
271        assert_eq!(routers.len(), 2);
272        assert_eq!(routers[0].routes[0].path, "/");
273        assert_eq!(routers[1].routes.len(), 2);
274        assert_eq!(routers[1].routes[0].path, "/");
275        assert_eq!(routers[1].routes[1].path, "/explore");
276    }
277
278    #[test]
279    fn no_routers_returns_empty() {
280        let ir = parse(
281            r#"
282            module App {
283                Column {
284                    Text("No routes here")
285                    Button("Click me")
286                }
287            }
288            "#,
289        );
290        assert!(discover_routers(&ir).is_empty());
291    }
292}