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 `layout.tsx` files. A layout file must declare exactly one
123/// component and cannot also claim a route through `@route()`.
124///
125/// # Errors
126///
127/// Returns stable diagnostics for ambiguous layouts and route patterns. Dynamic
128/// parameter names are intentionally not identity: `/posts/:id` and
129/// `/posts/:slug` conflict because they match the same requests.
130pub fn build_validated_file_route_graph_v1(
131    model: &ApplicationSemanticModel,
132) -> Result<FileRouteGraphV1, RouteGraphError> {
133    build_validated_file_route_graph_from_components_v1(&model.components)
134}
135
136/// Component-fact variant of [`build_validated_file_route_graph_v1`].
137///
138/// File-route identity and layout ownership are properties of compiler
139/// component facts, not of a completed application artifact. Keeping this
140/// lowering available before instance planning lets a file-route application
141/// assemble its canonical composed topology exactly once.
142///
143/// # Errors
144///
145/// Returns the same stable route diagnostics as the application-model entry
146/// point.
147pub fn build_validated_file_route_graph_from_components_v1(
148    components: &[crate::ComponentNode],
149) -> Result<FileRouteGraphV1, RouteGraphError> {
150    let mut layouts = BTreeMap::<Vec<String>, SemanticId>::new();
151    for component in components {
152        let Some(scope) = file_layout_scope(&component.module_path) else {
153            continue;
154        };
155        let scope = normalize_route_scope(scope);
156        if component.route_path.is_some() {
157            return Err(RouteGraphError {
158                code: "PSROUTE1010_LAYOUT_CANNOT_DECLARE_ROUTE",
159                message: format!(
160                    "layout component `{}` must not declare @route()",
161                    component.id
162                ),
163            });
164        }
165        if layouts
166            .insert(scope.clone(), component.id.clone())
167            .is_some()
168        {
169            return Err(RouteGraphError {
170                code: "PSROUTE1011_LAYOUT_COMPONENT_AMBIGUOUS",
171                message: format!(
172                    "layout scope `{}` must declare exactly one component",
173                    route_scope_display(&scope)
174                ),
175            });
176        }
177    }
178
179    let mut routes = Vec::new();
180    for component in components {
181        if file_layout_scope(&component.module_path).is_some() {
182            continue;
183        }
184        let Some(file_path) = file_route_path(&component.module_path) else {
185            continue;
186        };
187        let path = component.route_path.clone().unwrap_or(file_path);
188        if !is_file_route_path(&path) {
189            return Err(RouteGraphError {
190                code: "PSROUTE1012_INVALID_FILE_ROUTE_PATH",
191                message: format!("route path `{path}` is not a supported file-route path"),
192            });
193        }
194        let layouts = layout_chain_for_route(&path, &layouts);
195        routes.push(FileRouteNodeV1 {
196            path,
197            component: component.id.clone(),
198            layouts,
199        });
200    }
201    routes.sort_by(|left, right| {
202        route_match_shape(&left.path)
203            .cmp(&route_match_shape(&right.path))
204            .then_with(|| left.path.cmp(&right.path))
205            .then_with(|| left.component.cmp(&right.component))
206    });
207    for pair in routes.windows(2) {
208        if route_match_shape(&pair[0].path) == route_match_shape(&pair[1].path) {
209            return Err(RouteGraphError {
210                code: "PSROUTE1013_FILE_ROUTE_CONFLICT",
211                message: format!(
212                    "routes `{}` and `{}` match the same request path",
213                    pair[0].path, pair[1].path
214                ),
215            });
216        }
217    }
218    Ok(FileRouteGraphV1 { routes })
219}
220
221/// Produces the deterministic static route product accepted by Phase Q Q1.
222///
223/// # Errors
224///
225/// Returns a stable error for malformed or duplicate route identities.
226pub fn build_validated_route_graph_v1(
227    model: &ApplicationSemanticModel,
228) -> Result<RouteGraph, RouteGraphError> {
229    let mut graph = build_route_graph(model);
230    graph
231        .routes
232        .sort_by(|left, right| left.path.cmp(&right.path));
233    for route in &graph.routes {
234        if !is_static_route_path(&route.path) {
235            return Err(RouteGraphError {
236                code: "PSROUTE1001_INVALID_STATIC_ROUTE_PATH",
237                message: format!(
238                    "route path `{}` must begin with / and contain no dynamic segments",
239                    route.path
240                ),
241            });
242        }
243    }
244    if graph
245        .routes
246        .windows(2)
247        .any(|pair| pair[0].path == pair[1].path)
248    {
249        return Err(RouteGraphError {
250            code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
251            message: "each static route path must identify exactly one component".into(),
252        });
253    }
254    Ok(graph)
255}
256
257#[must_use]
258pub fn route_manifest_v1(graph: &RouteGraph) -> RouteManifestV1 {
259    RouteManifestV1 {
260        schema_version: 1,
261        routes: graph
262            .routes
263            .iter()
264            .map(|route| RouteManifestEntryV1 {
265                path: route.path.clone(),
266                component_id: route.component.to_string(),
267                artifact_root: route_artifact_root(&route.path),
268                parent_path: route_parent_path(&route.path),
269            })
270            .collect(),
271    }
272}
273
274/// Builds the complete static multi-route publication inventory solely from
275/// compiler products. Each route receives an immutable namespaced application
276/// artifact family; no host or JavaScript layer combines page artifacts.
277///
278/// # Errors
279///
280/// Returns route-identity or canonical application-publication errors.
281pub fn build_static_route_publication_v1(
282    requests: Vec<ApplicationPublicationRequestV1>,
283) -> Result<(RouteManifestV1, BTreeMap<std::path::PathBuf, Vec<u8>>), RouteGraphError> {
284    if requests.is_empty() {
285        return Err(RouteGraphError {
286            code: "PSROUTE1003_EMPTY_ROUTE_SET",
287            message: "static route publication requires explicit route entries".into(),
288        });
289    }
290    let mut artifacts = BTreeMap::new();
291    let mut routes = Vec::new();
292    for request in requests {
293        let validated =
294            validate_application_publication_request_v1(request).map_err(route_request_error)?;
295        let model = crate::build_application_semantic_model_for_unit_with_packages(
296            &validated.unit,
297            &validated.request.package_contracts,
298        );
299        let graph = build_validated_route_graph_v1(&model)?;
300        let route = graph
301            .routes
302            .iter()
303            .find(|route| route.component == validated.entry_component)
304            .ok_or_else(|| RouteGraphError {
305                code: "PSROUTE1004_ENTRY_ROUTE_MISSING",
306                message: "each route publication entry must declare one matching @route path"
307                    .into(),
308            })?;
309        let root = route_artifact_root(&route.path);
310        if routes
311            .iter()
312            .any(|entry: &RouteManifestEntryV1| entry.path == route.path)
313        {
314            return Err(RouteGraphError {
315                code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
316                message: "each static route path must identify exactly one component".into(),
317            });
318        }
319        let product =
320            build_application_publication_product_v1(validated).map_err(route_product_error)?;
321        insert_route_product(&mut artifacts, &root, product)?;
322        routes.push(RouteManifestEntryV1 {
323            path: route.path.clone(),
324            component_id: route.component.to_string(),
325            artifact_root: root,
326            parent_path: route_parent_path(&route.path),
327        });
328    }
329    routes.sort_by(|left, right| left.path.cmp(&right.path));
330    let manifest = RouteManifestV1 {
331        schema_version: 1,
332        routes,
333    };
334    artifacts.insert(
335        std::path::PathBuf::from("routes.manifest.json"),
336        route_manifest_json_v1(&manifest).into_bytes(),
337    );
338    Ok((manifest, artifacts))
339}
340
341fn insert_route_product(
342    artifacts: &mut BTreeMap<std::path::PathBuf, Vec<u8>>,
343    root: &str,
344    product: ApplicationPublicationProductV1,
345) -> Result<(), RouteGraphError> {
346    for (path, bytes) in product.artifacts {
347        let path = std::path::PathBuf::from(root).join(path);
348        if artifacts.insert(path.clone(), bytes).is_some() {
349            return Err(RouteGraphError {
350                code: "PSROUTE1005_ARTIFACT_PATH_COLLISION",
351                message: format!(
352                    "route publication generated colliding artifact {}",
353                    path.display()
354                ),
355            });
356        }
357    }
358    Ok(())
359}
360
361fn route_request_error(error: crate::ApplicationPublicationRequestErrorV1) -> RouteGraphError {
362    RouteGraphError {
363        code: error.code,
364        message: error.message,
365    }
366}
367fn route_product_error(error: ApplicationPublicationErrorV1) -> RouteGraphError {
368    RouteGraphError {
369        code: error.code,
370        message: error.message,
371    }
372}
373fn route_artifact_root(path: &str) -> String {
374    if path == "/" {
375        return "routes/root".into();
376    }
377    format!("routes/{}", path.trim_matches('/').replace('/', "__"))
378}
379fn route_parent_path(path: &str) -> Option<String> {
380    if path == "/" {
381        return None;
382    }
383    let trimmed = path.trim_matches('/');
384    let parent = trimmed.rsplit_once('/').map_or("", |(parent, _)| parent);
385    Some(if parent.is_empty() {
386        "/".into()
387    } else {
388        format!("/{parent}")
389    })
390}
391
392#[must_use]
393pub fn route_manifest_json_v1(manifest: &RouteManifestV1) -> String {
394    serde_json::to_string_pretty(manifest).expect("route manifest is serializable") + "\n"
395}
396
397fn is_static_route_path(path: &str) -> bool {
398    path.starts_with('/')
399        && !path.contains("//")
400        && !path.contains('*')
401        && !path.contains(':')
402        && !path.contains('{')
403        && !path.contains('}')
404        && path.split('/').all(|segment| !segment.contains(".."))
405}
406
407fn is_file_route_path(path: &str) -> bool {
408    path.starts_with('/')
409        && !path.contains("//")
410        && path.split('/').all(|segment| {
411            segment.is_empty()
412                || (segment.starts_with(':')
413                    && segment.len() > 1
414                    && segment[1..]
415                        .chars()
416                        .all(|character| character.is_ascii_alphanumeric() || character == '_'))
417                || (segment != "."
418                    && segment != ".."
419                    && !segment.contains('*')
420                    && !segment.contains('{')
421                    && !segment.contains('}'))
422        })
423}
424
425fn file_route_path(path: &std::path::Path) -> Option<String> {
426    let values = path
427        .components()
428        .map(|component| component.as_os_str().to_str())
429        .collect::<Option<Vec<_>>>()?;
430    let index = values.iter().position(|value| *value == "routes")?;
431    if values.get(index.checked_sub(1)?)? != &"app" {
432        return None;
433    }
434    let mut segments = values[index + 1..]
435        .iter()
436        .map(|value| (*value).to_string())
437        .collect::<Vec<_>>();
438    let filename = segments.pop()?;
439    let stem = filename
440        .strip_suffix(".tsx")
441        .or_else(|| filename.strip_suffix(".ts"))?;
442    if stem != "index" {
443        segments.push(route_segment(stem)?);
444    }
445    for segment in &mut segments {
446        *segment = route_segment(segment)?;
447    }
448    Some(if segments.is_empty() {
449        "/".into()
450    } else {
451        format!("/{}", segments.join("/"))
452    })
453}
454
455fn file_layout_scope(path: &std::path::Path) -> Option<Vec<String>> {
456    let values = path
457        .components()
458        .map(|component| component.as_os_str().to_str())
459        .collect::<Option<Vec<_>>>()?;
460    let filename = values.last()?;
461    if !matches!(*filename, "layout.ts" | "layout.tsx") {
462        return None;
463    }
464    match values.as_slice() {
465        ["app", _] => Some(Vec::new()),
466        ["app", "routes", rest @ .., _] => {
467            rest.iter().map(|segment| route_segment(segment)).collect()
468        }
469        _ => None,
470    }
471}
472
473fn layout_chain_for_route(
474    path: &str,
475    layouts: &BTreeMap<Vec<String>, SemanticId>,
476) -> Vec<SemanticId> {
477    let segments = path
478        .trim_matches('/')
479        .split('/')
480        .filter(|segment| !segment.is_empty())
481        .map(route_match_segment)
482        .collect::<Vec<_>>();
483    (0..=segments.len())
484        .filter_map(|length| layouts.get(&segments[..length]).cloned())
485        .collect()
486}
487
488fn route_match_shape(path: &str) -> String {
489    path.split('/')
490        .map(route_match_segment)
491        .collect::<Vec<_>>()
492        .join("/")
493}
494
495fn route_match_segment(segment: &str) -> String {
496    if segment.starts_with(':') {
497        ":".into()
498    } else {
499        segment.into()
500    }
501}
502
503fn normalize_route_scope(scope: Vec<String>) -> Vec<String> {
504    scope
505        .into_iter()
506        .map(|segment| route_match_segment(&segment))
507        .collect()
508}
509
510fn route_scope_display(scope: &[String]) -> String {
511    if scope.is_empty() {
512        "/".into()
513    } else {
514        format!("/{}", scope.join("/"))
515    }
516}
517
518fn route_segment(segment: &str) -> Option<String> {
519    if let Some(parameter) = segment
520        .strip_prefix('[')
521        .and_then(|value| value.strip_suffix(']'))
522    {
523        if parameter.is_empty()
524            || !parameter
525                .chars()
526                .all(|character| character.is_ascii_alphanumeric() || character == '_')
527        {
528            return None;
529        }
530        return Some(format!(":{parameter}"));
531    }
532    (!segment.is_empty()).then(|| segment.into())
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::{
539        build_application_semantic_model, build_application_semantic_model_for_unit,
540        CompilationUnit,
541    };
542    use presolve_parser::parse_file;
543
544    #[test]
545    fn validates_and_sorts_static_route_identity() {
546        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 />; } }"#;
547        let model = build_application_semantic_model(&parse_file("src/routes.tsx", source));
548        let graph = build_validated_route_graph_v1(&model).unwrap();
549        assert_eq!(
550            graph
551                .routes
552                .iter()
553                .map(|route| route.path.as_str())
554                .collect::<Vec<_>>(),
555            vec!["/", "/about"]
556        );
557        assert!(route_manifest_json_v1(&route_manifest_v1(&graph)).contains("schema_version"));
558    }
559
560    #[test]
561    fn derives_file_routes_and_keeps_explicit_route_override() {
562        let source =
563            r#"@component() class Home extends Component { render() { return <main />; } }"#;
564        let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
565        assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/");
566        let source = r#"@route("/welcome") @component() class Home extends Component { render() { return <main />; } }"#;
567        let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
568        assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/welcome");
569    }
570
571    #[test]
572    fn derives_typed_parameter_segment_from_file_name() {
573        let source =
574            r#"@component() class Post extends Component { render() { return <main />; } }"#;
575        let model =
576            build_application_semantic_model(&parse_file("app/routes/blog/[slug].tsx", source));
577        assert_eq!(
578            build_file_route_graph_v1(&model).routes[0].path,
579            "/blog/:slug"
580        );
581    }
582
583    #[test]
584    fn derives_nested_layout_chains_for_file_routes() {
585        let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
586            (
587                "app/layout.tsx",
588                r#"@component() class AppLayout extends Component { render() { return <main />; } }"#,
589            ),
590            (
591                "app/routes/blog/layout.tsx",
592                r#"@component() class BlogLayout extends Component { render() { return <section />; } }"#,
593            ),
594            (
595                "app/routes/blog/[slug].tsx",
596                r#"@component() class Post extends Component { render() { return <article />; } }"#,
597            ),
598        ]));
599
600        let graph = build_validated_file_route_graph_v1(&model).unwrap();
601        let component_graph =
602            build_validated_file_route_graph_from_components_v1(&model.components).unwrap();
603
604        assert_eq!(graph.routes.len(), 1);
605        assert_eq!(component_graph, graph);
606        assert_eq!(graph.routes[0].path, "/blog/:slug");
607        assert_eq!(
608            graph.routes[0]
609                .layouts
610                .iter()
611                .map(ToString::to_string)
612                .collect::<Vec<_>>(),
613            vec![
614                "module:app/layout.tsx/component:presolve-app-layout",
615                "module:app/routes/blog/layout.tsx/component:presolve-blog-layout"
616            ]
617        );
618    }
619
620    #[test]
621    fn rejects_file_routes_that_differ_only_by_parameter_name() {
622        let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
623            (
624                "app/routes/blog/[id].tsx",
625                r#"@component() class ById extends Component { render() { return <article />; } }"#,
626            ),
627            (
628                "app/routes/blog/[slug].tsx",
629                r#"@component() class BySlug extends Component { render() { return <article />; } }"#,
630            ),
631        ]));
632
633        assert_eq!(
634            build_validated_file_route_graph_v1(&model)
635                .unwrap_err()
636                .code,
637            "PSROUTE1013_FILE_ROUTE_CONFLICT"
638        );
639    }
640}