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}