Skip to main content

presolve_compiler/
layout_composition.rs

1//! Compiler-owned validation and planning for ergonomic file-route layouts.
2
3use std::collections::BTreeMap;
4
5use crate::{
6    ApplicationSemanticModel, ComponentInstancePlan, ComponentInvocationEntity,
7    ComponentInvocationId, ComponentInvocationResolutionStatus, ComponentNode, FileRouteGraphV1,
8    SemanticId, SlotKind, SourceProvenance, TemplatePositionId, TemplateSemanticEntity,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LayoutCompositionPlanV1 {
13    pub routes: Vec<LayoutCompositionRouteV1>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct LayoutCompositionRouteV1 {
18    pub path: String,
19    pub page: SemanticId,
20    /// Ordered outermost to nearest layout.
21    pub layouts: Vec<SemanticId>,
22    pub edges: Vec<LayoutCompositionEdgeV1>,
23}
24
25/// One compiler-issued default-Slot child edge. It is deliberately not an
26/// authored JSX invocation and carries no source-content fragment.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LayoutCompositionEdgeV1 {
29    pub invocation: ComponentInvocationId,
30    pub caller: SemanticId,
31    pub callee: SemanticId,
32    pub position: usize,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LayoutCompositionErrorV1 {
37    pub code: &'static str,
38    pub message: String,
39}
40
41/// Validates automatic default-Slot composition without changing the authored
42/// component instance plan. Instance/Slot lowering consumes this plan later.
43pub fn build_layout_composition_plan_v1(
44    model: &ApplicationSemanticModel,
45    graph: &FileRouteGraphV1,
46) -> Result<LayoutCompositionPlanV1, LayoutCompositionErrorV1> {
47    build_layout_composition_plan_from_components_v1(&model.components, graph)
48}
49
50/// Component-fact variant used while assembling a file-route application
51/// model, before instance/runtime products exist.
52pub fn build_layout_composition_plan_from_components_v1(
53    component_nodes: &[crate::ComponentNode],
54    graph: &FileRouteGraphV1,
55) -> Result<LayoutCompositionPlanV1, LayoutCompositionErrorV1> {
56    let components = component_nodes
57        .iter()
58        .map(|component| (component.id.clone(), component))
59        .collect::<BTreeMap<_, _>>();
60    let mut routes = Vec::new();
61    for route in &graph.routes {
62        for layout in &route.layouts {
63            let Some(component) = components.get(layout) else {
64                return Err(LayoutCompositionErrorV1 {
65                    code: "PSROUTE1022_LAYOUT_COMPOSITION_UNSUPPORTED",
66                    message: format!("missing layout component {layout}"),
67                });
68            };
69            let defaults = component
70                .slot_declarations
71                .iter()
72                .filter(|slot| slot.kind == SlotKind::Default)
73                .count();
74            match defaults {
75                0 => {
76                    return Err(LayoutCompositionErrorV1 {
77                        code: "PSROUTE1020_LAYOUT_DEFAULT_SLOT_MISSING",
78                        message: format!("layout component {layout} has no default @slot()"),
79                    })
80                }
81                1 => {}
82                _ => {
83                    return Err(LayoutCompositionErrorV1 {
84                        code: "PSROUTE1021_LAYOUT_DEFAULT_SLOT_AMBIGUOUS",
85                        message: format!(
86                            "layout component {layout} has multiple default @slot() declarations"
87                        ),
88                    })
89                }
90            }
91        }
92        let mut chain = route.layouts.clone();
93        chain.push(route.component.clone());
94        let edges = chain
95            .windows(2)
96            .enumerate()
97            .map(|(position, pair)| LayoutCompositionEdgeV1 {
98                invocation: ComponentInvocationId::for_layout_composition(
99                    &pair[0],
100                    &route.component,
101                    position,
102                ),
103                caller: pair[0].clone(),
104                callee: pair[1].clone(),
105                position,
106            })
107            .collect();
108        routes.push(LayoutCompositionRouteV1 {
109            path: route.path.clone(),
110            page: route.component.clone(),
111            layouts: route.layouts.clone(),
112            edges,
113        });
114    }
115    Ok(LayoutCompositionPlanV1 { routes })
116}
117
118/// Converts compiler-issued layout edges into the same resolved invocation
119/// facts consumed by component instance planning. No authored JSX or source
120/// content fragment is fabricated.
121#[must_use]
122pub fn layout_composition_virtual_invocations_v1(
123    model: &ApplicationSemanticModel,
124    plan: &LayoutCompositionPlanV1,
125) -> BTreeMap<ComponentInvocationId, ComponentInvocationEntity> {
126    plan.routes
127        .iter()
128        .flat_map(|route| route.edges.iter())
129        .filter_map(|edge| {
130            let provenance = model.provenance.get(&edge.caller)?.clone();
131            let template_entity = edge.caller.template();
132            Some((
133                edge.invocation.clone(),
134                ComponentInvocationEntity {
135                    id: edge.invocation.clone(),
136                    owner_component: edge.caller.clone(),
137                    target_component: Some(edge.callee.clone()),
138                    authored_symbol: "<presolve-layout-child>".into(),
139                    template_entity: template_entity.clone(),
140                    source_position: TemplatePositionId::for_template_entity(&template_entity),
141                    status: ComponentInvocationResolutionStatus::Resolved,
142                    provenance,
143                    virtual_layout_composition: true,
144                },
145            ))
146        })
147        .collect()
148}
149
150/// Produces the canonical composed instance topology for a file-route source
151/// set. Generic application-model builders retain authored-only planning.
152pub fn plan_file_route_component_instances_v1(
153    components: &[ComponentNode],
154    graph: &FileRouteGraphV1,
155    authored_invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
156    template_entities: &[TemplateSemanticEntity],
157    provenance: &BTreeMap<SemanticId, SourceProvenance>,
158) -> Result<ComponentInstancePlan, LayoutCompositionErrorV1> {
159    let plan = build_layout_composition_plan_from_components_v1(components, graph)?;
160    let virtuals = layout_composition_virtual_invocations_from_provenance_v1(&plan, provenance);
161    Ok(crate::plan_component_instances_with_virtual_invocations(
162        components,
163        authored_invocations,
164        &virtuals,
165        template_entities,
166        provenance,
167    ))
168}
169
170/// Lowers a layout plan to compiler-issued invocation facts while the
171/// application model is still being assembled.
172#[must_use]
173pub fn layout_composition_virtual_invocations_from_provenance_v1(
174    plan: &LayoutCompositionPlanV1,
175    provenance: &BTreeMap<SemanticId, SourceProvenance>,
176) -> BTreeMap<ComponentInvocationId, ComponentInvocationEntity> {
177    plan.routes
178        .iter()
179        .flat_map(|route| route.edges.iter())
180        .filter_map(|edge| {
181            let provenance = provenance.get(&edge.caller)?.clone();
182            let template_entity = edge.caller.template();
183            Some((
184                edge.invocation.clone(),
185                ComponentInvocationEntity {
186                    id: edge.invocation.clone(),
187                    owner_component: edge.caller.clone(),
188                    target_component: Some(edge.callee.clone()),
189                    authored_symbol: "<presolve-layout-child>".into(),
190                    template_entity: template_entity.clone(),
191                    source_position: TemplatePositionId::for_template_entity(&template_entity),
192                    status: ComponentInvocationResolutionStatus::Resolved,
193                    provenance,
194                    virtual_layout_composition: true,
195                },
196            ))
197        })
198        .collect()
199}
200
201#[cfg(test)]
202mod tests {
203    use crate::{
204        build_application_semantic_model_for_unit, build_validated_file_route_graph_v1,
205        CompilationUnit,
206    };
207
208    use super::*;
209
210    #[test]
211    fn requires_an_exact_default_slot_for_each_conventional_layout() {
212        let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
213            (
214                "app/layout.tsx",
215                r#"@component() class Shell extends Component { @slot() children!: SlotContent; render() { return <main><slot /></main>; } }"#,
216            ),
217            (
218                "app/routes/index.tsx",
219                r#"@component() class Home extends Component { render() { return <p>Home</p>; } }"#,
220            ),
221        ]));
222        let graph = build_validated_file_route_graph_v1(&model).unwrap();
223        let plan = build_layout_composition_plan_v1(&model, &graph).unwrap();
224        assert_eq!(plan.routes[0].layouts.len(), 1);
225        assert_eq!(plan.routes[0].edges.len(), 1);
226        assert_eq!(plan.routes[0].edges[0].caller, plan.routes[0].layouts[0]);
227        assert_eq!(plan.routes[0].edges[0].callee, plan.routes[0].page);
228        let virtuals = layout_composition_virtual_invocations_v1(&model, &plan);
229        assert_eq!(virtuals.len(), 1);
230        assert!(virtuals
231            .values()
232            .all(|invocation| invocation.virtual_layout_composition));
233    }
234}