1#[cfg(test)]
4use omena_cascade::{
5 run_cascade_conformance_seed_corpus, run_cascade_ordering_axis_self_check_corpus,
6};
7use omena_parser::{
8 ClosedWorldBundleV0, ModuleQualifiedSymbolSetV0, ParserDeclarationSyntaxFactV0, StyleDialect,
9 collect_parser_declaration_syntax_facts, lex,
10};
11#[cfg(test)]
12use omena_syntax::ident::AuthoredPropertyTextV0;
13use omena_syntax::{
14 SyntaxKind, css_keyword,
15 ident::{CanonicalPropertyKeyV0, PropertyNameV0},
16};
17use omena_transform_cst::{
18 IrBlockSpanV0, IrNodeKindV0, IrNodeV0, TransformIrV0, TransformPassKind,
19 lower_transform_ir_from_source, structural_block_spans_for_source,
20};
21#[cfg(test)]
22use serde::Deserialize;
23use serde::Serialize;
24use std::collections::{BTreeMap, BTreeSet};
25
26use crate::model::{
27 TransformSemanticObservationKeyAxisV0, TransformSemanticObservationOrderingRuleV0,
28 TransformSemanticObservationSurfaceV0, TransformSemanticObservationValueAxisV0,
29 TransformSemanticPreservationClaimScopeV0, TransformSemanticPreservationTelemetryV0,
30 TransformSemanticPreservationVocabularyReviewV0, TransformSemanticUnobservedAxisV0,
31};
32use crate::{
33 domains::{
34 css_modules_values::{
35 collect_css_modules_value_semantic_facts_from_ir,
36 collect_tree_shake_css_modules_value_removals_from_ir,
37 },
38 custom_property::{
39 collect_css_custom_property_semantic_facts_from_ir,
40 collect_tree_shake_css_custom_property_removals_from_ir,
41 },
42 keyframes::{
43 collect_referenced_keyframe_names_from_ir,
44 collect_tree_shake_css_keyframe_removals_from_ir, keyframe_name_is_reachable,
45 },
46 nesting::expand_nested_selector,
47 reachability::class_name_is_reachable,
48 },
49 helpers::selectors::selector_branch_owner_class_names,
50};
51
52impl TransformSemanticPreservationTelemetryV0 {
53 pub(crate) fn record(&mut self, decision: &TransformSemanticPreservationDecisionV0) {
54 self.observed_pass_count += 1;
55 if decision.preserved {
56 self.preserved_pass_count += 1;
57 } else {
58 self.blocked_pass_count += 1;
59 }
60 }
61
62 pub(crate) fn record_blocked_before_observation(&mut self) {
65 self.observed_pass_count += 1;
66 self.blocked_pass_count += 1;
67 }
68}
69
70impl Default for TransformSemanticPreservationTelemetryV0 {
71 fn default() -> Self {
72 Self {
73 observed_pass_count: 0,
74 preserved_pass_count: 0,
75 blocked_pass_count: 0,
76 observed_surface: semantic_observation_surface_descriptor(),
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct TransformSemanticPreservationDecisionV0 {
84 pub pass_id: &'static str,
85 pub preserved: bool,
86 pub input_entry_count: usize,
87 pub output_entry_count: usize,
88 pub mismatch_count: usize,
89}
90
91pub fn compare_transform_css_semantics_v0(
92 input_css: &str,
93 output_css: &str,
94 dialect: StyleDialect,
95) -> TransformSemanticPreservationDecisionV0 {
96 let input_ir = lower_transform_ir_from_source(
97 input_css,
98 dialect,
99 "omena-transform-passes.semantic-comparison.input",
100 );
101 let output_ir = lower_transform_ir_from_source(
102 output_css,
103 dialect,
104 "omena-transform-passes.semantic-comparison.output",
105 );
106 let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
107 compare_semantic_observation_for_pass_with_scopes(
108 "external-css-lowering",
109 &input_ir,
110 &output_ir,
111 scope,
112 scope,
113 )
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub enum ExternalCssSemanticChangeKindV0 {
119 Added,
120 Removed,
121 Modified,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub enum ExternalCssSemanticChangeClassificationV0 {
127 Understood,
128 Passthrough,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct ExternalCssSemanticEntryV0 {
134 pub selector: String,
135 pub property: CanonicalPropertyKeyV0,
136 pub context: String,
137 pub value: String,
138 pub important: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct ExternalCssSemanticChangeV0 {
144 pub kind: ExternalCssSemanticChangeKindV0,
145 pub classification: ExternalCssSemanticChangeClassificationV0,
146 pub explanation: &'static str,
147 pub before: Option<ExternalCssSemanticEntryV0>,
148 pub after: Option<ExternalCssSemanticEntryV0>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "camelCase")]
162pub struct ExternalCssCstCoverageV0 {
163 pub input_unit_count: usize,
164 pub output_unit_count: usize,
165 pub understood_addition_unit_count: usize,
166 pub uncovered_input_unit_count: usize,
167 pub uncovered_output_unit_count: usize,
168 pub complete: bool,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172#[serde(rename_all = "camelCase")]
173pub struct ExternalCssSemanticDiffV0 {
174 pub input_entry_count: usize,
175 pub output_entry_count: usize,
176 pub total_change_count: usize,
177 pub understood_change_count: usize,
178 pub passthrough_change_count: usize,
179 pub semantic_change_accounting_complete: bool,
183 pub cst_coverage: ExternalCssCstCoverageV0,
184 pub changes: Vec<ExternalCssSemanticChangeV0>,
185}
186
187pub fn compare_external_css_semantic_changes_v0(
188 input_css: &str,
189 output_css: &str,
190 dialect: StyleDialect,
191) -> ExternalCssSemanticDiffV0 {
192 let input_ir = lower_transform_ir_from_source(
193 input_css,
194 dialect,
195 "omena-transform-passes.external-css-comparison.input",
196 );
197 let output_ir = lower_transform_ir_from_source(
198 output_css,
199 dialect,
200 "omena-transform-passes.external-css-comparison.output",
201 );
202 let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
203 let input = semantic_observation(&input_ir, scope);
204 let output = semantic_observation(&output_ir, scope);
205 let mut changes = Vec::new();
206
207 for (key, input_value) in &input {
208 match output.get(key) {
209 None => changes.push(classify_external_semantic_change(
210 ExternalCssSemanticChangeKindV0::Removed,
211 Some(external_semantic_entry(key, input_value)),
212 None,
213 &input,
214 &output,
215 )),
216 Some(output_value) if output_value != input_value => {
217 changes.push(classify_external_semantic_change(
218 ExternalCssSemanticChangeKindV0::Modified,
219 Some(external_semantic_entry(key, input_value)),
220 Some(external_semantic_entry(key, output_value)),
221 &input,
222 &output,
223 ))
224 }
225 Some(_) => {}
226 }
227 }
228 for (key, output_value) in &output {
229 if !input.contains_key(key) {
230 changes.push(classify_external_semantic_change(
231 ExternalCssSemanticChangeKindV0::Added,
232 None,
233 Some(external_semantic_entry(key, output_value)),
234 &input,
235 &output,
236 ));
237 }
238 }
239 changes.sort();
240 let cst_coverage = external_css_cst_coverage(
241 input_css,
242 output_css,
243 dialect,
244 &input_ir,
245 &output_ir,
246 changes.as_slice(),
247 );
248 external_css_semantic_diff_from_changes(input.len(), output.len(), changes, cst_coverage)
249}
250
251pub fn external_css_adoption_boundary_is_complete_v0(report: &ExternalCssSemanticDiffV0) -> bool {
252 report.cst_coverage.complete
253 && report.cst_coverage.uncovered_input_unit_count == 0
254 && report.cst_coverage.uncovered_output_unit_count == 0
255 && report.total_change_count == report.changes.len()
256 && report.understood_change_count
257 == report
258 .changes
259 .iter()
260 .filter(|change| {
261 change.classification == ExternalCssSemanticChangeClassificationV0::Understood
262 })
263 .count()
264 && report.passthrough_change_count
265 == report
266 .changes
267 .iter()
268 .filter(|change| {
269 change.classification == ExternalCssSemanticChangeClassificationV0::Passthrough
270 })
271 .count()
272 && report.understood_change_count + report.passthrough_change_count
273 == report.total_change_count
274}
275
276pub fn external_css_semantic_diff_is_total_v0(report: &ExternalCssSemanticDiffV0) -> bool {
278 external_css_adoption_boundary_is_complete_v0(report)
279}
280
281fn external_css_semantic_diff_from_changes(
282 input_entry_count: usize,
283 output_entry_count: usize,
284 changes: Vec<ExternalCssSemanticChangeV0>,
285 cst_coverage: ExternalCssCstCoverageV0,
286) -> ExternalCssSemanticDiffV0 {
287 let understood_change_count = changes
288 .iter()
289 .filter(|change| {
290 change.classification == ExternalCssSemanticChangeClassificationV0::Understood
291 })
292 .count();
293 let passthrough_change_count = changes.len().saturating_sub(understood_change_count);
294 let semantic_change_accounting_complete =
295 understood_change_count + passthrough_change_count == changes.len();
296 ExternalCssSemanticDiffV0 {
297 input_entry_count,
298 output_entry_count,
299 total_change_count: changes.len(),
300 understood_change_count,
301 passthrough_change_count,
302 semantic_change_accounting_complete,
303 cst_coverage,
304 changes,
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309struct ExternalCssCoverageUnitV0 {
310 key: String,
311 understood_addition: bool,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315struct ExternalCssDeclarationCoverageSpanV0 {
316 start: usize,
317 end: usize,
318 key: String,
319 understood_addition: bool,
320}
321
322fn external_css_cst_coverage(
323 input_css: &str,
324 output_css: &str,
325 dialect: StyleDialect,
326 input_ir: &TransformIrV0,
327 output_ir: &TransformIrV0,
328 changes: &[ExternalCssSemanticChangeV0],
329) -> ExternalCssCstCoverageV0 {
330 let understood_entries = changes
331 .iter()
332 .filter(|change| {
333 change.kind == ExternalCssSemanticChangeKindV0::Added
334 && change.classification == ExternalCssSemanticChangeClassificationV0::Understood
335 })
336 .filter_map(|change| change.after.clone())
337 .collect::<BTreeSet<_>>();
338 let input_units = external_css_coverage_units(input_css, dialect, input_ir, &BTreeSet::new());
339 let output_units =
340 external_css_coverage_units(output_css, dialect, output_ir, &understood_entries);
341
342 let mut output_index = 0;
343 let mut understood_addition_unit_count = 0;
344 let mut uncovered_input_unit_count = 0;
345 let mut uncovered_output_unit_count = 0;
346
347 for input_unit in &input_units {
348 let mut matched = false;
349 while let Some(output_unit) = output_units.get(output_index) {
350 if output_unit.key == input_unit.key {
351 output_index += 1;
352 matched = true;
353 break;
354 }
355 if output_unit.understood_addition {
356 understood_addition_unit_count += 1;
357 } else {
358 uncovered_output_unit_count += 1;
359 }
360 output_index += 1;
361 }
362 if !matched {
363 uncovered_input_unit_count += 1;
364 }
365 }
366 for output_unit in &output_units[output_index..] {
367 if output_unit.understood_addition {
368 understood_addition_unit_count += 1;
369 } else {
370 uncovered_output_unit_count += 1;
371 }
372 }
373
374 ExternalCssCstCoverageV0 {
375 input_unit_count: input_units.len(),
376 output_unit_count: output_units.len(),
377 understood_addition_unit_count,
378 uncovered_input_unit_count,
379 uncovered_output_unit_count,
380 complete: uncovered_input_unit_count == 0 && uncovered_output_unit_count == 0,
381 }
382}
383
384fn external_css_coverage_units(
385 source: &str,
386 dialect: StyleDialect,
387 ir: &TransformIrV0,
388 understood_entries: &BTreeSet<ExternalCssSemanticEntryV0>,
389) -> Vec<ExternalCssCoverageUnitV0> {
390 let spans = external_css_declaration_coverage_spans(source, dialect, ir, understood_entries);
391 let mut units = Vec::new();
392 let mut cursor = 0;
393 for span in spans {
394 if span.start < cursor || span.end > source.len() {
395 continue;
396 }
397 units.extend(external_css_syntax_units(
398 source.get(cursor..span.start).unwrap_or_default(),
399 dialect,
400 ));
401 units.push(ExternalCssCoverageUnitV0 {
402 key: span.key,
403 understood_addition: span.understood_addition,
404 });
405 cursor = span.end;
406 }
407 units.extend(external_css_syntax_units(
408 source.get(cursor..).unwrap_or_default(),
409 dialect,
410 ));
411 units
412}
413
414fn external_css_declaration_coverage_spans(
415 source: &str,
416 dialect: StyleDialect,
417 ir: &TransformIrV0,
418 understood_entries: &BTreeSet<ExternalCssSemanticEntryV0>,
419) -> Vec<ExternalCssDeclarationCoverageSpanV0> {
420 let mut spans = ir
421 .nodes
422 .iter()
423 .filter(|node| !node.deleted && node.kind == IrNodeKindV0::Declaration)
424 .filter(|node| {
425 node.source_span_start < node.source_span_end && node.source_span_end <= source.len()
426 })
427 .map(|node| {
428 let declaration_entries = external_css_entries_for_declaration(ir, node);
429 let understood_addition = !declaration_entries.is_empty()
430 && declaration_entries
431 .iter()
432 .all(|entry| understood_entries.contains(entry));
433 ExternalCssDeclarationCoverageSpanV0 {
434 start: node.source_span_start,
435 end: node.source_span_end,
436 key: external_css_declaration_unit_key(
437 source
438 .get(node.source_span_start..node.source_span_end)
439 .unwrap_or_default(),
440 dialect,
441 ),
442 understood_addition,
443 }
444 })
445 .collect::<Vec<_>>();
446 spans.sort_by_key(|span| (span.start, span.end));
447 spans.dedup_by(|left, right| left.start == right.start && left.end == right.end);
448 spans
449}
450
451fn external_css_entries_for_declaration(
452 ir: &TransformIrV0,
453 node: &IrNodeV0,
454) -> Vec<ExternalCssSemanticEntryV0> {
455 let Some(declaration) = semantic_declaration_from_ir(ir, node) else {
456 return Vec::new();
457 };
458 let Some(selector_keys) = nearest_style_ancestor_selector_keys(ir, node) else {
459 return Vec::new();
460 };
461 let context = ancestor_at_rule_context_key(ir, node);
462 selector_keys
463 .into_iter()
464 .filter(|selector| {
465 !selector.eq_ignore_ascii_case(":export") && !selector.starts_with(":import")
466 })
467 .map(|selector| ExternalCssSemanticEntryV0 {
468 selector,
469 property: declaration.property_key.clone(),
470 context: context.clone(),
471 value: declaration.value.clone(),
472 important: declaration.important,
473 })
474 .collect()
475}
476
477fn external_css_declaration_unit_key(source: &str, dialect: StyleDialect) -> String {
478 format!(
479 "declaration:{}",
480 external_css_normalized_tokens(source, dialect)
481 )
482}
483
484fn external_css_syntax_units(
485 source: &str,
486 dialect: StyleDialect,
487) -> Vec<ExternalCssCoverageUnitV0> {
488 lex(source, dialect)
489 .tokens()
490 .iter()
491 .filter(|token| token.kind != SyntaxKind::Whitespace)
492 .map(|token| ExternalCssCoverageUnitV0 {
493 key: format!("syntax:{:?}:{}", token.kind, token.text),
494 understood_addition: false,
495 })
496 .collect()
497}
498
499fn external_css_normalized_tokens(source: &str, dialect: StyleDialect) -> String {
500 lex(source, dialect)
501 .tokens()
502 .iter()
503 .filter(|token| token.kind != SyntaxKind::Whitespace)
504 .map(|token| format!("{:?}:{}", token.kind, token.text))
505 .collect::<Vec<_>>()
506 .join("|")
507}
508
509fn classify_external_semantic_change(
510 kind: ExternalCssSemanticChangeKindV0,
511 before: Option<ExternalCssSemanticEntryV0>,
512 after: Option<ExternalCssSemanticEntryV0>,
513 input: &SemanticObservationV0,
514 output: &SemanticObservationV0,
515) -> ExternalCssSemanticChangeV0 {
516 let understood_prefix_addition = kind == ExternalCssSemanticChangeKindV0::Added
517 && after.as_ref().is_some_and(|entry| {
518 vendor_unprefixed_property(entry.property.as_str()).is_some_and(|unprefixed| {
519 let peer = SemanticObservationKeyV0 {
520 selector_key: entry.selector.clone(),
521 property: PropertyNameV0::from_authored(unprefixed).canonical_key(),
522 context_key: entry.context.clone(),
523 };
524 [input.get(&peer), output.get(&peer)]
525 .into_iter()
526 .flatten()
527 .any(|peer_value| {
528 peer_value.value == entry.value && peer_value.important == entry.important
529 })
530 })
531 });
532 let (classification, explanation) = if understood_prefix_addition {
533 (
534 ExternalCssSemanticChangeClassificationV0::Understood,
535 "targetVendorPrefixAddition",
536 )
537 } else {
538 (
539 ExternalCssSemanticChangeClassificationV0::Passthrough,
540 "externalSemanticChange",
541 )
542 };
543 ExternalCssSemanticChangeV0 {
544 kind,
545 classification,
546 explanation,
547 before,
548 after,
549 }
550}
551
552fn vendor_unprefixed_property(property: &str) -> Option<&str> {
553 ["-webkit-", "-moz-", "-ms-", "-o-"]
554 .into_iter()
555 .find_map(|prefix| property.strip_prefix(prefix))
556 .filter(|property| !property.is_empty())
557}
558
559fn external_semantic_entry(
560 key: &SemanticObservationKeyV0,
561 value: &SemanticObservationValueV0,
562) -> ExternalCssSemanticEntryV0 {
563 ExternalCssSemanticEntryV0 {
564 selector: key.selector_key.clone(),
565 property: key.property.clone(),
566 context: key.context_key.clone(),
567 value: value.value.clone(),
568 important: value.important,
569 }
570}
571
572pub(crate) fn semantic_preservation_applies(pass: TransformPassKind) -> bool {
573 matches!(
574 pass,
575 TransformPassKind::EmptyRuleRemoval
576 | TransformPassKind::RuleDeduplication
577 | TransformPassKind::RuleMerging
578 | TransformPassKind::SelectorMerging
579 | TransformPassKind::NestingUnwrap
580 | TransformPassKind::ScopeFlatten
581 | TransformPassKind::LayerFlatten
582 | TransformPassKind::TreeShakeClass
583 | TransformPassKind::TreeShakeKeyframes
584 | TransformPassKind::TreeShakeValue
585 | TransformPassKind::TreeShakeCustomProperty
586 )
587}
588
589#[cfg(test)]
590pub(crate) fn compare_semantic_observation_for_pass(
591 pass_id: &'static str,
592 input_ir: &TransformIrV0,
593 output_ir: &TransformIrV0,
594) -> TransformSemanticPreservationDecisionV0 {
595 compare_semantic_observation_for_pass_with_scope(
596 pass_id,
597 input_ir,
598 output_ir,
599 SemanticObservationScopeV0::default(),
600 )
601}
602
603#[cfg(test)]
604pub(crate) fn compare_semantic_observation_for_pass_with_scope<'a>(
605 pass_id: &'static str,
606 input_ir: &TransformIrV0,
607 output_ir: &TransformIrV0,
608 scope: SemanticObservationScopeV0<'a>,
609) -> TransformSemanticPreservationDecisionV0 {
610 compare_semantic_observation_for_pass_with_scopes(pass_id, input_ir, output_ir, scope, scope)
611}
612
613pub(crate) fn compare_semantic_observation_for_pass_with_scopes<'a>(
614 pass_id: &'static str,
615 input_ir: &TransformIrV0,
616 output_ir: &TransformIrV0,
617 input_scope: SemanticObservationScopeV0<'a>,
618 output_scope: SemanticObservationScopeV0<'a>,
619) -> TransformSemanticPreservationDecisionV0 {
620 let input = semantic_observation(input_ir, input_scope);
621 let output = semantic_observation(output_ir, output_scope);
622 let mismatch_count = semantic_observation_mismatch_count(&input, &output);
623 TransformSemanticPreservationDecisionV0 {
624 pass_id,
625 preserved: mismatch_count == 0,
626 input_entry_count: input.len(),
627 output_entry_count: output.len(),
628 mismatch_count,
629 }
630}
631
632#[derive(Debug, Clone, Copy)]
633pub(crate) struct SemanticObservationScopeV0<'a> {
634 reachable_class_names: Option<&'a [String]>,
635 reachable_keyframe_names: Option<&'a [String]>,
636 ignored_source_ranges: &'a [(usize, usize)],
637 dialect: StyleDialect,
638 force_ir_declarations: bool,
639}
640
641impl Default for SemanticObservationScopeV0<'_> {
642 fn default() -> Self {
643 Self {
644 reachable_class_names: None,
645 reachable_keyframe_names: None,
646 ignored_source_ranges: &[],
647 dialect: StyleDialect::Css,
648 force_ir_declarations: false,
649 }
650 }
651}
652
653impl<'a> SemanticObservationScopeV0<'a> {
654 fn from_parts(
655 reachable_class_names: Option<&'a [String]>,
656 reachable_keyframe_names: Option<&'a [String]>,
657 ignored_source_ranges: &'a [(usize, usize)],
658 dialect: StyleDialect,
659 ) -> Self {
660 Self {
661 reachable_class_names,
662 reachable_keyframe_names,
663 ignored_source_ranges,
664 dialect,
665 force_ir_declarations: !ignored_source_ranges.is_empty(),
666 }
667 }
668
669 pub(crate) fn for_pass(
670 pass: TransformPassKind,
671 dialect: StyleDialect,
672 closed_world_bundle: Option<&'a ClosedWorldBundleV0>,
673 module_qualified_symbols: Option<&'a ModuleQualifiedSymbolSetV0>,
674 module_reachable_class_names: Option<&'a [String]>,
675 projection: &'a SemanticObservationProjectionV0,
676 ) -> Self {
677 match pass {
678 TransformPassKind::TreeShakeClass
679 | TransformPassKind::TreeShakeKeyframes
680 | TransformPassKind::TreeShakeValue
681 | TransformPassKind::TreeShakeCustomProperty => Self::from_parts(
682 module_reachable_class_names
683 .or_else(|| {
684 module_qualified_symbols.map(ModuleQualifiedSymbolSetV0::class_names)
685 })
686 .or_else(|| {
687 closed_world_bundle.map(|bundle| bundle.reachability().class_names())
688 }),
689 projection.reachable_keyframe_names(),
690 projection.ignored_source_ranges(),
691 dialect,
692 ),
693 _ => Self::from_parts(
694 None,
695 projection.reachable_keyframe_names(),
696 projection.ignored_source_ranges(),
697 dialect,
698 ),
699 }
700 }
701
702 #[cfg(test)]
703 fn for_reachable_class_names(reachable_class_names: &'a [String]) -> Self {
704 Self::from_parts(Some(reachable_class_names), None, &[], StyleDialect::Css)
705 }
706
707 #[cfg(test)]
708 fn for_ignored_source_ranges(ignored_source_ranges: &'a [(usize, usize)]) -> Self {
709 Self::from_parts(None, None, ignored_source_ranges, StyleDialect::Css)
710 }
711
712 #[cfg(test)]
713 fn for_reachable_class_names_and_ignored_source_ranges(
714 reachable_class_names: &'a [String],
715 ignored_source_ranges: &'a [(usize, usize)],
716 ) -> Self {
717 Self::from_parts(
718 Some(reachable_class_names),
719 None,
720 ignored_source_ranges,
721 StyleDialect::Css,
722 )
723 }
724
725 pub(crate) fn without_ignored_source_ranges(self) -> Self {
726 Self {
727 reachable_class_names: self.reachable_class_names,
728 reachable_keyframe_names: self.reachable_keyframe_names,
729 ignored_source_ranges: &[],
730 dialect: self.dialect,
731 force_ir_declarations: self.force_ir_declarations,
732 }
733 }
734
735 pub(crate) fn for_cst_declarations(dialect: StyleDialect) -> Self {
736 Self {
737 reachable_class_names: None,
738 reachable_keyframe_names: None,
739 ignored_source_ranges: &[],
740 dialect,
741 force_ir_declarations: true,
742 }
743 }
744}
745
746#[derive(Debug, Clone, Default)]
747pub(crate) struct SemanticObservationProjectionV0 {
748 ignored_source_ranges: Vec<(usize, usize)>,
749 reachable_keyframe_names: Option<Vec<String>>,
750}
751
752impl SemanticObservationProjectionV0 {
753 pub(crate) fn for_pass_input(
754 pass: TransformPassKind,
755 input_ir: &TransformIrV0,
756 dialect: StyleDialect,
757 closed_world_bundle: Option<&ClosedWorldBundleV0>,
758 module_qualified_symbols: Option<&ModuleQualifiedSymbolSetV0>,
759 module_reachable_class_names: Option<&[String]>,
760 ) -> Self {
761 let Some(bundle) = closed_world_bundle else {
762 return Self::default();
763 };
764 let reachable_class_names = module_reachable_class_names.unwrap_or_else(|| {
765 module_qualified_symbols.map_or_else(
766 || bundle.reachability().class_names(),
767 ModuleQualifiedSymbolSetV0::class_names,
768 )
769 });
770 let reachable_keyframe_names = module_qualified_symbols.map_or_else(
771 || bundle.reachability().keyframe_names(),
772 ModuleQualifiedSymbolSetV0::keyframe_names,
773 );
774 let reachable_value_names = module_qualified_symbols.map_or_else(
775 || bundle.reachability().value_names(),
776 ModuleQualifiedSymbolSetV0::value_names,
777 );
778 let reachable_custom_property_names = module_qualified_symbols.map_or_else(
779 || bundle.reachability().custom_property_names(),
780 ModuleQualifiedSymbolSetV0::custom_property_names,
781 );
782 match pass {
783 TransformPassKind::TreeShakeKeyframes => Self {
784 ignored_source_ranges: collect_tree_shake_css_keyframe_removals_from_ir(
785 input_ir,
786 reachable_keyframe_names,
787 reachable_class_names,
788 )
789 .into_iter()
790 .map(|removal| (removal.source_span_start, removal.source_span_end))
791 .collect(),
792 reachable_keyframe_names: None,
793 },
794 TransformPassKind::TreeShakeValue => Self {
795 ignored_source_ranges: collect_tree_shake_css_modules_value_removals_from_ir(
796 input_ir,
797 dialect,
798 reachable_value_names,
799 reachable_keyframe_names,
800 reachable_class_names,
801 )
802 .into_iter()
803 .map(|removal| (removal.source_span_start, removal.source_span_end))
804 .collect(),
805 reachable_keyframe_names: None,
806 },
807 TransformPassKind::TreeShakeCustomProperty => Self {
808 ignored_source_ranges: collect_tree_shake_css_custom_property_removals_from_ir(
809 input_ir,
810 dialect,
811 reachable_custom_property_names,
812 reachable_keyframe_names,
813 reachable_class_names,
814 )
815 .into_iter()
816 .map(|removal| (removal.source_span_start, removal.source_span_end))
817 .collect(),
818 reachable_keyframe_names: reachable_keyframe_names_for_closed_class_scope(
819 input_ir,
820 reachable_keyframe_names,
821 reachable_class_names,
822 ),
823 },
824 _ => {
825 let _ = dialect;
826 Self::default()
827 }
828 }
829 }
830
831 pub(crate) fn ignored_source_ranges(&self) -> &[(usize, usize)] {
832 self.ignored_source_ranges.as_slice()
833 }
834
835 fn reachable_keyframe_names(&self) -> Option<&[String]> {
836 self.reachable_keyframe_names.as_deref()
837 }
838
839 #[cfg(test)]
840 fn for_keyframe_reachability(
841 input_ir: &TransformIrV0,
842 reachable_keyframe_names: &[String],
843 reachable_class_names: &[String],
844 ) -> Self {
845 Self {
846 ignored_source_ranges: collect_tree_shake_css_keyframe_removals_from_ir(
847 input_ir,
848 reachable_keyframe_names,
849 reachable_class_names,
850 )
851 .into_iter()
852 .map(|removal| (removal.source_span_start, removal.source_span_end))
853 .collect(),
854 reachable_keyframe_names: None,
855 }
856 }
857
858 #[cfg(test)]
859 fn for_value_reachability(
860 input_ir: &TransformIrV0,
861 dialect: StyleDialect,
862 reachable_value_names: &[String],
863 reachable_keyframe_names: &[String],
864 reachable_class_names: &[String],
865 ) -> Self {
866 Self {
867 ignored_source_ranges: collect_tree_shake_css_modules_value_removals_from_ir(
868 input_ir,
869 dialect,
870 reachable_value_names,
871 reachable_keyframe_names,
872 reachable_class_names,
873 )
874 .into_iter()
875 .map(|removal| (removal.source_span_start, removal.source_span_end))
876 .collect(),
877 reachable_keyframe_names: None,
878 }
879 }
880
881 #[cfg(test)]
882 fn for_custom_property_reachability(
883 input_ir: &TransformIrV0,
884 dialect: StyleDialect,
885 reachable_custom_property_names: &[AuthoredPropertyTextV0],
886 reachable_keyframe_names: &[String],
887 reachable_class_names: &[String],
888 ) -> Self {
889 Self {
890 ignored_source_ranges: collect_tree_shake_css_custom_property_removals_from_ir(
891 input_ir,
892 dialect,
893 reachable_custom_property_names,
894 reachable_keyframe_names,
895 reachable_class_names,
896 )
897 .into_iter()
898 .map(|removal| (removal.source_span_start, removal.source_span_end))
899 .collect(),
900 reachable_keyframe_names: reachable_keyframe_names_for_closed_class_scope(
901 input_ir,
902 reachable_keyframe_names,
903 reachable_class_names,
904 ),
905 }
906 }
907}
908
909fn reachable_keyframe_names_for_closed_class_scope(
910 input_ir: &TransformIrV0,
911 explicit_keyframe_names: &[String],
912 reachable_class_names: &[String],
913) -> Option<Vec<String>> {
914 let mut names = collect_referenced_keyframe_names_from_ir(input_ir, reachable_class_names)?;
915 for name in explicit_keyframe_names {
916 if !names.iter().any(|candidate| candidate == name) {
917 names.push(name.clone());
918 }
919 }
920 Some(names)
921}
922
923#[cfg(test)]
924#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
925#[serde(rename_all = "camelCase")]
926pub(crate) struct TransformSemanticPreservationKillRateReportV0 {
927 pub schema_version: &'static str,
928 pub product: &'static str,
929 pub fixture_count: usize,
930 pub rejected_count: usize,
931 pub required_rejected_count: usize,
932 pub non_empty_corpus: bool,
933 pub kill_rate_passed: bool,
934}
935
936#[cfg(test)]
937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
938#[serde(rename_all = "camelCase")]
939pub(crate) struct TransformSemanticModelConformanceReportV0 {
940 pub schema_version: String,
941 pub product: String,
942 pub cascade_seed_product: String,
943 pub cascade_seed_case_count: usize,
944 pub cascade_seed_failed_count: usize,
945 pub cascade_seed_digest: String,
946 pub ordering_axis_self_check_product: String,
947 pub ordering_axis_self_check_case_count: usize,
948 pub ordering_axis_self_check_failed_count: usize,
949 pub ordering_axis_self_check_digest: String,
950 pub semantic_observation_case_count: usize,
951 pub semantic_observation_failed_count: usize,
952 pub model_conformance_passed: bool,
953}
954
955#[cfg(test)]
956pub(crate) fn summarize_semantic_preservation_model_conformance()
957-> Result<TransformSemanticModelConformanceReportV0, serde_json::Error> {
958 let cascade_seed = run_cascade_conformance_seed_corpus();
959 let ordering_axis_self_check = run_cascade_ordering_axis_self_check_corpus();
960 let cascade_seed_source = serde_json::to_string(&cascade_seed)?;
961 let ordering_axis_self_check_source = serde_json::to_string(&ordering_axis_self_check)?;
962 let semantic_observation_results = semantic_model_conformance_case_results();
963 let semantic_observation_failed_count = semantic_observation_results
964 .iter()
965 .filter(|result| !**result)
966 .count();
967
968 Ok(TransformSemanticModelConformanceReportV0 {
969 schema_version: "0".to_string(),
970 product: "omena-transform-passes.semantic-preservation-model-conformance".to_string(),
971 cascade_seed_product: cascade_seed.product.to_string(),
972 cascade_seed_case_count: cascade_seed.case_count,
973 cascade_seed_failed_count: cascade_seed.failed_count,
974 cascade_seed_digest: stable_semantic_report_digest(&[
975 "cascade-seed",
976 cascade_seed_source.as_str(),
977 ]),
978 ordering_axis_self_check_product: ordering_axis_self_check.product.to_string(),
979 ordering_axis_self_check_case_count: ordering_axis_self_check.case_count,
980 ordering_axis_self_check_failed_count: ordering_axis_self_check.failed_count,
981 ordering_axis_self_check_digest: stable_semantic_report_digest(&[
982 "ordering-axis-self-check",
983 ordering_axis_self_check_source.as_str(),
984 ]),
985 semantic_observation_case_count: semantic_observation_results.len(),
986 semantic_observation_failed_count,
987 model_conformance_passed: cascade_seed.failed_count == 0
988 && ordering_axis_self_check.failed_count == 0
989 && semantic_observation_failed_count == 0,
990 })
991}
992
993#[cfg(test)]
994pub(crate) fn summarize_semantic_preservation_kill_rate_for_fixture_source(
995 source: &str,
996 dialect: StyleDialect,
997) -> Result<TransformSemanticPreservationKillRateReportV0, serde_json::Error> {
998 let fixtures = serde_json::from_str::<Vec<TransformSemanticPreservationFixtureV0>>(source)?;
999 let mut rejected_count = 0usize;
1000
1001 for fixture in &fixtures {
1002 let Some(pass) = transform_pass_kind_from_fixture_id(fixture.pass_id.as_str()) else {
1003 continue;
1004 };
1005 if !semantic_preservation_applies(pass) {
1006 continue;
1007 }
1008 let input_ir = lower_transform_ir_from_source(
1009 fixture.input.as_str(),
1010 dialect,
1011 "omena-transform-passes.semantic-preservation.input",
1012 );
1013 let output_ir = lower_transform_ir_from_source(
1014 fixture.output.as_str(),
1015 dialect,
1016 "omena-transform-passes.semantic-preservation.output",
1017 );
1018 let projection = if !fixture.reachable_custom_property_names.is_empty()
1019 || pass == TransformPassKind::TreeShakeCustomProperty
1020 {
1021 SemanticObservationProjectionV0::for_custom_property_reachability(
1022 &input_ir,
1023 dialect,
1024 &fixture.reachable_custom_property_names,
1025 &fixture.reachable_keyframe_names,
1026 &fixture.reachable_class_names,
1027 )
1028 } else if !fixture.reachable_value_names.is_empty()
1029 || pass == TransformPassKind::TreeShakeValue
1030 {
1031 SemanticObservationProjectionV0::for_value_reachability(
1032 &input_ir,
1033 dialect,
1034 &fixture.reachable_value_names,
1035 &fixture.reachable_keyframe_names,
1036 &fixture.reachable_class_names,
1037 )
1038 } else if !fixture.reachable_keyframe_names.is_empty()
1039 || pass == TransformPassKind::TreeShakeKeyframes
1040 {
1041 SemanticObservationProjectionV0::for_keyframe_reachability(
1042 &input_ir,
1043 &fixture.reachable_keyframe_names,
1044 &fixture.reachable_class_names,
1045 )
1046 } else {
1047 SemanticObservationProjectionV0::default()
1048 };
1049 let scope = if !fixture.reachable_class_names.is_empty()
1050 && !projection.ignored_source_ranges().is_empty()
1051 {
1052 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
1053 &fixture.reachable_class_names,
1054 projection.ignored_source_ranges(),
1055 )
1056 } else if !fixture.reachable_class_names.is_empty() {
1057 SemanticObservationScopeV0::for_reachable_class_names(&fixture.reachable_class_names)
1058 } else if !projection.ignored_source_ranges().is_empty() {
1059 SemanticObservationScopeV0::for_ignored_source_ranges(
1060 projection.ignored_source_ranges(),
1061 )
1062 } else {
1063 SemanticObservationScopeV0::default()
1064 };
1065 let decision = compare_semantic_observation_for_pass_with_scopes(
1066 pass.id(),
1067 &input_ir,
1068 &output_ir,
1069 scope,
1070 scope.without_ignored_source_ranges(),
1071 );
1072 if !decision.preserved {
1073 rejected_count += 1;
1074 }
1075 }
1076
1077 let required_rejected_count = fixtures
1078 .iter()
1079 .filter(|fixture| fixture.expected_rejected)
1080 .count();
1081 Ok(TransformSemanticPreservationKillRateReportV0 {
1082 schema_version: "0",
1083 product: "omena-transform-passes.semantic-preservation-kill-rate",
1084 fixture_count: fixtures.len(),
1085 rejected_count,
1086 required_rejected_count,
1087 non_empty_corpus: !fixtures.is_empty(),
1088 kill_rate_passed: !fixtures.is_empty() && rejected_count >= required_rejected_count,
1089 })
1090}
1091
1092#[cfg(test)]
1093#[derive(Debug, Clone, Deserialize)]
1094#[serde(rename_all = "camelCase")]
1095struct TransformSemanticPreservationFixtureV0 {
1096 pass_id: String,
1097 input: String,
1098 output: String,
1099 expected_rejected: bool,
1100 #[serde(default)]
1101 reachable_class_names: Vec<String>,
1102 #[serde(default)]
1103 reachable_keyframe_names: Vec<String>,
1104 #[serde(default)]
1105 reachable_value_names: Vec<String>,
1106 #[serde(default)]
1107 reachable_custom_property_names: Vec<AuthoredPropertyTextV0>,
1108}
1109
1110#[cfg(test)]
1111fn transform_pass_kind_from_fixture_id(pass_id: &str) -> Option<TransformPassKind> {
1112 match pass_id {
1113 "empty-rule-removal" => Some(TransformPassKind::EmptyRuleRemoval),
1114 "rule-deduplication" => Some(TransformPassKind::RuleDeduplication),
1115 "rule-merging" => Some(TransformPassKind::RuleMerging),
1116 "selector-merging" => Some(TransformPassKind::SelectorMerging),
1117 "nesting-unwrap" => Some(TransformPassKind::NestingUnwrap),
1118 "scope-flatten" => Some(TransformPassKind::ScopeFlatten),
1119 "layer-flatten" => Some(TransformPassKind::LayerFlatten),
1120 "tree-shake-class" => Some(TransformPassKind::TreeShakeClass),
1121 "tree-shake-keyframes" => Some(TransformPassKind::TreeShakeKeyframes),
1122 "tree-shake-value" => Some(TransformPassKind::TreeShakeValue),
1123 "tree-shake-custom-property" => Some(TransformPassKind::TreeShakeCustomProperty),
1124 _ => None,
1125 }
1126}
1127
1128type SemanticObservationV0 = BTreeMap<SemanticObservationKeyV0, SemanticObservationValueV0>;
1129
1130#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1131struct SemanticObservationKeyV0 {
1132 selector_key: String,
1133 property: CanonicalPropertyKeyV0,
1134 context_key: String,
1135}
1136
1137impl SemanticObservationKeyV0 {
1138 const FIELD_BINDINGS: [(&'static str, TransformSemanticObservationKeyAxisV0); 3] = [
1139 (
1140 "selector_key",
1141 TransformSemanticObservationKeyAxisV0::Selector,
1142 ),
1143 ("property", TransformSemanticObservationKeyAxisV0::Property),
1144 (
1145 "context_key",
1146 TransformSemanticObservationKeyAxisV0::Context,
1147 ),
1148 ];
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152struct SemanticObservationValueV0 {
1153 value: String,
1154 important: bool,
1155}
1156
1157impl SemanticObservationValueV0 {
1158 const FIELD_BINDINGS: [(&'static str, TransformSemanticObservationValueAxisV0); 2] = [
1159 ("value", TransformSemanticObservationValueAxisV0::Value),
1160 (
1161 "important",
1162 TransformSemanticObservationValueAxisV0::Important,
1163 ),
1164 ];
1165}
1166
1167fn semantic_observation_surface_descriptor() -> TransformSemanticObservationSurfaceV0 {
1168 TransformSemanticObservationSurfaceV0 {
1169 key_axes: SemanticObservationKeyV0::FIELD_BINDINGS
1170 .iter()
1171 .map(|(_, axis)| *axis)
1172 .collect(),
1173 value_axes: SemanticObservationValueV0::FIELD_BINDINGS
1174 .iter()
1175 .map(|(_, axis)| *axis)
1176 .collect(),
1177 ordering_rules: vec![
1178 TransformSemanticObservationOrderingRuleV0::SourceOrder,
1179 TransformSemanticObservationOrderingRuleV0::ImportantPrecedence,
1180 ],
1181 unobserved_axes: vec![
1182 TransformSemanticUnobservedAxisV0::NonStyleRuleDeclarationCarriers,
1183 TransformSemanticUnobservedAxisV0::IntraRuleDeclarationOrdering,
1184 TransformSemanticUnobservedAxisV0::InterSelectorSpecificityCompetition,
1185 TransformSemanticUnobservedAxisV0::CascadeLayerOrder,
1186 TransformSemanticUnobservedAxisV0::Origin,
1187 TransformSemanticUnobservedAxisV0::ScopeProximity,
1188 TransformSemanticUnobservedAxisV0::DomDependentMatching,
1189 TransformSemanticUnobservedAxisV0::Inheritance,
1190 TransformSemanticUnobservedAxisV0::CustomPropertyEnvironment,
1191 TransformSemanticUnobservedAxisV0::AnimationAndTransition,
1192 ],
1193 claim_scope: TransformSemanticPreservationClaimScopeV0::ObservedSurfaceOnly,
1194 vocabulary_review:
1195 TransformSemanticPreservationVocabularyReviewV0::DeferredUntilFullCascadeObservation,
1196 }
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200struct SemanticDeclarationCandidateV0 {
1201 key: SemanticObservationKeyV0,
1202 value: SemanticObservationValueV0,
1203 source_order: usize,
1204 source_span_start: usize,
1205 source_span_end: usize,
1206}
1207
1208#[derive(Debug, Clone)]
1209pub(crate) struct SemanticCascadeCandidateV0 {
1210 pub(crate) selector: String,
1211 pub(crate) property: omena_syntax::ident::AuthoredPropertyTextV0,
1212 pub(crate) value: String,
1213 pub(crate) important: bool,
1214 pub(crate) source_span_start: usize,
1215 pub(crate) source_span_end: usize,
1216 pub(crate) context_key: String,
1217}
1218
1219impl PartialEq for SemanticCascadeCandidateV0 {
1220 fn eq(&self, other: &Self) -> bool {
1221 self.selector == other.selector
1222 && self
1223 .property
1224 .to_property_name()
1225 .same_as(&other.property.to_property_name())
1226 && self.value == other.value
1227 && self.important == other.important
1228 && self.source_span_start == other.source_span_start
1229 && self.source_span_end == other.source_span_end
1230 && self.context_key == other.context_key
1231 }
1232}
1233
1234impl Eq for SemanticCascadeCandidateV0 {}
1235
1236#[cfg(test)]
1237mod authored_property_identity_tests {
1238 use super::*;
1239
1240 #[test]
1241 fn semantic_cascade_candidate_identity_uses_sealed_property_keys() {
1242 let candidate = |property: &str| SemanticCascadeCandidateV0 {
1243 selector: ".button".to_string(),
1244 property: omena_syntax::ident::AuthoredPropertyTextV0::new(property),
1245 value: "red".to_string(),
1246 important: false,
1247 source_span_start: 0,
1248 source_span_end: 1,
1249 context_key: "root".to_string(),
1250 };
1251
1252 assert_eq!(candidate("COLOR"), candidate(r"C\4f LOR"));
1253 assert_ne!(candidate("--foo"), candidate("--FOO"));
1254 }
1255}
1256
1257fn semantic_observation(
1258 ir: &TransformIrV0,
1259 scope: SemanticObservationScopeV0<'_>,
1260) -> SemanticObservationV0 {
1261 let mut observation = SemanticObservationV0::new();
1262 let candidates = semantic_declaration_candidates(ir, scope);
1263
1264 for candidate in candidates {
1265 match observation.get(&candidate.key) {
1266 Some(current) if current.important && !candidate.value.important => {
1267 continue;
1268 }
1269 _ => {
1270 observation.insert(candidate.key, candidate.value);
1271 }
1272 }
1273 }
1274
1275 observation
1276}
1277
1278fn semantic_declaration_candidates(
1279 ir: &TransformIrV0,
1280 scope: SemanticObservationScopeV0<'_>,
1281) -> Vec<SemanticDeclarationCandidateV0> {
1282 let mut candidates = ir
1283 .nodes
1284 .iter()
1285 .filter(|node| !node.deleted)
1286 .filter(|node| {
1287 !source_range_is_fully_ignored(node.source_span_start, node.source_span_end, scope)
1288 })
1289 .filter_map(|node| match node.kind {
1290 IrNodeKindV0::StyleRule => semantic_style_rule_candidates(ir, node, scope),
1291 IrNodeKindV0::AtRule => semantic_at_rule_style_rule_candidates(ir, node, scope),
1292 _ => None,
1293 })
1294 .flatten()
1295 .collect::<Vec<_>>();
1296 candidates.extend(semantic_css_modules_value_candidates(ir, scope));
1297 candidates.extend(semantic_custom_property_candidates(ir, scope));
1298 candidates.sort_by_key(|candidate| candidate.source_order);
1299 candidates
1300}
1301
1302pub(crate) fn semantic_cascade_candidates(
1303 ir: &TransformIrV0,
1304 scope: SemanticObservationScopeV0<'_>,
1305) -> Vec<SemanticCascadeCandidateV0> {
1306 semantic_declaration_candidates(ir, scope)
1307 .into_iter()
1308 .map(|candidate| SemanticCascadeCandidateV0 {
1309 selector: candidate.key.selector_key,
1310 property: omena_syntax::ident::AuthoredPropertyTextV0::new(
1311 candidate.key.property.as_str(),
1312 ),
1313 value: candidate.value.value,
1314 important: candidate.value.important,
1315 source_span_start: candidate.source_span_start,
1316 source_span_end: candidate.source_span_end,
1317 context_key: candidate.key.context_key,
1318 })
1319 .collect()
1320}
1321
1322fn semantic_custom_property_candidates(
1323 ir: &TransformIrV0,
1324 scope: SemanticObservationScopeV0<'_>,
1325) -> Vec<SemanticDeclarationCandidateV0> {
1326 collect_css_custom_property_semantic_facts_from_ir(ir)
1327 .into_iter()
1328 .filter(|fact| {
1329 !source_range_is_ignored(fact.source_span_start, fact.source_span_end, scope)
1330 })
1331 .map(|fact| SemanticDeclarationCandidateV0 {
1332 source_order: fact.source_span_start,
1333 source_span_start: fact.source_span_start,
1334 source_span_end: fact.source_span_end,
1335 key: SemanticObservationKeyV0 {
1336 selector_key: fact.fact_kind.to_string(),
1337 property: fact.name.to_property_name().canonical_key(),
1338 context_key: "css-custom-properties".to_string(),
1339 },
1340 value: SemanticObservationValueV0 {
1341 value: fact.value,
1342 important: false,
1343 },
1344 })
1345 .collect()
1346}
1347
1348fn semantic_css_modules_value_candidates(
1349 ir: &TransformIrV0,
1350 scope: SemanticObservationScopeV0<'_>,
1351) -> Vec<SemanticDeclarationCandidateV0> {
1352 collect_css_modules_value_semantic_facts_from_ir(ir, scope.dialect)
1353 .into_iter()
1354 .filter(|fact| {
1355 !source_range_is_ignored(fact.source_span_start, fact.source_span_end, scope)
1356 })
1357 .map(|fact| SemanticDeclarationCandidateV0 {
1358 source_order: fact.source_span_start,
1359 source_span_start: fact.source_span_start,
1360 source_span_end: fact.source_span_end,
1361 key: SemanticObservationKeyV0 {
1362 selector_key: fact.fact_kind.to_string(),
1363 property: PropertyNameV0::from_authored(fact.name).canonical_key(),
1364 context_key: "css-modules".to_string(),
1365 },
1366 value: SemanticObservationValueV0 {
1367 value: fact.value,
1368 important: false,
1369 },
1370 })
1371 .collect()
1372}
1373
1374fn semantic_style_rule_candidates(
1375 ir: &TransformIrV0,
1376 node: &IrNodeV0,
1377 scope: SemanticObservationScopeV0<'_>,
1378) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1379 if has_deleted_ancestor(ir, node) {
1380 return None;
1381 }
1382 let selector_keys =
1383 observation_selector_keys(expanded_style_rule_selector_keys(ir, node)?, scope)
1384 .into_iter()
1385 .filter(|selector_key| {
1386 !selector_key.eq_ignore_ascii_case(":export")
1387 && !selector_key.starts_with(":import")
1388 })
1389 .collect::<Vec<_>>();
1390 if selector_keys.is_empty() {
1391 return None;
1392 }
1393 let context_key = ancestor_at_rule_context_key(ir, node);
1394 let mut declarations = semantic_declarations_from_style_rule_text(ir, node, scope)
1395 .unwrap_or_else(|| semantic_declarations_from_direct_ir_children(ir, node, scope));
1396 declarations.sort_by_key(|declaration| declaration.source_order);
1397
1398 Some(candidates_from_selector_declarations(
1399 selector_keys.as_slice(),
1400 context_key.as_str(),
1401 declarations,
1402 node.source_span_start,
1403 node.source_span_end,
1404 ))
1405}
1406
1407fn semantic_at_rule_style_rule_candidates(
1408 ir: &TransformIrV0,
1409 node: &IrNodeV0,
1410 scope: SemanticObservationScopeV0<'_>,
1411) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1412 if has_deleted_ancestor(ir, node) {
1413 return None;
1414 }
1415 let block_view = node_text_block_view(ir, node)?;
1416 let prelude = block_view.prelude(block_view.primary)?.trim();
1417 if !at_rule_prelude_is_reachable_in_scope(prelude, scope) {
1418 return None;
1419 }
1420 if has_style_rule_ancestor(ir, node) {
1421 return nested_at_rule_declaration_candidates(ir, node, prelude, scope);
1422 }
1423 let context_key = join_context_components(
1424 ancestor_at_rule_context_key(ir, node),
1425 at_rule_context_component_from_prelude(prelude),
1426 );
1427 let mut candidates = Vec::new();
1428
1429 for (index, rule_span) in block_view.direct_child_spans().into_iter().enumerate() {
1430 let selector = block_view.prelude(rule_span)?;
1431 if selector.trim_start().starts_with('@') {
1432 continue;
1433 }
1434 let selector_keys =
1435 observation_selector_keys(selector_keys_from_selector_text(selector), scope)
1436 .into_iter()
1437 .filter(|selector_key| {
1438 !selector_key.eq_ignore_ascii_case(":export")
1439 && !selector_key.starts_with(":import")
1440 })
1441 .collect::<Vec<_>>();
1442 if selector_keys.is_empty() {
1443 continue;
1444 }
1445 let declarations = semantic_declarations_from_block(
1446 block_view.source,
1447 rule_span,
1448 node.global_order
1449 .saturating_mul(4096)
1450 .saturating_add(index.saturating_mul(1024)),
1451 )
1452 .unwrap_or_default();
1453 candidates.extend(candidates_from_selector_declarations(
1454 selector_keys.as_slice(),
1455 context_key.as_str(),
1456 declarations,
1457 node.source_span_start,
1458 node.source_span_end,
1459 ));
1460 }
1461
1462 if candidates.is_empty() {
1463 None
1464 } else {
1465 Some(candidates)
1466 }
1467}
1468
1469fn nested_at_rule_declaration_candidates(
1470 ir: &TransformIrV0,
1471 node: &IrNodeV0,
1472 prelude: &str,
1473 scope: SemanticObservationScopeV0<'_>,
1474) -> Option<Vec<SemanticDeclarationCandidateV0>> {
1475 let (selector_keys, context_key) =
1476 if let Some(nest_selector) = nest_at_rule_selector_from_prelude(prelude) {
1477 let parent_selector = nearest_style_ancestor_expanded_selector(ir, node)?;
1478 let selector = expand_nested_selector(parent_selector.as_str(), nest_selector)?;
1479 (
1480 selector_keys_from_selector_text(selector.as_str()),
1481 ancestor_at_rule_context_key(ir, node),
1482 )
1483 } else {
1484 (
1485 nearest_style_ancestor_selector_keys(ir, node)?,
1486 join_context_components(
1487 ancestor_at_rule_context_key(ir, node),
1488 at_rule_context_component_from_prelude(prelude),
1489 ),
1490 )
1491 };
1492 let selector_keys = observation_selector_keys(selector_keys, scope)
1493 .into_iter()
1494 .filter(|selector_key| {
1495 !selector_key.eq_ignore_ascii_case(":export") && !selector_key.starts_with(":import")
1496 })
1497 .collect::<Vec<_>>();
1498 if selector_keys.is_empty() {
1499 return None;
1500 }
1501 let mut declarations = semantic_declarations_from_direct_ir_children(ir, node, scope);
1502 if declarations.is_empty() {
1503 return None;
1504 }
1505 declarations.sort_by_key(|declaration| declaration.source_order);
1506 Some(candidates_from_selector_declarations(
1507 selector_keys.as_slice(),
1508 context_key.as_str(),
1509 declarations,
1510 node.source_span_start,
1511 node.source_span_end,
1512 ))
1513}
1514
1515fn at_rule_prelude_is_reachable_in_scope(
1516 prelude: &str,
1517 scope: SemanticObservationScopeV0<'_>,
1518) -> bool {
1519 let Some(reachable_keyframe_names) = scope.reachable_keyframe_names else {
1520 return true;
1521 };
1522 let Some(keyframe_name) = keyframe_name_from_at_rule_prelude(prelude) else {
1523 return true;
1524 };
1525 keyframe_name_is_reachable(keyframe_name, reachable_keyframe_names)
1526}
1527
1528fn keyframe_name_from_at_rule_prelude(prelude: &str) -> Option<&str> {
1529 let trimmed = prelude.trim();
1530 let after_keyword = css_keyword(trimmed)
1531 .strip_prefix("@keyframes")
1532 .or_else(|| css_keyword(trimmed).strip_prefix("@-webkit-keyframes"))?;
1533 after_keyword.split_whitespace().next()
1534}
1535
1536fn observation_selector_keys(
1537 selector_keys: Vec<String>,
1538 scope: SemanticObservationScopeV0<'_>,
1539) -> Vec<String> {
1540 match scope.reachable_class_names {
1541 Some(reachable_class_names) => selector_keys
1542 .into_iter()
1543 .filter(|selector_key| {
1544 selector_is_reachable_in_closed_class_scope(selector_key, reachable_class_names)
1545 })
1546 .collect(),
1547 None => selector_keys,
1548 }
1549}
1550
1551fn selector_is_reachable_in_closed_class_scope(
1552 selector_key: &str,
1553 reachable_class_names: &[String],
1554) -> bool {
1555 let Some(owner_class_names) = selector_branch_owner_class_names(selector_key) else {
1556 return true;
1557 };
1558 owner_class_names
1559 .iter()
1560 .any(|owner| class_name_is_reachable(owner, reachable_class_names))
1561}
1562
1563fn source_range_is_ignored(
1564 source_span_start: usize,
1565 source_span_end: usize,
1566 scope: SemanticObservationScopeV0<'_>,
1567) -> bool {
1568 scope
1569 .ignored_source_ranges
1570 .iter()
1571 .any(|(start, end)| source_span_start < *end && source_span_end > *start)
1572}
1573
1574fn source_range_is_fully_ignored(
1575 source_span_start: usize,
1576 source_span_end: usize,
1577 scope: SemanticObservationScopeV0<'_>,
1578) -> bool {
1579 scope
1580 .ignored_source_ranges
1581 .iter()
1582 .any(|(start, end)| source_span_start >= *start && source_span_end <= *end)
1583}
1584
1585fn candidates_from_selector_declarations(
1586 selector_keys: &[String],
1587 context_key: &str,
1588 declarations: Vec<SemanticDeclarationV0>,
1589 source_span_start: usize,
1590 source_span_end: usize,
1591) -> Vec<SemanticDeclarationCandidateV0> {
1592 declarations
1593 .into_iter()
1594 .flat_map(|declaration| {
1595 let property = declaration.property_key;
1596 let value = declaration.value;
1597 let context_key = context_key.to_string();
1598 selector_keys
1599 .iter()
1600 .map(move |selector_key| SemanticDeclarationCandidateV0 {
1601 key: SemanticObservationKeyV0 {
1602 selector_key: selector_key.clone(),
1603 property: property.clone(),
1604 context_key: context_key.clone(),
1605 },
1606 value: SemanticObservationValueV0 {
1607 value: value.clone(),
1608 important: declaration.important,
1609 },
1610 source_order: declaration.source_order,
1611 source_span_start,
1612 source_span_end,
1613 })
1614 })
1615 .collect()
1616}
1617
1618#[derive(Debug, Clone, PartialEq, Eq)]
1619struct SemanticDeclarationV0 {
1620 property_key: CanonicalPropertyKeyV0,
1621 value: String,
1622 important: bool,
1623 source_order: usize,
1624}
1625
1626fn semantic_declaration_from_ir(
1627 ir: &TransformIrV0,
1628 node: &IrNodeV0,
1629) -> Option<SemanticDeclarationV0> {
1630 if has_deleted_ancestor(ir, node) {
1631 return None;
1632 }
1633 let source = node_text(ir, node)?.trim().trim_end_matches(';').trim();
1634 semantic_declaration_from_source(source, node.global_order)
1635}
1636
1637fn semantic_declarations_from_style_rule_text(
1638 ir: &TransformIrV0,
1639 node: &IrNodeV0,
1640 scope: SemanticObservationScopeV0<'_>,
1641) -> Option<Vec<SemanticDeclarationV0>> {
1642 if scope.force_ir_declarations {
1643 return None;
1644 }
1645 let block_view = node_text_block_view(ir, node)?;
1646 semantic_declarations_from_block(
1647 block_view.source,
1648 block_view.primary,
1649 node.global_order.saturating_mul(1024),
1650 )
1651}
1652
1653fn semantic_declarations_from_block(
1654 source: &str,
1655 span: IrBlockSpanV0,
1656 base_source_order: usize,
1657) -> Option<Vec<SemanticDeclarationV0>> {
1658 let body = source.get(span.body_start..span.body_end)?;
1659 if contains_nested_block_or_comment(body) {
1660 return None;
1661 }
1662 let declarations = split_declaration_list(body)
1663 .into_iter()
1664 .enumerate()
1665 .filter_map(|(index, declaration)| {
1666 semantic_declaration_from_source(
1667 declaration.as_str(),
1668 base_source_order.saturating_add(index),
1669 )
1670 })
1671 .collect::<Vec<_>>();
1672 if declarations.is_empty() {
1673 None
1674 } else {
1675 Some(declarations)
1676 }
1677}
1678
1679fn semantic_declaration_from_source(
1680 source: &str,
1681 source_order: usize,
1682) -> Option<SemanticDeclarationV0> {
1683 if source.is_empty() || contains_nested_block_or_comment(source) {
1684 return None;
1685 }
1686 let colon = declaration_colon_index(source)?;
1687 let property = source.get(..colon)?.trim();
1688 let value = source.get(colon + 1..)?.trim();
1689 if property.is_empty() || value.is_empty() {
1690 return None;
1691 }
1692 let property_name = PropertyNameV0::from_authored(property);
1693 Some(SemanticDeclarationV0 {
1694 property_key: property_name.canonical_key(),
1695 value: normalize_declaration_value(value),
1696 important: declaration_value_is_important(value),
1697 source_order,
1698 })
1699}
1700
1701fn expanded_style_rule_selector_keys(ir: &TransformIrV0, node: &IrNodeV0) -> Option<Vec<String>> {
1702 let selector = expanded_style_rule_selector_text(ir, node)?;
1703 let selector_keys = selector_keys_from_selector_text(selector.as_str());
1704 if selector_keys.is_empty() {
1705 None
1706 } else {
1707 Some(selector_keys)
1708 }
1709}
1710
1711fn expanded_style_rule_selector_text(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1712 let mut selector = node_block_prelude(ir, node)?.trim().to_string();
1713 let mut expanded_parent_selector: Option<String> = None;
1714 for parent_selector in style_rule_ancestor_selectors(ir, node)?.into_iter().rev() {
1715 expanded_parent_selector = Some(match expanded_parent_selector {
1716 Some(expanded) => expand_nested_selector(expanded.as_str(), parent_selector.as_str())?,
1717 None => parent_selector,
1718 });
1719 }
1720 if let Some(parent_selector) = expanded_parent_selector {
1721 selector = expand_nested_selector(parent_selector.as_str(), selector.as_str())?;
1722 }
1723 Some(selector)
1724}
1725
1726fn nearest_style_ancestor_selector_keys(
1727 ir: &TransformIrV0,
1728 node: &IrNodeV0,
1729) -> Option<Vec<String>> {
1730 let mut parent = node.parent;
1731 while let Some(parent_id) = parent {
1732 let parent_node = ir.nodes.get(parent_id.index())?;
1733 if parent_node.deleted {
1734 return None;
1735 }
1736 if parent_node.kind == IrNodeKindV0::StyleRule {
1737 return expanded_style_rule_selector_keys(ir, parent_node);
1738 }
1739 parent = parent_node.parent;
1740 }
1741 None
1742}
1743
1744fn nearest_style_ancestor_expanded_selector(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1745 let mut parent = node.parent;
1746 while let Some(parent_id) = parent {
1747 let parent_node = ir.nodes.get(parent_id.index())?;
1748 if parent_node.deleted {
1749 return None;
1750 }
1751 if parent_node.kind == IrNodeKindV0::StyleRule {
1752 return expanded_style_rule_selector_text(ir, parent_node);
1753 }
1754 parent = parent_node.parent;
1755 }
1756 None
1757}
1758
1759fn style_rule_ancestor_selectors(ir: &TransformIrV0, node: &IrNodeV0) -> Option<Vec<String>> {
1760 let mut selectors = Vec::new();
1761 let mut parent = node.parent;
1762 while let Some(parent_id) = parent {
1763 let parent_node = ir.nodes.get(parent_id.index())?;
1764 if parent_node.deleted {
1765 return None;
1766 }
1767 match parent_node.kind {
1768 IrNodeKindV0::StyleRule => {
1769 selectors.push(node_block_prelude(ir, parent_node)?.trim().to_string());
1770 }
1771 IrNodeKindV0::AtRule => {
1772 if let Some(selector) =
1773 node_block_prelude(ir, parent_node).and_then(nest_at_rule_selector_from_prelude)
1774 {
1775 selectors.push(selector.to_string());
1776 }
1777 }
1778 _ => {}
1779 }
1780 parent = parent_node.parent;
1781 }
1782 Some(selectors)
1783}
1784
1785fn nest_at_rule_selector_from_prelude(prelude: &str) -> Option<&str> {
1786 let prelude = prelude.trim();
1787 let selector = prelude.strip_prefix("@nest")?.trim();
1788 (!selector.is_empty()).then_some(selector)
1789}
1790
1791fn selector_keys_from_selector_text(selector: &str) -> Vec<String> {
1792 split_selector_list(selector)
1793 .into_iter()
1794 .map(|selector| normalize_selector_key(selector.as_str()))
1795 .filter(|selector| !selector.is_empty())
1796 .collect::<Vec<_>>()
1797}
1798
1799fn ancestor_at_rule_context_key(ir: &TransformIrV0, node: &IrNodeV0) -> String {
1800 let mut ancestors = Vec::new();
1801 let mut parent = node.parent;
1802 while let Some(parent_id) = parent {
1803 let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
1804 break;
1805 };
1806 if parent_node.deleted {
1807 break;
1808 }
1809 if parent_node.kind == IrNodeKindV0::AtRule
1810 && let Some(context) = at_rule_context_component(ir, parent_node)
1811 {
1812 ancestors.push(context);
1813 }
1814 parent = parent_node.parent;
1815 }
1816 ancestors.reverse();
1817 ancestors.join("|")
1818}
1819
1820fn at_rule_context_component(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
1821 let prelude = node_block_prelude(ir, node).or_else(|| node_text(ir, node))?;
1822 at_rule_context_component_from_prelude(prelude.trim())
1823}
1824
1825fn at_rule_context_component_from_prelude(prelude: &str) -> Option<String> {
1826 if prelude.is_empty() {
1827 return None;
1828 }
1829 let normalized = normalize_space(prelude);
1830 if at_rule_prelude_is_semantically_transparent(normalized.as_str()) {
1831 None
1832 } else {
1833 Some(normalized)
1834 }
1835}
1836
1837fn at_rule_prelude_is_semantically_transparent(prelude: &str) -> bool {
1838 let lower = prelude.to_ascii_lowercase();
1839 let compact = lower
1840 .chars()
1841 .filter(|ch| !ch.is_ascii_whitespace())
1842 .collect::<String>();
1843 compact == "@scope(:root)"
1844 || lower.starts_with("@nest ")
1845 || lower
1846 .strip_prefix("@layer")
1847 .is_some_and(|name| !name.trim().is_empty() && !name.contains(','))
1848}
1849
1850fn join_context_components(base: String, current: Option<String>) -> String {
1851 match (base.is_empty(), current) {
1852 (true, Some(current)) => current,
1853 (false, Some(current)) => format!("{base}|{current}"),
1854 _ => base,
1855 }
1856}
1857
1858fn semantic_declarations_from_direct_ir_children(
1859 ir: &TransformIrV0,
1860 node: &IrNodeV0,
1861 scope: SemanticObservationScopeV0<'_>,
1862) -> Vec<SemanticDeclarationV0> {
1863 let syntax_facts = scope
1864 .force_ir_declarations
1865 .then(|| collect_parser_declaration_syntax_facts(ir.source_text(), scope.dialect));
1866 let mut declarations = if scope.force_ir_declarations {
1867 Vec::new()
1868 } else {
1869 semantic_declarations_from_direct_source_segments(ir, node, scope)
1870 };
1871 declarations.extend(
1872 node.children
1873 .iter()
1874 .filter_map(|child_id| ir.nodes.get(child_id.index()))
1875 .filter(|child| !child.deleted && child.kind == IrNodeKindV0::Declaration)
1876 .filter(|child| {
1877 !source_range_is_ignored(child.source_span_start, child.source_span_end, scope)
1878 })
1879 .filter_map(|child| {
1880 semantic_declaration_from_ir_fact(ir, child, syntax_facts.as_deref())
1881 }),
1882 );
1883 declarations.sort_by_key(|declaration| declaration.source_order);
1884 declarations.dedup_by(|left, right| {
1885 left.source_order == right.source_order
1886 && left.property_key == right.property_key
1887 && left.value == right.value
1888 && left.important == right.important
1889 });
1890 declarations
1891}
1892
1893fn semantic_declaration_from_ir_fact(
1894 ir: &TransformIrV0,
1895 node: &IrNodeV0,
1896 syntax_facts: Option<&[ParserDeclarationSyntaxFactV0]>,
1897) -> Option<SemanticDeclarationV0> {
1898 if node.canonical_text.is_none()
1899 && let Some(fact) = syntax_facts.and_then(|facts| {
1900 facts.iter().find(|fact| {
1901 fact.byte_span.start == node.source_span_start
1902 && fact.byte_span.end <= node.source_span_end
1903 })
1904 })
1905 {
1906 return Some(SemanticDeclarationV0 {
1907 property_key: fact.property_key.clone(),
1908 value: fact.value_text.clone(),
1909 important: fact.important,
1910 source_order: node.global_order,
1911 });
1912 }
1913 semantic_declaration_from_ir(ir, node)
1914}
1915
1916fn semantic_declarations_from_direct_source_segments(
1917 ir: &TransformIrV0,
1918 node: &IrNodeV0,
1919 scope: SemanticObservationScopeV0<'_>,
1920) -> Vec<SemanticDeclarationV0> {
1921 let Some(block_span) = node.block_span else {
1922 return Vec::new();
1923 };
1924 let body_start = block_span.body_start;
1925 let body_end = block_span.body_end;
1926 let mut children = node
1927 .children
1928 .iter()
1929 .filter_map(|child_id| ir.nodes.get(child_id.index()))
1930 .filter(|child| {
1931 !child.deleted
1932 && matches!(child.kind, IrNodeKindV0::StyleRule | IrNodeKindV0::AtRule)
1933 && child.source_span_start >= body_start
1934 && child.source_span_end <= body_end
1935 })
1936 .collect::<Vec<_>>();
1937 children.sort_by_key(|child| (child.source_span_start, child.global_order));
1938
1939 let mut declarations = Vec::new();
1940 let mut cursor = body_start;
1941 for child in children {
1942 if cursor < child.source_span_start {
1943 declarations.extend(semantic_declarations_from_source_segment(
1944 ir,
1945 cursor,
1946 child.source_span_start,
1947 scope,
1948 ));
1949 }
1950 cursor = cursor.max(child.source_span_end);
1951 }
1952 if cursor < body_end {
1953 declarations.extend(semantic_declarations_from_source_segment(
1954 ir, cursor, body_end, scope,
1955 ));
1956 }
1957 declarations
1958}
1959
1960fn semantic_declarations_from_source_segment(
1961 ir: &TransformIrV0,
1962 start: usize,
1963 end: usize,
1964 scope: SemanticObservationScopeV0<'_>,
1965) -> Vec<SemanticDeclarationV0> {
1966 if source_range_is_ignored(start, end, scope) {
1967 return Vec::new();
1968 }
1969 let Some(segment) = ir.source_text().get(start..end) else {
1970 return Vec::new();
1971 };
1972 split_declaration_list(segment)
1973 .into_iter()
1974 .enumerate()
1975 .filter_map(|(index, declaration)| {
1976 semantic_declaration_from_source(declaration.as_str(), start.saturating_add(index))
1977 })
1978 .collect()
1979}
1980
1981fn semantic_observation_mismatch_count(
1982 input: &SemanticObservationV0,
1983 output: &SemanticObservationV0,
1984) -> usize {
1985 let missing_or_changed = input
1986 .iter()
1987 .filter(|(key, value)| output.get(*key) != Some(*value))
1988 .count();
1989 let added = output
1990 .keys()
1991 .filter(|key| !input.contains_key(*key))
1992 .count();
1993 missing_or_changed + added
1994}
1995
1996fn has_deleted_ancestor(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
1997 let mut parent = node.parent;
1998 while let Some(parent_id) = parent {
1999 let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
2000 return true;
2001 };
2002 if parent_node.deleted {
2003 return true;
2004 }
2005 parent = parent_node.parent;
2006 }
2007 false
2008}
2009
2010fn has_style_rule_ancestor(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2011 let mut parent = node.parent;
2012 while let Some(parent_id) = parent {
2013 let Some(parent_node) = ir.nodes.get(parent_id.index()) else {
2014 return false;
2015 };
2016 if parent_node.kind == IrNodeKindV0::StyleRule {
2017 return true;
2018 }
2019 parent = parent_node.parent;
2020 }
2021 false
2022}
2023
2024fn node_text<'a>(ir: &'a TransformIrV0, node: &'a IrNodeV0) -> Option<&'a str> {
2025 node.canonical_text.as_deref().or_else(|| {
2026 ir.source_text()
2027 .get(node.source_span_start..node.source_span_end)
2028 })
2029}
2030
2031struct NodeTextBlockViewV0<'source> {
2032 source: &'source str,
2033 primary: IrBlockSpanV0,
2034 spans: Vec<IrBlockSpanV0>,
2035}
2036
2037impl NodeTextBlockViewV0<'_> {
2038 fn prelude(&self, span: IrBlockSpanV0) -> Option<&str> {
2039 self.source.get(span.prelude_start..span.open_brace_start)
2040 }
2041
2042 fn direct_child_spans(&self) -> Vec<IrBlockSpanV0> {
2043 let nested = self
2044 .spans
2045 .iter()
2046 .copied()
2047 .filter(|span| {
2048 *span != self.primary
2049 && self.primary.body_start <= span.prelude_start
2050 && span.rule_end <= self.primary.body_end
2051 })
2052 .collect::<Vec<_>>();
2053 nested
2054 .iter()
2055 .copied()
2056 .filter(|candidate| {
2057 !nested.iter().any(|owner| {
2058 owner != candidate
2059 && owner.body_start <= candidate.prelude_start
2060 && candidate.rule_end <= owner.body_end
2061 })
2062 })
2063 .collect()
2064 }
2065}
2066
2067fn node_text_block_view<'source>(
2068 ir: &'source TransformIrV0,
2069 node: &'source IrNodeV0,
2070) -> Option<NodeTextBlockViewV0<'source>> {
2071 let source = node_text(ir, node)?;
2072 if node.canonical_text.is_some() {
2073 let spans = structural_block_spans_for_source(source, ir_style_dialect(ir)?);
2074 let primary = spans
2075 .iter()
2076 .copied()
2077 .filter(|span| span.prelude_start == 0)
2078 .max_by_key(|span| span.rule_end)?;
2079 return Some(NodeTextBlockViewV0 {
2080 source,
2081 primary,
2082 spans,
2083 });
2084 }
2085
2086 let primary = shift_block_span(node.block_span?, node.source_span_start)?;
2087 let spans = ir
2088 .structural_block_spans()
2089 .iter()
2090 .copied()
2091 .filter(|span| {
2092 node.source_span_start <= span.prelude_start && span.rule_end <= node.source_span_end
2093 })
2094 .filter_map(|span| shift_block_span(span, node.source_span_start))
2095 .collect();
2096 Some(NodeTextBlockViewV0 {
2097 source,
2098 primary,
2099 spans,
2100 })
2101}
2102
2103fn shift_block_span(span: IrBlockSpanV0, offset: usize) -> Option<IrBlockSpanV0> {
2104 Some(IrBlockSpanV0 {
2105 prelude_start: span.prelude_start.checked_sub(offset)?,
2106 open_brace_start: span.open_brace_start.checked_sub(offset)?,
2107 body_start: span.body_start.checked_sub(offset)?,
2108 body_end: span.body_end.checked_sub(offset)?,
2109 rule_end: span.rule_end.checked_sub(offset)?,
2110 })
2111}
2112
2113fn ir_style_dialect(ir: &TransformIrV0) -> Option<StyleDialect> {
2114 match ir.dialect {
2115 "css" => Some(StyleDialect::Css),
2116 "scss" => Some(StyleDialect::Scss),
2117 "sass" => Some(StyleDialect::Sass),
2118 "less" => Some(StyleDialect::Less),
2119 _ => None,
2120 }
2121}
2122
2123fn node_block_prelude<'source>(
2124 ir: &'source TransformIrV0,
2125 node: &'source IrNodeV0,
2126) -> Option<&'source str> {
2127 let view = node_text_block_view(ir, node)?;
2128 view.source
2129 .get(view.primary.prelude_start..view.primary.open_brace_start)
2130}
2131
2132fn normalize_selector_key(selector: &str) -> String {
2133 normalize_space(selector)
2134}
2135
2136fn normalize_declaration_value(value: &str) -> String {
2137 normalize_space(value)
2138}
2139
2140fn normalize_space(value: &str) -> String {
2141 value.split_whitespace().collect::<Vec<_>>().join(" ")
2142}
2143
2144fn split_selector_list(selector: &str) -> Vec<String> {
2145 let mut parts = Vec::new();
2146 let mut start = 0usize;
2147 let mut quote = None;
2148 let mut escaped = false;
2149 let mut paren_depth = 0usize;
2150 let mut bracket_depth = 0usize;
2151
2152 for (index, byte) in selector.bytes().enumerate() {
2153 if let Some(quote_byte) = quote {
2154 if escaped {
2155 escaped = false;
2156 } else if byte == b'\\' {
2157 escaped = true;
2158 } else if byte == quote_byte {
2159 quote = None;
2160 }
2161 continue;
2162 }
2163
2164 match byte {
2165 b'\'' | b'"' => quote = Some(byte),
2166 b'(' => paren_depth = paren_depth.saturating_add(1),
2167 b')' => paren_depth = paren_depth.saturating_sub(1),
2168 b'[' => bracket_depth = bracket_depth.saturating_add(1),
2169 b']' => bracket_depth = bracket_depth.saturating_sub(1),
2170 b',' if paren_depth == 0 && bracket_depth == 0 => {
2171 if let Some(part) = selector.get(start..index) {
2172 parts.push(part.trim().to_string());
2173 }
2174 start = index.saturating_add(1);
2175 }
2176 _ => {}
2177 }
2178 }
2179
2180 if let Some(part) = selector.get(start..) {
2181 parts.push(part.trim().to_string());
2182 }
2183 parts
2184}
2185
2186fn split_declaration_list(body: &str) -> Vec<String> {
2187 let mut parts = Vec::new();
2188 let mut start = 0usize;
2189 let mut quote = None;
2190 let mut escaped = false;
2191 let mut paren_depth = 0usize;
2192 let mut bracket_depth = 0usize;
2193
2194 for (index, byte) in body.bytes().enumerate() {
2195 if let Some(quote_byte) = quote {
2196 if escaped {
2197 escaped = false;
2198 } else if byte == b'\\' {
2199 escaped = true;
2200 } else if byte == quote_byte {
2201 quote = None;
2202 }
2203 continue;
2204 }
2205
2206 match byte {
2207 b'\'' | b'"' => quote = Some(byte),
2208 b'(' => paren_depth = paren_depth.saturating_add(1),
2209 b')' => paren_depth = paren_depth.saturating_sub(1),
2210 b'[' => bracket_depth = bracket_depth.saturating_add(1),
2211 b']' => bracket_depth = bracket_depth.saturating_sub(1),
2212 b';' if paren_depth == 0 && bracket_depth == 0 => {
2213 if let Some(part) = body.get(start..index) {
2214 let trimmed = part.trim();
2215 if !trimmed.is_empty() {
2216 parts.push(trimmed.to_string());
2217 }
2218 }
2219 start = index.saturating_add(1);
2220 }
2221 _ => {}
2222 }
2223 }
2224
2225 if let Some(part) = body.get(start..) {
2226 let trimmed = part.trim();
2227 if !trimmed.is_empty() {
2228 parts.push(trimmed.to_string());
2229 }
2230 }
2231 parts
2232}
2233
2234fn contains_nested_block_or_comment(source: &str) -> bool {
2235 let bytes = source.as_bytes();
2236 let mut index = 0usize;
2237 let mut quote = None;
2238 let mut escaped = false;
2239 while index < bytes.len() {
2240 let byte = bytes[index];
2241 if let Some(quote_byte) = quote {
2242 if escaped {
2243 escaped = false;
2244 } else if byte == b'\\' {
2245 escaped = true;
2246 } else if byte == quote_byte {
2247 quote = None;
2248 }
2249 index += 1;
2250 continue;
2251 }
2252 if matches!(byte, b'\'' | b'"') {
2253 quote = Some(byte);
2254 index += 1;
2255 continue;
2256 }
2257 if matches!(byte, b'{' | b'}') || (byte == b'/' && bytes.get(index + 1) == Some(&b'*')) {
2258 return true;
2259 }
2260 index += 1;
2261 }
2262 false
2263}
2264
2265fn declaration_colon_index(source: &str) -> Option<usize> {
2266 let bytes = source.as_bytes();
2267 let mut index = 0usize;
2268 let mut quote = None;
2269 let mut escaped = false;
2270 let mut paren_depth = 0usize;
2271 let mut bracket_depth = 0usize;
2272
2273 while index < bytes.len() {
2274 let byte = bytes[index];
2275 if let Some(quote_byte) = quote {
2276 if escaped {
2277 escaped = false;
2278 } else if byte == b'\\' {
2279 escaped = true;
2280 } else if byte == quote_byte {
2281 quote = None;
2282 }
2283 index += 1;
2284 continue;
2285 }
2286 match byte {
2287 b'\'' | b'"' => quote = Some(byte),
2288 b'(' => paren_depth = paren_depth.saturating_add(1),
2289 b')' => paren_depth = paren_depth.saturating_sub(1),
2290 b'[' => bracket_depth = bracket_depth.saturating_add(1),
2291 b']' => bracket_depth = bracket_depth.saturating_sub(1),
2292 b':' if paren_depth == 0 && bracket_depth == 0 => return Some(index),
2293 _ => {}
2294 }
2295 index += 1;
2296 }
2297 None
2298}
2299
2300fn declaration_value_is_important(value: &str) -> bool {
2301 let bytes = value.as_bytes();
2302 let mut index = 0usize;
2303 while index < bytes.len() {
2304 if bytes[index] == b'!' {
2305 let rest = value.get(index + 1..).unwrap_or_default().trim_start();
2306 return rest
2307 .get(.."important".len())
2308 .is_some_and(|candidate| candidate.eq_ignore_ascii_case("important"));
2309 }
2310 index += 1;
2311 }
2312 false
2313}
2314
2315#[cfg(test)]
2316fn semantic_model_conformance_case_results() -> Vec<bool> {
2317 let cases = [
2318 (
2319 "empty-rule-removal",
2320 ".a { color: red; }\n.a { color: blue; }\n.empty {}\n",
2321 ".a { color: red; }\n.a { color: blue; }\n",
2322 true,
2323 ),
2324 (
2325 "rule-deduplication",
2326 ".a { color: red !important; }\n.a { color: blue; }\n",
2327 ".a { color: red !important; }\n.a { color: blue; }\n",
2328 true,
2329 ),
2330 (
2331 "rule-deduplication",
2332 "@media (min-width: 1px) { .a { color: red; } }\n.a { color: blue; }\n",
2333 "@media (min-width: 1px) { .a { color: red; } }\n.a { color: blue; }\n",
2334 true,
2335 ),
2336 (
2337 "rule-deduplication",
2338 ".a { color: red !important; }\n.a { color: blue; }\n",
2339 ".a { color: blue; }\n",
2340 false,
2341 ),
2342 (
2343 "tree-shake-class",
2344 ".used { color: red; }\n.dead { color: blue; }\n",
2345 ".used { color: red; }\n",
2346 true,
2347 ),
2348 (
2349 "tree-shake-keyframes",
2350 "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2351 "@keyframes used { to { opacity: 1; } }\n.btn { animation: used 1s; }\n",
2352 true,
2353 ),
2354 (
2355 "tree-shake-value",
2356 "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2357 "@value used: red;\n.btn { color: used; }\n",
2358 true,
2359 ),
2360 (
2361 "tree-shake-custom-property",
2362 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2363 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2364 true,
2365 ),
2366 (
2367 "nesting-unwrap",
2368 ".card { color: red; & .title { color: blue; } }\n",
2369 ".card { color: red; }\n.card .title { color: blue; }\n",
2370 true,
2371 ),
2372 (
2373 "scope-flatten",
2374 "@scope (:root) { .card { color: red; } }\n",
2375 ".card { color: red; }\n",
2376 true,
2377 ),
2378 (
2379 "layer-flatten",
2380 "@layer theme { .card { color: red; } }\n",
2381 ".card { color: red; }\n",
2382 true,
2383 ),
2384 ];
2385
2386 cases
2387 .into_iter()
2388 .map(|(pass_id, input, output, expected_preserved)| {
2389 let input_ir = lower_transform_ir_from_source(input, StyleDialect::Css, "input");
2390 let output_ir = lower_transform_ir_from_source(output, StyleDialect::Css, "output");
2391 let reachable_class_names = vec!["used".to_string()];
2392 let keyframe_class_names = vec!["btn".to_string()];
2393 let value_class_names = vec!["btn".to_string()];
2394 let custom_property_class_names = vec!["btn".to_string()];
2395 let projection = if pass_id == "tree-shake-keyframes" {
2396 SemanticObservationProjectionV0::for_keyframe_reachability(
2397 &input_ir,
2398 &[],
2399 &keyframe_class_names,
2400 )
2401 } else if pass_id == "tree-shake-value" {
2402 SemanticObservationProjectionV0::for_value_reachability(
2403 &input_ir,
2404 StyleDialect::Css,
2405 &[],
2406 &[],
2407 &value_class_names,
2408 )
2409 } else if pass_id == "tree-shake-custom-property" {
2410 SemanticObservationProjectionV0::for_custom_property_reachability(
2411 &input_ir,
2412 StyleDialect::Css,
2413 &[],
2414 &[],
2415 &custom_property_class_names,
2416 )
2417 } else {
2418 SemanticObservationProjectionV0::default()
2419 };
2420 let scope = if pass_id == "tree-shake-class" {
2421 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names)
2422 } else if pass_id == "tree-shake-keyframes" {
2423 SemanticObservationScopeV0::for_ignored_source_ranges(
2424 projection.ignored_source_ranges(),
2425 )
2426 } else if pass_id == "tree-shake-value" {
2427 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2428 &value_class_names,
2429 projection.ignored_source_ranges(),
2430 )
2431 } else if pass_id == "tree-shake-custom-property" {
2432 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2433 &custom_property_class_names,
2434 projection.ignored_source_ranges(),
2435 )
2436 } else {
2437 SemanticObservationScopeV0::default()
2438 };
2439 let decision = compare_semantic_observation_for_pass_with_scopes(
2440 pass_id,
2441 &input_ir,
2442 &output_ir,
2443 scope,
2444 scope.without_ignored_source_ranges(),
2445 );
2446 decision.preserved == expected_preserved
2447 })
2448 .collect()
2449}
2450
2451#[cfg(test)]
2452fn stable_semantic_report_digest(parts: &[&str]) -> String {
2453 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
2454 for part in parts {
2455 for byte in part.as_bytes() {
2456 hash ^= u64::from(*byte);
2457 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2458 }
2459 hash ^= 0xff;
2460 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2461 }
2462 format!("fnv1a64:{hash:016x}")
2463}
2464
2465#[cfg(test)]
2466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2467#[serde(rename_all = "camelCase")]
2468struct SemanticObservationContractV0 {
2469 schema_version: String,
2470 product: String,
2471 cases: Vec<SemanticObservationContractCaseV0>,
2472}
2473
2474#[cfg(test)]
2475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2476#[serde(rename_all = "camelCase")]
2477struct SemanticObservationContractCaseV0 {
2478 case_id: String,
2479 entries: Vec<SemanticObservationContractEntryV0>,
2480}
2481
2482#[cfg(test)]
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2484#[serde(rename_all = "camelCase")]
2485struct SemanticObservationContractEntryV0 {
2486 selector: String,
2487 property: String,
2488 context: String,
2489 value: String,
2490 important: bool,
2491}
2492
2493#[cfg(test)]
2494fn semantic_observation_contract_snapshot() -> SemanticObservationContractV0 {
2495 let cases = [
2496 (
2497 "cascade-and-media",
2498 StyleDialect::Css,
2499 ".card { color: red; } .card { color: blue !important; } @media (width > 20rem) { .card { display: grid; } }",
2500 ),
2501 (
2502 "nested-scss",
2503 StyleDialect::Scss,
2504 ".card { color: red; &:hover { color: blue; } @media (width > 20rem) { &__title { display: block; } } }",
2505 ),
2506 (
2507 "keyframes",
2508 StyleDialect::Css,
2509 "@keyframes fade { from { opacity: 0; } 50% { opacity: .5; } to { opacity: 1; } } .card { animation: fade 1s; }",
2510 ),
2511 (
2512 "css-modules",
2513 StyleDialect::Css,
2514 "@value tone: red; :export { exported: tone; } .button { composes: base from \"./base.css\"; color: tone; }",
2515 ),
2516 (
2517 "delimiter-and-comment",
2518 StyleDialect::Css,
2519 ".card { content: \"{; }\"; /* observer falls back to typed declarations */ color: red; }",
2520 ),
2521 ]
2522 .into_iter()
2523 .map(|(case_id, dialect, source)| {
2524 let ir = lower_transform_ir_from_source(source, dialect, case_id);
2525 let scope = SemanticObservationScopeV0::from_parts(None, None, &[], dialect);
2526 let entries = semantic_observation(&ir, scope)
2527 .into_iter()
2528 .map(|(key, value)| SemanticObservationContractEntryV0 {
2529 selector: key.selector_key,
2530 property: key.property.as_str().to_string(),
2531 context: key.context_key,
2532 value: value.value,
2533 important: value.important,
2534 })
2535 .collect();
2536 SemanticObservationContractCaseV0 {
2537 case_id: case_id.to_string(),
2538 entries,
2539 }
2540 })
2541 .collect();
2542
2543 SemanticObservationContractV0 {
2544 schema_version: "0".to_string(),
2545 product: "omena-transform-passes.semantic-observation-contract".to_string(),
2546 cases,
2547 }
2548}
2549
2550#[cfg(test)]
2551mod tests {
2552 use super::*;
2553
2554 #[test]
2555 fn extracts_keyframe_names_from_mixed_case_at_rule_preludes() {
2556 assert_eq!(
2557 keyframe_name_from_at_rule_prelude("@KEYFRAMES fade"),
2558 Some("fade")
2559 );
2560 assert_eq!(
2561 keyframe_name_from_at_rule_prelude("@-WeBkIt-KeYfRaMeS pulse"),
2562 Some("pulse")
2563 );
2564 }
2565
2566 fn struct_field_names(source: &str, struct_name: &str) -> Vec<String> {
2567 let marker = format!("struct {struct_name} {{");
2568 let body = source.split_once(&marker).map(|(_, body)| body);
2569 assert!(body.is_some(), "missing struct {struct_name}");
2570 let Some(body) = body else {
2571 return Vec::new();
2572 };
2573 body.lines()
2574 .take_while(|line| line.trim() != "}")
2575 .filter_map(|line| {
2576 let (field, _) = line.trim().trim_end_matches(',').split_once(':')?;
2577 let field = field.trim();
2578 field
2579 .chars()
2580 .all(|character| character == '_' || character.is_ascii_alphanumeric())
2581 .then(|| field.to_string())
2582 })
2583 .collect()
2584 }
2585
2586 fn observer_shape_matches_bindings(source: &str) -> bool {
2587 let key_fields = struct_field_names(source, "SemanticObservationKeyV0");
2588 let value_fields = struct_field_names(source, "SemanticObservationValueV0");
2589 let bound_key_fields = SemanticObservationKeyV0::FIELD_BINDINGS
2590 .iter()
2591 .map(|(field, _)| field.to_string())
2592 .collect::<Vec<_>>();
2593 let bound_value_fields = SemanticObservationValueV0::FIELD_BINDINGS
2594 .iter()
2595 .map(|(field, _)| field.to_string())
2596 .collect::<Vec<_>>();
2597 key_fields == bound_key_fields && value_fields == bound_value_fields
2598 }
2599
2600 #[test]
2601 fn semantic_observation_surface_descriptor_matches_observer_types() {
2602 let source = include_str!("semantic_preservation.rs");
2603 assert!(observer_shape_matches_bindings(source));
2604
2605 let descriptor = semantic_observation_surface_descriptor();
2606 assert_eq!(
2607 descriptor.key_axes,
2608 SemanticObservationKeyV0::FIELD_BINDINGS
2609 .iter()
2610 .map(|(_, axis)| *axis)
2611 .collect::<Vec<_>>()
2612 );
2613 assert_eq!(
2614 descriptor.value_axes,
2615 SemanticObservationValueV0::FIELD_BINDINGS
2616 .iter()
2617 .map(|(_, axis)| *axis)
2618 .collect::<Vec<_>>()
2619 );
2620 assert_eq!(
2621 descriptor.ordering_rules,
2622 vec![
2623 TransformSemanticObservationOrderingRuleV0::SourceOrder,
2624 TransformSemanticObservationOrderingRuleV0::ImportantPrecedence,
2625 ]
2626 );
2627 assert_eq!(descriptor.unobserved_axes.len(), 10);
2628 assert!(
2629 descriptor
2630 .unobserved_axes
2631 .contains(&TransformSemanticUnobservedAxisV0::NonStyleRuleDeclarationCarriers)
2632 );
2633 assert!(
2634 descriptor
2635 .unobserved_axes
2636 .contains(&TransformSemanticUnobservedAxisV0::IntraRuleDeclarationOrdering)
2637 );
2638 assert_eq!(
2639 descriptor.claim_scope,
2640 TransformSemanticPreservationClaimScopeV0::ObservedSurfaceOnly
2641 );
2642 assert_eq!(
2643 descriptor.vocabulary_review,
2644 TransformSemanticPreservationVocabularyReviewV0::DeferredUntilFullCascadeObservation
2645 );
2646 }
2647
2648 #[test]
2649 fn semantic_observation_surface_descriptor_detects_shape_drift() {
2650 let source = include_str!("semantic_preservation.rs");
2651 let widened = source.replacen(
2652 "context_key: String,",
2653 "context_key: String,\n specificity: String,",
2654 1,
2655 );
2656 assert!(!observer_shape_matches_bindings(&widened));
2657 }
2658
2659 #[test]
2660 fn semantic_observation_ordering_matches_the_disclosed_rules() {
2661 let ir = lower_transform_ir_from_source(
2662 ".a { color: red; } .a { color: blue !important; } .a { color: green; } .b { color: red; } .b { color: blue; }",
2663 StyleDialect::Css,
2664 "semantic-observation-ordering",
2665 );
2666 let observation = semantic_observation(&ir, SemanticObservationScopeV0::default());
2667
2668 let important = observation.get(&SemanticObservationKeyV0 {
2669 selector_key: ".a".to_string(),
2670 property: PropertyNameV0::standard("color").canonical_key(),
2671 context_key: String::new(),
2672 });
2673 assert!(important.is_some(), "important declaration observation");
2674 if let Some(important) = important {
2675 assert_eq!(important.value, "blue !important");
2676 assert!(important.important);
2677 }
2678
2679 let source_order = observation.get(&SemanticObservationKeyV0 {
2680 selector_key: ".b".to_string(),
2681 property: PropertyNameV0::standard("color").canonical_key(),
2682 context_key: String::new(),
2683 });
2684 assert!(
2685 source_order.is_some(),
2686 "source-order declaration observation"
2687 );
2688 if let Some(source_order) = source_order {
2689 assert_eq!(source_order.value, "blue");
2690 assert!(!source_order.important);
2691 }
2692 }
2693
2694 #[test]
2695 fn semantic_observation_matches_committed_contract() -> Result<(), serde_json::Error> {
2696 let actual = semantic_observation_contract_snapshot();
2697 let expected = serde_json::from_str::<SemanticObservationContractV0>(include_str!(
2698 "../../fixtures/semantic-preservation/observer-contract.json"
2699 ))?;
2700
2701 assert_eq!(actual, expected);
2702 Ok(())
2703 }
2704
2705 #[test]
2706 fn semantic_observation_contract_detects_a_dropped_declaration() -> Result<(), serde_json::Error>
2707 {
2708 let expected = serde_json::from_str::<SemanticObservationContractV0>(include_str!(
2709 "../../fixtures/semantic-preservation/observer-contract.json"
2710 ))?;
2711 let mut lossy = semantic_observation_contract_snapshot();
2712 assert_eq!(lossy.cases[0].case_id, "cascade-and-media");
2713 let case = &mut lossy.cases[0];
2714 let entry_count = case.entries.len();
2715 case.entries
2716 .retain(|entry| entry.property != "display" || entry.context.is_empty());
2717 assert_eq!(case.entries.len() + 1, entry_count);
2718 assert_ne!(lossy, expected);
2719
2720 let input = lower_transform_ir_from_source(
2721 ".card { color: red; display: grid; }",
2722 StyleDialect::Css,
2723 "semantic-drop-input",
2724 );
2725 let output = lower_transform_ir_from_source(
2726 ".card { color: red; }",
2727 StyleDialect::Css,
2728 "semantic-drop-output",
2729 );
2730 let decision = compare_semantic_observation_for_pass("rule-deduplication", &input, &output);
2731 assert!(!decision.preserved);
2732 assert_eq!(decision.mismatch_count, 1);
2733 Ok(())
2734 }
2735
2736 #[test]
2737 fn observation_ignores_removed_empty_rules() {
2738 let input = lower_transform_ir_from_source(
2739 ".a { color: red; }\n.empty {}\n",
2740 StyleDialect::Css,
2741 "test",
2742 );
2743 let output =
2744 lower_transform_ir_from_source(".a { color: red; }\n", StyleDialect::Css, "test");
2745 let decision = compare_semantic_observation_for_pass("empty-rule-removal", &input, &output);
2746
2747 assert!(decision.preserved);
2748 assert_eq!(decision.mismatch_count, 0);
2749 assert_eq!(decision.input_entry_count, 1);
2750 assert_eq!(decision.output_entry_count, 1);
2751 }
2752
2753 #[test]
2754 fn observation_catches_declared_value_changes() {
2755 let input = lower_transform_ir_from_source(".a { color: red; }", StyleDialect::Css, "test");
2756 let output =
2757 lower_transform_ir_from_source(".a { color: blue; }", StyleDialect::Css, "test");
2758 let decision = compare_semantic_observation_for_pass("rule-deduplication", &input, &output);
2759
2760 assert!(!decision.preserved);
2761 assert_eq!(decision.mismatch_count, 1);
2762 }
2763
2764 #[test]
2765 fn observation_projects_class_tree_shake_to_reachable_selectors() {
2766 let reachable_class_names = vec!["used".to_string()];
2767 let input = lower_transform_ir_from_source(
2768 ".used { color: red; }\n.dead { color: blue; }\n.used, .dead-mixed { background: blue; }\n",
2769 StyleDialect::Css,
2770 "test",
2771 );
2772 let output = lower_transform_ir_from_source(
2773 ".used { color: red; }\n.used { background: blue; }\n",
2774 StyleDialect::Css,
2775 "test",
2776 );
2777 let decision = compare_semantic_observation_for_pass_with_scope(
2778 "tree-shake-class",
2779 &input,
2780 &output,
2781 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2782 );
2783
2784 assert!(decision.preserved);
2785 assert_eq!(decision.mismatch_count, 0);
2786 }
2787
2788 #[test]
2789 fn observation_rejects_reachable_class_tree_shake_changes() {
2790 let reachable_class_names = vec!["used".to_string()];
2791 let input = lower_transform_ir_from_source(
2792 ".used { color: red; }\n.dead { color: blue; }\n",
2793 StyleDialect::Css,
2794 "test",
2795 );
2796 let output =
2797 lower_transform_ir_from_source(".used { color: green; }\n", StyleDialect::Css, "test");
2798 let decision = compare_semantic_observation_for_pass_with_scope(
2799 "tree-shake-class",
2800 &input,
2801 &output,
2802 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2803 );
2804
2805 assert!(!decision.preserved);
2806 }
2807
2808 #[test]
2809 fn observation_projects_keyframe_tree_shake_to_reachable_rules() {
2810 let reachable_class_names = vec!["btn".to_string()];
2811 let input = lower_transform_ir_from_source(
2812 "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2813 StyleDialect::Css,
2814 "test",
2815 );
2816 let output = lower_transform_ir_from_source(
2817 "@keyframes used { to { opacity: 1; } }\n.btn { animation: used 1s; }\n",
2818 StyleDialect::Css,
2819 "test",
2820 );
2821 let projection = SemanticObservationProjectionV0::for_keyframe_reachability(
2822 &input,
2823 &[],
2824 &reachable_class_names,
2825 );
2826 let decision = compare_semantic_observation_for_pass_with_scopes(
2827 "tree-shake-keyframes",
2828 &input,
2829 &output,
2830 SemanticObservationScopeV0::for_ignored_source_ranges(
2831 projection.ignored_source_ranges(),
2832 ),
2833 SemanticObservationScopeV0::default(),
2834 );
2835
2836 assert!(decision.preserved);
2837 assert_eq!(decision.mismatch_count, 0);
2838 }
2839
2840 #[test]
2841 fn observation_rejects_reachable_keyframe_tree_shake_changes() {
2842 let reachable_class_names = vec!["btn".to_string()];
2843 let input = lower_transform_ir_from_source(
2844 "@keyframes used { to { opacity: 1; } }\n@keyframes dead { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2845 StyleDialect::Css,
2846 "test",
2847 );
2848 let output = lower_transform_ir_from_source(
2849 "@keyframes used { to { opacity: 0; } }\n.btn { animation: used 1s; }\n",
2850 StyleDialect::Css,
2851 "test",
2852 );
2853 let projection = SemanticObservationProjectionV0::for_keyframe_reachability(
2854 &input,
2855 &[],
2856 &reachable_class_names,
2857 );
2858 let decision = compare_semantic_observation_for_pass_with_scopes(
2859 "tree-shake-keyframes",
2860 &input,
2861 &output,
2862 SemanticObservationScopeV0::for_ignored_source_ranges(
2863 projection.ignored_source_ranges(),
2864 ),
2865 SemanticObservationScopeV0::default(),
2866 );
2867
2868 assert!(!decision.preserved);
2869 }
2870
2871 #[test]
2872 fn observation_projects_value_tree_shake_to_reachable_values() {
2873 let reachable_class_names = vec!["btn".to_string()];
2874 let input = lower_transform_ir_from_source(
2875 "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2876 StyleDialect::Css,
2877 "test",
2878 );
2879 let output = lower_transform_ir_from_source(
2880 "@value used: red;\n.btn { color: used; }\n",
2881 StyleDialect::Css,
2882 "test",
2883 );
2884 let projection = SemanticObservationProjectionV0::for_value_reachability(
2885 &input,
2886 StyleDialect::Css,
2887 &[],
2888 &[],
2889 &reachable_class_names,
2890 );
2891 let decision = compare_semantic_observation_for_pass_with_scopes(
2892 "tree-shake-value",
2893 &input,
2894 &output,
2895 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2896 &reachable_class_names,
2897 projection.ignored_source_ranges(),
2898 ),
2899 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2900 );
2901
2902 assert!(decision.preserved);
2903 assert_eq!(decision.mismatch_count, 0);
2904 }
2905
2906 #[test]
2907 fn observation_rejects_reachable_value_tree_shake_changes() {
2908 let reachable_class_names = vec!["btn".to_string()];
2909 let input = lower_transform_ir_from_source(
2910 "@value used: red;\n@value dead: blue;\n.btn { color: used; }\n",
2911 StyleDialect::Css,
2912 "test",
2913 );
2914 let output = lower_transform_ir_from_source(
2915 "@value used: blue;\n.btn { color: used; }\n",
2916 StyleDialect::Css,
2917 "test",
2918 );
2919 let projection = SemanticObservationProjectionV0::for_value_reachability(
2920 &input,
2921 StyleDialect::Css,
2922 &[],
2923 &[],
2924 &reachable_class_names,
2925 );
2926 let decision = compare_semantic_observation_for_pass_with_scopes(
2927 "tree-shake-value",
2928 &input,
2929 &output,
2930 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2931 &reachable_class_names,
2932 projection.ignored_source_ranges(),
2933 ),
2934 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2935 );
2936
2937 assert!(!decision.preserved);
2938 }
2939
2940 #[test]
2941 fn observation_projects_custom_property_tree_shake_to_reachable_roots() {
2942 let reachable_class_names = vec!["btn".to_string()];
2943 let input = lower_transform_ir_from_source(
2944 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2945 StyleDialect::Css,
2946 "test",
2947 );
2948 let output = lower_transform_ir_from_source(
2949 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2950 StyleDialect::Css,
2951 "test",
2952 );
2953 let projection = SemanticObservationProjectionV0::for_custom_property_reachability(
2954 &input,
2955 StyleDialect::Css,
2956 &[],
2957 &[],
2958 &reachable_class_names,
2959 );
2960 let decision = compare_semantic_observation_for_pass_with_scopes(
2961 "tree-shake-custom-property",
2962 &input,
2963 &output,
2964 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
2965 &reachable_class_names,
2966 projection.ignored_source_ranges(),
2967 ),
2968 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
2969 );
2970
2971 assert!(decision.preserved);
2972 assert_eq!(decision.mismatch_count, 0);
2973 }
2974
2975 #[test]
2976 fn observation_rejects_reachable_custom_property_registration_changes() {
2977 let reachable_class_names = vec!["btn".to_string()];
2978 let input = lower_transform_ir_from_source(
2979 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: red; }\n@property --dead { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; --dead: blue; }\n.btn { color: var(--used); }\n",
2980 StyleDialect::Css,
2981 "test",
2982 );
2983 let output = lower_transform_ir_from_source(
2984 "@property --used { syntax: \"<color>\"; inherits: false; initial-value: blue; }\n:root { --used: red; }\n.btn { color: var(--used); }\n",
2985 StyleDialect::Css,
2986 "test",
2987 );
2988 let projection = SemanticObservationProjectionV0::for_custom_property_reachability(
2989 &input,
2990 StyleDialect::Css,
2991 &[],
2992 &[],
2993 &reachable_class_names,
2994 );
2995 let decision = compare_semantic_observation_for_pass_with_scopes(
2996 "tree-shake-custom-property",
2997 &input,
2998 &output,
2999 SemanticObservationScopeV0::for_reachable_class_names_and_ignored_source_ranges(
3000 &reachable_class_names,
3001 projection.ignored_source_ranges(),
3002 ),
3003 SemanticObservationScopeV0::for_reachable_class_names(&reachable_class_names),
3004 );
3005
3006 assert!(!decision.preserved);
3007 }
3008
3009 #[test]
3010 fn observation_expands_selector_lists_for_selector_merging() {
3011 let input = lower_transform_ir_from_source(
3012 ".a { color: red; }\n.b { color: red; }\n:is(.c, .d) { color: blue; }\n",
3013 StyleDialect::Css,
3014 "test",
3015 );
3016 let output = lower_transform_ir_from_source(
3017 ".a, .b { color: red; }\n:is(.c, .d) { color: blue; }\n",
3018 StyleDialect::Css,
3019 "test",
3020 );
3021 let decision = compare_semantic_observation_for_pass("selector-merging", &input, &output);
3022
3023 assert!(decision.preserved);
3024 assert_eq!(decision.mismatch_count, 0);
3025 assert_eq!(decision.input_entry_count, 3);
3026 assert_eq!(decision.output_entry_count, 3);
3027 }
3028
3029 #[test]
3030 fn observation_preserves_rule_merging_declaration_union() {
3031 let input = lower_transform_ir_from_source(
3032 ".a { color: red; }\n.a { background: blue; }\n",
3033 StyleDialect::Css,
3034 "test",
3035 );
3036 let output = lower_transform_ir_from_source(
3037 ".a { color: red; background: blue; }\n",
3038 StyleDialect::Css,
3039 "test",
3040 );
3041 let decision = compare_semantic_observation_for_pass("rule-merging", &input, &output);
3042
3043 assert!(decision.preserved);
3044 assert_eq!(decision.mismatch_count, 0);
3045 assert_eq!(decision.input_entry_count, 2);
3046 assert_eq!(decision.output_entry_count, 2);
3047 }
3048
3049 #[test]
3050 fn external_css_diff_classifies_known_prefixes_and_preserves_unknown_changes() {
3051 let input = ".input { appearance: none; } ::placeholder { color: gray; }";
3052 let output = ".input { -webkit-appearance: none; appearance: none; } ::-moz-placeholder { color: gray; } ::placeholder { color: gray; }";
3053 let report = compare_external_css_semantic_changes_v0(input, output, StyleDialect::Css);
3054
3055 assert!(report.understood_change_count >= 1);
3056 assert!(report.passthrough_change_count >= 1);
3057 assert_eq!(
3058 report.understood_change_count + report.passthrough_change_count,
3059 report.total_change_count
3060 );
3061 assert!(report.changes.iter().any(|change| {
3062 change.classification == ExternalCssSemanticChangeClassificationV0::Understood
3063 && change
3064 .after
3065 .as_ref()
3066 .is_some_and(|entry| entry.property.as_str() == "-webkit-appearance")
3067 }));
3068 assert!(report.changes.iter().any(|change| {
3069 change.classification == ExternalCssSemanticChangeClassificationV0::Passthrough
3070 && change
3071 .after
3072 .as_ref()
3073 .is_some_and(|entry| entry.selector.contains("::-moz-placeholder"))
3074 }));
3075 }
3076
3077 #[test]
3078 fn external_css_cst_coverage_is_whitespace_modulo_but_selector_semantics_are_not() {
3079 let report = compare_external_css_semantic_changes_v0(
3080 ".a .b { color: red; }",
3081 ".a.b { color: red; }",
3082 StyleDialect::Css,
3083 );
3084
3085 assert!(report.cst_coverage.complete);
3086 assert_eq!(report.cst_coverage.uncovered_input_unit_count, 0);
3087 assert_eq!(report.cst_coverage.uncovered_output_unit_count, 0);
3088 assert_eq!(report.understood_change_count, 0);
3089 assert!(report.passthrough_change_count > 0);
3090 assert_eq!(report.passthrough_change_count, report.total_change_count);
3091 }
3092
3093 #[test]
3094 fn external_css_diff_totality_rejects_an_unreported_change() {
3095 let mut report = compare_external_css_semantic_changes_v0(
3096 ".input { appearance: none; }",
3097 ".input { -webkit-appearance: none; appearance: none; }",
3098 StyleDialect::Css,
3099 );
3100 assert!(external_css_adoption_boundary_is_complete_v0(&report));
3101
3102 report.changes.clear();
3103 assert!(!external_css_adoption_boundary_is_complete_v0(&report));
3104 }
3105
3106 #[test]
3107 fn external_css_diff_does_not_understand_a_prefix_with_different_semantics() {
3108 let report = compare_external_css_semantic_changes_v0(
3109 ".input { appearance: none; }",
3110 ".input { -webkit-appearance: auto; appearance: none; }",
3111 StyleDialect::Css,
3112 );
3113
3114 assert_eq!(report.understood_change_count, 0);
3115 assert_eq!(report.passthrough_change_count, 1);
3116 }
3117
3118 #[test]
3119 fn semantic_preservation_broken_translation_corpus_rejects_known_bad_outputs()
3120 -> Result<(), serde_json::Error> {
3121 let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3122 include_str!("../../fixtures/semantic-preservation/broken-simple.json"),
3123 StyleDialect::Css,
3124 )?;
3125
3126 assert!(report.non_empty_corpus);
3127 assert_eq!(report.fixture_count, 2);
3128 assert_eq!(report.required_rejected_count, 2);
3129 assert_eq!(report.rejected_count, 2);
3130 assert!(report.kill_rate_passed);
3131 Ok(())
3132 }
3133
3134 #[test]
3135 fn semantic_preservation_broken_merge_corpus_rejects_known_bad_outputs()
3136 -> Result<(), serde_json::Error> {
3137 let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3138 include_str!("../../fixtures/semantic-preservation/broken-merge.json"),
3139 StyleDialect::Css,
3140 )?;
3141
3142 assert!(report.non_empty_corpus);
3143 assert_eq!(report.fixture_count, 2);
3144 assert_eq!(report.required_rejected_count, 2);
3145 assert_eq!(report.rejected_count, 2);
3146 assert!(report.kill_rate_passed);
3147 Ok(())
3148 }
3149
3150 #[test]
3151 fn semantic_preservation_broken_shake_corpus_rejects_known_bad_outputs()
3152 -> Result<(), serde_json::Error> {
3153 let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3154 include_str!("../../fixtures/semantic-preservation/broken-shake.json"),
3155 StyleDialect::Css,
3156 )?;
3157
3158 assert!(report.non_empty_corpus);
3159 assert_eq!(report.fixture_count, 8);
3160 assert_eq!(report.required_rejected_count, 8);
3161 assert_eq!(report.rejected_count, 8);
3162 assert!(report.kill_rate_passed);
3163 Ok(())
3164 }
3165
3166 #[test]
3167 fn semantic_preservation_broken_flatten_corpus_rejects_known_bad_outputs()
3168 -> Result<(), serde_json::Error> {
3169 let report = summarize_semantic_preservation_kill_rate_for_fixture_source(
3170 include_str!("../../fixtures/semantic-preservation/broken-flatten.json"),
3171 StyleDialect::Css,
3172 )?;
3173
3174 assert!(report.non_empty_corpus);
3175 assert_eq!(report.fixture_count, 3);
3176 assert_eq!(report.required_rejected_count, 3);
3177 assert_eq!(report.rejected_count, 3);
3178 assert!(report.kill_rate_passed);
3179 Ok(())
3180 }
3181
3182 #[test]
3183 fn semantic_preservation_model_conformance_report_matches_committed_artifact()
3184 -> Result<(), serde_json::Error> {
3185 let actual = summarize_semantic_preservation_model_conformance()?;
3186 let expected = serde_json::from_str::<TransformSemanticModelConformanceReportV0>(
3187 include_str!("../../fixtures/semantic-preservation/model-conformance.json"),
3188 )?;
3189
3190 assert_eq!(actual, expected);
3191 assert!(actual.model_conformance_passed);
3192 assert_eq!(actual.cascade_seed_failed_count, 0);
3193 assert_eq!(actual.ordering_axis_self_check_failed_count, 0);
3194 assert_eq!(actual.semantic_observation_failed_count, 0);
3195 Ok(())
3196 }
3197}