1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4 ComponentNode, ComputedPurity, ComputedValue, EffectStatementSyntaxKind, ExecutionBoundary,
5 ExpressionGraph, IrComputedEvaluationPlan, IrReactiveGraph, IrReactiveNode, IrReactiveNodeKind,
6 IrReactiveTransitiveAnalysis, IrUpdateScheduler, SemanticId, SemanticOwner, SemanticTypeModel,
7 SourceProvenance, UnsupportedEffectStatementKind,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EffectExecutionPolicy {
17 AfterInitialRenderAndCompletedActionBatch,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EffectValidation {
23 Unvalidated,
24 Valid,
25 Invalid,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30pub enum EffectSemanticViolationKind {
31 Async,
32 ReactiveStateMutation,
33 ActionInvocation,
34 EffectInvocation,
35 ComponentMethodInvocation,
36 UnresolvedComponentCall,
37 UnresolvedComponentAssignment,
38 UnknownExternalCapability,
39 CapabilitySignature,
40 CapabilityBoundary,
41 CapabilitySerialization,
42 ValueReturn,
43 UnsupportedStatement,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EffectSemanticViolation {
49 pub kind: EffectSemanticViolationKind,
50 pub statement: Option<SemanticId>,
51 pub provenance: SourceProvenance,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct EffectReactiveAnalysis {
57 pub effect: SemanticId,
58 pub dependencies: Vec<SemanticId>,
59 pub dependents: Vec<SemanticId>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ActionBatch {
64 pub id: SemanticId,
65 pub authored_action_method: SemanticId,
66 pub ordered_write_records: Vec<SemanticId>,
67 pub written_states: Vec<SemanticId>,
68 pub provenance: SourceProvenance,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ActionBatchEffectTrigger {
73 pub action_batch: SemanticId,
74 pub effects: Vec<SemanticId>,
75 pub matched_states: BTreeMap<SemanticId, Vec<SemanticId>>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct EffectTriggerPlan {
80 pub initial_effects: Vec<SemanticId>,
81 pub action_batches: BTreeMap<SemanticId, ActionBatch>,
82 pub action_batch_triggers: Vec<ActionBatchEffectTrigger>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct EffectComputedPrerequisiteBatch {
92 pub source_batch_index: u32,
93 pub computed: Vec<SemanticId>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum EffectRenderBoundary {
99 AfterInitialRender,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct EffectExecutionBatch {
105 pub index: u32,
106 pub effects: Vec<SemanticId>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum UnplannedEffectReason {
112 UnavailableComputedPrerequisite,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct UnplannedEffect {
118 pub effect: SemanticId,
119 pub reason: UnplannedEffectReason,
120 pub computed_dependencies: Vec<SemanticId>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct InitialEffectExecutionPlan {
126 pub required_computed: Vec<SemanticId>,
127 pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
128 pub render_boundary: Option<EffectRenderBoundary>,
129 pub effect_batches: Vec<EffectExecutionBatch>,
130 pub unplanned_effects: Vec<UnplannedEffect>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ActionEffectExecutionPlan {
136 pub action_batch: SemanticId,
137 pub required_computed: Vec<SemanticId>,
138 pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
139 pub effect_batches: Vec<EffectExecutionBatch>,
140 pub unplanned_effects: Vec<UnplannedEffect>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct EffectExecutionPlan {
146 pub initial: InitialEffectExecutionPlan,
147 pub actions: Vec<ActionEffectExecutionPlan>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Effect {
153 pub id: SemanticId,
154 pub owner: SemanticOwner,
155 pub method: SemanticId,
156 pub name: String,
157 pub execution_boundary: ExecutionBoundary,
158 pub execution_policy: EffectExecutionPolicy,
159 pub validation: EffectValidation,
160 pub semantic_violations: Vec<EffectSemanticViolation>,
161 pub provenance: SourceProvenance,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct EffectBody {
167 pub effect: SemanticId,
168 pub statements: Vec<SemanticId>,
169 pub provenance: SourceProvenance,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct EffectStatement {
175 pub id: SemanticId,
176 pub owner: SemanticId,
177 pub kind: EffectStatementKind,
178 pub provenance: SourceProvenance,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum EffectStatementKind {
183 ExternalMemberAssignment {
184 target: SemanticId,
185 value: SemanticId,
186 },
187 CapabilityCall {
188 callee: SemanticId,
189 arguments: Vec<SemanticId>,
190 },
191 EffectReturn {
192 value: Option<SemanticId>,
193 },
194 Empty,
195 Unsupported(UnsupportedEffectStatementKind),
196}
197
198#[must_use]
204pub fn collect_effects(
205 components: &[ComponentNode],
206 provenance: &BTreeMap<SemanticId, SourceProvenance>,
207) -> BTreeMap<SemanticId, Effect> {
208 components
209 .iter()
210 .flat_map(|component| {
211 component
212 .methods
213 .iter()
214 .filter(|method| method.is_effect())
215 .map(move |method| {
216 let id = component.id.effect(&method.name);
217 let provenance = provenance
218 .get(&method.id)
219 .expect("effect methods should have canonical provenance")
220 .clone();
221 (
222 id.clone(),
223 Effect {
224 id,
225 owner: SemanticOwner::entity(component.id.clone()),
226 method: method.id.clone(),
227 name: method.name.clone(),
228 execution_boundary: ExecutionBoundary::Client,
229 execution_policy:
230 EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch,
231 validation: EffectValidation::Unvalidated,
232 semantic_violations: Vec::new(),
233 provenance,
234 },
235 )
236 })
237 })
238 .collect()
239}
240
241#[allow(clippy::too_many_lines)]
243#[must_use]
244pub fn validate_effects(
245 components: &[ComponentNode],
246 mut effects: BTreeMap<SemanticId, Effect>,
247 statements: &BTreeMap<SemanticId, EffectStatement>,
248 semantic_types: &SemanticTypeModel,
249) -> BTreeMap<SemanticId, Effect> {
250 for effect in effects.values_mut() {
251 let mut violations = Vec::new();
252 let method = effect
253 .owner
254 .entity_id()
255 .and_then(|component_id| {
256 components
257 .iter()
258 .find(|component| component.id == *component_id)
259 })
260 .and_then(|component| {
261 component
262 .methods
263 .iter()
264 .find(|method| method.id == effect.method)
265 });
266 if method.is_some_and(|method| method.is_async) {
267 violations.push(EffectSemanticViolation {
268 kind: EffectSemanticViolationKind::Async,
269 statement: None,
270 provenance: effect.provenance.clone(),
271 });
272 }
273 for statement in statements
274 .values()
275 .filter(|statement| statement.owner == effect.id)
276 {
277 let Some(record) = semantic_types.effect_statements.get(&statement.id) else {
278 continue;
279 };
280 let statement_violation = match record.operation_classification {
281 crate::EffectOperationClassification::ReactiveStateAssignment => {
282 Some(EffectSemanticViolationKind::ReactiveStateMutation)
283 }
284 crate::EffectOperationClassification::ComponentActionCall => {
285 Some(EffectSemanticViolationKind::ActionInvocation)
286 }
287 crate::EffectOperationClassification::ComponentEffectCall => {
288 Some(EffectSemanticViolationKind::EffectInvocation)
289 }
290 crate::EffectOperationClassification::ComponentMethodCall => {
291 Some(EffectSemanticViolationKind::ComponentMethodInvocation)
292 }
293 crate::EffectOperationClassification::UnresolvedComponentCall => {
294 Some(EffectSemanticViolationKind::UnresolvedComponentCall)
295 }
296 crate::EffectOperationClassification::UnresolvedComponentAssignment => {
297 Some(EffectSemanticViolationKind::UnresolvedComponentAssignment)
298 }
299 crate::EffectOperationClassification::UnknownExternalCapability => {
300 Some(EffectSemanticViolationKind::UnknownExternalCapability)
301 }
302 crate::EffectOperationClassification::ValueReturn => {
303 Some(EffectSemanticViolationKind::ValueReturn)
304 }
305 crate::EffectOperationClassification::UnsupportedStatement => {
306 Some(EffectSemanticViolationKind::UnsupportedStatement)
307 }
308 crate::EffectOperationClassification::RecognizedCapability
309 | crate::EffectOperationClassification::BareReturn
310 | crate::EffectOperationClassification::Empty => None,
311 };
312 if let Some(kind) = statement_violation {
313 violations.push(EffectSemanticViolation {
314 kind,
315 statement: Some(statement.id.clone()),
316 provenance: statement.provenance.clone(),
317 });
318 }
319 for (compatibility, kind) in [
320 (
321 record.signature_compatibility,
322 EffectSemanticViolationKind::CapabilitySignature,
323 ),
324 (
325 record.boundary_compatibility,
326 EffectSemanticViolationKind::CapabilityBoundary,
327 ),
328 (
329 record.serialization_compatibility,
330 EffectSemanticViolationKind::CapabilitySerialization,
331 ),
332 ] {
333 if compatibility != crate::EffectCompatibility::Compatible
334 && record.operation_classification
335 == crate::EffectOperationClassification::RecognizedCapability
336 {
337 violations.push(EffectSemanticViolation {
338 kind,
339 statement: Some(statement.id.clone()),
340 provenance: statement.provenance.clone(),
341 });
342 }
343 }
344 }
345 violations.sort_by(|left, right| {
346 (
347 left.kind,
348 left.provenance.path.as_path(),
349 left.provenance.span.start,
350 left.provenance.span.end,
351 )
352 .cmp(&(
353 right.kind,
354 right.provenance.path.as_path(),
355 right.provenance.span.start,
356 right.provenance.span.end,
357 ))
358 });
359 violations.dedup_by(|left, right| {
360 left.kind == right.kind
361 && left.statement == right.statement
362 && left.provenance == right.provenance
363 });
364 effect.validation = if violations.is_empty() {
365 EffectValidation::Valid
366 } else {
367 EffectValidation::Invalid
368 };
369 effect.semantic_violations = violations;
370 }
371 effects
372}
373
374#[must_use]
376pub fn analyze_effect_reactivity(
377 components: &[ComponentNode],
378 computed_values: &BTreeMap<SemanticId, crate::ComputedValue>,
379 effects: &BTreeMap<SemanticId, Effect>,
380 transitive: &IrReactiveTransitiveAnalysis,
381) -> BTreeMap<SemanticId, EffectReactiveAnalysis> {
382 let identities = components
383 .iter()
384 .flat_map(|component| component.state_fields.iter().map(|field| field.id.clone()))
385 .chain(computed_values.keys().cloned())
386 .chain(effects.keys().cloned())
387 .map(|id| (id.as_str().to_string(), id))
388 .collect::<BTreeMap<_, _>>();
389 effects
390 .values()
391 .filter(|effect| effect.validation == EffectValidation::Valid)
392 .map(|effect| {
393 let map_ids = |ids: Option<&Vec<String>>| {
394 ids.into_iter()
395 .flatten()
396 .filter_map(|id| identities.get(id).cloned())
397 .collect()
398 };
399 (
400 effect.id.clone(),
401 EffectReactiveAnalysis {
402 effect: effect.id.clone(),
403 dependencies: map_ids(transitive.dependencies.get(effect.id.as_str())),
404 dependents: map_ids(transitive.dependents.get(effect.id.as_str())),
405 },
406 )
407 })
408 .collect()
409}
410
411#[must_use]
415pub fn derive_effect_trigger_plan(
416 components: &[ComponentNode],
417 effects: &BTreeMap<SemanticId, Effect>,
418 analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
419 provenance: &BTreeMap<SemanticId, SourceProvenance>,
420) -> EffectTriggerPlan {
421 let initial_effects = effects
422 .values()
423 .filter(|effect| effect.validation == EffectValidation::Valid)
424 .map(|effect| effect.id.clone())
425 .collect::<Vec<_>>();
426 let mut action_batches = BTreeMap::new();
427 for component in components {
428 for method in component.methods.iter().filter(|method| method.is_action()) {
429 let writes = component
430 .actions
431 .iter()
432 .filter(|action| action.method == method.name)
433 .collect::<Vec<_>>();
434 let id = component.id.action_batch(&method.name);
435 action_batches.insert(
436 id.clone(),
437 ActionBatch {
438 id,
439 authored_action_method: method.id.clone(),
440 ordered_write_records: writes.iter().map(|action| action.id.clone()).collect(),
441 written_states: writes
442 .iter()
443 .map(|action| component.id.state_field(&action.field))
444 .collect::<std::collections::BTreeSet<_>>()
445 .into_iter()
446 .collect(),
447 provenance: provenance
448 .get(&method.id)
449 .expect("action methods should have canonical provenance")
450 .clone(),
451 },
452 );
453 }
454 }
455 let action_batch_triggers = action_batches
456 .values()
457 .filter_map(|batch| {
458 let matched_states = initial_effects
459 .iter()
460 .filter_map(|effect| {
461 let matches = analysis
462 .get(effect)?
463 .dependencies
464 .iter()
465 .filter(|dependency| batch.written_states.contains(dependency))
466 .cloned()
467 .collect::<Vec<_>>();
468 (!matches.is_empty()).then(|| (effect.clone(), matches))
469 })
470 .collect::<BTreeMap<_, _>>();
471 (!matched_states.is_empty()).then(|| ActionBatchEffectTrigger {
472 action_batch: batch.id.clone(),
473 effects: matched_states.keys().cloned().collect(),
474 matched_states,
475 })
476 })
477 .collect();
478 EffectTriggerPlan {
479 initial_effects,
480 action_batches,
481 action_batch_triggers,
482 }
483}
484
485#[must_use]
492pub fn plan_effect_execution(
493 computed_values: &BTreeMap<SemanticId, ComputedValue>,
494 effects: &BTreeMap<SemanticId, Effect>,
495 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
496 trigger_plan: &EffectTriggerPlan,
497 reactive_transitive_analysis: &IrReactiveTransitiveAnalysis,
498 computed_evaluation_plan: &IrComputedEvaluationPlan,
499) -> EffectExecutionPlan {
500 let computed_by_text = computed_values
501 .keys()
502 .map(|id| (id.as_str().to_string(), id.clone()))
503 .collect::<BTreeMap<_, _>>();
504 let executable = computed_evaluation_plan
505 .evaluation_order
506 .iter()
507 .filter_map(|id| computed_by_text.get(id))
508 .filter(|id| {
509 computed_values
510 .get(*id)
511 .is_some_and(|computed| computed.purity == ComputedPurity::Pure)
512 })
513 .cloned()
514 .collect::<BTreeSet<_>>();
515 let initial = build_initial_effect_execution_plan(
516 &trigger_plan.initial_effects,
517 effects,
518 effect_analysis,
519 computed_values,
520 &executable,
521 computed_evaluation_plan,
522 );
523 let actions = trigger_plan
524 .action_batch_triggers
525 .iter()
526 .filter_map(|trigger| {
527 let batch = trigger_plan.action_batches.get(&trigger.action_batch)?;
528 let invalidated = batch
529 .written_states
530 .iter()
531 .flat_map(|state| reactive_transitive_analysis.dependents_of(state.as_str()))
532 .filter_map(|id| computed_by_text.get(id).cloned())
533 .collect::<BTreeSet<_>>();
534 Some(build_action_effect_execution_plan(
535 &batch.id,
536 &trigger.effects,
537 effects,
538 effect_analysis,
539 computed_values,
540 &executable,
541 &invalidated,
542 computed_evaluation_plan,
543 ))
544 })
545 .collect();
546
547 EffectExecutionPlan { initial, actions }
548}
549
550fn build_initial_effect_execution_plan(
551 eligible_effects: &[SemanticId],
552 effects: &BTreeMap<SemanticId, Effect>,
553 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
554 computed_values: &BTreeMap<SemanticId, ComputedValue>,
555 executable: &BTreeSet<SemanticId>,
556 computed_evaluation_plan: &IrComputedEvaluationPlan,
557) -> InitialEffectExecutionPlan {
558 let membership = effect_execution_membership(
559 eligible_effects,
560 effects,
561 effect_analysis,
562 computed_values,
563 executable,
564 );
565 let required_computed = membership
566 .schedulable_effects
567 .iter()
568 .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
569 .collect::<BTreeSet<_>>();
570 InitialEffectExecutionPlan {
571 prerequisite_batches: select_prerequisite_batches(
572 &required_computed,
573 computed_evaluation_plan,
574 computed_values,
575 ),
576 required_computed: required_computed.into_iter().collect(),
577 render_boundary: Some(EffectRenderBoundary::AfterInitialRender),
578 effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
579 unplanned_effects: membership.unplanned_effects,
580 }
581}
582
583#[allow(clippy::too_many_arguments)]
584fn build_action_effect_execution_plan(
585 action_batch: &SemanticId,
586 eligible_effects: &[SemanticId],
587 effects: &BTreeMap<SemanticId, Effect>,
588 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
589 computed_values: &BTreeMap<SemanticId, ComputedValue>,
590 executable: &BTreeSet<SemanticId>,
591 invalidated: &BTreeSet<SemanticId>,
592 computed_evaluation_plan: &IrComputedEvaluationPlan,
593) -> ActionEffectExecutionPlan {
594 let membership = effect_execution_membership(
595 eligible_effects,
596 effects,
597 effect_analysis,
598 computed_values,
599 executable,
600 );
601 let required_computed = membership
602 .schedulable_effects
603 .iter()
604 .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
605 .filter(|computed| invalidated.contains(computed))
606 .collect::<BTreeSet<_>>();
607 ActionEffectExecutionPlan {
608 action_batch: action_batch.clone(),
609 prerequisite_batches: select_prerequisite_batches(
610 &required_computed,
611 computed_evaluation_plan,
612 computed_values,
613 ),
614 required_computed: required_computed.into_iter().collect(),
615 effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
616 unplanned_effects: membership.unplanned_effects,
617 }
618}
619
620#[derive(Debug, Default)]
621struct EffectExecutionMembership {
622 schedulable_effects: Vec<SemanticId>,
623 unplanned_effects: Vec<UnplannedEffect>,
624}
625
626fn effect_execution_membership(
627 eligible_effects: &[SemanticId],
628 effects: &BTreeMap<SemanticId, Effect>,
629 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
630 computed_values: &BTreeMap<SemanticId, ComputedValue>,
631 executable: &BTreeSet<SemanticId>,
632) -> EffectExecutionMembership {
633 let mut membership = EffectExecutionMembership::default();
634 for effect in eligible_effects {
635 if !effects.contains_key(effect) {
636 continue;
637 }
638 let unavailable = required_computed_for_effect(effect, effect_analysis, computed_values)
639 .into_iter()
640 .filter(|computed| !executable.contains(computed))
641 .collect::<Vec<_>>();
642 if unavailable.is_empty() {
643 membership.schedulable_effects.push(effect.clone());
644 } else {
645 membership.unplanned_effects.push(UnplannedEffect {
646 effect: effect.clone(),
647 reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
648 computed_dependencies: unavailable,
649 });
650 }
651 }
652 membership.schedulable_effects.sort();
653 membership.schedulable_effects.dedup();
654 membership
655 .unplanned_effects
656 .sort_by(|left, right| left.effect.cmp(&right.effect));
657 membership
658}
659
660fn required_computed_for_effect(
661 effect: &SemanticId,
662 effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
663 computed_values: &BTreeMap<SemanticId, ComputedValue>,
664) -> Vec<SemanticId> {
665 effect_analysis
666 .get(effect)
667 .into_iter()
668 .flat_map(|analysis| &analysis.dependencies)
669 .filter(|dependency| computed_values.contains_key(*dependency))
670 .cloned()
671 .collect::<BTreeSet<_>>()
672 .into_iter()
673 .collect()
674}
675
676fn select_prerequisite_batches(
677 required_computed: &BTreeSet<SemanticId>,
678 computed_evaluation_plan: &IrComputedEvaluationPlan,
679 computed_values: &BTreeMap<SemanticId, ComputedValue>,
680) -> Vec<EffectComputedPrerequisiteBatch> {
681 computed_evaluation_plan
682 .update_batches
683 .iter()
684 .enumerate()
685 .filter_map(|(index, batch)| {
686 let computed = batch
687 .iter()
688 .filter_map(|id| {
689 computed_values
690 .keys()
691 .find(|computed| computed.as_str() == id)
692 })
693 .filter(|computed| required_computed.contains(*computed))
694 .cloned()
695 .collect::<Vec<_>>();
696 (!computed.is_empty()).then_some(EffectComputedPrerequisiteBatch {
697 source_batch_index: u32::try_from(index)
698 .expect("computed scheduler batch index should fit u32"),
699 computed,
700 })
701 })
702 .collect()
703}
704
705fn schedule_terminal_effects(
706 effects: &[SemanticId],
707 canonical_effects: &BTreeMap<SemanticId, Effect>,
708) -> Vec<EffectExecutionBatch> {
709 let nodes = effects
710 .iter()
711 .filter_map(|effect| {
712 canonical_effects.get(effect).map(|canonical| {
713 (
714 effect.as_str().to_string(),
715 IrReactiveNode {
716 id: effect.as_str().to_string(),
717 kind: IrReactiveNodeKind::Effect,
718 provenance: canonical.provenance.clone(),
719 },
720 )
721 })
722 })
723 .collect();
724 let inspection = IrUpdateScheduler::new(IrReactiveGraph {
725 nodes,
726 edges: Vec::new(),
727 })
728 .inspect();
729 inspection
730 .batches
731 .into_iter()
732 .enumerate()
733 .filter_map(|(index, batch)| {
734 let effects = batch
735 .into_iter()
736 .filter_map(|id| {
737 canonical_effects
738 .keys()
739 .find(|effect| effect.as_str() == id)
740 .cloned()
741 })
742 .collect::<Vec<_>>();
743 (!effects.is_empty()).then_some(EffectExecutionBatch {
744 index: u32::try_from(index).expect("effect scheduler batch index should fit u32"),
745 effects,
746 })
747 })
748 .collect()
749}
750
751#[must_use]
753pub fn lower_effect_bodies(
754 components: &[ComponentNode],
755 effects: &BTreeMap<SemanticId, Effect>,
756 expression_graph: &ExpressionGraph,
757) -> (
758 BTreeMap<SemanticId, EffectBody>,
759 BTreeMap<SemanticId, EffectStatement>,
760) {
761 let mut bodies = BTreeMap::new();
762 let mut statements = BTreeMap::new();
763 for effect in effects.values() {
764 let Some(component_id) = effect.owner.entity_id() else {
765 continue;
766 };
767 let Some(method) = components
768 .iter()
769 .find(|component| component.id == *component_id)
770 .and_then(|component| {
771 component
772 .methods
773 .iter()
774 .find(|method| method.id == effect.method)
775 })
776 else {
777 continue;
778 };
779 let Some(syntax) = &method.effect_body else {
780 continue;
781 };
782 let mut body_statement_ids = Vec::new();
783 for (index, statement) in syntax.statements.iter().enumerate() {
784 let id = effect.id.effect_statement(index);
785 let path = format!("statement:{index}");
786 let expression = |suffix: &str| effect.id.expression(&format!("{path}/{suffix}"));
787 let kind = match &statement.kind {
788 EffectStatementSyntaxKind::StaticMemberAssignment { .. } => {
789 EffectStatementKind::ExternalMemberAssignment {
790 target: expression("target"),
791 value: expression("value"),
792 }
793 }
794 EffectStatementSyntaxKind::CapabilityCall { arguments, .. } => {
795 EffectStatementKind::CapabilityCall {
796 callee: expression("callee"),
797 arguments: (0..arguments.len())
798 .map(|argument| expression(&format!("argument:{argument}")))
799 .collect(),
800 }
801 }
802 EffectStatementSyntaxKind::EffectReturn { value } => {
803 EffectStatementKind::EffectReturn {
804 value: value.as_ref().map(|_| expression("return")),
805 }
806 }
807 EffectStatementSyntaxKind::Empty => EffectStatementKind::Empty,
808 EffectStatementSyntaxKind::Unsupported(kind) => {
809 EffectStatementKind::Unsupported(*kind)
810 }
811 };
812 assert_effect_statement_expressions_exist(&kind, expression_graph);
813 body_statement_ids.push(id.clone());
814 statements.insert(
815 id.clone(),
816 EffectStatement {
817 id,
818 owner: effect.id.clone(),
819 kind,
820 provenance: SourceProvenance::new(&effect.provenance.path, statement.span),
821 },
822 );
823 }
824 bodies.insert(
825 effect.id.clone(),
826 EffectBody {
827 effect: effect.id.clone(),
828 statements: body_statement_ids,
829 provenance: effect.provenance.clone(),
830 },
831 );
832 }
833 (bodies, statements)
834}
835
836fn assert_effect_statement_expressions_exist(kind: &EffectStatementKind, graph: &ExpressionGraph) {
837 let expressions = match kind {
838 EffectStatementKind::ExternalMemberAssignment { target, value } => vec![target, value],
839 EffectStatementKind::CapabilityCall { callee, arguments } => {
840 let mut expressions = vec![callee];
841 expressions.extend(arguments);
842 expressions
843 }
844 EffectStatementKind::EffectReturn { value } => value.iter().collect(),
845 EffectStatementKind::Empty | EffectStatementKind::Unsupported(_) => Vec::new(),
846 };
847 assert!(expressions.iter().all(|id| graph.node(id).is_some()));
848}
849
850#[cfg(test)]
851mod tests {
852 use crate::{
853 build_application_semantic_model, build_component_graph, build_semantic_graph,
854 collect_effects, validate_application_semantic_model, EffectExecutionPolicy,
855 EffectSemanticViolationKind, EffectStatementKind, EffectValidation, ExecutionBoundary,
856 ExpressionNodeKind, SemanticEntity, SemanticEntityKind, SemanticOwner,
857 SemanticReferenceKind, UnsupportedEffectStatementKind,
858 };
859
860 #[test]
861 fn collects_stable_effect_entities_from_decorated_methods() {
862 let parsed = presolve_parser::parse_file(
863 "src/Effects.tsx",
864 r#"
865@component("x-effects")
866class Effects extends Component {
867 @effect()
868 syncTitle() {
869 document.title = "Presolve";
870 }
871}
872"#,
873 );
874 let graph = build_component_graph(&parsed);
875 let component = &graph.components[0];
876 let effect = collect_effects(&graph.components, &graph.provenance)
877 .into_values()
878 .next()
879 .expect("effect entity");
880
881 assert_eq!(effect.id.as_str(), "component:x-effects/effect:syncTitle");
882 assert_eq!(effect.method, component.methods[0].id);
883 assert_eq!(effect.owner, component.methods[0].owner);
884 assert_eq!(effect.execution_boundary, ExecutionBoundary::Client);
885 assert_eq!(
886 effect.execution_policy,
887 EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch
888 );
889 }
890
891 #[test]
892 fn assembles_effects_into_canonical_asm_without_reactive_products() {
893 let parsed = presolve_parser::parse_file(
894 "src/Effects.tsx",
895 r#"
896@component("x-effects")
897class Effects extends Component {
898 title = state("Presolve");
899
900 @effect()
901 syncTitle() {
902 document.title = this.title;
903 }
904
905 render() {
906 return <p>{this.title}</p>;
907 }
908}
909"#,
910 );
911 let asm = build_application_semantic_model(&parsed);
912 let component = &asm.components[0];
913 let effect_id = component.id.effect("syncTitle");
914 let effect = asm.effect(&effect_id).expect("effect entity");
915
916 assert_eq!(effect.method, component.id.method("syncTitle"));
917 assert_eq!(effect.owner, SemanticOwner::entity(component.id.clone()));
918 assert_eq!(asm.owner(&effect_id), Some(&effect.owner));
919 assert_eq!(asm.provenance(&effect_id), asm.provenance(&effect.method));
920 assert_eq!(
921 asm.entity(&effect_id).map(SemanticEntity::kind),
922 Some(SemanticEntityKind::Effect)
923 );
924 assert!(asm.references_from(&effect_id).iter().any(|reference| {
925 reference.kind == SemanticReferenceKind::EffectState
926 && reference.target == component.id.state_field("title")
927 }));
928 assert!(asm.semantic_type_of(&effect_id).is_none());
929 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
930 assert!(build_semantic_graph(&asm)
931 .nodes
932 .iter()
933 .any(|node| node.id == effect_id));
934 }
935
936 #[test]
937 fn lowers_ordered_effect_statements_and_expression_operands_without_resolution() {
938 let parsed = presolve_parser::parse_file(
939 "src/Effects.tsx",
940 include_str!("../../../fixtures/0052-effect-body-lowering/input/Effects.tsx"),
941 );
942 let asm = build_application_semantic_model(&parsed);
943 let component = &asm.components[0];
944 let sync = component.id.effect("sync");
945 let body = asm.effect_body(&sync).expect("effect body");
946
947 assert_eq!(body.statements.len(), 3);
948 let assignment = asm
949 .effect_statement(&body.statements[0])
950 .expect("assignment");
951 let call = asm.effect_statement(&body.statements[1]).expect("call");
952 let completion = asm.effect_statement(&body.statements[2]).expect("return");
953 let EffectStatementKind::ExternalMemberAssignment { target, value } = &assignment.kind
954 else {
955 panic!("expected static member assignment");
956 };
957 assert!(matches!(
958 asm.expression(target).map(|node| &node.kind),
959 Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "title"
960 ));
961 assert!(matches!(
962 asm.expression(value).map(|node| &node.kind),
963 Some(ExpressionNodeKind::ThisMember { name }) if name == "title"
964 ));
965 let EffectStatementKind::CapabilityCall { callee, arguments } = &call.kind else {
966 panic!("expected capability call");
967 };
968 assert_eq!(arguments.len(), 2);
969 assert!(matches!(
970 asm.expression(callee).map(|node| &node.kind),
971 Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "track"
972 ));
973 assert!(matches!(
974 asm.expression(&arguments[1]).map(|node| &node.kind),
975 Some(ExpressionNodeKind::Arithmetic { .. })
976 ));
977 assert!(matches!(
978 completion.kind,
979 EffectStatementKind::EffectReturn { value: None }
980 ));
981 assert!(asm.references_from(&sync).iter().any(|reference| {
982 reference.kind == SemanticReferenceKind::EffectState
983 && reference.target == component.id.state_field("title")
984 }));
985 assert!(asm.semantic_types.assignments.keys().any(|id| id == target));
986 assert_eq!(
987 asm.semantic_type_of(value),
988 Some(&crate::SemanticType::String)
989 );
990
991 let invalid = asm
992 .effect_body(&component.id.effect("invalid"))
993 .expect("invalid body");
994 assert!(matches!(
995 asm.effect_statement(&invalid.statements[0])
996 .map(|statement| &statement.kind),
997 Some(EffectStatementKind::ExternalMemberAssignment { .. })
998 ));
999 assert!(matches!(
1000 asm.effect_statement(&invalid.statements[1])
1001 .map(|statement| &statement.kind),
1002 Some(EffectStatementKind::CapabilityCall { .. })
1003 ));
1004 assert!(matches!(
1005 asm.effect_statement(&invalid.statements[2])
1006 .map(|statement| &statement.kind),
1007 Some(EffectStatementKind::Unsupported(
1008 UnsupportedEffectStatementKind::CleanupReturnCandidate
1009 ))
1010 ));
1011 assert!(matches!(
1012 asm.effect_statement(&invalid.statements[3])
1013 .map(|statement| &statement.kind),
1014 Some(EffectStatementKind::Unsupported(
1015 UnsupportedEffectStatementKind::LocalDeclaration
1016 ))
1017 ));
1018 }
1019
1020 #[test]
1021 #[allow(clippy::too_many_lines)]
1022 fn types_effect_statements_against_the_versioned_capability_registry() {
1023 let parsed = presolve_parser::parse_file(
1024 "src/Effects.tsx",
1025 r#"
1026@component("x-effects")
1027class Effects extends Component {
1028 title = state("Presolve");
1029 theme = state("dark");
1030 total = state(3);
1031 profile = state({ name: "Ada" });
1032
1033 @action()
1034 increment() { this.total += 1; this.total += 1; }
1035
1036 helper() {}
1037
1038 @effect()
1039 sync() {
1040 document.title = this.title;
1041 console.log("total", this.total);
1042 console.info(this.title);
1043 console.warn(this.title);
1044 console.error(this.title);
1045 localStorage.setItem("theme", this.theme);
1046 localStorage.removeItem("theme");
1047 sessionStorage.setItem("theme", this.theme);
1048 sessionStorage.removeItem("theme");
1049 }
1050
1051 @effect()
1052 facts() {
1053 document.title = this.profile;
1054 analytics.track(this.total);
1055 this.total = 1;
1056 this.increment();
1057 this.sync();
1058 this.helper();
1059 this.unknown();
1060 return;
1061 }
1062
1063 render() { return <p />; }
1064}
1065"#,
1066 );
1067 let asm = build_application_semantic_model(&parsed);
1068 let component = &asm.components[0];
1069 let sync = asm
1070 .effect_body(&component.id.effect("sync"))
1071 .expect("sync body");
1072 let facts = asm
1073 .effect_body(&component.id.effect("facts"))
1074 .expect("facts body");
1075 let record = |statement: &crate::SemanticId| {
1076 asm.effect_statement_type(statement)
1077 .expect("statement type")
1078 };
1079
1080 assert_eq!(crate::EFFECT_CAPABILITY_REGISTRY.version(), 1);
1081 assert_eq!(
1082 record(&sync.statements[0]).capability_operation,
1083 Some(crate::CapabilityOperationId(
1084 "builtin.browser.document.title.assign"
1085 ))
1086 );
1087 for statement in &sync.statements[1..5] {
1088 assert_eq!(
1089 record(statement).operation_classification,
1090 crate::EffectOperationClassification::RecognizedCapability
1091 );
1092 assert_eq!(
1093 record(statement).serialization_compatibility,
1094 crate::EffectCompatibility::Compatible
1095 );
1096 }
1097 for statement in &sync.statements[5..] {
1098 assert_eq!(
1099 record(statement).signature_compatibility,
1100 crate::EffectCompatibility::Compatible
1101 );
1102 }
1103 assert_eq!(
1104 record(&facts.statements[0]).signature_compatibility,
1105 crate::EffectCompatibility::Incompatible
1106 );
1107 assert_eq!(
1108 record(&facts.statements[1]).operation_classification,
1109 crate::EffectOperationClassification::UnknownExternalCapability
1110 );
1111 assert_eq!(
1112 record(&facts.statements[1]).signature_compatibility,
1113 crate::EffectCompatibility::Incompatible
1114 );
1115 assert_eq!(
1116 record(&facts.statements[1]).operand_types,
1117 vec![crate::SemanticType::Number]
1118 );
1119 assert_eq!(
1120 record(&facts.statements[2]).operation_classification,
1121 crate::EffectOperationClassification::ReactiveStateAssignment
1122 );
1123 assert_eq!(
1124 record(&facts.statements[3]).operation_classification,
1125 crate::EffectOperationClassification::ComponentActionCall
1126 );
1127 assert_eq!(
1128 record(&facts.statements[4]).operation_classification,
1129 crate::EffectOperationClassification::ComponentEffectCall
1130 );
1131 assert_eq!(
1132 record(&facts.statements[5]).operation_classification,
1133 crate::EffectOperationClassification::ComponentMethodCall
1134 );
1135 assert_eq!(
1136 record(&facts.statements[6]).operation_classification,
1137 crate::EffectOperationClassification::UnresolvedComponentCall
1138 );
1139 assert_eq!(
1140 record(&facts.statements[7]).operation_classification,
1141 crate::EffectOperationClassification::BareReturn
1142 );
1143 assert_eq!(
1144 asm.effect(&component.id.effect("sync"))
1145 .expect("valid effect")
1146 .validation,
1147 EffectValidation::Valid
1148 );
1149 let violations = &asm
1150 .effect(&component.id.effect("facts"))
1151 .expect("invalid effect")
1152 .semantic_violations;
1153 assert_eq!(
1154 asm.effect(&component.id.effect("facts"))
1155 .expect("invalid effect")
1156 .validation,
1157 EffectValidation::Invalid
1158 );
1159 for kind in [
1160 EffectSemanticViolationKind::CapabilitySignature,
1161 EffectSemanticViolationKind::UnknownExternalCapability,
1162 EffectSemanticViolationKind::ReactiveStateMutation,
1163 EffectSemanticViolationKind::ActionInvocation,
1164 EffectSemanticViolationKind::EffectInvocation,
1165 EffectSemanticViolationKind::ComponentMethodInvocation,
1166 EffectSemanticViolationKind::UnresolvedComponentCall,
1167 ] {
1168 assert!(violations.iter().any(|violation| violation.kind == kind));
1169 }
1170 let sync_id = component.id.effect("sync");
1171 let facts_id = component.id.effect("facts");
1172 assert_eq!(
1173 asm.reactive_graph.nodes[sync_id.as_str()].kind,
1174 crate::IrReactiveNodeKind::Effect
1175 );
1176 assert!(!asm.reactive_graph.nodes.contains_key(facts_id.as_str()));
1177 assert!(asm.reactive_graph.edges.iter().any(|edge| {
1178 edge.source == sync_id.as_str()
1179 && edge.target == component.id.state_field("title").as_str()
1180 && edge.kind == crate::IrReactiveEdgeKind::Reads
1181 }));
1182 assert!(asm.reactive_graph.edges.iter().any(|edge| {
1183 edge.source == component.id.state_field("title").as_str()
1184 && edge.target == sync_id.as_str()
1185 && edge.kind == crate::IrReactiveEdgeKind::Invalidates
1186 }));
1187 let analysis = asm
1188 .effect_reactive_analysis(&sync_id)
1189 .expect("effect reactive analysis");
1190 assert!(analysis
1191 .dependencies
1192 .contains(&component.id.state_field("title")));
1193 assert!(analysis
1194 .dependencies
1195 .contains(&component.id.state_field("total")));
1196 assert!(analysis.dependents.is_empty());
1197 assert!(asm.effect_reactive_analysis(&facts_id).is_none());
1198 let trigger_plan = asm.effect_trigger_plan();
1199 assert!(trigger_plan.initial_effects.contains(&sync_id));
1200 assert!(!trigger_plan.initial_effects.contains(&facts_id));
1201 let batch = trigger_plan
1202 .action_batches
1203 .get(&component.id.action_batch("increment"))
1204 .expect("increment action batch");
1205 assert_eq!(batch.ordered_write_records.len(), 2);
1206 assert_eq!(
1207 batch.written_states,
1208 vec![component.id.state_field("total")]
1209 );
1210 let trigger = trigger_plan
1211 .action_batch_triggers
1212 .iter()
1213 .find(|trigger| trigger.action_batch == batch.id)
1214 .expect("batch trigger");
1215 assert_eq!(trigger.effects, vec![sync_id.clone()]);
1216 assert_eq!(
1217 trigger.matched_states[&sync_id],
1218 vec![component.id.state_field("total")]
1219 );
1220 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1221 }
1222
1223 #[test]
1224 #[allow(clippy::too_many_lines)]
1225 fn schedules_minimal_effect_prerequisites_from_existing_computed_batches() {
1226 let parsed = presolve_parser::parse_file(
1227 "src/EffectExecutionPlan.tsx",
1228 r#"
1229@component("x-effect-execution-plan")
1230class EffectExecutionPlan extends Component {
1231 price = state(1);
1232 locale = state("en-US");
1233 theme = state("light");
1234
1235 @computed()
1236 get subtotal() { return this.price * 2; }
1237
1238 @computed()
1239 get total() { return this.subtotal + 1; }
1240
1241 @computed()
1242 get currentLocale() { return this.locale; }
1243
1244 @computed()
1245 get unrelated() { return this.theme; }
1246
1247 @action()
1248 increment() { this.price += 1; }
1249
1250 @action()
1251 restyle() { this.theme = "dark"; }
1252
1253 @effect()
1254 report() { console.log(this.total, this.currentLocale); }
1255
1256 @effect()
1257 audit() { console.log(this.price); }
1258
1259 @effect()
1260 bootLog() { console.log("ready"); }
1261
1262 render() { return <p />; }
1263}
1264"#,
1265 );
1266 let asm = build_application_semantic_model(&parsed);
1267 let component = &asm.components[0];
1268 let subtotal = component.id.computed("subtotal");
1269 let total = component.id.computed("total");
1270 let current_locale = component.id.computed("currentLocale");
1271 let unrelated = component.id.computed("unrelated");
1272 let report = component.id.effect("report");
1273 let audit = component.id.effect("audit");
1274 let boot_log = component.id.effect("bootLog");
1275 let execution = asm.effect_execution_plan();
1276
1277 assert_eq!(
1278 execution.initial.required_computed,
1279 vec![current_locale.clone(), subtotal.clone(), total.clone()]
1280 );
1281 assert_eq!(
1282 execution.initial.render_boundary,
1283 Some(crate::EffectRenderBoundary::AfterInitialRender)
1284 );
1285 assert_eq!(
1286 execution
1287 .initial
1288 .prerequisite_batches
1289 .iter()
1290 .map(|batch| batch.computed.clone())
1291 .collect::<Vec<_>>(),
1292 vec![vec![current_locale, subtotal.clone()], vec![total.clone()]]
1293 );
1294 assert!(!execution.initial.required_computed.contains(&unrelated));
1295 assert_eq!(
1296 execution.initial.effect_batches,
1297 vec![crate::EffectExecutionBatch {
1298 index: 0,
1299 effects: vec![audit.clone(), boot_log.clone(), report.clone()],
1300 }]
1301 );
1302 assert!(execution.initial.unplanned_effects.is_empty());
1303
1304 assert_eq!(execution.actions.len(), 1);
1305 let action = &execution.actions[0];
1306 assert_eq!(action.action_batch, component.id.action_batch("increment"));
1307 assert_eq!(action.required_computed, vec![subtotal, total.clone()]);
1308 assert_eq!(
1309 action
1310 .prerequisite_batches
1311 .iter()
1312 .map(|batch| batch.computed.clone())
1313 .collect::<Vec<_>>(),
1314 vec![
1315 vec![component.id.computed("subtotal")],
1316 vec![component.id.computed("total")],
1317 ]
1318 );
1319 assert_eq!(
1320 action.effect_batches,
1321 vec![crate::EffectExecutionBatch {
1322 index: 0,
1323 effects: vec![audit, report.clone()],
1324 }]
1325 );
1326 assert!(action.unplanned_effects.is_empty());
1327
1328 let unavailable_plan = crate::IrComputedEvaluationPlan {
1329 evaluation_order: asm
1330 .computed_evaluation_plan
1331 .evaluation_order
1332 .iter()
1333 .filter(|computed| computed.as_str() != total.as_str())
1334 .cloned()
1335 .collect(),
1336 update_batches: asm
1337 .computed_evaluation_plan
1338 .update_batches
1339 .iter()
1340 .map(|batch| {
1341 batch
1342 .iter()
1343 .filter(|computed| computed.as_str() != total.as_str())
1344 .cloned()
1345 .collect()
1346 })
1347 .collect(),
1348 unplanned: vec![total.as_str().to_string()],
1349 };
1350 let unavailable = crate::plan_effect_execution(
1351 &asm.computed_values,
1352 &asm.effects,
1353 &asm.effect_reactive_analysis,
1354 asm.effect_trigger_plan(),
1355 &asm.reactive_transitive_analysis,
1356 &unavailable_plan,
1357 );
1358 assert_eq!(
1359 unavailable.initial.unplanned_effects,
1360 vec![crate::UnplannedEffect {
1361 effect: report,
1362 reason: crate::UnplannedEffectReason::UnavailableComputedPrerequisite,
1363 computed_dependencies: vec![total],
1364 }]
1365 );
1366 assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1367 }
1368
1369 #[test]
1370 fn rejects_async_effects_without_runtime_execution() {
1371 let parsed = presolve_parser::parse_file(
1372 "src/AsyncEffect.tsx",
1373 r#"
1374@component("x-async-effect")
1375class AsyncEffect extends Component {
1376 title = state("Presolve");
1377
1378 @effect()
1379 async syncTitle() {
1380 document.title = this.title;
1381 }
1382
1383 @effect()
1384 invalidValueReturn() {
1385 return this.title;
1386 }
1387
1388 @effect()
1389 invalidCleanupReturn() {
1390 return () => unsubscribe();
1391 }
1392
1393 render() { return <p />; }
1394}
1395"#,
1396 );
1397 let asm = build_application_semantic_model(&parsed);
1398 let effect = asm
1399 .effect(&asm.components[0].id.effect("syncTitle"))
1400 .expect("effect");
1401
1402 assert_eq!(effect.validation, EffectValidation::Invalid);
1403 assert!(effect
1404 .semantic_violations
1405 .iter()
1406 .any(|violation| violation.kind == EffectSemanticViolationKind::Async));
1407 for (name, kind) in [
1408 (
1409 "invalidValueReturn",
1410 EffectSemanticViolationKind::ValueReturn,
1411 ),
1412 (
1413 "invalidCleanupReturn",
1414 EffectSemanticViolationKind::UnsupportedStatement,
1415 ),
1416 ] {
1417 let effect = asm
1418 .effect(&asm.components[0].id.effect(name))
1419 .expect("invalid return effect");
1420 assert_eq!(effect.validation, EffectValidation::Invalid);
1421 assert!(effect
1422 .semantic_violations
1423 .iter()
1424 .any(|violation| violation.kind == kind));
1425 }
1426 let codes = asm
1427 .diagnostics
1428 .iter()
1429 .map(|diagnostic| diagnostic.code.as_str())
1430 .collect::<Vec<_>>();
1431 assert_eq!(codes, vec!["PSC1042", "PSC1046", "PSC1046"]);
1432 }
1433}