Skip to main content

presolve_compiler/
route_graph.rs

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/// Compiler-derived route topology for the ergonomic `app/routes` convention.
22///
23/// This is intentionally distinct from the frozen explicit static-route
24/// product. It records layout ownership before a later publication product
25/// decides how a route page is emitted; it does not introduce a router runtime.
26#[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    /// Ordered from the application shell to the nearest route layout.
36    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/// Builds the ergonomic route graph: explicit `@route()` wins, otherwise an
76/// `app/routes/**/*.tsx` component derives its path from its module path.
77#[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/// Builds ergonomic file-route topology directly from compiler component facts.
98/// This is used during file-route application-model assembly before the full
99/// application model has derived instance/runtime products.
100#[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
121/// Builds and validates the complete `app/routes` topology, including
122/// conventional application-shell and `layout.tsx` files. `app/app.tsx` is
123/// the canonical application shell; `app/layout.tsx` remains a compatibility
124/// alias during the beta. A layout file must declare exactly one component and
125/// cannot also claim a route through `@route()`.
126///
127/// # Errors
128///
129/// Returns stable diagnostics for ambiguous layouts and route patterns. Dynamic
130/// parameter names are intentionally not identity: `/posts/:id` and
131/// `/posts/:slug` conflict because they match the same requests.
132pub 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
138/// Component-fact variant of [`build_validated_file_route_graph_v1`].
139///
140/// File-route identity and layout ownership are properties of compiler
141/// component facts, not of a completed application artifact. Keeping this
142/// lowering available before instance planning lets a file-route application
143/// assemble its canonical composed topology exactly once.
144///
145/// # Errors
146///
147/// Returns the same stable route diagnostics as the application-model entry
148/// point.
149pub 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
223/// Produces the deterministic static route product accepted by Phase Q Q1.
224///
225/// # Errors
226///
227/// Returns a stable error for malformed or duplicate route identities.
228pub 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
276/// Builds the complete static multi-route publication inventory solely from
277/// compiler products. Each route receives an immutable namespaced application
278/// artifact family; no host or JavaScript layer combines page artifacts.
279///
280/// # Errors
281///
282/// Returns route-identity or canonical application-publication errors.
283pub 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}