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
13pub trait ImmutableAsmPass {
19 type Output;
20
21 fn transform(&self, model: &ApplicationSemanticModel) -> Self::Output;
22}
23
24pub 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#[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#[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[®ion.id].semantic_type,
1167 crate::SemanticType::String
1168 );
1169 assert_eq!(
1170 asm.semantic_types.member_accesses[®ion.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}