Skip to main content

presolve_compiler/
compiler_pass.rs

1use std::collections::BTreeMap;
2
3use crate::application_semantic_model::{ApplicationSemanticModel, SemanticEntityKind};
4use crate::component_graph::{
5    ComponentDiagnostic, ComponentDiagnosticSeverity, ComponentGraph, SerializableValue,
6    StateOperation,
7};
8use crate::semantic_id::SemanticId;
9use crate::semantic_provenance::SourceProvenance;
10use crate::validate_application_semantic_model;
11use crate::{build_application_semantic_model_from_component_graph, build_template_graph};
12
13/// An immutable transformation from canonical ASM input to a compiler product.
14///
15/// Implementations must treat the input ASM as read-only and return a newly
16/// constructed output. This is the common pass boundary for analysis products
17/// today and future transformed ASM products where applicable.
18pub trait ImmutableAsmPass {
19    type Output;
20
21    fn transform(&self, model: &ApplicationSemanticModel) -> Self::Output;
22}
23
24/// Compatibility surface for existing analysis consumers.
25pub trait AnalysisPass: ImmutableAsmPass {
26    fn analyze(&self, model: &ApplicationSemanticModel) -> Self::Output {
27        self.transform(model)
28    }
29}
30
31impl<T> AnalysisPass for T where T: ImmutableAsmPass {}
32
33#[derive(Debug, Default, Clone, Copy)]
34pub struct DependencyAnalysisPass;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DependencyAnalysis {
38    pub dependencies: BTreeMap<SemanticId, Vec<SemanticId>>,
39    pub dependents: BTreeMap<SemanticId, Vec<SemanticId>>,
40}
41
42impl ImmutableAsmPass for DependencyAnalysisPass {
43    type Output = DependencyAnalysis;
44
45    fn transform(&self, model: &ApplicationSemanticModel) -> DependencyAnalysis {
46        let mut dependencies = BTreeMap::<SemanticId, Vec<SemanticId>>::new();
47        let mut dependents = BTreeMap::<SemanticId, Vec<SemanticId>>::new();
48
49        for reference in &model.references {
50            dependencies
51                .entry(reference.source.clone())
52                .or_default()
53                .push(reference.target.clone());
54            dependents
55                .entry(reference.target.clone())
56                .or_default()
57                .push(reference.source.clone());
58        }
59
60        DependencyAnalysis {
61            dependencies,
62            dependents,
63        }
64    }
65}
66
67#[derive(Debug, Default, Clone, Copy)]
68pub struct ConstantEvaluationPass;
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ConstantEvaluation {
72    pub values: BTreeMap<SemanticId, SerializableValue>,
73}
74
75/// Immutable constant-folding pass over compiler-lowered state expressions.
76#[derive(Debug, Default, Clone, Copy)]
77pub struct ConstantFoldingPass;
78
79impl ImmutableAsmPass for ConstantFoldingPass {
80    type Output = ApplicationSemanticModel;
81
82    fn transform(&self, model: &ApplicationSemanticModel) -> ApplicationSemanticModel {
83        let mut folded = model.clone();
84
85        for component in &mut folded.components {
86            for field in &mut component.state_fields {
87                let Some(result) = folded.expression_graph.evaluate(&field.id) else {
88                    continue;
89                };
90
91                let root = folded
92                    .expression_graph
93                    .root_for(&field.id)
94                    .expect("expression evaluation should have a graph root");
95                let expression = folded
96                    .expression_graph
97                    .nodes
98                    .get(root)
99                    .expect("expression graph root should be a node");
100
101                match result {
102                    Ok(value) => {
103                        field.initial_value = Some(value);
104                    }
105                    Err(error) => {
106                        let mut diagnostic = ComponentDiagnostic::error(
107                            constant_expression_diagnostic_code_from_node(&expression.kind)
108                                .as_str(),
109                            format!(
110                                "state field `{}` has an invalid {} initializer: {error}",
111                                field.name,
112                                constant_expression_kind_name_from_node(&expression.kind)
113                            ),
114                        );
115                        diagnostic.provenance = Some(expression.provenance.clone());
116                        push_diagnostic_once(&mut folded.diagnostics, diagnostic);
117                    }
118                }
119            }
120        }
121
122        for component in &folded.components {
123            for field in &component.state_fields {
124                if let Some(diagnostic) =
125                    unknown_declared_type_diagnostic(&folded, field, component)
126                {
127                    push_diagnostic_once(&mut folded.diagnostics, diagnostic);
128                }
129                if let Some(diagnostic) =
130                    folded_type_mismatch_diagnostic(&folded, field, &component.class_name)
131                {
132                    push_diagnostic_once(&mut folded.diagnostics, diagnostic);
133                }
134            }
135            for action in &component.actions {
136                if let Some(diagnostic) =
137                    action_assignment_mismatch_diagnostic(&folded, component, action)
138                {
139                    push_diagnostic_once(&mut folded.diagnostics, diagnostic);
140                }
141                for diagnostic in compound_mutation_type_diagnostics(&folded, component, action) {
142                    push_diagnostic_once(&mut folded.diagnostics, diagnostic);
143                }
144            }
145        }
146
147        for entity in &folded.template_entities {
148            if let Some(diagnostic) = template_binding_type_diagnostic(&folded, entity) {
149                push_diagnostic_once(&mut folded.diagnostics, diagnostic);
150            }
151            if let Some(diagnostic) = attribute_binding_type_diagnostic(&folded, entity) {
152                push_diagnostic_once(&mut folded.diagnostics, diagnostic);
153            }
154            if let Some(diagnostic) = conditional_type_diagnostic(&folded, entity) {
155                push_diagnostic_once(&mut folded.diagnostics, diagnostic);
156            }
157            if let Some(diagnostic) = list_iterable_type_diagnostic(&folded, entity) {
158                push_diagnostic_once(&mut folded.diagnostics, diagnostic);
159            }
160            if let Some(diagnostic) = member_access_type_diagnostic(&folded, entity) {
161                push_diagnostic_once(&mut folded.diagnostics, diagnostic);
162            }
163        }
164
165        let graph = ComponentGraph {
166            components: folded.components.clone(),
167            diagnostics: folded.diagnostics.clone(),
168            references: folded.references.clone(),
169            provenance: folded.provenance.clone(),
170        };
171        folded.templates = build_template_graph(&graph).templates;
172        folded
173    }
174}
175
176fn constant_expression_diagnostic_code_from_node(
177    kind: &crate::ExpressionNodeKind,
178) -> crate::TypeDiagnosticCode {
179    match kind {
180        crate::ExpressionNodeKind::Arithmetic { .. } => {
181            crate::TypeDiagnosticCode::InvalidArithmeticOperator
182        }
183        crate::ExpressionNodeKind::Comparison { .. } => {
184            crate::TypeDiagnosticCode::InvalidComparisonOperator
185        }
186        crate::ExpressionNodeKind::Boolean(_) | crate::ExpressionNodeKind::Logical { .. } => {
187            crate::TypeDiagnosticCode::InvalidLogicalOperator
188        }
189        crate::ExpressionNodeKind::Literal(_)
190        | crate::ExpressionNodeKind::NullishCoalescing { .. } => {
191            crate::TypeDiagnosticCode::InvalidNullishOperator
192        }
193        crate::ExpressionNodeKind::Identifier(_)
194        | crate::ExpressionNodeKind::ThisMember { .. }
195        | crate::ExpressionNodeKind::MemberAccess { .. }
196        | crate::ExpressionNodeKind::IndexAccess { .. }
197        | crate::ExpressionNodeKind::Conditional { .. }
198        | crate::ExpressionNodeKind::Template { .. }
199        | crate::ExpressionNodeKind::Call { .. }
200        | crate::ExpressionNodeKind::BuiltinPureCall { .. }
201        | crate::ExpressionNodeKind::SemanticPackagePureCall { .. } => {
202            unreachable!("constant folding only evaluates state initializer expressions")
203        }
204        crate::ExpressionNodeKind::Unary { .. } => crate::TypeDiagnosticCode::InvalidUnaryOperator,
205    }
206}
207
208fn constant_expression_kind_name_from_node(kind: &crate::ExpressionNodeKind) -> &'static str {
209    match kind {
210        crate::ExpressionNodeKind::Arithmetic { .. } => "arithmetic",
211        crate::ExpressionNodeKind::Comparison { .. } => "comparison",
212        crate::ExpressionNodeKind::Boolean(_) | crate::ExpressionNodeKind::Logical { .. } => {
213            "logical"
214        }
215        crate::ExpressionNodeKind::Literal(_)
216        | crate::ExpressionNodeKind::NullishCoalescing { .. } => "nullish-coalescing",
217        crate::ExpressionNodeKind::Identifier(_)
218        | crate::ExpressionNodeKind::ThisMember { .. }
219        | crate::ExpressionNodeKind::MemberAccess { .. }
220        | crate::ExpressionNodeKind::IndexAccess { .. }
221        | crate::ExpressionNodeKind::Conditional { .. }
222        | crate::ExpressionNodeKind::Template { .. }
223        | crate::ExpressionNodeKind::Call { .. }
224        | crate::ExpressionNodeKind::BuiltinPureCall { .. }
225        | crate::ExpressionNodeKind::SemanticPackagePureCall { .. } => {
226            unreachable!("constant folding only evaluates state initializer expressions")
227        }
228        crate::ExpressionNodeKind::Unary { .. } => "unary",
229    }
230}
231
232/// Fold a legacy component graph through the canonical ASM pass for backend consumers.
233#[must_use]
234pub fn fold_component_graph(component_graph: &ComponentGraph) -> ComponentGraph {
235    let model = build_application_semantic_model_from_component_graph(component_graph);
236    let folded = ConstantFoldingPass.transform(&model);
237
238    ComponentGraph {
239        components: folded.components,
240        diagnostics: folded.diagnostics,
241        references: component_graph.references.clone(),
242        provenance: folded.provenance,
243    }
244}
245
246impl ImmutableAsmPass for ConstantEvaluationPass {
247    type Output = ConstantEvaluation;
248    fn transform(&self, model: &ApplicationSemanticModel) -> ConstantEvaluation {
249        let folded = ConstantFoldingPass.transform(model);
250        let mut values = BTreeMap::new();
251        for component in &folded.components {
252            for field in &component.state_fields {
253                if let Some(value) = &field.initial_value {
254                    values.insert(field.id.clone(), value.clone());
255                }
256            }
257            for action in &component.actions {
258                let value = match &action.operation {
259                    StateOperation::AddAssign(value)
260                    | StateOperation::SubtractAssign(value)
261                    | StateOperation::Assign(value) => Some(value),
262                    _ => None,
263                };
264                if let Some(value) = value {
265                    values.insert(action.id.clone(), value.clone());
266                }
267            }
268        }
269        ConstantEvaluation { values }
270    }
271}
272
273fn folded_type_mismatch_diagnostic(
274    model: &ApplicationSemanticModel,
275    field: &crate::component_graph::StateField,
276    class_name: &str,
277) -> Option<ComponentDiagnostic> {
278    let declared_type = field.declared_type.as_ref()?;
279    let target = model.semantic_types.assignments.get(&field.id)?;
280    let source = crate::state_initializer_value_type(field.initial_value.as_ref()?);
281    (!crate::is_assignable(&source, &target.semantic_type)).then(|| ComponentDiagnostic {
282        severity: ComponentDiagnosticSeverity::Error,
283        effect_id: None,
284        statement_id: None,
285        context_declaration_candidate_id: None,
286        context_id: None,
287        provider_id: None,
288        consumer_id: None,
289        slot_id: None,
290        invocation_id: None,
291        component_instance_id: None,
292        slot_binding_id: None,
293        structural_region_id: None,
294        component_id: None,
295        provider_instance_id: None,
296        consumer_instance_id: None,
297        secondary_labels: Vec::new(),
298        provenance: Some(declared_type.provenance.clone()),
299        code: crate::TypeDiagnosticCode::IncompatibleStateInitializer
300            .as_str()
301            .to_string(),
302        message: format!(
303            "state field `{}` in class `{class_name}` declares `{}` but initializes with `{}`",
304            field.name,
305            declared_type.text,
306            state_initializer_type_name(&source)
307        ),
308    })
309}
310
311fn unknown_declared_type_diagnostic(
312    model: &ApplicationSemanticModel,
313    field: &crate::component_graph::StateField,
314    component: &crate::component_graph::ComponentNode,
315) -> Option<ComponentDiagnostic> {
316    let declared_type = field.declared_type.as_ref()?;
317    (!model.semantic_types.assignments.contains_key(&field.id)).then(|| ComponentDiagnostic {
318        severity: ComponentDiagnosticSeverity::Error,
319        effect_id: None,
320        statement_id: None,
321        context_declaration_candidate_id: None,
322        context_id: None,
323        provider_id: None,
324        consumer_id: None,
325        slot_id: None,
326        invocation_id: None,
327        component_instance_id: None,
328        slot_binding_id: None,
329        structural_region_id: None,
330        component_id: None,
331        provider_instance_id: None,
332        consumer_instance_id: None,
333        secondary_labels: Vec::new(),
334        provenance: Some(declared_type.provenance.clone()),
335        code: crate::TypeDiagnosticCode::UnknownType.as_str().to_string(),
336        message: format!(
337            "state field `{}` in class `{}` declares unresolved type `{}`",
338            field.name, component.class_name, declared_type.text
339        ),
340    })
341}
342
343fn action_assignment_mismatch_diagnostic(
344    model: &ApplicationSemanticModel,
345    component: &crate::component_graph::ComponentNode,
346    action: &crate::component_graph::ComponentAction,
347) -> Option<ComponentDiagnostic> {
348    let StateOperation::Assign(value) = &action.operation else {
349        return None;
350    };
351    let field = component
352        .state_fields
353        .iter()
354        .find(|field| field.name == action.field)?;
355    let declared_type = field.declared_type.as_ref()?;
356    let target = model.semantic_types.assignments.get(&field.id)?;
357    let source = crate::state_initializer_value_type(value);
358    (!crate::is_assignable(&source, &target.semantic_type)).then(|| ComponentDiagnostic {
359        severity: ComponentDiagnosticSeverity::Error,
360        effect_id: None,
361        statement_id: None,
362        context_declaration_candidate_id: None,
363        context_id: None,
364        provider_id: None,
365        consumer_id: None,
366        slot_id: None,
367        invocation_id: None,
368        component_instance_id: None,
369        slot_binding_id: None,
370        structural_region_id: None,
371        component_id: None,
372        provider_instance_id: None,
373        consumer_instance_id: None,
374        secondary_labels: Vec::new(),
375        provenance: model.provenance.get(&action.id).cloned(),
376        code: crate::TypeDiagnosticCode::IncompatibleAssignment
377            .as_str()
378            .to_string(),
379        message: format!(
380            "state field `{}` in class `{}` declares `{}` but action `{}` assigns `{}`",
381            field.name,
382            component.class_name,
383            declared_type.text,
384            action.method,
385            state_initializer_type_name(&source)
386        ),
387    })
388}
389
390#[allow(clippy::too_many_lines)]
391fn compound_mutation_type_diagnostics(
392    model: &ApplicationSemanticModel,
393    component: &crate::component_graph::ComponentNode,
394    action: &crate::component_graph::ComponentAction,
395) -> Vec<ComponentDiagnostic> {
396    let Some(field) = component
397        .state_fields
398        .iter()
399        .find(|field| field.name == action.field)
400    else {
401        return Vec::new();
402    };
403    let Some(declared_type) = field.declared_type.as_ref() else {
404        return Vec::new();
405    };
406    let Some(target) = model.semantic_types.assignments.get(&field.id) else {
407        return Vec::new();
408    };
409    let provenance = model.provenance.get(&action.id).cloned();
410    let number_compatible =
411        crate::is_assignable(&crate::SemanticType::Number, &target.semantic_type);
412    let boolean_compatible =
413        crate::is_assignable(&crate::SemanticType::Boolean, &target.semantic_type);
414
415    match &action.operation {
416        StateOperation::Toggle if !boolean_compatible => vec![ComponentDiagnostic {
417            severity: ComponentDiagnosticSeverity::Error,
418            effect_id: None,
419            statement_id: None,
420            context_declaration_candidate_id: None,
421            context_id: None,
422            provider_id: None,
423            consumer_id: None,
424            slot_id: None,
425            invocation_id: None,
426            component_instance_id: None,
427            slot_binding_id: None,
428            structural_region_id: None,
429            component_id: None,
430            provider_instance_id: None,
431            consumer_instance_id: None,
432            secondary_labels: Vec::new(),
433            provenance,
434            code: crate::TypeDiagnosticCode::InvalidToggleTarget
435                .as_str()
436                .to_string(),
437            message: format!(
438                "state field `{}` in class `{}` declares `{}` but action `{}` applies a boolean toggle",
439                field.name, component.class_name, declared_type.text, action.method
440            ),
441        }],
442        StateOperation::Increment | StateOperation::Decrement if !number_compatible => {
443            let operation = if matches!(action.operation, StateOperation::Increment) {
444                "increment"
445            } else {
446                "decrement"
447            };
448            vec![ComponentDiagnostic {
449            severity: ComponentDiagnosticSeverity::Error,
450            effect_id: None,
451            statement_id: None,
452            context_declaration_candidate_id: None,
453            context_id: None,
454            provider_id: None,
455            consumer_id: None,
456            slot_id: None,
457            invocation_id: None,
458            component_instance_id: None,
459            slot_binding_id: None,
460            structural_region_id: None,
461            component_id: None,
462            provider_instance_id: None,
463            consumer_instance_id: None,
464            secondary_labels: Vec::new(),
465                provenance,
466                code: crate::TypeDiagnosticCode::InvalidNumericMutationTarget
467                    .as_str()
468                    .to_string(),
469                message: format!(
470                    "state field `{}` in class `{}` declares `{}` but action `{}` applies numeric {}",
471                    field.name, component.class_name, declared_type.text, action.method, operation
472                ),
473            }]
474        }
475        StateOperation::AddAssign(value) | StateOperation::SubtractAssign(value) => {
476            let operation = if matches!(action.operation, StateOperation::AddAssign(_)) {
477                "add assignment"
478            } else {
479                "subtract assignment"
480            };
481            let mut diagnostics = Vec::new();
482            if !number_compatible {
483                diagnostics.push(ComponentDiagnostic {
484            severity: ComponentDiagnosticSeverity::Error,
485            effect_id: None,
486            statement_id: None,
487            context_declaration_candidate_id: None,
488            context_id: None,
489            provider_id: None,
490            consumer_id: None,
491            slot_id: None,
492            invocation_id: None,
493            component_instance_id: None,
494            slot_binding_id: None,
495            structural_region_id: None,
496            component_id: None,
497            provider_instance_id: None,
498            consumer_instance_id: None,
499            secondary_labels: Vec::new(),
500                    provenance: provenance.clone(),
501                    code: crate::TypeDiagnosticCode::InvalidCompoundMutationTarget
502                        .as_str()
503                        .to_string(),
504                    message: format!(
505                        "state field `{}` in class `{}` declares `{}` but action `{}` applies numeric {}",
506                        field.name, component.class_name, declared_type.text, action.method, operation
507                    ),
508                });
509            }
510            let source = crate::state_initializer_value_type(value);
511            if !crate::is_assignable(&source, &crate::SemanticType::Number) {
512                diagnostics.push(ComponentDiagnostic {
513            severity: ComponentDiagnosticSeverity::Error,
514            effect_id: None,
515            statement_id: None,
516            context_declaration_candidate_id: None,
517            context_id: None,
518            provider_id: None,
519            consumer_id: None,
520            slot_id: None,
521            invocation_id: None,
522            component_instance_id: None,
523            slot_binding_id: None,
524            structural_region_id: None,
525            component_id: None,
526            provider_instance_id: None,
527            consumer_instance_id: None,
528            secondary_labels: Vec::new(),
529                    provenance,
530                    code: crate::TypeDiagnosticCode::InvalidCompoundMutationOperand
531                        .as_str()
532                        .to_string(),
533                    message: format!(
534                        "action `{}` applies numeric {} to state field `{}` with `{}` operand",
535                        action.method, operation, field.name, state_initializer_type_name(&source)
536                    ),
537                });
538            }
539            diagnostics
540        }
541        _ => Vec::new(),
542    }
543}
544
545fn template_binding_type_diagnostic(
546    model: &ApplicationSemanticModel,
547    entity: &crate::TemplateSemanticEntity,
548) -> Option<ComponentDiagnostic> {
549    if entity.kind != crate::TemplateSemanticKind::Binding {
550        return None;
551    }
552    let assignment = model.semantic_types.assignments.get(&entity.id)?;
553    (!is_text_renderable(&assignment.semantic_type)).then(|| ComponentDiagnostic {
554        severity: ComponentDiagnosticSeverity::Error,
555        effect_id: None,
556        statement_id: None,
557        context_declaration_candidate_id: None,
558        context_id: None,
559        provider_id: None,
560        consumer_id: None,
561        slot_id: None,
562        invocation_id: None,
563        component_instance_id: None,
564        slot_binding_id: None,
565        structural_region_id: None,
566        component_id: None,
567        provider_instance_id: None,
568        consumer_instance_id: None,
569        secondary_labels: Vec::new(),
570        provenance: Some(entity.provenance.clone()),
571        code: crate::TypeDiagnosticCode::NonRenderableValue
572            .as_str()
573            .to_string(),
574        message: format!(
575            "template text binding `{}` cannot render a {} value directly",
576            entity.expression.as_deref().unwrap_or("<unknown>"),
577            state_initializer_type_name(&assignment.semantic_type)
578        ),
579    })
580}
581
582fn attribute_binding_type_diagnostic(
583    model: &ApplicationSemanticModel,
584    entity: &crate::TemplateSemanticEntity,
585) -> Option<ComponentDiagnostic> {
586    if entity.kind != crate::TemplateSemanticKind::AttributeBinding {
587        return None;
588    }
589    let name = entity.attribute_name.as_deref()?;
590    let contract = crate::dom_binding_contract(name)?;
591    let assignment = model.semantic_types.assignments.get(&entity.id)?;
592    (!crate::is_assignable(&assignment.semantic_type, &contract.semantic_type)).then(|| {
593        ComponentDiagnostic {
594            severity: ComponentDiagnosticSeverity::Error,
595            effect_id: None,
596            statement_id: None,
597            context_declaration_candidate_id: None,
598            context_id: None,
599            provider_id: None,
600            consumer_id: None,
601            slot_id: None,
602            invocation_id: None,
603            component_instance_id: None,
604            slot_binding_id: None,
605            structural_region_id: None,
606            component_id: None,
607            provider_instance_id: None,
608            consumer_instance_id: None,
609            secondary_labels: Vec::new(),
610            provenance: Some(entity.provenance.clone()),
611            code: crate::TypeDiagnosticCode::InvalidBinding
612                .as_str()
613                .to_string(),
614            message: format!(
615                "{} binding `{}` requires {}, but expression `{}` has {}",
616                match contract.kind {
617                    crate::DomBindingKind::Attribute => "attribute",
618                    crate::DomBindingKind::Property => "property",
619                },
620                name,
621                state_initializer_type_name(&contract.semantic_type),
622                entity.expression.as_deref().unwrap_or("<unknown>"),
623                state_initializer_type_name(&assignment.semantic_type)
624            ),
625        }
626    })
627}
628
629fn conditional_type_diagnostic(
630    model: &ApplicationSemanticModel,
631    entity: &crate::TemplateSemanticEntity,
632) -> Option<ComponentDiagnostic> {
633    if entity.kind != crate::TemplateSemanticKind::Conditional {
634        return None;
635    }
636    let assignment = model.semantic_types.assignments.get(&entity.id)?;
637    (!is_boolean_condition(&assignment.semantic_type)).then(|| ComponentDiagnostic {
638        severity: ComponentDiagnosticSeverity::Error,
639        effect_id: None,
640        statement_id: None,
641        context_declaration_candidate_id: None,
642        context_id: None,
643        provider_id: None,
644        consumer_id: None,
645        slot_id: None,
646        invocation_id: None,
647        component_instance_id: None,
648        slot_binding_id: None,
649        structural_region_id: None,
650        component_id: None,
651        provider_instance_id: None,
652        consumer_instance_id: None,
653        secondary_labels: Vec::new(),
654        provenance: Some(entity.provenance.clone()),
655        code: crate::TypeDiagnosticCode::InvalidCondition
656            .as_str()
657            .to_string(),
658        message: format!(
659            "conditional expression `{}` requires boolean, but has {}",
660            entity.expression.as_deref().unwrap_or("<unknown>"),
661            state_initializer_type_name(&assignment.semantic_type)
662        ),
663    })
664}
665
666fn list_iterable_type_diagnostic(
667    model: &ApplicationSemanticModel,
668    entity: &crate::TemplateSemanticEntity,
669) -> Option<ComponentDiagnostic> {
670    if entity.kind != crate::TemplateSemanticKind::List {
671        return None;
672    }
673    let assignment = model.semantic_types.assignments.get(&entity.id)?;
674    let iterable = matches!(
675        assignment.semantic_type,
676        crate::SemanticType::Array(_)
677            | crate::SemanticType::Tuple(_)
678            | crate::SemanticType::Unknown
679    );
680    (!iterable).then(|| ComponentDiagnostic {
681        severity: ComponentDiagnosticSeverity::Error,
682        effect_id: None,
683        statement_id: None,
684        context_declaration_candidate_id: None,
685        context_id: None,
686        provider_id: None,
687        consumer_id: None,
688        slot_id: None,
689        invocation_id: None,
690        component_instance_id: None,
691        slot_binding_id: None,
692        structural_region_id: None,
693        component_id: None,
694        provider_instance_id: None,
695        consumer_instance_id: None,
696        secondary_labels: Vec::new(),
697        provenance: Some(entity.provenance.clone()),
698        code: crate::TypeDiagnosticCode::NonIterableList
699            .as_str()
700            .to_string(),
701        message: format!(
702            "list iterable `{}` requires an array-like value, but has {}",
703            entity.expression.as_deref().unwrap_or("<unknown>"),
704            state_initializer_type_name(&assignment.semantic_type)
705        ),
706    })
707}
708
709fn member_access_type_diagnostic(
710    model: &ApplicationSemanticModel,
711    entity: &crate::TemplateSemanticEntity,
712) -> Option<ComponentDiagnostic> {
713    let access = model.semantic_types.member_accesses.get(&entity.id)?;
714    access.semantic_type.is_none().then(|| ComponentDiagnostic {
715        severity: ComponentDiagnosticSeverity::Error,
716        effect_id: None,
717        statement_id: None,
718        context_declaration_candidate_id: None,
719        context_id: None,
720        provider_id: None,
721        consumer_id: None,
722        slot_id: None,
723        invocation_id: None,
724        component_instance_id: None,
725        slot_binding_id: None,
726        structural_region_id: None,
727        component_id: None,
728        provider_instance_id: None,
729        consumer_instance_id: None,
730        secondary_labels: Vec::new(),
731        provenance: Some(entity.provenance.clone()),
732        code: crate::TypeDiagnosticCode::MissingMember
733            .as_str()
734            .to_string(),
735        message: format!(
736            "member access `{}` does not resolve against its canonical object type",
737            access.expression
738        ),
739    })
740}
741
742fn is_boolean_condition(semantic_type: &crate::SemanticType) -> bool {
743    match semantic_type {
744        crate::SemanticType::Unknown
745        | crate::SemanticType::Boolean
746        | crate::SemanticType::BooleanLiteral(_) => true,
747        crate::SemanticType::Union(members) => members.iter().all(is_boolean_condition),
748        _ => false,
749    }
750}
751
752fn is_text_renderable(semantic_type: &crate::SemanticType) -> bool {
753    match semantic_type {
754        crate::SemanticType::Unknown
755        | crate::SemanticType::Null
756        | crate::SemanticType::Boolean
757        | crate::SemanticType::Number
758        | crate::SemanticType::String
759        | crate::SemanticType::BooleanLiteral(_)
760        | crate::SemanticType::NumberLiteral(_)
761        | crate::SemanticType::StringLiteral(_) => true,
762        crate::SemanticType::Union(members) => members.iter().all(is_text_renderable),
763        crate::SemanticType::Never
764        | crate::SemanticType::Form
765        | crate::SemanticType::SlotContent
766        | crate::SemanticType::File
767        | crate::SemanticType::Array(_)
768        | crate::SemanticType::Tuple(_)
769        | crate::SemanticType::Object(_)
770        | crate::SemanticType::Resource(_) => false,
771    }
772}
773
774fn push_diagnostic_once(
775    diagnostics: &mut Vec<ComponentDiagnostic>,
776    diagnostic: ComponentDiagnostic,
777) {
778    if !diagnostics.contains(&diagnostic) {
779        diagnostics.push(diagnostic);
780    }
781}
782
783fn state_initializer_type_name(semantic_type: &crate::SemanticType) -> &'static str {
784    match semantic_type {
785        crate::SemanticType::Unknown => "unknown",
786        crate::SemanticType::Never => "never",
787        crate::SemanticType::Null => "null",
788        crate::SemanticType::Boolean | crate::SemanticType::BooleanLiteral(_) => "boolean",
789        crate::SemanticType::Number | crate::SemanticType::NumberLiteral(_) => "number",
790        crate::SemanticType::String | crate::SemanticType::StringLiteral(_) => "string",
791        crate::SemanticType::File => "File",
792        crate::SemanticType::Form => "Form",
793        crate::SemanticType::SlotContent => "SlotContent",
794        crate::SemanticType::Array(_) => "array",
795        crate::SemanticType::Tuple(_) => "tuple",
796        crate::SemanticType::Object(_) => "object",
797        crate::SemanticType::Union(_) => "union",
798        crate::SemanticType::Resource(_) => "resource",
799    }
800}
801
802#[derive(Debug, Default, Clone, Copy)]
803pub struct DeadSemanticAnalysisPass;
804
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct DeadSemanticAnalysis {
807    pub unreferenced_methods: Vec<SemanticId>,
808    pub unreferenced_actions: Vec<SemanticId>,
809}
810
811impl ImmutableAsmPass for DeadSemanticAnalysisPass {
812    type Output = DeadSemanticAnalysis;
813    fn transform(&self, model: &ApplicationSemanticModel) -> DeadSemanticAnalysis {
814        let live = model
815            .references
816            .iter()
817            .map(|reference| reference.target.clone())
818            .collect::<std::collections::BTreeSet<_>>();
819        let mut methods = Vec::new();
820        let mut actions = Vec::new();
821        for component in &model.components {
822            for method in &component.methods {
823                if method.name != "render" && !live.contains(&method.id) {
824                    methods.push(method.id.clone());
825                    actions.extend(
826                        model
827                            .children_of(&method.id)
828                            .iter()
829                            .filter(|id| {
830                                model.entity(id).is_some_and(|entity| {
831                                    entity.kind() == SemanticEntityKind::Action
832                                })
833                            })
834                            .map(|id| (*id).clone()),
835                    );
836                }
837            }
838        }
839        DeadSemanticAnalysis {
840            unreferenced_methods: methods,
841            unreferenced_actions: actions,
842        }
843    }
844}
845
846#[derive(Debug, Default, Clone, Copy)]
847pub struct OptimizationPlanningPass;
848#[derive(Debug, Clone, PartialEq, Eq)]
849pub struct OptimizationPlan {
850    pub recommendations: Vec<OptimizationRecommendation>,
851}
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct OptimizationRecommendation {
854    pub id: SemanticId,
855    pub provenance: SourceProvenance,
856}
857
858impl ImmutableAsmPass for OptimizationPlanningPass {
859    type Output = OptimizationPlan;
860    fn transform(&self, model: &ApplicationSemanticModel) -> OptimizationPlan {
861        let dead = DeadSemanticAnalysisPass.transform(model);
862        let recommendations = dead
863            .unreferenced_methods
864            .into_iter()
865            .chain(dead.unreferenced_actions)
866            .filter_map(|id| {
867                model
868                    .provenance(&id)
869                    .cloned()
870                    .map(|provenance| OptimizationRecommendation { id, provenance })
871            })
872            .collect();
873        OptimizationPlan { recommendations }
874    }
875}
876
877#[derive(Debug, Default, Clone, Copy)]
878pub struct ExplainabilityPass;
879#[derive(Debug, Clone, PartialEq, Eq)]
880pub struct ExplainabilityReport {
881    pub lines: Vec<String>,
882}
883impl ImmutableAsmPass for ExplainabilityPass {
884    type Output = ExplainabilityReport;
885    fn transform(&self, model: &ApplicationSemanticModel) -> ExplainabilityReport {
886        let dependencies = DependencyAnalysisPass.transform(model);
887        let constants = ConstantEvaluationPass.transform(model);
888        let optimizations = OptimizationPlanningPass.transform(model);
889        let validation = validate_application_semantic_model(model);
890        ExplainabilityReport {
891            lines: vec![
892                format!("components={}", model.components.len()),
893                format!("dependencies={}", dependencies.dependencies.len()),
894                format!("constants={}", constants.values.len()),
895                format!(
896                    "optimization_recommendations={}",
897                    optimizations.recommendations.len()
898                ),
899                format!("validation_diagnostics={}", validation.len()),
900            ],
901        }
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::{
908        AnalysisPass, ConstantEvaluationPass, ConstantFoldingPass, DeadSemanticAnalysisPass,
909        DependencyAnalysisPass, ExplainabilityPass, ImmutableAsmPass, OptimizationPlanningPass,
910    };
911    use crate::{build_application_semantic_model, SemanticOwner, SerializableValue};
912
913    #[test]
914    fn folds_lowered_constant_expressions_immutably_before_backend_consumption() {
915        let parsed = presolve_parser::parse_file(
916            "src/FoldedState.tsx",
917            r#"
918@component("x-folded-state")
919class FoldedState extends Component {
920  total: number = state((1 + 2) * 3);
921
922  render() {
923    return <output>{this.total}</output>;
924  }
925}
926"#,
927        );
928        let asm = build_application_semantic_model(&parsed);
929        let original = asm.clone();
930        let field_id = asm.components[0].state_fields[0].id.clone();
931
932        assert_eq!(asm.components[0].state_fields[0].initial_value, None);
933        assert!(asm.diagnostics.is_empty());
934
935        let folded = ConstantFoldingPass.transform(&asm);
936        assert_eq!(
937            folded.components[0].state_fields[0].initial_value,
938            Some(SerializableValue::Number("9".to_string()))
939        );
940        assert_eq!(
941            ConstantEvaluationPass.transform(&asm).values[&field_id],
942            SerializableValue::Number("9".to_string())
943        );
944        assert_eq!(
945            crate::generate_static_html(&crate::TemplateGraph {
946                templates: folded.templates.clone(),
947            }),
948            "<output data-presolve-node=\"n0\" data-presolve-bindings=\"this.total\"><!-- presolve-binding:n1:this.total -->9</output>\n"
949        );
950        assert_eq!(ConstantFoldingPass.transform(&folded), folded);
951        assert_eq!(asm, original);
952    }
953
954    #[test]
955    fn rejects_non_renderable_direct_text_bindings_with_canonical_types() {
956        let parsed = presolve_parser::parse_file(
957            "src/NonRenderableBinding.tsx",
958            r#"
959@component("x-non-renderable")
960class NonRenderableBinding extends Component {
961  user = state({ id: "1" });
962  label = state("Presolve");
963
964  render() {
965    return <p>{this.user}{this.label}</p>;
966  }
967}
968"#,
969        );
970        let asm = build_application_semantic_model(&parsed);
971        let binding = asm
972            .template_entities
973            .iter()
974            .find(|entity| entity.expression.as_deref() == Some("this.user"))
975            .expect("user text binding");
976
977        assert_eq!(
978            asm.semantic_types.assignments[&binding.id].semantic_type,
979            crate::SemanticType::Object(crate::ObjectType {
980                properties: std::collections::BTreeMap::from([(
981                    "id".to_string(),
982                    crate::SemanticType::String,
983                )]),
984            })
985        );
986
987        let folded = ConstantFoldingPass.transform(&asm);
988        assert!(folded.diagnostics.iter().any(|diagnostic| {
989            diagnostic.code == "PSC1027"
990                && diagnostic.message.contains("this.user")
991                && diagnostic.message.contains("object")
992        }));
993        assert!(!folded
994            .diagnostics
995            .iter()
996            .any(|diagnostic| diagnostic.message.contains("this.label")));
997    }
998
999    #[test]
1000    fn validates_typed_dom_attribute_and_property_bindings() {
1001        let parsed = presolve_parser::parse_file(
1002            "src/TypedAttributes.tsx",
1003            r#"
1004@component("x-typed-attributes")
1005class TypedAttributes extends Component {
1006  enabled = state(true);
1007  label = state("Presolve");
1008  count = state(1);
1009
1010  render() {
1011    return <><button disabled={this.label} value={this.count}>Save</button><a href={this.enabled}>Link</a></>;
1012  }
1013}
1014"#,
1015        );
1016        let asm = build_application_semantic_model(&parsed);
1017        let value_binding = asm
1018            .template_entities
1019            .iter()
1020            .find(|entity| entity.attribute_name.as_deref() == Some("value"))
1021            .expect("value binding");
1022        assert_eq!(
1023            asm.semantic_types.assignments[&value_binding.id].semantic_type,
1024            crate::SemanticType::Number
1025        );
1026
1027        let folded = ConstantFoldingPass.transform(&asm);
1028        let diagnostics = folded
1029            .diagnostics
1030            .iter()
1031            .filter(|diagnostic| diagnostic.code == "PSC1028")
1032            .collect::<Vec<_>>();
1033        assert_eq!(diagnostics.len(), 2);
1034        assert!(diagnostics.iter().any(|diagnostic| {
1035            diagnostic.message.contains("property binding `disabled`")
1036                && diagnostic.message.contains("string")
1037        }));
1038        assert!(diagnostics.iter().any(|diagnostic| {
1039            diagnostic.message.contains("attribute binding `href`")
1040                && diagnostic.message.contains("boolean")
1041        }));
1042    }
1043
1044    #[test]
1045    fn rejects_string_binding_for_boolean_aria_attribute() {
1046        let parsed = presolve_parser::parse_file(
1047            "src/InvalidAriaBinding.tsx",
1048            r#"
1049@component("x-invalid-aria")
1050class InvalidAria extends Component {
1051  label = state("invalid");
1052  render() { return <button aria-invalid={this.label}>Save</button>; }
1053}
1054"#,
1055        );
1056        let folded = ConstantFoldingPass.transform(&build_application_semantic_model(&parsed));
1057        let diagnostics = folded
1058            .diagnostics
1059            .iter()
1060            .filter(|diagnostic| diagnostic.code == "PSC1028")
1061            .collect::<Vec<_>>();
1062        assert_eq!(diagnostics.len(), 1);
1063        assert!(diagnostics[0]
1064            .message
1065            .contains("attribute binding `aria-invalid`"));
1066        assert!(diagnostics[0].message.contains("requires boolean"));
1067    }
1068
1069    #[test]
1070    fn requires_boolean_canonical_template_conditions() {
1071        let parsed = presolve_parser::parse_file(
1072            "src/TypedConditions.tsx",
1073            r#"
1074@component("x-typed-conditions")
1075class TypedConditions extends Component {
1076  enabled = state(true);
1077  count = state(1);
1078
1079  render() {
1080    return <>{this.enabled ? <p>On</p> : <p>Off</p>}{this.count ? <p>Count</p> : <p>Empty</p>}</>;
1081  }
1082}
1083"#,
1084        );
1085        let asm = build_application_semantic_model(&parsed);
1086        let count_condition = asm
1087            .template_entities
1088            .iter()
1089            .find(|entity| {
1090                entity.kind == crate::TemplateSemanticKind::Conditional
1091                    && entity.expression.as_deref() == Some("this.count")
1092            })
1093            .expect("count conditional");
1094        assert_eq!(
1095            asm.semantic_types.assignments[&count_condition.id].semantic_type,
1096            crate::SemanticType::Number
1097        );
1098
1099        let folded = ConstantFoldingPass.transform(&asm);
1100        let diagnostics = folded
1101            .diagnostics
1102            .iter()
1103            .filter(|diagnostic| diagnostic.code == "PSC1029")
1104            .collect::<Vec<_>>();
1105        assert_eq!(diagnostics.len(), 1);
1106        assert!(diagnostics[0].message.contains("this.count"));
1107    }
1108
1109    #[test]
1110    fn types_list_iterables_and_rejects_non_arrays() {
1111        let parsed = presolve_parser::parse_file(
1112            "src/TypedLists.tsx",
1113            r#"
1114@component("x-typed-lists")
1115class TypedLists extends Component {
1116  items = state([{ id: "a" }]);
1117  count = state(1);
1118
1119  render() {
1120    return <><ul>{this.items.map((item, index) => <li key={item.id}>{index}</li>)}</ul><div>{this.count.map((item) => <span>{item}</span>)}</div></>;
1121  }
1122}
1123"#,
1124        );
1125        let asm = build_application_semantic_model(&parsed);
1126        let item_list = asm
1127            .template_entities
1128            .iter()
1129            .find(|entity| entity.expression.as_deref() == Some("this.items"))
1130            .expect("items list");
1131        let scope = asm
1132            .semantic_types
1133            .list_scopes
1134            .get(&item_list.id)
1135            .expect("typed item list scope");
1136        assert_eq!(scope.item_name, "item");
1137        assert_eq!(scope.index_type, Some(crate::SemanticType::Number));
1138        assert!(matches!(scope.item_type, crate::SemanticType::Object(_)));
1139
1140        let folded = ConstantFoldingPass.transform(&asm);
1141        assert!(folded.diagnostics.iter().any(|diagnostic| {
1142            diagnostic.code == "PSC1030" && diagnostic.message.contains("this.count")
1143        }));
1144    }
1145
1146    #[test]
1147    fn resolves_typed_list_item_member_accesses() {
1148        let parsed = presolve_parser::parse_file(
1149            "src/TypedMembers.tsx",
1150            r#"
1151@component("x-typed-members")
1152class TypedMembers extends Component {
1153  todos = state([{ details: { region: "west" } }]);
1154
1155  render() {
1156    return <ul>{this.todos.map((todo) => <li key={todo.details.region}>{todo.details.region}{todo.missing}</li>)}</ul>;
1157  }
1158}
1159"#,
1160        );
1161        let asm = build_application_semantic_model(&parsed);
1162        let region = asm
1163            .template_entities
1164            .iter()
1165            .find(|entity| entity.expression.as_deref() == Some("todo.details.region"))
1166            .expect("region member binding");
1167        assert_eq!(
1168            asm.semantic_types.assignments[&region.id].semantic_type,
1169            crate::SemanticType::String
1170        );
1171        assert_eq!(
1172            asm.semantic_types.member_accesses[&region.id].semantic_type,
1173            Some(crate::SemanticType::String)
1174        );
1175
1176        let folded = ConstantFoldingPass.transform(&asm);
1177        assert!(folded.diagnostics.iter().any(|diagnostic| {
1178            diagnostic.code == "PSC1031" && diagnostic.message.contains("todo.missing")
1179        }));
1180    }
1181
1182    #[test]
1183    fn validates_state_initializers_with_canonical_semantic_assignability() {
1184        let parsed = presolve_parser::parse_file(
1185            "src/StateCompatibility.tsx",
1186            r#"
1187@component("x-state-compatibility")
1188class StateCompatibility extends Component {
1189  names: string[] = state([]);
1190  filter: "all" | "active" = state("all");
1191  user: { id: string } | null = state(null);
1192  invalid: string[] = state([1]);
1193}
1194"#,
1195        );
1196
1197        let asm = build_application_semantic_model(&parsed);
1198        let folded = ConstantFoldingPass.transform(&asm);
1199        let diagnostics = folded
1200            .diagnostics
1201            .iter()
1202            .filter(|diagnostic| diagnostic.code == "PSC1016")
1203            .collect::<Vec<_>>();
1204
1205        assert_eq!(diagnostics.len(), 1);
1206        assert!(diagnostics[0].message.contains("invalid"));
1207        assert!(diagnostics[0].message.contains("declares `string[]`"));
1208        assert!(diagnostics[0].message.contains("initializes with `tuple`"));
1209    }
1210
1211    #[test]
1212    fn reports_unresolved_declared_state_types_with_a_stable_type_diagnostic() {
1213        let parsed = presolve_parser::parse_file(
1214            "src/UnknownStateType.tsx",
1215            r#"
1216@component("x-unknown-state-type")
1217class UnknownStateType extends Component {
1218  value: MissingType = state(null);
1219}
1220"#,
1221        );
1222        let asm = build_application_semantic_model(&parsed);
1223        let folded = ConstantFoldingPass.transform(&asm);
1224        assert!(folded.diagnostics.iter().any(|diagnostic| {
1225            diagnostic.code == crate::TypeDiagnosticCode::UnknownType.as_str()
1226                && diagnostic.message.contains("MissingType")
1227        }));
1228    }
1229
1230    #[test]
1231    fn uses_canonical_expression_provenance_for_folding_diagnostics() {
1232        let parsed = presolve_parser::parse_file(
1233            "src/InvalidExpression.tsx",
1234            r#"
1235@component("x-invalid-expression")
1236class InvalidExpression extends Component {
1237  total = state(10 / 0);
1238}
1239"#,
1240        );
1241        let asm = build_application_semantic_model(&parsed);
1242        let field = &asm.components[0].state_fields[0];
1243        let root = asm
1244            .expression_graph
1245            .root_for(&field.id)
1246            .expect("expression root");
1247        let expected_provenance = asm
1248            .expression_graph
1249            .nodes
1250            .get(root)
1251            .expect("expression root node")
1252            .provenance
1253            .clone();
1254
1255        let folded = ConstantFoldingPass.transform(&asm);
1256        let diagnostic = folded
1257            .diagnostics
1258            .iter()
1259            .find(|diagnostic| diagnostic.code == "PSC1022")
1260            .expect("constant-expression diagnostic");
1261
1262        assert_eq!(diagnostic.provenance.as_ref(), Some(&expected_provenance));
1263    }
1264
1265    #[test]
1266    fn finds_dead_actions_from_canonical_ownership() {
1267        let parsed = presolve_parser::parse_file(
1268            "src/Counter.tsx",
1269            r#"
1270@component("x-counter")
1271class Counter extends Component {
1272  count = state(0);
1273
1274  unused() {
1275    this.count++;
1276  }
1277
1278  render() {
1279    return <div>Counter</div>;
1280  }
1281}
1282"#,
1283        );
1284        let mut asm = build_application_semantic_model(&parsed);
1285        let action_id = asm.components[0].actions[0].id.clone();
1286        asm.components[0].actions[0].owner = SemanticOwner::Application;
1287
1288        let analysis = DeadSemanticAnalysisPass.analyze(&asm);
1289        assert_eq!(analysis.unreferenced_actions, vec![action_id]);
1290    }
1291
1292    #[test]
1293    fn transforms_asm_immutably_with_compatibility_analysis_results() {
1294        let parsed = presolve_parser::parse_file(
1295            "src/Counter.tsx",
1296            r#"
1297@component("x-counter")
1298class Counter extends Component {
1299  count = state(0);
1300
1301  increment() {
1302    this.count++;
1303  }
1304
1305  render() {
1306    return <button onClick={() => this.increment()}>{this.count}</button>;
1307  }
1308}
1309"#,
1310        );
1311        let asm = build_application_semantic_model(&parsed);
1312        let original = asm.clone();
1313
1314        assert_eq!(
1315            DependencyAnalysisPass.transform(&asm),
1316            DependencyAnalysisPass.analyze(&asm)
1317        );
1318        assert_eq!(
1319            ConstantEvaluationPass.transform(&asm),
1320            ConstantEvaluationPass.analyze(&asm)
1321        );
1322        assert_eq!(
1323            ConstantFoldingPass.transform(&asm),
1324            ConstantFoldingPass.analyze(&asm)
1325        );
1326        assert_eq!(
1327            DeadSemanticAnalysisPass.transform(&asm),
1328            DeadSemanticAnalysisPass.analyze(&asm)
1329        );
1330        assert_eq!(
1331            OptimizationPlanningPass.transform(&asm),
1332            OptimizationPlanningPass.analyze(&asm)
1333        );
1334        assert_eq!(
1335            ExplainabilityPass.transform(&asm),
1336            ExplainabilityPass.analyze(&asm)
1337        );
1338        assert_eq!(asm, original);
1339    }
1340}