1use crate::application_semantic_model::ApplicationSemanticModel;
2use crate::semantic_id::SemanticId;
3use crate::{
4 build_application_publication_product_v1, validate_application_publication_request_v1,
5 ApplicationPublicationErrorV1, ApplicationPublicationProductV1,
6 ApplicationPublicationRequestV1,
7};
8use serde::Serialize;
9use std::collections::BTreeMap;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RouteGraph {
13 pub routes: Vec<RouteNode>,
14}
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RouteNode {
17 pub path: String,
18 pub component: SemanticId,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct FileRouteGraphV1 {
28 pub routes: Vec<FileRouteNodeV1>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FileRouteNodeV1 {
33 pub path: String,
34 pub component: SemanticId,
35 pub layouts: Vec<SemanticId>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RouteGraphError {
41 pub code: &'static str,
42 pub message: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct RouteManifestV1 {
47 pub schema_version: u32,
48 pub routes: Vec<RouteManifestEntryV1>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RouteManifestEntryV1 {
53 pub path: String,
54 pub component_id: String,
55 pub artifact_root: String,
56 pub parent_path: Option<String>,
57}
58
59#[must_use]
60pub fn build_route_graph(model: &ApplicationSemanticModel) -> RouteGraph {
61 RouteGraph {
62 routes: model
63 .components
64 .iter()
65 .filter_map(|component| {
66 component.route_path.as_ref().map(|path| RouteNode {
67 path: path.clone(),
68 component: component.id.clone(),
69 })
70 })
71 .collect(),
72 }
73}
74
75#[must_use]
78pub fn build_file_route_graph_v1(model: &ApplicationSemanticModel) -> RouteGraph {
79 RouteGraph {
80 routes: model
81 .components
82 .iter()
83 .filter_map(|component| {
84 component
85 .route_path
86 .clone()
87 .or_else(|| file_route_path(&component.module_path))
88 .map(|path| RouteNode {
89 path,
90 component: component.id.clone(),
91 })
92 })
93 .collect(),
94 }
95}
96
97#[must_use]
101pub fn build_file_route_graph_from_components_v1(
102 components: &[crate::ComponentNode],
103) -> RouteGraph {
104 RouteGraph {
105 routes: components
106 .iter()
107 .filter_map(|component| {
108 component
109 .route_path
110 .clone()
111 .or_else(|| file_route_path(&component.module_path))
112 .map(|path| RouteNode {
113 path,
114 component: component.id.clone(),
115 })
116 })
117 .collect(),
118 }
119}
120
121pub fn build_validated_file_route_graph_v1(
133 model: &ApplicationSemanticModel,
134) -> Result<FileRouteGraphV1, RouteGraphError> {
135 build_validated_file_route_graph_from_components_v1(&model.components)
136}
137
138pub fn build_validated_file_route_graph_from_components_v1(
150 components: &[crate::ComponentNode],
151) -> Result<FileRouteGraphV1, RouteGraphError> {
152 let mut layouts = BTreeMap::<Vec<String>, SemanticId>::new();
153 for component in components {
154 let Some(scope) = file_layout_scope(&component.module_path) else {
155 continue;
156 };
157 let scope = normalize_route_scope(scope);
158 if component.route_path.is_some() {
159 return Err(RouteGraphError {
160 code: "PSROUTE1010_LAYOUT_CANNOT_DECLARE_ROUTE",
161 message: format!(
162 "layout component `{}` must not declare @route()",
163 component.id
164 ),
165 });
166 }
167 if layouts
168 .insert(scope.clone(), component.id.clone())
169 .is_some()
170 {
171 return Err(RouteGraphError {
172 code: "PSROUTE1011_LAYOUT_COMPONENT_AMBIGUOUS",
173 message: format!(
174 "layout scope `{}` must declare exactly one component",
175 route_scope_display(&scope)
176 ),
177 });
178 }
179 }
180
181 let mut routes = Vec::new();
182 for component in components {
183 if file_layout_scope(&component.module_path).is_some() {
184 continue;
185 }
186 let Some(file_path) = file_route_path(&component.module_path) else {
187 continue;
188 };
189 let path = component.route_path.clone().unwrap_or(file_path);
190 if !is_file_route_path(&path) {
191 return Err(RouteGraphError {
192 code: "PSROUTE1012_INVALID_FILE_ROUTE_PATH",
193 message: format!("route path `{path}` is not a supported file-route path"),
194 });
195 }
196 let layouts = layout_chain_for_route(&path, &layouts);
197 routes.push(FileRouteNodeV1 {
198 path,
199 component: component.id.clone(),
200 layouts,
201 });
202 }
203 routes.sort_by(|left, right| {
204 route_match_shape(&left.path)
205 .cmp(&route_match_shape(&right.path))
206 .then_with(|| left.path.cmp(&right.path))
207 .then_with(|| left.component.cmp(&right.component))
208 });
209 for pair in routes.windows(2) {
210 if route_match_shape(&pair[0].path) == route_match_shape(&pair[1].path) {
211 return Err(RouteGraphError {
212 code: "PSROUTE1013_FILE_ROUTE_CONFLICT",
213 message: format!(
214 "routes `{}` and `{}` match the same request path",
215 pair[0].path, pair[1].path
216 ),
217 });
218 }
219 }
220 Ok(FileRouteGraphV1 { routes })
221}
222
223pub fn build_validated_route_graph_v1(
229 model: &ApplicationSemanticModel,
230) -> Result<RouteGraph, RouteGraphError> {
231 let mut graph = build_route_graph(model);
232 graph
233 .routes
234 .sort_by(|left, right| left.path.cmp(&right.path));
235 for route in &graph.routes {
236 if !is_static_route_path(&route.path) {
237 return Err(RouteGraphError {
238 code: "PSROUTE1001_INVALID_STATIC_ROUTE_PATH",
239 message: format!(
240 "route path `{}` must begin with / and contain no dynamic segments",
241 route.path
242 ),
243 });
244 }
245 }
246 if graph
247 .routes
248 .windows(2)
249 .any(|pair| pair[0].path == pair[1].path)
250 {
251 return Err(RouteGraphError {
252 code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
253 message: "each static route path must identify exactly one component".into(),
254 });
255 }
256 Ok(graph)
257}
258
259#[must_use]
260pub fn route_manifest_v1(graph: &RouteGraph) -> RouteManifestV1 {
261 RouteManifestV1 {
262 schema_version: 1,
263 routes: graph
264 .routes
265 .iter()
266 .map(|route| RouteManifestEntryV1 {
267 path: route.path.clone(),
268 component_id: route.component.to_string(),
269 artifact_root: route_artifact_root(&route.path),
270 parent_path: route_parent_path(&route.path),
271 })
272 .collect(),
273 }
274}
275
276pub fn build_static_route_publication_v1(
284 requests: Vec<ApplicationPublicationRequestV1>,
285) -> Result<(RouteManifestV1, BTreeMap<std::path::PathBuf, Vec<u8>>), RouteGraphError> {
286 if requests.is_empty() {
287 return Err(RouteGraphError {
288 code: "PSROUTE1003_EMPTY_ROUTE_SET",
289 message: "static route publication requires explicit route entries".into(),
290 });
291 }
292 let mut artifacts = BTreeMap::new();
293 let mut routes = Vec::new();
294 for request in requests {
295 let validated =
296 validate_application_publication_request_v1(request).map_err(route_request_error)?;
297 let model = crate::build_application_semantic_model_for_unit_with_packages(
298 &validated.unit,
299 &validated.request.package_contracts,
300 );
301 let graph = build_validated_route_graph_v1(&model)?;
302 let route = graph
303 .routes
304 .iter()
305 .find(|route| route.component == validated.entry_component)
306 .ok_or_else(|| RouteGraphError {
307 code: "PSROUTE1004_ENTRY_ROUTE_MISSING",
308 message: "each route publication entry must declare one matching @route path"
309 .into(),
310 })?;
311 let root = route_artifact_root(&route.path);
312 if routes
313 .iter()
314 .any(|entry: &RouteManifestEntryV1| entry.path == route.path)
315 {
316 return Err(RouteGraphError {
317 code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
318 message: "each static route path must identify exactly one component".into(),
319 });
320 }
321 let product =
322 build_application_publication_product_v1(validated).map_err(route_product_error)?;
323 insert_route_product(&mut artifacts, &root, product)?;
324 routes.push(RouteManifestEntryV1 {
325 path: route.path.clone(),
326 component_id: route.component.to_string(),
327 artifact_root: root,
328 parent_path: route_parent_path(&route.path),
329 });
330 }
331 routes.sort_by(|left, right| left.path.cmp(&right.path));
332 let manifest = RouteManifestV1 {
333 schema_version: 1,
334 routes,
335 };
336 artifacts.insert(
337 std::path::PathBuf::from("routes.manifest.json"),
338 route_manifest_json_v1(&manifest).into_bytes(),
339 );
340 Ok((manifest, artifacts))
341}
342
343fn insert_route_product(
344 artifacts: &mut BTreeMap<std::path::PathBuf, Vec<u8>>,
345 root: &str,
346 product: ApplicationPublicationProductV1,
347) -> Result<(), RouteGraphError> {
348 for (path, bytes) in product.artifacts {
349 let path = std::path::PathBuf::from(root).join(path);
350 if artifacts.insert(path.clone(), bytes).is_some() {
351 return Err(RouteGraphError {
352 code: "PSROUTE1005_ARTIFACT_PATH_COLLISION",
353 message: format!(
354 "route publication generated colliding artifact {}",
355 path.display()
356 ),
357 });
358 }
359 }
360 Ok(())
361}
362
363fn route_request_error(error: crate::ApplicationPublicationRequestErrorV1) -> RouteGraphError {
364 RouteGraphError {
365 code: error.code,
366 message: error.message,
367 }
368}
369fn route_product_error(error: ApplicationPublicationErrorV1) -> RouteGraphError {
370 RouteGraphError {
371 code: error.code,
372 message: error.message,
373 }
374}
375fn route_artifact_root(path: &str) -> String {
376 if path == "/" {
377 return "routes/root".into();
378 }
379 format!("routes/{}", path.trim_matches('/').replace('/', "__"))
380}
381fn route_parent_path(path: &str) -> Option<String> {
382 if path == "/" {
383 return None;
384 }
385 let trimmed = path.trim_matches('/');
386 let parent = trimmed.rsplit_once('/').map_or("", |(parent, _)| parent);
387 Some(if parent.is_empty() {
388 "/".into()
389 } else {
390 format!("/{parent}")
391 })
392}
393
394#[must_use]
395pub fn route_manifest_json_v1(manifest: &RouteManifestV1) -> String {
396 serde_json::to_string_pretty(manifest).expect("route manifest is serializable") + "\n"
397}
398
399fn is_static_route_path(path: &str) -> bool {
400 path.starts_with('/')
401 && !path.contains("//")
402 && !path.contains('*')
403 && !path.contains(':')
404 && !path.contains('{')
405 && !path.contains('}')
406 && path.split('/').all(|segment| !segment.contains(".."))
407}
408
409fn is_file_route_path(path: &str) -> bool {
410 path.starts_with('/')
411 && !path.contains("//")
412 && path.split('/').all(|segment| {
413 segment.is_empty()
414 || (segment.starts_with(':')
415 && segment.len() > 1
416 && segment[1..]
417 .chars()
418 .all(|character| character.is_ascii_alphanumeric() || character == '_'))
419 || (segment != "."
420 && segment != ".."
421 && !segment.contains('*')
422 && !segment.contains('{')
423 && !segment.contains('}'))
424 })
425}
426
427fn file_route_path(path: &std::path::Path) -> Option<String> {
428 let values = path
429 .components()
430 .map(|component| component.as_os_str().to_str())
431 .collect::<Option<Vec<_>>>()?;
432 let index = values.iter().position(|value| *value == "routes")?;
433 if values.get(index.checked_sub(1)?)? != &"app" {
434 return None;
435 }
436 let mut segments = values[index + 1..]
437 .iter()
438 .map(|value| (*value).to_string())
439 .collect::<Vec<_>>();
440 let filename = segments.pop()?;
441 let stem = filename
442 .strip_suffix(".tsx")
443 .or_else(|| filename.strip_suffix(".ts"))?;
444 if stem != "index" {
445 segments.push(route_segment(stem)?);
446 }
447 for segment in &mut segments {
448 *segment = route_segment(segment)?;
449 }
450 Some(if segments.is_empty() {
451 "/".into()
452 } else {
453 format!("/{}", segments.join("/"))
454 })
455}
456
457fn file_layout_scope(path: &std::path::Path) -> Option<Vec<String>> {
458 let values = path
459 .components()
460 .map(|component| component.as_os_str().to_str())
461 .collect::<Option<Vec<_>>>()?;
462 match values.as_slice() {
463 ["app", "app.ts" | "app.tsx" | "layout.ts" | "layout.tsx"] => Some(Vec::new()),
464 ["app", "routes", rest @ .., "layout.ts" | "layout.tsx"] => {
465 rest.iter().map(|segment| route_segment(segment)).collect()
466 }
467 _ => None,
468 }
469}
470
471fn layout_chain_for_route(
472 path: &str,
473 layouts: &BTreeMap<Vec<String>, SemanticId>,
474) -> Vec<SemanticId> {
475 let segments = path
476 .trim_matches('/')
477 .split('/')
478 .filter(|segment| !segment.is_empty())
479 .map(route_match_segment)
480 .collect::<Vec<_>>();
481 (0..=segments.len())
482 .filter_map(|length| layouts.get(&segments[..length]).cloned())
483 .collect()
484}
485
486fn route_match_shape(path: &str) -> String {
487 path.split('/')
488 .map(route_match_segment)
489 .collect::<Vec<_>>()
490 .join("/")
491}
492
493fn route_match_segment(segment: &str) -> String {
494 if segment.starts_with(':') {
495 ":".into()
496 } else {
497 segment.into()
498 }
499}
500
501fn normalize_route_scope(scope: Vec<String>) -> Vec<String> {
502 scope
503 .into_iter()
504 .map(|segment| route_match_segment(&segment))
505 .collect()
506}
507
508fn route_scope_display(scope: &[String]) -> String {
509 if scope.is_empty() {
510 "/".into()
511 } else {
512 format!("/{}", scope.join("/"))
513 }
514}
515
516fn route_segment(segment: &str) -> Option<String> {
517 if let Some(parameter) = segment
518 .strip_prefix('[')
519 .and_then(|value| value.strip_suffix(']'))
520 {
521 if parameter.is_empty()
522 || !parameter
523 .chars()
524 .all(|character| character.is_ascii_alphanumeric() || character == '_')
525 {
526 return None;
527 }
528 return Some(format!(":{parameter}"));
529 }
530 (!segment.is_empty()).then(|| segment.into())
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::{
537 build_application_semantic_model, build_application_semantic_model_for_unit,
538 CompilationUnit,
539 };
540 use presolve_parser::parse_file;
541
542 #[test]
543 fn validates_and_sorts_static_route_identity() {
544 let source = r#"@route("/about") @component("x-about") class About extends Component { render() { return <main />; } } @route("/") @component("x-home") class Home extends Component { render() { return <main />; } }"#;
545 let model = build_application_semantic_model(&parse_file("src/routes.tsx", source));
546 let graph = build_validated_route_graph_v1(&model).unwrap();
547 assert_eq!(
548 graph
549 .routes
550 .iter()
551 .map(|route| route.path.as_str())
552 .collect::<Vec<_>>(),
553 vec!["/", "/about"]
554 );
555 assert!(route_manifest_json_v1(&route_manifest_v1(&graph)).contains("schema_version"));
556 }
557
558 #[test]
559 fn derives_file_routes_and_keeps_explicit_route_override() {
560 let source =
561 r#"@component() class Home extends Component { render() { return <main />; } }"#;
562 let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
563 assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/");
564 let source = r#"@route("/welcome") @component() class Home extends Component { render() { return <main />; } }"#;
565 let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
566 assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/welcome");
567 }
568
569 #[test]
570 fn derives_typed_parameter_segment_from_file_name() {
571 let source =
572 r#"@component() class Post extends Component { render() { return <main />; } }"#;
573 let model =
574 build_application_semantic_model(&parse_file("app/routes/blog/[slug].tsx", source));
575 assert_eq!(
576 build_file_route_graph_v1(&model).routes[0].path,
577 "/blog/:slug"
578 );
579 }
580
581 #[test]
582 fn derives_nested_layout_chains_for_file_routes() {
583 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
584 (
585 "app/layout.tsx",
586 r#"@component() class AppLayout extends Component { render() { return <main />; } }"#,
587 ),
588 (
589 "app/routes/blog/layout.tsx",
590 r#"@component() class BlogLayout extends Component { render() { return <section />; } }"#,
591 ),
592 (
593 "app/routes/blog/[slug].tsx",
594 r#"@component() class Post extends Component { render() { return <article />; } }"#,
595 ),
596 ]));
597
598 let graph = build_validated_file_route_graph_v1(&model).unwrap();
599 let component_graph =
600 build_validated_file_route_graph_from_components_v1(&model.components).unwrap();
601
602 assert_eq!(graph.routes.len(), 1);
603 assert_eq!(component_graph, graph);
604 assert_eq!(graph.routes[0].path, "/blog/:slug");
605 assert_eq!(
606 graph.routes[0]
607 .layouts
608 .iter()
609 .map(ToString::to_string)
610 .collect::<Vec<_>>(),
611 vec![
612 "module:app/layout.tsx/component:presolve-app-layout",
613 "module:app/routes/blog/layout.tsx/component:presolve-blog-layout"
614 ]
615 );
616 }
617
618 #[test]
619 fn recognizes_the_canonical_application_shell() {
620 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
621 (
622 "app/app.tsx",
623 r#"@component() class App extends Component { render() { return <div />; } }"#,
624 ),
625 (
626 "app/routes/index.tsx",
627 r#"@component() class Home extends Component { render() { return <main />; } }"#,
628 ),
629 ]));
630 let graph = build_validated_file_route_graph_v1(&model).unwrap();
631 assert_eq!(graph.routes[0].layouts.len(), 1);
632 assert!(graph.routes[0].layouts[0]
633 .to_string()
634 .contains("module:app/app.tsx"));
635 }
636
637 #[test]
638 fn rejects_file_routes_that_differ_only_by_parameter_name() {
639 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
640 (
641 "app/routes/blog/[id].tsx",
642 r#"@component() class ById extends Component { render() { return <article />; } }"#,
643 ),
644 (
645 "app/routes/blog/[slug].tsx",
646 r#"@component() class BySlug extends Component { render() { return <article />; } }"#,
647 ),
648 ]));
649
650 assert_eq!(
651 build_validated_file_route_graph_v1(&model)
652 .unwrap_err()
653 .code,
654 "PSROUTE1013_FILE_ROUTE_CONFLICT"
655 );
656 }
657}