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}