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