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::Array(_)
767        | crate::SemanticType::Tuple(_)
768        | crate::SemanticType::Object(_)
769        | crate::SemanticType::Resource(_) => false,
770    }
771}
772
773fn push_diagnostic_once(
774    diagnostics: &mut Vec<ComponentDiagnostic>,
775    diagnostic: ComponentDiagnostic,
776) {
777    if !diagnostics.contains(&diagnostic) {
778        diagnostics.push(diagnostic);
779    }
780}
781
782fn state_initializer_type_name(semantic_type: &crate::SemanticType) -> &'static str {
783    match semantic_type {
784        crate::SemanticType::Unknown => "unknown",
785        crate::SemanticType::Never => "never",
786        crate::SemanticType::Null => "null",
787        crate::SemanticType::Boolean | crate::SemanticType::BooleanLiteral(_) => "boolean",
788        crate::SemanticType::Number | crate::SemanticType::NumberLiteral(_) => "number",
789        crate::SemanticType::String | crate::SemanticType::StringLiteral(_) => "string",
790        crate::SemanticType::Form => "Form",
791        crate::SemanticType::SlotContent => "SlotContent",
792        crate::SemanticType::Array(_) => "array",
793        crate::SemanticType::Tuple(_) => "tuple",
794        crate::SemanticType::Object(_) => "object",
795        crate::SemanticType::Union(_) => "union",
796        crate::SemanticType::Resource(_) => "resource",
797    }
798}
799
800#[derive(Debug, Default, Clone, Copy)]
801pub struct DeadSemanticAnalysisPass;
802
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub struct DeadSemanticAnalysis {
805    pub unreferenced_methods: Vec<SemanticId>,
806    pub unreferenced_actions: Vec<SemanticId>,
807}
808
809impl ImmutableAsmPass for DeadSemanticAnalysisPass {
810    type Output = DeadSemanticAnalysis;
811    fn transform(&self, model: &ApplicationSemanticModel) -> DeadSemanticAnalysis {
812        let live = model
813            .references
814            .iter()
815            .map(|reference| reference.target.clone())
816            .collect::<std::collections::BTreeSet<_>>();
817        let mut methods = Vec::new();
818        let mut actions = Vec::new();
819        for component in &model.components {
820            for method in &component.methods {
821                if method.name != "render" && !live.contains(&method.id) {
822                    methods.push(method.id.clone());
823                    actions.extend(
824                        model
825                            .children_of(&method.id)
826                            .iter()
827                            .filter(|id| {
828                                model.entity(id).is_some_and(|entity| {
829                                    entity.kind() == SemanticEntityKind::Action
830                                })
831                            })
832                            .map(|id| (*id).clone()),
833                    );
834                }
835            }
836        }
837        DeadSemanticAnalysis {
838            unreferenced_methods: methods,
839            unreferenced_actions: actions,
840        }
841    }
842}
843
844#[derive(Debug, Default, Clone, Copy)]
845pub struct OptimizationPlanningPass;
846#[derive(Debug, Clone, PartialEq, Eq)]
847pub struct OptimizationPlan {
848    pub recommendations: Vec<OptimizationRecommendation>,
849}
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct OptimizationRecommendation {
852    pub id: SemanticId,
853    pub provenance: SourceProvenance,
854}
855
856impl ImmutableAsmPass for OptimizationPlanningPass {
857    type Output = OptimizationPlan;
858    fn transform(&self, model: &ApplicationSemanticModel) -> OptimizationPlan {
859        let dead = DeadSemanticAnalysisPass.transform(model);
860        let recommendations = dead
861            .unreferenced_methods
862            .into_iter()
863            .chain(dead.unreferenced_actions)
864            .filter_map(|id| {
865                model
866                    .provenance(&id)
867                    .cloned()
868                    .map(|provenance| OptimizationRecommendation { id, provenance })
869            })
870            .collect();
871        OptimizationPlan { recommendations }
872    }
873}
874
875#[derive(Debug, Default, Clone, Copy)]
876pub struct ExplainabilityPass;
877#[derive(Debug, Clone, PartialEq, Eq)]
878pub struct ExplainabilityReport {
879    pub lines: Vec<String>,
880}
881impl ImmutableAsmPass for ExplainabilityPass {
882    type Output = ExplainabilityReport;
883    fn transform(&self, model: &ApplicationSemanticModel) -> ExplainabilityReport {
884        let dependencies = DependencyAnalysisPass.transform(model);
885        let constants = ConstantEvaluationPass.transform(model);
886        let optimizations = OptimizationPlanningPass.transform(model);
887        let validation = validate_application_semantic_model(model);
888        ExplainabilityReport {
889            lines: vec![
890                format!("components={}", model.components.len()),
891                format!("dependencies={}", dependencies.dependencies.len()),
892                format!("constants={}", constants.values.len()),
893                format!(
894                    "optimization_recommendations={}",
895                    optimizations.recommendations.len()
896                ),
897                format!("validation_diagnostics={}", validation.len()),
898            ],
899        }
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::{
906        AnalysisPass, ConstantEvaluationPass, ConstantFoldingPass, DeadSemanticAnalysisPass,
907        DependencyAnalysisPass, ExplainabilityPass, ImmutableAsmPass, OptimizationPlanningPass,
908    };
909    use crate::{build_application_semantic_model, SemanticOwner, SerializableValue};
910
911    #[test]
912    fn folds_lowered_constant_expressions_immutably_before_backend_consumption() {
913        let parsed = presolve_parser::parse_file(
914            "src/FoldedState.tsx",
915            r#"
916@component("x-folded-state")
917class FoldedState extends Component {
918  total: number = state((1 + 2) * 3);
919
920  render() {
921    return <output>{this.total}</output>;
922  }
923}
924"#,
925        );
926        let asm = build_application_semantic_model(&parsed);
927        let original = asm.clone();
928        let field_id = asm.components[0].state_fields[0].id.clone();
929
930        assert_eq!(asm.components[0].state_fields[0].initial_value, None);
931        assert!(asm.diagnostics.is_empty());
932
933        let folded = ConstantFoldingPass.transform(&asm);
934        assert_eq!(
935            folded.components[0].state_fields[0].initial_value,
936            Some(SerializableValue::Number("9".to_string()))
937        );
938        assert_eq!(
939            ConstantEvaluationPass.transform(&asm).values[&field_id],
940            SerializableValue::Number("9".to_string())
941        );
942        assert_eq!(
943            crate::generate_static_html(&crate::TemplateGraph {
944                templates: folded.templates.clone(),
945            }),
946            "<output data-presolve-node=\"n0\" data-presolve-bindings=\"this.total\"><!-- presolve-binding:n1:this.total -->9</output>\n"
947        );
948        assert_eq!(ConstantFoldingPass.transform(&folded), folded);
949        assert_eq!(asm, original);
950    }
951
952    #[test]
953    fn rejects_non_renderable_direct_text_bindings_with_canonical_types() {
954        let parsed = presolve_parser::parse_file(
955            "src/NonRenderableBinding.tsx",
956            r#"
957@component("x-non-renderable")
958class NonRenderableBinding extends Component {
959  user = state({ id: "1" });
960  label = state("Presolve");
961
962  render() {
963    return <p>{this.user}{this.label}</p>;
964  }
965}
966"#,
967        );
968        let asm = build_application_semantic_model(&parsed);
969        let binding = asm
970            .template_entities
971            .iter()
972            .find(|entity| entity.expression.as_deref() == Some("this.user"))
973            .expect("user text binding");
974
975        assert_eq!(
976            asm.semantic_types.assignments[&binding.id].semantic_type,
977            crate::SemanticType::Object(crate::ObjectType {
978                properties: std::collections::BTreeMap::from([(
979                    "id".to_string(),
980                    crate::SemanticType::String,
981                )]),
982            })
983        );
984
985        let folded = ConstantFoldingPass.transform(&asm);
986        assert!(folded.diagnostics.iter().any(|diagnostic| {
987            diagnostic.code == "PSC1027"
988                && diagnostic.message.contains("this.user")
989                && diagnostic.message.contains("object")
990        }));
991        assert!(!folded
992            .diagnostics
993            .iter()
994            .any(|diagnostic| diagnostic.message.contains("this.label")));
995    }
996
997    #[test]
998    fn validates_typed_dom_attribute_and_property_bindings() {
999        let parsed = presolve_parser::parse_file(
1000            "src/TypedAttributes.tsx",
1001            r#"
1002@component("x-typed-attributes")
1003class TypedAttributes extends Component {
1004  enabled = state(true);
1005  label = state("Presolve");
1006  count = state(1);
1007
1008  render() {
1009    return <><button disabled={this.label} value={this.count}>Save</button><a href={this.enabled}>Link</a></>;
1010  }
1011}
1012"#,
1013        );
1014        let asm = build_application_semantic_model(&parsed);
1015        let value_binding = asm
1016            .template_entities
1017            .iter()
1018            .find(|entity| entity.attribute_name.as_deref() == Some("value"))
1019            .expect("value binding");
1020        assert_eq!(
1021            asm.semantic_types.assignments[&value_binding.id].semantic_type,
1022            crate::SemanticType::Number
1023        );
1024
1025        let folded = ConstantFoldingPass.transform(&asm);
1026        let diagnostics = folded
1027            .diagnostics
1028            .iter()
1029            .filter(|diagnostic| diagnostic.code == "PSC1028")
1030            .collect::<Vec<_>>();
1031        assert_eq!(diagnostics.len(), 2);
1032        assert!(diagnostics.iter().any(|diagnostic| {
1033            diagnostic.message.contains("property binding `disabled`")
1034                && diagnostic.message.contains("string")
1035        }));
1036        assert!(diagnostics.iter().any(|diagnostic| {
1037            diagnostic.message.contains("attribute binding `href`")
1038                && diagnostic.message.contains("boolean")
1039        }));
1040    }
1041
1042    #[test]
1043    fn rejects_string_binding_for_boolean_aria_attribute() {
1044        let parsed = presolve_parser::parse_file(
1045            "src/InvalidAriaBinding.tsx",
1046            r#"
1047@component("x-invalid-aria")
1048class InvalidAria extends Component {
1049  label = state("invalid");
1050  render() { return <button aria-invalid={this.label}>Save</button>; }
1051}
1052"#,
1053        );
1054        let folded = ConstantFoldingPass.transform(&build_application_semantic_model(&parsed));
1055        let diagnostics = folded
1056            .diagnostics
1057            .iter()
1058            .filter(|diagnostic| diagnostic.code == "PSC1028")
1059            .collect::<Vec<_>>();
1060        assert_eq!(diagnostics.len(), 1);
1061        assert!(diagnostics[0]
1062            .message
1063            .contains("attribute binding `aria-invalid`"));
1064        assert!(diagnostics[0].message.contains("requires boolean"));
1065    }
1066
1067    #[test]
1068    fn requires_boolean_canonical_template_conditions() {
1069        let parsed = presolve_parser::parse_file(
1070            "src/TypedConditions.tsx",
1071            r#"
1072@component("x-typed-conditions")
1073class TypedConditions extends Component {
1074  enabled = state(true);
1075  count = state(1);
1076
1077  render() {
1078    return <>{this.enabled ? <p>On</p> : <p>Off</p>}{this.count ? <p>Count</p> : <p>Empty</p>}</>;
1079  }
1080}
1081"#,
1082        );
1083        let asm = build_application_semantic_model(&parsed);
1084        let count_condition = asm
1085            .template_entities
1086            .iter()
1087            .find(|entity| {
1088                entity.kind == crate::TemplateSemanticKind::Conditional
1089                    && entity.expression.as_deref() == Some("this.count")
1090            })
1091            .expect("count conditional");
1092        assert_eq!(
1093            asm.semantic_types.assignments[&count_condition.id].semantic_type,
1094            crate::SemanticType::Number
1095        );
1096
1097        let folded = ConstantFoldingPass.transform(&asm);
1098        let diagnostics = folded
1099            .diagnostics
1100            .iter()
1101            .filter(|diagnostic| diagnostic.code == "PSC1029")
1102            .collect::<Vec<_>>();
1103        assert_eq!(diagnostics.len(), 1);
1104        assert!(diagnostics[0].message.contains("this.count"));
1105    }
1106
1107    #[test]
1108    fn types_list_iterables_and_rejects_non_arrays() {
1109        let parsed = presolve_parser::parse_file(
1110            "src/TypedLists.tsx",
1111            r#"
1112@component("x-typed-lists")
1113class TypedLists extends Component {
1114  items = state([{ id: "a" }]);
1115  count = state(1);
1116
1117  render() {
1118    return <><ul>{this.items.map((item, index) => <li key={item.id}>{index}</li>)}</ul><div>{this.count.map((item) => <span>{item}</span>)}</div></>;
1119  }
1120}
1121"#,
1122        );
1123        let asm = build_application_semantic_model(&parsed);
1124        let item_list = asm
1125            .template_entities
1126            .iter()
1127            .find(|entity| entity.expression.as_deref() == Some("this.items"))
1128            .expect("items list");
1129        let scope = asm
1130            .semantic_types
1131            .list_scopes
1132            .get(&item_list.id)
1133            .expect("typed item list scope");
1134        assert_eq!(scope.item_name, "item");
1135        assert_eq!(scope.index_type, Some(crate::SemanticType::Number));
1136        assert!(matches!(scope.item_type, crate::SemanticType::Object(_)));
1137
1138        let folded = ConstantFoldingPass.transform(&asm);
1139        assert!(folded.diagnostics.iter().any(|diagnostic| {
1140            diagnostic.code == "PSC1030" && diagnostic.message.contains("this.count")
1141        }));
1142    }
1143
1144    #[test]
1145    fn resolves_typed_list_item_member_accesses() {
1146        let parsed = presolve_parser::parse_file(
1147            "src/TypedMembers.tsx",
1148            r#"
1149@component("x-typed-members")
1150class TypedMembers extends Component {
1151  todos = state([{ details: { region: "west" } }]);
1152
1153  render() {
1154    return <ul>{this.todos.map((todo) => <li key={todo.details.region}>{todo.details.region}{todo.missing}</li>)}</ul>;
1155  }
1156}
1157"#,
1158        );
1159        let asm = build_application_semantic_model(&parsed);
1160        let region = asm
1161            .template_entities
1162            .iter()
1163            .find(|entity| entity.expression.as_deref() == Some("todo.details.region"))
1164            .expect("region member binding");
1165        assert_eq!(
1166            asm.semantic_types.assignments[&region.id].semantic_type,
1167            crate::SemanticType::String
1168        );
1169        assert_eq!(
1170            asm.semantic_types.member_accesses[&region.id].semantic_type,
1171            Some(crate::SemanticType::String)
1172        );
1173
1174        let folded = ConstantFoldingPass.transform(&asm);
1175        assert!(folded.diagnostics.iter().any(|diagnostic| {
1176            diagnostic.code == "PSC1031" && diagnostic.message.contains("todo.missing")
1177        }));
1178    }
1179
1180    #[test]
1181    fn validates_state_initializers_with_canonical_semantic_assignability() {
1182        let parsed = presolve_parser::parse_file(
1183            "src/StateCompatibility.tsx",
1184            r#"
1185@component("x-state-compatibility")
1186class StateCompatibility extends Component {
1187  names: string[] = state([]);
1188  filter: "all" | "active" = state("all");
1189  user: { id: string } | null = state(null);
1190  invalid: string[] = state([1]);
1191}
1192"#,
1193        );
1194
1195        let asm = build_application_semantic_model(&parsed);
1196        let folded = ConstantFoldingPass.transform(&asm);
1197        let diagnostics = folded
1198            .diagnostics
1199            .iter()
1200            .filter(|diagnostic| diagnostic.code == "PSC1016")
1201            .collect::<Vec<_>>();
1202
1203        assert_eq!(diagnostics.len(), 1);
1204        assert!(diagnostics[0].message.contains("invalid"));
1205        assert!(diagnostics[0].message.contains("declares `string[]`"));
1206        assert!(diagnostics[0].message.contains("initializes with `tuple`"));
1207    }
1208
1209    #[test]
1210    fn reports_unresolved_declared_state_types_with_a_stable_type_diagnostic() {
1211        let parsed = presolve_parser::parse_file(
1212            "src/UnknownStateType.tsx",
1213            r#"
1214@component("x-unknown-state-type")
1215class UnknownStateType extends Component {
1216  value: MissingType = state(null);
1217}
1218"#,
1219        );
1220        let asm = build_application_semantic_model(&parsed);
1221        let folded = ConstantFoldingPass.transform(&asm);
1222        assert!(folded.diagnostics.iter().any(|diagnostic| {
1223            diagnostic.code == crate::TypeDiagnosticCode::UnknownType.as_str()
1224                && diagnostic.message.contains("MissingType")
1225        }));
1226    }
1227
1228    #[test]
1229    fn uses_canonical_expression_provenance_for_folding_diagnostics() {
1230        let parsed = presolve_parser::parse_file(
1231            "src/InvalidExpression.tsx",
1232            r#"
1233@component("x-invalid-expression")
1234class InvalidExpression extends Component {
1235  total = state(10 / 0);
1236}
1237"#,
1238        );
1239        let asm = build_application_semantic_model(&parsed);
1240        let field = &asm.components[0].state_fields[0];
1241        let root = asm
1242            .expression_graph
1243            .root_for(&field.id)
1244            .expect("expression root");
1245        let expected_provenance = asm
1246            .expression_graph
1247            .nodes
1248            .get(root)
1249            .expect("expression root node")
1250            .provenance
1251            .clone();
1252
1253        let folded = ConstantFoldingPass.transform(&asm);
1254        let diagnostic = folded
1255            .diagnostics
1256            .iter()
1257            .find(|diagnostic| diagnostic.code == "PSC1022")
1258            .expect("constant-expression diagnostic");
1259
1260        assert_eq!(diagnostic.provenance.as_ref(), Some(&expected_provenance));
1261    }
1262
1263    #[test]
1264    fn finds_dead_actions_from_canonical_ownership() {
1265        let parsed = presolve_parser::parse_file(
1266            "src/Counter.tsx",
1267            r#"
1268@component("x-counter")
1269class Counter extends Component {
1270  count = state(0);
1271
1272  unused() {
1273    this.count++;
1274  }
1275
1276  render() {
1277    return <div>Counter</div>;
1278  }
1279}
1280"#,
1281        );
1282        let mut asm = build_application_semantic_model(&parsed);
1283        let action_id = asm.components[0].actions[0].id.clone();
1284        asm.components[0].actions[0].owner = SemanticOwner::Application;
1285
1286        let analysis = DeadSemanticAnalysisPass.analyze(&asm);
1287        assert_eq!(analysis.unreferenced_actions, vec![action_id]);
1288    }
1289
1290    #[test]
1291    fn transforms_asm_immutably_with_compatibility_analysis_results() {
1292        let parsed = presolve_parser::parse_file(
1293            "src/Counter.tsx",
1294            r#"
1295@component("x-counter")
1296class Counter extends Component {
1297  count = state(0);
1298
1299  increment() {
1300    this.count++;
1301  }
1302
1303  render() {
1304    return <button onClick={() => this.increment()}>{this.count}</button>;
1305  }
1306}
1307"#,
1308        );
1309        let asm = build_application_semantic_model(&parsed);
1310        let original = asm.clone();
1311
1312        assert_eq!(
1313            DependencyAnalysisPass.transform(&asm),
1314            DependencyAnalysisPass.analyze(&asm)
1315        );
1316        assert_eq!(
1317            ConstantEvaluationPass.transform(&asm),
1318            ConstantEvaluationPass.analyze(&asm)
1319        );
1320        assert_eq!(
1321            ConstantFoldingPass.transform(&asm),
1322            ConstantFoldingPass.analyze(&asm)
1323        );
1324        assert_eq!(
1325            DeadSemanticAnalysisPass.transform(&asm),
1326            DeadSemanticAnalysisPass.analyze(&asm)
1327        );
1328        assert_eq!(
1329            OptimizationPlanningPass.transform(&asm),
1330            OptimizationPlanningPass.analyze(&asm)
1331        );
1332        assert_eq!(
1333            ExplainabilityPass.transform(&asm),
1334            ExplainabilityPass.analyze(&asm)
1335        );
1336        assert_eq!(asm, original);
1337    }
1338}