1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4 ApplicationSemanticModel, ComponentDiagnostic, ComponentDiagnosticSeverity,
5 DiagnosticSecondaryLabel, Effect, EffectId, EffectSemanticViolation,
6 EffectSemanticViolationKind, EffectStatementId, EffectStatementKind, ExpressionNodeKind,
7 SemanticId, EFFECT_CAPABILITY_REGISTRY,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum EffectDiagnosticCode {
13 InvalidDeclaration,
14 UnsupportedBody,
15 UnresolvedReference,
16 ReactiveStateMutation,
17 InvalidComponentInvocation,
18 AsyncOrCleanupUnsupported,
19 UnknownCapability,
20 CapabilitySignature,
21 CapabilityBoundary,
22 CapabilitySerialization,
23 UnavailableComputedPrerequisite,
24}
25
26impl EffectDiagnosticCode {
27 #[must_use]
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::InvalidDeclaration => "PSC1041",
31 Self::UnsupportedBody => "PSC1042",
32 Self::UnresolvedReference => "PSC1043",
33 Self::ReactiveStateMutation => "PSC1044",
34 Self::InvalidComponentInvocation => "PSC1045",
35 Self::AsyncOrCleanupUnsupported => "PSC1046",
36 Self::UnknownCapability => "PSC1047",
37 Self::CapabilitySignature => "PSC1048",
38 Self::CapabilityBoundary => "PSC1049",
39 Self::CapabilitySerialization => "PSC1050",
40 Self::UnavailableComputedPrerequisite => "PSC1051",
41 }
42 }
43}
44
45#[must_use]
51pub fn collect_effect_diagnostics(model: &ApplicationSemanticModel) -> Vec<ComponentDiagnostic> {
52 let mut diagnostics = Vec::new();
53 for effect in model.effects.values() {
54 let mut violations = effect.semantic_violations.iter().collect::<Vec<_>>();
55 violations.sort_by(|left, right| {
56 (
57 statement_position(model, effect, left.statement.as_ref()),
58 cascade_precedence(left.kind),
59 left.provenance.path.as_path(),
60 left.provenance.span.start,
61 violation_code(left.kind),
62 )
63 .cmp(&(
64 statement_position(model, effect, right.statement.as_ref()),
65 cascade_precedence(right.kind),
66 right.provenance.path.as_path(),
67 right.provenance.span.start,
68 violation_code(right.kind),
69 ))
70 });
71
72 let mut reported_subjects = BTreeSet::new();
76 for violation in violations {
77 if !reported_subjects.insert(violation.statement.clone()) {
78 continue;
79 }
80 diagnostics.push(effect_violation_diagnostic(model, effect, violation));
81 }
82 }
83
84 let mut unavailable = BTreeMap::<SemanticId, BTreeSet<SemanticId>>::new();
88 for unplanned in model
89 .effect_execution_plan
90 .initial
91 .unplanned_effects
92 .iter()
93 .chain(
94 model
95 .effect_execution_plan
96 .actions
97 .iter()
98 .flat_map(|plan| &plan.unplanned_effects),
99 )
100 {
101 unavailable
102 .entry(unplanned.effect.clone())
103 .or_default()
104 .extend(unplanned.computed_dependencies.iter().cloned());
105 }
106 for (effect_id, computed) in unavailable {
107 let Some(effect) = model.effects.get(&effect_id) else {
108 continue;
109 };
110 let computed = computed.into_iter().collect::<Vec<_>>();
111 diagnostics.push(unavailable_prerequisite_diagnostic(
112 model, effect, &computed,
113 ));
114 }
115
116 diagnostics.sort_by(|left, right| diagnostic_order(left).cmp(&diagnostic_order(right)));
117 diagnostics.dedup();
118 diagnostics
119}
120
121fn effect_violation_diagnostic(
122 model: &ApplicationSemanticModel,
123 effect: &Effect,
124 violation: &EffectSemanticViolation,
125) -> ComponentDiagnostic {
126 let code = violation_code(violation.kind);
127 let statement_id = canonical_statement_id(model, effect, violation.statement.as_ref());
128 ComponentDiagnostic {
129 code: code.as_str().to_string(),
130 severity: ComponentDiagnosticSeverity::Error,
131 message: violation_message(model, effect, violation, code),
132 provenance: Some(violation.provenance.clone()),
133 effect_id: Some(EffectId::from_semantic(&effect.id)),
134 statement_id,
135 context_declaration_candidate_id: None,
136 context_id: None,
137 provider_id: None,
138 consumer_id: None,
139 slot_id: None,
140 invocation_id: None,
141 component_instance_id: None,
142 slot_binding_id: None,
143 structural_region_id: None,
144 component_id: None,
145 provider_instance_id: None,
146 consumer_instance_id: None,
147 secondary_labels: normalized_labels(secondary_labels(model, effect, violation)),
148 }
149}
150
151fn unavailable_prerequisite_diagnostic(
152 model: &ApplicationSemanticModel,
153 effect: &Effect,
154 computed: &[SemanticId],
155) -> ComponentDiagnostic {
156 let names = computed
157 .iter()
158 .filter_map(|id| {
159 model
160 .computed_values
161 .get(id)
162 .map(|value| value.name.as_str())
163 })
164 .collect::<Vec<_>>();
165 let subject = if names.is_empty() {
166 "a required computed value".to_string()
167 } else {
168 format!(
169 "computed value{} `{}`",
170 if names.len() == 1 { "" } else { "s" },
171 names.join("`, `")
172 )
173 };
174 let labels = computed
175 .iter()
176 .filter_map(|id| {
177 model
178 .provenance(id)
179 .map(|provenance| DiagnosticSecondaryLabel {
180 provenance: provenance.clone(),
181 message: "Required computed value is unavailable for effect scheduling."
182 .to_string(),
183 })
184 })
185 .collect();
186 ComponentDiagnostic {
187 code: EffectDiagnosticCode::UnavailableComputedPrerequisite
188 .as_str()
189 .to_string(),
190 severity: ComponentDiagnosticSeverity::Error,
191 message: format!(
192 "Effect `{}` cannot be scheduled because {subject} has no executable evaluation plan.",
193 effect.name
194 ),
195 provenance: Some(effect.provenance.clone()),
196 effect_id: Some(EffectId::from_semantic(&effect.id)),
197 statement_id: None,
198 context_declaration_candidate_id: None,
199 context_id: None,
200 provider_id: None,
201 consumer_id: None,
202 slot_id: None,
203 invocation_id: None,
204 component_instance_id: None,
205 slot_binding_id: None,
206 structural_region_id: None,
207 component_id: None,
208 provider_instance_id: None,
209 consumer_instance_id: None,
210 secondary_labels: normalized_labels(labels),
211 }
212}
213
214fn canonical_statement_id(
215 model: &ApplicationSemanticModel,
216 effect: &Effect,
217 statement: Option<&SemanticId>,
218) -> Option<EffectStatementId> {
219 statement
220 .filter(|id| {
221 model
222 .effect_statements
223 .get(*id)
224 .is_some_and(|candidate| candidate.owner == effect.id)
225 })
226 .map(EffectStatementId::from_semantic)
227}
228
229fn statement_position(
230 model: &ApplicationSemanticModel,
231 effect: &Effect,
232 statement: Option<&SemanticId>,
233) -> usize {
234 statement
235 .and_then(|statement| {
236 model
237 .effect_bodies
238 .get(&effect.id)
239 .and_then(|body| body.statements.iter().position(|id| id == statement))
240 })
241 .unwrap_or(usize::MAX)
242}
243
244fn cascade_precedence(kind: EffectSemanticViolationKind) -> u8 {
245 match kind {
246 EffectSemanticViolationKind::UnsupportedStatement => 1,
247 EffectSemanticViolationKind::Async
248 | EffectSemanticViolationKind::CleanupLifecycleUnavailable
249 | EffectSemanticViolationKind::ReactiveStateMutation
250 | EffectSemanticViolationKind::ActionInvocation
251 | EffectSemanticViolationKind::EffectInvocation
252 | EffectSemanticViolationKind::ComponentMethodInvocation
253 | EffectSemanticViolationKind::ValueReturn => 2,
254 EffectSemanticViolationKind::UnresolvedComponentCall
255 | EffectSemanticViolationKind::UnresolvedComponentAssignment => 3,
256 EffectSemanticViolationKind::UnknownExternalCapability => 4,
257 EffectSemanticViolationKind::CapabilityBoundary => 5,
258 EffectSemanticViolationKind::CapabilitySignature => 6,
259 EffectSemanticViolationKind::CapabilitySerialization => 7,
260 }
261}
262
263fn violation_code(kind: EffectSemanticViolationKind) -> EffectDiagnosticCode {
264 match kind {
265 EffectSemanticViolationKind::UnsupportedStatement => EffectDiagnosticCode::UnsupportedBody,
266 EffectSemanticViolationKind::UnresolvedComponentAssignment => {
267 EffectDiagnosticCode::UnresolvedReference
268 }
269 EffectSemanticViolationKind::ReactiveStateMutation => {
270 EffectDiagnosticCode::ReactiveStateMutation
271 }
272 EffectSemanticViolationKind::ActionInvocation
273 | EffectSemanticViolationKind::EffectInvocation
274 | EffectSemanticViolationKind::ComponentMethodInvocation
275 | EffectSemanticViolationKind::UnresolvedComponentCall => {
276 EffectDiagnosticCode::InvalidComponentInvocation
277 }
278 EffectSemanticViolationKind::Async
279 | EffectSemanticViolationKind::CleanupLifecycleUnavailable
280 | EffectSemanticViolationKind::ValueReturn => {
281 EffectDiagnosticCode::AsyncOrCleanupUnsupported
282 }
283 EffectSemanticViolationKind::UnknownExternalCapability => {
284 EffectDiagnosticCode::UnknownCapability
285 }
286 EffectSemanticViolationKind::CapabilitySignature => {
287 EffectDiagnosticCode::CapabilitySignature
288 }
289 EffectSemanticViolationKind::CapabilityBoundary => EffectDiagnosticCode::CapabilityBoundary,
290 EffectSemanticViolationKind::CapabilitySerialization => {
291 EffectDiagnosticCode::CapabilitySerialization
292 }
293 }
294}
295
296fn violation_message(
297 model: &ApplicationSemanticModel,
298 effect: &Effect,
299 violation: &EffectSemanticViolation,
300 code: EffectDiagnosticCode,
301) -> String {
302 let statement = violation
303 .statement
304 .as_ref()
305 .and_then(|id| model.effect_statements.get(id));
306 match code {
307 EffectDiagnosticCode::ReactiveStateMutation => format!(
308 "Effect `{}` writes reactive state{}. Effects synchronize reactive values with external systems and cannot mutate component state. Move this write into an `@action()` method.",
309 effect.name,
310 reactive_state_name(model, statement).map_or_else(String::new, |name| format!(" `{name}`")),
311 ),
312 EffectDiagnosticCode::InvalidComponentInvocation => format!(
313 "Effect `{}` {}. Effects cannot invoke component actions, effects, or methods.",
314 effect.name,
315 component_invocation_description(model, effect, statement),
316 ),
317 EffectDiagnosticCode::AsyncOrCleanupUnsupported => match violation.kind {
318 EffectSemanticViolationKind::Async => format!(
319 "Effect `{}` is async. Effects are synchronous and do not support async or cleanup semantics.",
320 effect.name
321 ),
322 EffectSemanticViolationKind::CleanupLifecycleUnavailable => format!(
323 "Effect `{}` returns a cleanup callback, but its lifecycle runtime program is unavailable.",
324 effect.name
325 ),
326 _ => format!(
327 "Effect `{}` returns a value. Effects do not support cleanup callbacks or value-return semantics.",
328 effect.name
329 ),
330 },
331 EffectDiagnosticCode::UnknownCapability => format!(
332 "Effect `{}` uses unknown capability `{}`. Effects may call only compiler-recognized capabilities from registry version 1.",
333 effect.name,
334 capability_path(model, statement).unwrap_or_else(|| "<dynamic capability>".to_string()),
335 ),
336 EffectDiagnosticCode::CapabilitySignature => format!(
337 "Effect `{}` uses a capability with an incompatible signature or value type{}.",
338 effect.name,
339 recognized_capability_suffix(model, violation.statement.as_ref()),
340 ),
341 EffectDiagnosticCode::CapabilityBoundary => format!(
342 "Effect `{}` uses a capability incompatible with the client execution boundary{}.",
343 effect.name,
344 recognized_capability_suffix(model, violation.statement.as_ref()),
345 ),
346 EffectDiagnosticCode::CapabilitySerialization => format!(
347 "Effect `{}` passes a value incompatible with the capability serialization policy{}.",
348 effect.name,
349 recognized_capability_suffix(model, violation.statement.as_ref()),
350 ),
351 EffectDiagnosticCode::UnsupportedBody => format!(
352 "Effect `{}` contains an unsupported effect-body statement.",
353 effect.name
354 ),
355 EffectDiagnosticCode::UnresolvedReference => format!(
356 "Effect `{}` assigns an unresolved component reference.",
357 effect.name
358 ),
359 EffectDiagnosticCode::InvalidDeclaration => format!(
360 "Effect `{}` has an invalid declaration.",
361 effect.name
362 ),
363 EffectDiagnosticCode::UnavailableComputedPrerequisite => unreachable!(),
364 }
365}
366
367fn secondary_labels(
368 model: &ApplicationSemanticModel,
369 effect: &Effect,
370 violation: &EffectSemanticViolation,
371) -> Vec<DiagnosticSecondaryLabel> {
372 let Some(statement_id) = violation.statement.as_ref() else {
373 return Vec::new();
374 };
375 let Some(statement) = model.effect_statements.get(statement_id) else {
376 return Vec::new();
377 };
378 let Some(component_id) = effect.owner.entity_id() else {
379 return Vec::new();
380 };
381 let Some(component) = model
382 .components
383 .iter()
384 .find(|component| component.id == *component_id)
385 else {
386 return Vec::new();
387 };
388 match (&violation.kind, &statement.kind) {
389 (
390 EffectSemanticViolationKind::ReactiveStateMutation,
391 EffectStatementKind::ExternalMemberAssignment { target, .. },
392 ) => this_member_name(model, target)
393 .and_then(|name| {
394 component
395 .state_fields
396 .iter()
397 .find(|field| field.name == name)
398 .and_then(|field| {
399 model
400 .provenance(&field.id)
401 .map(|provenance| DiagnosticSecondaryLabel {
402 provenance: provenance.clone(),
403 message: format!("Reactive state `{name}` is declared here."),
404 })
405 })
406 })
407 .into_iter()
408 .collect(),
409 (
410 EffectSemanticViolationKind::ActionInvocation
411 | EffectSemanticViolationKind::EffectInvocation
412 | EffectSemanticViolationKind::ComponentMethodInvocation,
413 EffectStatementKind::CapabilityCall { callee, .. },
414 ) => this_member_name(model, callee)
415 .and_then(|name| {
416 component
417 .methods
418 .iter()
419 .find(|method| method.name == name)
420 .and_then(|method| {
421 model
422 .provenance(&method.id)
423 .map(|provenance| DiagnosticSecondaryLabel {
424 provenance: provenance.clone(),
425 message: format!("Component method `{name}` is declared here."),
426 })
427 })
428 })
429 .into_iter()
430 .collect(),
431 _ => Vec::new(),
432 }
433}
434
435fn reactive_state_name(
436 model: &ApplicationSemanticModel,
437 statement: Option<&crate::EffectStatement>,
438) -> Option<String> {
439 match statement?.kind {
440 EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
441 this_member_name(model, target)
442 }
443 _ => None,
444 }
445}
446
447fn component_invocation_description(
448 model: &ApplicationSemanticModel,
449 effect: &Effect,
450 statement: Option<&crate::EffectStatement>,
451) -> String {
452 let name = match statement.map(|statement| &statement.kind) {
453 Some(EffectStatementKind::CapabilityCall { callee, .. }) => this_member_name(model, callee),
454 _ => None,
455 };
456 let category = effect
457 .owner
458 .entity_id()
459 .and_then(|owner| {
460 model
461 .components
462 .iter()
463 .find(|component| component.id == *owner)
464 })
465 .and_then(|component| {
466 name.as_ref()
467 .and_then(|name| component.methods.iter().find(|method| method.name == *name))
468 })
469 .map_or("an unresolved component method", |method| {
470 if method.is_action() {
471 "an `@action()` method"
472 } else if method.is_effect() {
473 "an `@effect()` method"
474 } else {
475 "a component method"
476 }
477 });
478 name.map_or_else(
479 || format!("invokes {category}"),
480 |name| format!("invokes {category} `{name}`"),
481 )
482}
483
484fn recognized_capability_suffix(
485 model: &ApplicationSemanticModel,
486 statement: Option<&SemanticId>,
487) -> String {
488 let operation = statement
489 .and_then(|id| model.semantic_types.effect_statements.get(id))
490 .and_then(|record| record.capability_operation)
491 .and_then(|id| EFFECT_CAPABILITY_REGISTRY.operation(id));
492 operation.map_or_else(String::new, |operation| {
493 format!(" for `{}`", operation.static_path.0)
494 })
495}
496
497fn capability_path(
498 model: &ApplicationSemanticModel,
499 statement: Option<&crate::EffectStatement>,
500) -> Option<String> {
501 match statement?.kind {
502 EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
503 static_path(model, target)
504 }
505 EffectStatementKind::CapabilityCall { ref callee, .. } => static_path(model, callee),
506 _ => None,
507 }
508}
509
510fn static_path(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
511 match &model.expression(id)?.kind {
512 ExpressionNodeKind::Identifier(name) => Some(name.clone()),
513 ExpressionNodeKind::ThisMember { name } => Some(format!("this.{name}")),
514 ExpressionNodeKind::MemberAccess {
515 object, property, ..
516 } => Some(format!("{}.{}", static_path(model, object)?, property)),
517 _ => None,
518 }
519}
520
521fn this_member_name(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
522 match &model.expression(id)?.kind {
523 ExpressionNodeKind::ThisMember { name } => Some(name.clone()),
524 _ => None,
525 }
526}
527
528fn normalized_labels(mut labels: Vec<DiagnosticSecondaryLabel>) -> Vec<DiagnosticSecondaryLabel> {
529 labels.sort_by(|left, right| {
530 (
531 left.provenance.path.as_path(),
532 left.provenance.span.start,
533 left.provenance.span.end,
534 left.message.as_str(),
535 )
536 .cmp(&(
537 right.provenance.path.as_path(),
538 right.provenance.span.start,
539 right.provenance.span.end,
540 right.message.as_str(),
541 ))
542 });
543 labels.dedup();
544 labels
545}
546
547fn diagnostic_order(diagnostic: &ComponentDiagnostic) -> (&str, &std::path::Path, usize, &str) {
548 (
549 diagnostic.effect_id.as_ref().map_or("", EffectId::as_str),
550 diagnostic.provenance.as_ref().map_or_else(
551 || std::path::Path::new(""),
552 |provenance| provenance.path.as_path(),
553 ),
554 diagnostic
555 .provenance
556 .as_ref()
557 .map_or(usize::MAX, |provenance| provenance.span.start),
558 diagnostic.code.as_str(),
559 )
560}
561
562#[cfg(test)]
563mod tests {
564 use crate::{
565 build_application_semantic_model, collect_effect_diagnostics, EffectSemanticViolationKind,
566 UnplannedEffect, UnplannedEffectReason,
567 };
568
569 #[test]
570 fn projects_ordered_effect_validation_facts_with_shared_diagnostic_metadata() {
571 let parsed = presolve_parser::parse_file(
572 "src/EffectDiagnostics.tsx",
573 r#"
574@component("x-effect-diagnostics")
575class EffectDiagnostics extends Component {
576 count = state(1);
577 @action() increment() { this.count += 1; }
578 @effect() invalid() { this.count = 0; this.increment(); analytics.track(this.count); }
579}
580"#,
581 );
582 let model = build_application_semantic_model(&parsed);
583 let diagnostics = collect_effect_diagnostics(&model);
584 let codes = diagnostics
585 .iter()
586 .map(|diagnostic| diagnostic.code.as_str())
587 .collect::<Vec<_>>();
588 assert_eq!(codes, vec!["PSC1044", "PSC1045", "PSC1047"]);
589 assert!(diagnostics.iter().all(|diagnostic| {
590 diagnostic.effect_id.is_some()
591 && diagnostic.statement_id.is_some()
592 && diagnostic.severity == crate::ComponentDiagnosticSeverity::Error
593 }));
594 assert_eq!(diagnostics[0].secondary_labels.len(), 1);
595 assert_eq!(diagnostics[1].secondary_labels.len(), 1);
596 }
597
598 #[test]
599 fn maps_each_existing_f5_violation_category_to_one_stable_public_code() {
600 let cases = [
601 (EffectSemanticViolationKind::UnsupportedStatement, "PSC1042"),
602 (
603 EffectSemanticViolationKind::UnresolvedComponentAssignment,
604 "PSC1043",
605 ),
606 (
607 EffectSemanticViolationKind::ReactiveStateMutation,
608 "PSC1044",
609 ),
610 (EffectSemanticViolationKind::ActionInvocation, "PSC1045"),
611 (EffectSemanticViolationKind::EffectInvocation, "PSC1045"),
612 (
613 EffectSemanticViolationKind::ComponentMethodInvocation,
614 "PSC1045",
615 ),
616 (
617 EffectSemanticViolationKind::UnresolvedComponentCall,
618 "PSC1045",
619 ),
620 (EffectSemanticViolationKind::Async, "PSC1046"),
621 (
622 EffectSemanticViolationKind::CleanupLifecycleUnavailable,
623 "PSC1046",
624 ),
625 (EffectSemanticViolationKind::ValueReturn, "PSC1046"),
626 (
627 EffectSemanticViolationKind::UnknownExternalCapability,
628 "PSC1047",
629 ),
630 (EffectSemanticViolationKind::CapabilitySignature, "PSC1048"),
631 (EffectSemanticViolationKind::CapabilityBoundary, "PSC1049"),
632 (
633 EffectSemanticViolationKind::CapabilitySerialization,
634 "PSC1050",
635 ),
636 ];
637
638 for (kind, code) in cases {
639 assert_eq!(super::violation_code(kind).as_str(), code);
640 }
641 }
642
643 #[test]
644 fn projects_f9_unavailable_prerequisites_once_with_computed_evidence() {
645 let parsed = presolve_parser::parse_file(
646 "src/UnplannedEffect.tsx",
647 r#"
648@component("x-unplanned-effect")
649class UnplannedEffect extends Component {
650 count = state(1);
651 @action() increment() { this.count += 1; }
652 @computed() get total() { return this.count; }
653 @effect() report() { console.log(this.total); }
654 render() { return <p />; }
655}
656"#,
657 );
658 let mut model = build_application_semantic_model(&parsed);
659 let component = &model.components[0];
660 let effect = component.id.effect("report");
661 let computed = component.id.computed("total");
662 model.effect_execution_plan.initial.unplanned_effects = vec![UnplannedEffect {
663 effect: effect.clone(),
664 reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
665 computed_dependencies: vec![computed.clone()],
666 }];
667 model.effect_execution_plan.actions[0].unplanned_effects = vec![UnplannedEffect {
670 effect: effect.clone(),
671 reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
672 computed_dependencies: vec![computed],
673 }];
674
675 let diagnostics = collect_effect_diagnostics(&model);
676 assert_eq!(diagnostics.len(), 1);
677 let diagnostic = &diagnostics[0];
678 assert_eq!(diagnostic.code, "PSC1051");
679 assert_eq!(
680 diagnostic.effect_id.as_ref().map(crate::EffectId::as_str),
681 Some(effect.as_str())
682 );
683 assert!(diagnostic.statement_id.is_none());
684 assert_eq!(diagnostic.secondary_labels.len(), 1);
685 }
686}