1use omena_parser::{
4 ParsedCssModuleComposesFactKind, ParsedCssModuleValueFactKind, ParsedIcssFactKind,
5 ParsedStyleFacts, StyleDialect, TypedCstNode, expand_nested_selector_from_cst, facts_from_cst,
6 parse_only,
7};
8use omena_syntax::{SyntaxKind, SyntaxNode};
9use serde::Serialize;
10use std::{
11 cell::Cell,
12 collections::{BTreeMap, BTreeSet},
13 sync::OnceLock,
14};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "camelCase")]
18#[non_exhaustive]
19pub struct TransformIrMetadataTelemetryV0 {
20 pub ir_metadata_refresh_count: u64,
21 pub ir_transaction_commit_count: u64,
22 pub ir_materialization_count: u64,
23 pub ir_mutation_count: u64,
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30#[non_exhaustive]
31pub struct TransformIrTransactionCostTelemetryV0 {
32 pub transaction_count: u64,
33 pub input_node_count: u64,
34 pub snapshotted_node_count: u64,
35 pub shifted_global_order_node_count: u64,
36 pub whole_validation_pass_count: u64,
37 pub whole_validation_outer_node_visit_count: u64,
38 pub delta_validation_pass_count: u64,
39 pub delta_validation_node_visit_count: u64,
40}
41
42impl TransformIrTransactionCostTelemetryV0 {
43 pub fn shared_node_fraction(self) -> f64 {
44 if self.input_node_count == 0 {
45 return 1.0;
46 }
47 self.input_node_count
48 .saturating_sub(self.snapshotted_node_count) as f64
49 / self.input_node_count as f64
50 }
51}
52
53impl TransformIrMetadataTelemetryV0 {
54 pub const fn refresh_conservation_holds(self) -> bool {
55 self.ir_metadata_refresh_count
56 == self
57 .ir_transaction_commit_count
58 .saturating_add(self.ir_materialization_count)
59 }
60}
61
62thread_local! {
63 static TRANSFORM_IR_METADATA_TELEMETRY:
64 Cell<TransformIrMetadataTelemetryV0> =
65 const { Cell::new(TransformIrMetadataTelemetryV0 {
66 ir_metadata_refresh_count: 0,
67 ir_transaction_commit_count: 0,
68 ir_materialization_count: 0,
69 ir_mutation_count: 0,
70 }) };
71 static TRANSFORM_IR_TRANSACTION_COST_TELEMETRY:
72 Cell<TransformIrTransactionCostTelemetryV0> =
73 const { Cell::new(TransformIrTransactionCostTelemetryV0 {
74 transaction_count: 0,
75 input_node_count: 0,
76 snapshotted_node_count: 0,
77 shifted_global_order_node_count: 0,
78 whole_validation_pass_count: 0,
79 whole_validation_outer_node_visit_count: 0,
80 delta_validation_pass_count: 0,
81 delta_validation_node_visit_count: 0,
82 }) };
83}
84
85pub fn reset_transform_ir_metadata_telemetry() {
86 TRANSFORM_IR_METADATA_TELEMETRY.with(|telemetry| {
87 telemetry.set(TransformIrMetadataTelemetryV0::default());
88 });
89}
90
91pub fn transform_ir_metadata_telemetry_snapshot() -> TransformIrMetadataTelemetryV0 {
92 TRANSFORM_IR_METADATA_TELEMETRY.with(Cell::get)
93}
94
95pub fn reset_transform_ir_transaction_cost_telemetry() {
96 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(|telemetry| {
97 telemetry.set(TransformIrTransactionCostTelemetryV0::default());
98 });
99}
100
101pub fn transform_ir_transaction_cost_telemetry_snapshot() -> TransformIrTransactionCostTelemetryV0 {
102 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(Cell::get)
103}
104
105fn record_transform_ir_transaction_start(node_count: usize) {
106 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(|telemetry| {
107 let mut snapshot = telemetry.get();
108 snapshot.transaction_count = snapshot.transaction_count.saturating_add(1);
109 snapshot.input_node_count = snapshot.input_node_count.saturating_add(node_count as u64);
110 telemetry.set(snapshot);
111 });
112}
113
114fn record_transform_ir_node_snapshot() {
115 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(|telemetry| {
116 let mut snapshot = telemetry.get();
117 snapshot.snapshotted_node_count = snapshot.snapshotted_node_count.saturating_add(1);
118 telemetry.set(snapshot);
119 });
120}
121
122fn record_transform_ir_global_order_shift() {
123 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(|telemetry| {
124 let mut snapshot = telemetry.get();
125 snapshot.shifted_global_order_node_count =
126 snapshot.shifted_global_order_node_count.saturating_add(1);
127 telemetry.set(snapshot);
128 });
129}
130
131fn record_transform_ir_validation_cost(node_count: usize, changed_node_count: usize) {
132 let (whole_validation_pass_count, delta_validation_pass_count) =
133 transaction_validation_pass_counts();
134 TRANSFORM_IR_TRANSACTION_COST_TELEMETRY.with(|telemetry| {
135 let mut snapshot = telemetry.get();
136 snapshot.whole_validation_pass_count = snapshot
137 .whole_validation_pass_count
138 .saturating_add(whole_validation_pass_count);
139 snapshot.whole_validation_outer_node_visit_count = snapshot
140 .whole_validation_outer_node_visit_count
141 .saturating_add((node_count as u64).saturating_mul(whole_validation_pass_count));
142 snapshot.delta_validation_pass_count = snapshot
143 .delta_validation_pass_count
144 .saturating_add(delta_validation_pass_count);
145 snapshot.delta_validation_node_visit_count =
146 snapshot.delta_validation_node_visit_count.saturating_add(
147 (changed_node_count as u64).saturating_mul(delta_validation_pass_count),
148 );
149 telemetry.set(snapshot);
150 });
151}
152
153fn record_transform_ir_metadata_refresh() {
154 TRANSFORM_IR_METADATA_TELEMETRY.with(|telemetry| {
155 let mut snapshot = telemetry.get();
156 snapshot.ir_metadata_refresh_count = snapshot.ir_metadata_refresh_count.saturating_add(1);
157 telemetry.set(snapshot);
158 });
159}
160
161fn record_transform_ir_transaction_commit() {
162 TRANSFORM_IR_METADATA_TELEMETRY.with(|telemetry| {
163 let mut snapshot = telemetry.get();
164 snapshot.ir_transaction_commit_count =
165 snapshot.ir_transaction_commit_count.saturating_add(1);
166 telemetry.set(snapshot);
167 });
168}
169
170fn record_transform_ir_materialization() {
171 TRANSFORM_IR_METADATA_TELEMETRY.with(|telemetry| {
172 let mut snapshot = telemetry.get();
173 snapshot.ir_materialization_count = snapshot.ir_materialization_count.saturating_add(1);
174 telemetry.set(snapshot);
175 });
176}
177
178fn record_transform_ir_mutation() {
179 TRANSFORM_IR_METADATA_TELEMETRY.with(|telemetry| {
180 let mut snapshot = telemetry.get();
181 snapshot.ir_mutation_count = snapshot.ir_mutation_count.saturating_add(1);
182 telemetry.set(snapshot);
183 });
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
187#[serde(transparent)]
188pub struct IrNodeIdV0(pub usize);
189
190impl IrNodeIdV0 {
191 pub const fn index(self) -> usize {
192 self.0
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum IrTargetV0 {
198 Node {
199 node_id: IrNodeIdV0,
200 observed_epoch: u64,
201 },
202}
203
204impl IrTargetV0 {
205 pub const fn node(node_id: IrNodeIdV0, observed_epoch: u64) -> Self {
206 Self::Node {
207 node_id,
208 observed_epoch,
209 }
210 }
211
212 pub const fn node_id(self) -> IrNodeIdV0 {
213 match self {
214 Self::Node { node_id, .. } => node_id,
215 }
216 }
217
218 pub const fn observed_epoch(self) -> u64 {
219 match self {
220 Self::Node { observed_epoch, .. } => observed_epoch,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
226#[serde(rename_all = "camelCase")]
227pub enum IrNodeKindV0 {
228 StyleRule,
229 AtRule,
230 Declaration,
231 Selector,
232 Value,
233 UrlValue,
234}
235
236impl IrNodeKindV0 {
237 pub const fn as_label(self) -> &'static str {
238 match self {
239 Self::StyleRule => "style-rule",
240 Self::AtRule => "at-rule",
241 Self::Declaration => "declaration",
242 Self::Selector => "selector",
243 Self::Value => "value",
244 Self::UrlValue => "url-value",
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub enum NodeTextOriginV0 {
252 Original {
253 source_id: String,
254 source_span_start: usize,
255 source_span_end: usize,
256 },
257 Synthesized {
258 pass_id: String,
259 parent_node_ids: Vec<IrNodeIdV0>,
260 },
261}
262
263impl NodeTextOriginV0 {
264 pub const fn is_original(&self) -> bool {
265 matches!(self, Self::Original { .. })
266 }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
270pub struct IrBlockSpanV0 {
271 pub prelude_start: usize,
272 pub open_brace_start: usize,
273 pub body_start: usize,
274 pub body_end: usize,
275 pub rule_end: usize,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
279#[serde(rename_all = "camelCase")]
280pub struct IrNodeV0 {
281 pub node_id: IrNodeIdV0,
282 pub kind: IrNodeKindV0,
283 pub parent: Option<IrNodeIdV0>,
284 pub children: Vec<IrNodeIdV0>,
285 pub source_span_start: usize,
286 pub source_span_end: usize,
287 pub origin_index: usize,
288 pub global_order: usize,
289 pub dirty: bool,
290 pub deleted: bool,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub canonical_text: Option<String>,
293 #[serde(skip_serializing)]
294 pub block_span: Option<IrBlockSpanV0>,
295 #[serde(skip_serializing)]
296 pub owner_block_span: Option<IrBlockSpanV0>,
297}
298
299impl IrNodeV0 {
300 pub fn source_span_len(&self) -> usize {
301 self.source_span_end.saturating_sub(self.source_span_start)
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306#[serde(rename_all = "camelCase")]
307pub struct TransformIrKindIndexV0 {
308 pub kind: IrNodeKindV0,
309 pub node_ids: Vec<IrNodeIdV0>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313#[serde(rename_all = "camelCase")]
314pub struct TransformIrParentIndexV0 {
315 pub parent: Option<IrNodeIdV0>,
316 pub node_ids: Vec<IrNodeIdV0>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
320#[serde(rename_all = "camelCase")]
321pub struct TransformIrIndexesV0 {
322 pub by_kind: Vec<TransformIrKindIndexV0>,
323 pub by_parent: Vec<TransformIrParentIndexV0>,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct TransformIrParseErrorSpanV0 {
329 pub source_span_start: usize,
330 pub source_span_end: usize,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
334#[serde(rename_all = "camelCase")]
335pub struct IrEditRegionV0 {
336 pub source_span_start: usize,
337 pub source_span_end: usize,
338}
339
340impl IrEditRegionV0 {
341 pub const fn full(source_byte_len: usize) -> Self {
342 Self {
343 source_span_start: 0,
344 source_span_end: source_byte_len,
345 }
346 }
347
348 pub const fn contains_span(self, source_span_start: usize, source_span_end: usize) -> bool {
349 self.source_span_start <= source_span_start && source_span_end <= self.source_span_end
350 }
351}
352
353#[derive(Debug, Clone, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct TransformIrV0 {
356 pub schema_version: &'static str,
357 pub product: &'static str,
358 pub source_id: String,
359 pub dialect: &'static str,
360 pub source_byte_len: usize,
361 pub parser_error_count: usize,
362 pub parse_error_spans: Vec<TransformIrParseErrorSpanV0>,
363 pub root_nodes: Vec<IrNodeIdV0>,
364 pub nodes: Vec<IrNodeV0>,
365 pub origins: Vec<NodeTextOriginV0>,
366 #[serde(skip_serializing)]
369 pub indexes: OnceLock<TransformIrIndexesV0>,
370 pub original_node_count: usize,
371 pub synthesized_node_count: usize,
372 #[serde(skip_serializing)]
373 ir_epoch: u64,
374 #[serde(skip_serializing)]
375 structural_block_spans: Vec<IrBlockSpanV0>,
376 source_text: String,
377}
378
379impl PartialEq for TransformIrV0 {
380 fn eq(&self, other: &Self) -> bool {
381 self.schema_version == other.schema_version
382 && self.product == other.product
383 && self.source_id == other.source_id
384 && self.dialect == other.dialect
385 && self.source_byte_len == other.source_byte_len
386 && self.parser_error_count == other.parser_error_count
387 && self.parse_error_spans == other.parse_error_spans
388 && self.root_nodes == other.root_nodes
389 && self.nodes == other.nodes
390 && self.origins == other.origins
391 && self.original_node_count == other.original_node_count
392 && self.synthesized_node_count == other.synthesized_node_count
393 && self.ir_epoch == other.ir_epoch
394 && self.structural_block_spans == other.structural_block_spans
395 && self.source_text == other.source_text
396 }
397}
398
399impl Eq for TransformIrV0 {}
400
401impl TransformIrV0 {
402 pub const fn ir_epoch(&self) -> u64 {
403 self.ir_epoch
404 }
405
406 pub fn indexes(&self) -> &TransformIrIndexesV0 {
407 self.indexes.get_or_init(|| build_indexes(&self.nodes))
408 }
409
410 pub fn all_nodes_original(&self) -> bool {
411 self.nodes.iter().all(|node| {
412 !node.dirty
413 && !node.deleted
414 && self
415 .origins
416 .get(node.origin_index)
417 .is_some_and(NodeTextOriginV0::is_original)
418 })
419 }
420
421 pub fn source_text(&self) -> &str {
422 self.source_text.as_str()
423 }
424
425 pub fn structural_block_spans(&self) -> &[IrBlockSpanV0] {
426 self.structural_block_spans.as_slice()
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
431#[serde(rename_all = "camelCase")]
432pub enum TransformIrPrintErrorV0 {
433 MissingNodeOrigin {
434 node_index: usize,
435 },
436 InvalidOriginalSpan {
437 node_index: usize,
438 source_span_start: usize,
439 source_span_end: usize,
440 source_byte_len: usize,
441 },
442 MissingSynthesizedText {
443 node_index: usize,
444 },
445 CannotMaterializeParseErrorSpans {
446 parser_error_count: usize,
447 },
448 MissingRenderedSpan {
449 node_index: usize,
450 },
451 UnprojectableDirtyChild {
452 node_index: usize,
453 child_index: usize,
454 },
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
458#[serde(rename_all = "camelCase")]
459pub struct TransformIrIdentityRoundTripV0 {
460 pub schema_version: &'static str,
461 pub product: &'static str,
462 pub source_id: String,
463 pub dialect: &'static str,
464 pub source_byte_len: usize,
465 pub printed_byte_len: usize,
466 pub node_count: usize,
467 pub original_node_count: usize,
468 pub synthesized_node_count: usize,
469 pub parser_error_count: usize,
470 pub all_nodes_original: bool,
471 pub byte_identical: bool,
472 pub printed_css: String,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
476#[serde(rename_all = "camelCase")]
477pub enum IrTransactionValidationErrorV0 {
478 DanglingNode {
479 node_index: usize,
480 dangling_node_index: usize,
481 },
482 ParentChildLinkMismatch {
483 node_index: usize,
484 parent_index: usize,
485 },
486 DeclarationWithoutRuleOwner {
487 node_index: usize,
488 },
489 DuplicateGlobalOrder {
490 global_order: usize,
491 },
492 MissingProvenance {
493 node_index: usize,
494 origin_index: usize,
495 },
496 EditOutsideDeclaredRegion {
497 node_index: usize,
498 region: IrEditRegionV0,
499 },
500 EditInsideParseErrorRegion {
501 node_index: usize,
502 parse_error_span: TransformIrParseErrorSpanV0,
503 },
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
507#[serde(rename_all = "camelCase")]
508pub enum IrTransactionErrorV0 {
509 UnknownNode {
510 node_index: usize,
511 },
512 InvalidSourceSpan {
513 node_index: usize,
514 source_span_start: usize,
515 source_span_end: usize,
516 },
517 NodeKindMismatch {
518 node_index: usize,
519 expected: IrNodeKindV0,
520 actual: IrNodeKindV0,
521 },
522 Validation(IrTransactionValidationErrorV0),
523}
524
525pub struct IrTransactionV0<'ir> {
526 working: &'ir mut TransformIrV0,
527 pass_id: String,
528 declared_region: IrEditRegionV0,
529 changed_node_ids: Vec<IrNodeIdV0>,
530 original_nodes_len: usize,
531 original_origins_len: usize,
532 original_root_nodes: Option<Vec<IrNodeIdV0>>,
533 original_nodes: BTreeMap<usize, IrNodeV0>,
534 original_global_orders: BTreeMap<usize, usize>,
535 committed: bool,
536}
537
538struct IrSubtreeCopyStateV0<'inserted, 'mapping, 'copied> {
539 inserted_ir: &'inserted TransformIrV0,
540 anchor_id: IrNodeIdV0,
541 canonical_text_overrides: &'inserted [(IrNodeIdV0, String)],
542 node_mapping: &'mapping mut [Option<IrNodeIdV0>],
543 next_global_order: &'mapping mut usize,
544 copied_nodes: &'copied mut Vec<IrNodeIdV0>,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
548struct CandidateNodeV0 {
549 kind: IrNodeKindV0,
550 source_span_start: usize,
551 source_span_end: usize,
552 block_span: Option<IrBlockSpanV0>,
553 owner_block_span: Option<IrBlockSpanV0>,
554}
555
556pub fn lower_transform_ir_from_source(
557 source: &str,
558 dialect: StyleDialect,
559 source_id: impl Into<String>,
560) -> TransformIrV0 {
561 let source_id = source_id.into();
562 let parse = parse_only(source, dialect);
563 let cst = parse.cst();
564 let facts = facts_from_cst(source, &parse);
565 let mut candidates = Vec::new();
566
567 candidates.extend(
568 cst.rules()
569 .into_iter()
570 .map(|node| candidate_from_typed_node(IrNodeKindV0::StyleRule, node)),
571 );
572 candidates.extend(
573 cst.at_rules()
574 .into_iter()
575 .map(|node| candidate_from_typed_node(IrNodeKindV0::AtRule, node)),
576 );
577 candidates.extend(css_module_value_statement_candidates(&facts, cst.root()));
578 candidates.extend(css_module_composes_declaration_candidates(
579 &facts,
580 cst.root(),
581 ));
582 candidates.extend(icss_module_block_candidates(&facts, cst.root()));
583 candidates.extend(
584 cst.declarations()
585 .into_iter()
586 .map(|node| candidate_from_typed_node(IrNodeKindV0::Declaration, node)),
587 );
588 candidates.extend(
589 cst.selectors()
590 .into_iter()
591 .map(|node| candidate_from_typed_node(IrNodeKindV0::Selector, node)),
592 );
593 candidates.extend(
594 cst.values()
595 .into_iter()
596 .map(|node| candidate_from_typed_node(IrNodeKindV0::Value, node)),
597 );
598 candidates.extend(
599 cst.url_values()
600 .into_iter()
601 .map(|node| candidate_from_typed_node(IrNodeKindV0::UrlValue, node)),
602 );
603
604 let block_spans = collect_structural_block_spans(cst.root());
605 for candidate in &mut candidates {
606 assign_candidate_block_spans(candidate, block_spans.as_slice());
607 }
608
609 candidates.sort_by_key(|candidate| {
610 (
611 candidate.source_span_start,
612 std::cmp::Reverse(candidate.source_span_end),
613 kind_order(candidate.kind),
614 )
615 });
616 candidates.dedup();
617
618 let mut nodes = candidates
619 .iter()
620 .enumerate()
621 .map(|(index, candidate)| IrNodeV0 {
622 node_id: IrNodeIdV0(index),
623 kind: candidate.kind,
624 parent: None,
625 children: Vec::new(),
626 source_span_start: candidate.source_span_start,
627 source_span_end: candidate.source_span_end,
628 origin_index: index,
629 global_order: index,
630 dirty: false,
631 deleted: false,
632 canonical_text: None,
633 block_span: candidate.block_span,
634 owner_block_span: candidate.owner_block_span,
635 })
636 .collect::<Vec<_>>();
637
638 let origins = candidates
639 .iter()
640 .map(|candidate| NodeTextOriginV0::Original {
641 source_id: source_id.clone(),
642 source_span_start: candidate.source_span_start,
643 source_span_end: candidate.source_span_end,
644 })
645 .collect::<Vec<_>>();
646
647 assign_parent_links(&mut nodes);
648 let root_nodes = nodes
649 .iter()
650 .filter(|node| node.parent.is_none())
651 .map(|node| node.node_id)
652 .collect::<Vec<_>>();
653 let original_node_count = origins.iter().filter(|origin| origin.is_original()).count();
654
655 TransformIrV0 {
656 schema_version: "0",
657 product: "omena-transform-cst.transform-ir",
658 source_id,
659 dialect: dialect_label(dialect),
660 source_byte_len: source.len(),
661 parser_error_count: parse.errors().len(),
662 parse_error_spans: parse
663 .errors()
664 .iter()
665 .map(|error| TransformIrParseErrorSpanV0 {
666 source_span_start: error.range.start().into(),
667 source_span_end: error.range.end().into(),
668 })
669 .collect(),
670 root_nodes,
671 nodes,
672 origins,
673 indexes: OnceLock::new(),
674 original_node_count,
675 synthesized_node_count: 0,
676 ir_epoch: 0,
677 structural_block_spans: block_spans,
678 source_text: source.to_string(),
679 }
680}
681
682fn css_module_value_statement_candidates(
683 facts: &ParsedStyleFacts,
684 root: &SyntaxNode,
685) -> Vec<CandidateNodeV0> {
686 facts
687 .css_module_values
688 .iter()
689 .filter(|value| {
690 matches!(
691 value.kind,
692 ParsedCssModuleValueFactKind::Definition
693 | ParsedCssModuleValueFactKind::ImportSource
694 )
695 })
696 .filter_map(|value| {
697 let fact_start = value.range.start().into();
698 let fact_end = value.range.end().into();
699 let (source_span_start, source_span_end) =
700 syntax_node_span_containing(root, fact_start, fact_end, |kind| {
701 matches!(
702 kind,
703 SyntaxKind::CssModuleExportBlock | SyntaxKind::CssModuleImportBlock
704 )
705 })?;
706 Some(CandidateNodeV0 {
707 kind: IrNodeKindV0::AtRule,
708 source_span_start,
709 source_span_end,
710 block_span: None,
711 owner_block_span: None,
712 })
713 })
714 .collect()
715}
716
717fn css_module_composes_declaration_candidates(
718 facts: &ParsedStyleFacts,
719 root: &SyntaxNode,
720) -> Vec<CandidateNodeV0> {
721 facts
722 .css_module_composes
723 .iter()
724 .filter(|composes| {
725 matches!(
726 composes.kind,
727 ParsedCssModuleComposesFactKind::Target
728 | ParsedCssModuleComposesFactKind::ImportSource
729 )
730 })
731 .filter_map(|composes| {
732 let fact_start = composes.range.start().into();
733 let fact_end = composes.range.end().into();
734 let (source_span_start, source_span_end) =
735 syntax_node_span_containing(root, fact_start, fact_end, |kind| {
736 kind == SyntaxKind::CssModuleComposesDeclaration
737 })?;
738 Some(CandidateNodeV0 {
739 kind: IrNodeKindV0::Declaration,
740 source_span_start,
741 source_span_end,
742 block_span: None,
743 owner_block_span: None,
744 })
745 })
746 .collect::<BTreeSet<_>>()
747 .into_iter()
748 .collect()
749}
750
751fn icss_module_block_candidates(
752 facts: &ParsedStyleFacts,
753 root: &SyntaxNode,
754) -> Vec<CandidateNodeV0> {
755 facts
756 .icss
757 .iter()
758 .filter(|icss| {
759 matches!(
760 icss.kind,
761 ParsedIcssFactKind::ExportName
762 | ParsedIcssFactKind::ImportLocalName
763 | ParsedIcssFactKind::ImportRemoteName
764 | ParsedIcssFactKind::ImportSource
765 )
766 })
767 .filter_map(|icss| {
768 let fact_start = icss.range.start().into();
769 let fact_end = icss.range.end().into();
770 let (source_span_start, source_span_end) =
771 syntax_node_span_containing(root, fact_start, fact_end, |kind| {
772 matches!(
773 kind,
774 SyntaxKind::CssModuleExportBlock | SyntaxKind::CssModuleImportBlock
775 )
776 })?;
777 Some(CandidateNodeV0 {
778 kind: IrNodeKindV0::StyleRule,
779 source_span_start,
780 source_span_end,
781 block_span: None,
782 owner_block_span: None,
783 })
784 })
785 .collect::<BTreeSet<_>>()
786 .into_iter()
787 .collect()
788}
789
790fn syntax_node_span_containing(
791 root: &SyntaxNode,
792 fact_start: usize,
793 fact_end: usize,
794 accepts_kind: impl Fn(SyntaxKind) -> bool,
795) -> Option<(usize, usize)> {
796 root.descendants()
797 .filter(|node| accepts_kind(node.kind()))
798 .filter_map(|node| {
799 let range = node.text_range();
800 let start = usize::from(range.start());
801 let end = usize::from(range.end());
802 (start <= fact_start && fact_end <= end).then_some((start, end))
803 })
804 .min_by_key(|(start, end)| end.saturating_sub(*start))
805}
806
807pub fn print_transform_ir_css(ir: &TransformIrV0) -> Result<String, TransformIrPrintErrorV0> {
808 validate_node_origins(ir)?;
809 if ir.all_nodes_original() {
810 return Ok(ir.source_text.clone());
811 }
812
813 let mut output = String::new();
814 let mut cursor = 0;
815 for node_id in sorted_root_nodes(ir) {
816 let node = &ir.nodes[node_id.index()];
817 if node.source_span_start > cursor {
818 output.push_str(source_slice(
819 ir,
820 node.node_id.index(),
821 cursor,
822 node.source_span_start,
823 )?);
824 }
825 output.push_str(render_node_css(ir, node.node_id)?.as_str());
826 cursor = cursor.max(node.source_span_end);
827 }
828 if cursor < ir.source_text.len() {
829 output.push_str(source_slice(ir, 0, cursor, ir.source_text.len())?);
830 }
831 Ok(output)
832}
833
834fn materialize_transform_ir_printed_source_without_metadata_refresh(
835 ir: &mut TransformIrV0,
836) -> Result<String, TransformIrPrintErrorV0> {
837 if ir.parser_error_count > 0 && ir.parse_error_spans.is_empty() {
838 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
839 parser_error_count: ir.parser_error_count,
840 });
841 }
842
843 let rendered = render_transform_ir_css_with_node_spans(ir)?;
844 let printed_css = rendered.css;
845 let source_id = ir.source_id.clone();
846 let deleted_subtree_nodes = deleted_subtree_flags(ir);
847 let materialized_spans = ir
848 .nodes
849 .iter()
850 .map(|node| {
851 rendered
852 .node_spans
853 .get(node.node_id.index())
854 .and_then(|span| *span)
855 .or_else(|| deleted_subtree_nodes[node.node_id.index()].then_some((0, 0)))
856 .ok_or(TransformIrPrintErrorV0::MissingRenderedSpan {
857 node_index: node.node_id.index(),
858 })
859 })
860 .collect::<Result<Vec<_>, _>>()?;
861 let remapped_parse_error_spans = remap_parse_error_spans_after_materialization(
862 ir,
863 printed_css.as_str(),
864 materialized_spans.as_slice(),
865 deleted_subtree_nodes.as_slice(),
866 )?;
867 let mut origins = Vec::with_capacity(ir.nodes.len());
868
869 for node in &mut ir.nodes {
870 let (source_span_start, source_span_end) = materialized_spans[node.node_id.index()];
871 let origin_index = origins.len();
872 origins.push(NodeTextOriginV0::Original {
873 source_id: source_id.clone(),
874 source_span_start,
875 source_span_end,
876 });
877 node.source_span_start = source_span_start;
878 node.source_span_end = source_span_end;
879 node.origin_index = origin_index;
880 node.dirty = false;
881 node.deleted = deleted_subtree_nodes[node.node_id.index()];
882 node.canonical_text = None;
883 }
884
885 ir.source_text = printed_css.clone();
886 ir.source_byte_len = ir.source_text.len();
887 ir.origins = origins;
888 ir.parser_error_count = remapped_parse_error_spans.len();
889 ir.parse_error_spans = remapped_parse_error_spans;
890 refresh_ir_block_spans(ir);
891 Ok(printed_css)
892}
893
894pub use sealed_metadata_refresh::materialize_transform_ir_printed_source;
899
900struct RenderedTransformIrCssV0 {
901 css: String,
902 node_spans: Vec<Option<(usize, usize)>>,
903}
904
905fn render_transform_ir_css_with_node_spans(
906 ir: &TransformIrV0,
907) -> Result<RenderedTransformIrCssV0, TransformIrPrintErrorV0> {
908 validate_node_origins(ir)?;
909 let mut css = String::new();
910 let mut node_spans = vec![None; ir.nodes.len()];
911 let mut cursor = 0;
912
913 for node_id in sorted_root_nodes(ir) {
914 let node = &ir.nodes[node_id.index()];
915 if node.source_span_start > cursor {
916 css.push_str(source_slice(
917 ir,
918 node.node_id.index(),
919 cursor,
920 node.source_span_start,
921 )?);
922 }
923 render_node_css_with_spans(ir, node.node_id, &mut css, node_spans.as_mut_slice())?;
924 cursor = cursor.max(node.source_span_end);
925 }
926 if cursor < ir.source_text.len() {
927 css.push_str(source_slice(ir, 0, cursor, ir.source_text.len())?);
928 }
929
930 Ok(RenderedTransformIrCssV0 { css, node_spans })
931}
932
933impl<'ir> IrTransactionV0<'ir> {
934 pub fn new(
935 ir: &'ir mut TransformIrV0,
936 pass_id: impl Into<String>,
937 declared_region: IrEditRegionV0,
938 ) -> Self {
939 record_transform_ir_transaction_start(ir.nodes.len());
940 Self {
941 original_nodes_len: ir.nodes.len(),
942 original_origins_len: ir.origins.len(),
943 working: ir,
944 pass_id: pass_id.into(),
945 declared_region,
946 changed_node_ids: Vec::new(),
947 original_root_nodes: None,
948 original_nodes: BTreeMap::new(),
949 original_global_orders: BTreeMap::new(),
950 committed: false,
951 }
952 }
953
954 fn record_node_before_mutation(&mut self, node_id: IrNodeIdV0) {
955 let node_index = node_id.index();
956 if node_index >= self.original_nodes_len || self.original_nodes.contains_key(&node_index) {
957 return;
958 }
959 let mut original = self.working.nodes[node_index].clone();
960 if let Some(global_order) = self.original_global_orders.get(&node_index) {
961 original.global_order = *global_order;
962 }
963 self.original_nodes.insert(node_index, original);
964 record_transform_ir_node_snapshot();
965 }
966
967 fn record_global_order_before_mutation(&mut self, node_id: IrNodeIdV0) {
968 let node_index = node_id.index();
969 if node_index >= self.original_nodes_len || self.original_nodes.contains_key(&node_index) {
970 return;
971 }
972 self.original_global_orders
973 .entry(node_index)
974 .or_insert(self.working.nodes[node_index].global_order);
975 }
976
977 fn record_root_nodes_before_mutation(&mut self) {
978 if self.original_root_nodes.is_none() {
979 self.original_root_nodes = Some(self.working.root_nodes.clone());
980 }
981 }
982
983 fn rollback(&mut self) {
984 self.working.nodes.truncate(self.original_nodes_len);
985 self.working.origins.truncate(self.original_origins_len);
986 for (node_index, original) in std::mem::take(&mut self.original_nodes) {
987 self.working.nodes[node_index] = original;
988 }
989 for (node_index, global_order) in std::mem::take(&mut self.original_global_orders) {
990 if node_index < self.working.nodes.len() {
991 self.working.nodes[node_index].global_order = global_order;
992 }
993 }
994 if let Some(root_nodes) = self.original_root_nodes.take() {
995 self.working.root_nodes = root_nodes;
996 }
997 self.working.indexes.take();
998 }
999
1000 pub fn replace_node(
1001 &mut self,
1002 node_id: IrNodeIdV0,
1003 canonical_text: impl Into<String>,
1004 ) -> Result<(), IrTransactionErrorV0> {
1005 self.mark_node_synthesized(node_id, canonical_text.into(), false)
1006 }
1007
1008 pub fn replace_node_covering_span(
1009 &mut self,
1010 node_id: IrNodeIdV0,
1011 canonical_text: impl Into<String>,
1012 source_span_start: usize,
1013 source_span_end: usize,
1014 ) -> Result<(), IrTransactionErrorV0> {
1015 self.mark_node_covering_span(
1016 node_id,
1017 canonical_text.into(),
1018 false,
1019 source_span_start,
1020 source_span_end,
1021 )
1022 }
1023
1024 pub fn delete_node(&mut self, node_id: IrNodeIdV0) -> Result<(), IrTransactionErrorV0> {
1025 self.mark_node_synthesized(node_id, String::new(), true)
1026 }
1027
1028 pub fn unwrap_node(&mut self, node_id: IrNodeIdV0) -> Result<(), IrTransactionErrorV0> {
1029 let Some(node) = self.working.nodes.get(node_id.index()).cloned() else {
1030 return Err(IrTransactionErrorV0::UnknownNode {
1031 node_index: node_id.index(),
1032 });
1033 };
1034 let (promoted_children, retained_children): (Vec<_>, Vec<_>) = node
1035 .children
1036 .iter()
1037 .copied()
1038 .partition(|child_id| self.node_should_be_promoted_by_unwrap(node.kind, *child_id));
1039 self.mark_node_synthesized(node_id, String::new(), true)?;
1040 self.working.nodes[node_id.index()].children = retained_children;
1041 for child_id in &promoted_children {
1042 self.record_node_before_mutation(*child_id);
1043 self.working.nodes[child_id.index()].parent = node.parent;
1044 }
1045 self.promote_nodes_after_anchor(node_id, &promoted_children);
1046 Ok(())
1047 }
1048
1049 pub fn insert_before(
1050 &mut self,
1051 anchor_id: IrNodeIdV0,
1052 kind: IrNodeKindV0,
1053 canonical_text: impl Into<String>,
1054 ) -> Result<IrNodeIdV0, IrTransactionErrorV0> {
1055 let Some(anchor) = self.working.nodes.get(anchor_id.index()).cloned() else {
1056 return Err(IrTransactionErrorV0::UnknownNode {
1057 node_index: anchor_id.index(),
1058 });
1059 };
1060 let anchor_order = anchor.global_order;
1061 let shifted_node_ids = self
1062 .working
1063 .nodes
1064 .iter()
1065 .filter(|node| node.global_order >= anchor_order)
1066 .map(|node| node.node_id)
1067 .collect::<Vec<_>>();
1068 for shifted_node_id in shifted_node_ids {
1069 self.record_global_order_before_mutation(shifted_node_id);
1070 self.working.nodes[shifted_node_id.index()].global_order += 1;
1071 record_transform_ir_global_order_shift();
1072 }
1073 let node_id = IrNodeIdV0(self.working.nodes.len());
1074 let origin_index = self.push_synthesized_origin([anchor_id]);
1075 let node = IrNodeV0 {
1076 node_id,
1077 kind,
1078 parent: anchor.parent,
1079 children: Vec::new(),
1080 source_span_start: anchor.source_span_start,
1081 source_span_end: anchor.source_span_start,
1082 origin_index,
1083 global_order: anchor_order,
1084 dirty: true,
1085 deleted: false,
1086 canonical_text: Some(canonical_text.into()),
1087 block_span: None,
1088 owner_block_span: None,
1089 };
1090 self.working.nodes.push(node);
1091 self.insert_node_in_parent(anchor_id, node_id);
1092 self.changed_node_ids.push(node_id);
1093 self.working.indexes.take();
1094 record_transform_ir_mutation();
1095 Ok(node_id)
1096 }
1097
1098 pub fn insert_ir_roots_before(
1099 &mut self,
1100 anchor_id: IrNodeIdV0,
1101 inserted_ir: &TransformIrV0,
1102 ) -> Result<Vec<IrNodeIdV0>, IrTransactionErrorV0> {
1103 let Some(anchor) = self.working.nodes.get(anchor_id.index()).cloned() else {
1104 return Err(IrTransactionErrorV0::UnknownNode {
1105 node_index: anchor_id.index(),
1106 });
1107 };
1108 let root_ids = sorted_root_nodes(inserted_ir)
1109 .into_iter()
1110 .filter(|node_id| !inserted_ir.nodes[node_id.index()].deleted)
1111 .collect::<Vec<_>>();
1112 if root_ids.is_empty() {
1113 return Ok(Vec::new());
1114 }
1115
1116 let insertion_count = root_ids
1117 .iter()
1118 .map(|root_id| active_subtree_node_count(inserted_ir, *root_id))
1119 .sum::<usize>();
1120 let shifted_node_ids = self
1121 .working
1122 .nodes
1123 .iter()
1124 .filter(|node| node.global_order >= anchor.global_order)
1125 .map(|node| node.node_id)
1126 .collect::<Vec<_>>();
1127 for shifted_node_id in shifted_node_ids {
1128 self.record_global_order_before_mutation(shifted_node_id);
1129 self.working.nodes[shifted_node_id.index()].global_order += insertion_count;
1130 record_transform_ir_global_order_shift();
1131 }
1132
1133 let mut copied_roots = Vec::with_capacity(root_ids.len());
1134 let mut copied_nodes = Vec::with_capacity(insertion_count);
1135 let mut node_mapping = vec![None; inserted_ir.nodes.len()];
1136 let mut next_global_order = anchor.global_order;
1137 let canonical_text_overrides =
1138 root_canonical_text_overrides(inserted_ir, root_ids.as_slice())?;
1139 let mut copy_state = IrSubtreeCopyStateV0 {
1140 inserted_ir,
1141 anchor_id,
1142 canonical_text_overrides: canonical_text_overrides.as_slice(),
1143 node_mapping: node_mapping.as_mut_slice(),
1144 next_global_order: &mut next_global_order,
1145 copied_nodes: &mut copied_nodes,
1146 };
1147 for root_id in root_ids {
1148 let copied_root =
1149 self.copy_ir_subtree_before_anchor(&mut copy_state, root_id, anchor.parent)?;
1150 copied_roots.push(copied_root);
1151 }
1152 for copied_root in &copied_roots {
1153 self.insert_node_in_parent(anchor_id, *copied_root);
1154 }
1155 self.changed_node_ids.extend(copied_nodes);
1156 self.working.indexes.take();
1157 record_transform_ir_mutation();
1158 Ok(copied_roots)
1159 }
1160
1161 pub fn rewrite_value(
1162 &mut self,
1163 node_id: IrNodeIdV0,
1164 canonical_text: impl Into<String>,
1165 ) -> Result<(), IrTransactionErrorV0> {
1166 let Some(node) = self.working.nodes.get(node_id.index()) else {
1167 return Err(IrTransactionErrorV0::UnknownNode {
1168 node_index: node_id.index(),
1169 });
1170 };
1171 if node.kind != IrNodeKindV0::Value {
1172 return Err(IrTransactionErrorV0::NodeKindMismatch {
1173 node_index: node_id.index(),
1174 expected: IrNodeKindV0::Value,
1175 actual: node.kind,
1176 });
1177 }
1178 self.mark_node_synthesized(node_id, canonical_text.into(), false)
1179 }
1180
1181 fn mark_node_synthesized(
1182 &mut self,
1183 node_id: IrNodeIdV0,
1184 canonical_text: String,
1185 deleted: bool,
1186 ) -> Result<(), IrTransactionErrorV0> {
1187 if self.working.nodes.get(node_id.index()).is_none() {
1188 return Err(IrTransactionErrorV0::UnknownNode {
1189 node_index: node_id.index(),
1190 });
1191 }
1192 self.record_node_before_mutation(node_id);
1193 let origin_index = self.push_synthesized_origin([node_id]);
1194 let node = &mut self.working.nodes[node_id.index()];
1195 node.origin_index = origin_index;
1196 node.dirty = true;
1197 node.deleted = deleted;
1198 node.canonical_text = Some(canonical_text);
1199 self.changed_node_ids.push(node_id);
1200 self.working.indexes.take();
1201 record_transform_ir_mutation();
1202 Ok(())
1203 }
1204
1205 fn mark_node_covering_span(
1206 &mut self,
1207 node_id: IrNodeIdV0,
1208 canonical_text: String,
1209 deleted: bool,
1210 source_span_start: usize,
1211 source_span_end: usize,
1212 ) -> Result<(), IrTransactionErrorV0> {
1213 let Some(node) = self.working.nodes.get(node_id.index()) else {
1214 return Err(IrTransactionErrorV0::UnknownNode {
1215 node_index: node_id.index(),
1216 });
1217 };
1218 if source_span_start > node.source_span_start
1219 || source_span_end < node.source_span_end
1220 || source_span_start > source_span_end
1221 || source_span_end > self.working.source_text.len()
1222 || !self.working.source_text.is_char_boundary(source_span_start)
1223 || !self.working.source_text.is_char_boundary(source_span_end)
1224 {
1225 return Err(IrTransactionErrorV0::InvalidSourceSpan {
1226 node_index: node_id.index(),
1227 source_span_start,
1228 source_span_end,
1229 });
1230 }
1231 self.mark_node_synthesized(node_id, canonical_text, deleted)?;
1232 let node = &mut self.working.nodes[node_id.index()];
1233 node.source_span_start = source_span_start;
1234 node.source_span_end = source_span_end;
1235 Ok(())
1236 }
1237
1238 fn push_synthesized_origin(
1239 &mut self,
1240 parent_node_ids: impl IntoIterator<Item = IrNodeIdV0>,
1241 ) -> usize {
1242 let origin_index = self.working.origins.len();
1243 self.working.origins.push(NodeTextOriginV0::Synthesized {
1244 pass_id: self.pass_id.clone(),
1245 parent_node_ids: parent_node_ids.into_iter().collect(),
1246 });
1247 origin_index
1248 }
1249
1250 fn copy_ir_subtree_before_anchor(
1251 &mut self,
1252 copy_state: &mut IrSubtreeCopyStateV0<'_, '_, '_>,
1253 source_node_id: IrNodeIdV0,
1254 parent: Option<IrNodeIdV0>,
1255 ) -> Result<IrNodeIdV0, IrTransactionErrorV0> {
1256 if let Some(copied_node_id) = copy_state.node_mapping[source_node_id.index()] {
1257 return Ok(copied_node_id);
1258 }
1259 let source_node = ©_state.inserted_ir.nodes[source_node_id.index()];
1260 let canonical_text =
1261 canonical_text_override_for_node(copy_state.canonical_text_overrides, source_node_id)
1262 .map(str::to_string)
1263 .map(Ok)
1264 .unwrap_or_else(|| {
1265 source_slice(
1266 copy_state.inserted_ir,
1267 source_node.node_id.index(),
1268 source_node.source_span_start,
1269 source_node.source_span_end,
1270 )
1271 .map(str::to_string)
1272 .map_err(|_| IrTransactionErrorV0::InvalidSourceSpan {
1273 node_index: source_node.node_id.index(),
1274 source_span_start: source_node.source_span_start,
1275 source_span_end: source_node.source_span_end,
1276 })
1277 })?;
1278 let anchor_source_span_start =
1279 self.working.nodes[copy_state.anchor_id.index()].source_span_start;
1280 let node_id = IrNodeIdV0(self.working.nodes.len());
1281 let origin_index = self.push_synthesized_origin([copy_state.anchor_id]);
1282 let global_order = *copy_state.next_global_order;
1283 *copy_state.next_global_order += 1;
1284 self.working.nodes.push(IrNodeV0 {
1285 node_id,
1286 kind: source_node.kind,
1287 parent,
1288 children: Vec::new(),
1289 source_span_start: anchor_source_span_start,
1290 source_span_end: anchor_source_span_start,
1291 origin_index,
1292 global_order,
1293 dirty: true,
1294 deleted: false,
1295 canonical_text: Some(canonical_text),
1296 block_span: None,
1297 owner_block_span: None,
1298 });
1299 copy_state.node_mapping[source_node_id.index()] = Some(node_id);
1300 copy_state.copied_nodes.push(node_id);
1301
1302 let copied_children = sorted_child_nodes(copy_state.inserted_ir, source_node)
1303 .into_iter()
1304 .filter(|child_id| !copy_state.inserted_ir.nodes[child_id.index()].deleted)
1305 .map(|child_id| self.copy_ir_subtree_before_anchor(copy_state, child_id, Some(node_id)))
1306 .collect::<Result<Vec<_>, _>>()?;
1307 self.working.nodes[node_id.index()].children = copied_children;
1308 Ok(node_id)
1309 }
1310
1311 fn insert_node_in_parent(&mut self, anchor_id: IrNodeIdV0, node_id: IrNodeIdV0) {
1312 let parent = self.working.nodes[node_id.index()].parent;
1313 match parent {
1314 Some(parent_id) => {
1315 self.record_node_before_mutation(parent_id);
1316 insert_before_in_list(
1317 &mut self.working.nodes[parent_id.index()].children,
1318 anchor_id,
1319 node_id,
1320 )
1321 }
1322 None => {
1323 self.record_root_nodes_before_mutation();
1324 insert_before_in_list(&mut self.working.root_nodes, anchor_id, node_id)
1325 }
1326 }
1327 }
1328
1329 fn promote_nodes_after_anchor(&mut self, anchor_id: IrNodeIdV0, node_ids: &[IrNodeIdV0]) {
1330 let parent = self.working.nodes[anchor_id.index()].parent;
1331 match parent {
1332 Some(parent_id) => self.record_node_before_mutation(parent_id),
1333 None => self.record_root_nodes_before_mutation(),
1334 }
1335 let list = match parent {
1336 Some(parent_id) => &mut self.working.nodes[parent_id.index()].children,
1337 None => &mut self.working.root_nodes,
1338 };
1339 let mut insert_index = list
1340 .iter()
1341 .position(|candidate| *candidate == anchor_id)
1342 .map_or(list.len(), |index| index + 1);
1343 for node_id in node_ids {
1344 if list.contains(node_id) {
1345 continue;
1346 }
1347 list.insert(insert_index, *node_id);
1348 insert_index += 1;
1349 }
1350 }
1351
1352 fn node_should_be_promoted_by_unwrap(
1353 &self,
1354 wrapper_kind: IrNodeKindV0,
1355 child_id: IrNodeIdV0,
1356 ) -> bool {
1357 wrapper_kind != IrNodeKindV0::StyleRule
1358 || self.working.nodes[child_id.index()].kind != IrNodeKindV0::Selector
1359 }
1360}
1361
1362impl Drop for IrTransactionV0<'_> {
1363 fn drop(&mut self) {
1364 if !self.committed {
1365 self.rollback();
1366 }
1367 }
1368}
1369
1370pub fn summarize_transform_ir_identity_round_trip(
1371 source: &str,
1372 dialect: StyleDialect,
1373 source_id: impl Into<String>,
1374) -> Result<TransformIrIdentityRoundTripV0, TransformIrPrintErrorV0> {
1375 let source_id = source_id.into();
1376 let ir = lower_transform_ir_from_source(source, dialect, source_id.clone());
1377 let printed_css = print_transform_ir_css(&ir)?;
1378 Ok(TransformIrIdentityRoundTripV0 {
1379 schema_version: "0",
1380 product: "omena-transform-cst.transform-ir-identity-round-trip",
1381 source_id,
1382 dialect: dialect_label(dialect),
1383 source_byte_len: source.len(),
1384 printed_byte_len: printed_css.len(),
1385 node_count: ir.nodes.len(),
1386 original_node_count: ir.original_node_count,
1387 synthesized_node_count: ir.synthesized_node_count,
1388 parser_error_count: ir.parser_error_count,
1389 all_nodes_original: ir.all_nodes_original(),
1390 byte_identical: printed_css == source,
1391 printed_css,
1392 })
1393}
1394
1395fn candidate_from_typed_node<T: TypedCstNode>(kind: IrNodeKindV0, node: T) -> CandidateNodeV0 {
1396 let range = node.text_range();
1397 CandidateNodeV0 {
1398 kind,
1399 source_span_start: range.start().into(),
1400 source_span_end: range.end().into(),
1401 block_span: None,
1402 owner_block_span: None,
1403 }
1404}
1405
1406fn collect_structural_block_spans(root: &SyntaxNode) -> Vec<IrBlockSpanV0> {
1407 let mut spans = root
1408 .descendants()
1409 .filter(|node| syntax_kind_can_own_block(node.kind()))
1410 .filter_map(structural_block_span)
1411 .collect::<Vec<_>>();
1412 spans.sort_unstable();
1413 spans.dedup();
1414 spans
1415}
1416
1417fn structural_block_span(node: &SyntaxNode) -> Option<IrBlockSpanV0> {
1418 let open = node
1419 .descendants_with_tokens()
1420 .filter_map(|element| element.into_token())
1421 .find(|token| token.kind() == SyntaxKind::LeftBrace)?
1422 .text_range();
1423 let close = node
1424 .descendants_with_tokens()
1425 .filter_map(|element| element.into_token())
1426 .filter(|token| token.kind() == SyntaxKind::RightBrace)
1427 .last()?
1428 .text_range();
1429 let prelude_start = node.text_range().start().into();
1430 let open_brace_start = open.start().into();
1431 let body_start = open.end().into();
1432 let body_end = close.start().into();
1433 let rule_end = close.end().into();
1434 (prelude_start <= open_brace_start && body_start <= body_end).then_some(IrBlockSpanV0 {
1435 prelude_start,
1436 open_brace_start,
1437 body_start,
1438 body_end,
1439 rule_end,
1440 })
1441}
1442
1443fn syntax_kind_can_own_block(kind: SyntaxKind) -> bool {
1444 omena_parser::is_at_rule_node_kind(kind)
1445 || matches!(
1446 kind,
1447 SyntaxKind::Rule
1448 | SyntaxKind::KeyframeBlock
1449 | SyntaxKind::CssModuleExportBlock
1450 | SyntaxKind::CssModuleImportBlock
1451 | SyntaxKind::ScssControlIf
1452 | SyntaxKind::ScssControlElse
1453 | SyntaxKind::ScssControlEach
1454 | SyntaxKind::ScssControlFor
1455 | SyntaxKind::ScssControlWhile
1456 | SyntaxKind::LessMixinDeclaration
1457 | SyntaxKind::LessDetachedRulesetNode
1458 )
1459}
1460
1461fn assign_candidate_block_spans(candidate: &mut CandidateNodeV0, spans: &[IrBlockSpanV0]) {
1462 if candidate.source_span_start == candidate.source_span_end {
1463 return;
1464 }
1465 candidate.block_span = block_span_for_range(
1466 candidate.source_span_start,
1467 candidate.source_span_end,
1468 spans,
1469 );
1470 candidate.owner_block_span = owner_block_span_for_range(
1471 candidate.source_span_start,
1472 candidate.source_span_end,
1473 spans,
1474 );
1475}
1476
1477fn block_span_for_range(
1478 source_span_start: usize,
1479 source_span_end: usize,
1480 spans: &[IrBlockSpanV0],
1481) -> Option<IrBlockSpanV0> {
1482 spans
1483 .iter()
1484 .copied()
1485 .find(|span| span.prelude_start == source_span_start && span.rule_end == source_span_end)
1486 .or_else(|| {
1487 spans
1488 .iter()
1489 .copied()
1490 .filter(|span| {
1491 source_span_start <= span.prelude_start && span.rule_end <= source_span_end
1492 })
1493 .min_by_key(|span| span.rule_end.saturating_sub(span.prelude_start))
1494 })
1495}
1496
1497fn owner_block_span_for_range(
1498 source_span_start: usize,
1499 source_span_end: usize,
1500 spans: &[IrBlockSpanV0],
1501) -> Option<IrBlockSpanV0> {
1502 spans
1503 .iter()
1504 .copied()
1505 .filter(|span| span.body_start <= source_span_start && source_span_end <= span.body_end)
1506 .min_by_key(|span| span.body_end.saturating_sub(span.body_start))
1507}
1508
1509pub fn structural_block_spans_for_source(
1510 source: &str,
1511 dialect: StyleDialect,
1512) -> Vec<IrBlockSpanV0> {
1513 let parsed = parse_only(source, dialect);
1514 collect_structural_block_spans(parsed.cst().root())
1515}
1516
1517fn refresh_ir_block_spans(ir: &mut TransformIrV0) {
1518 let Some(dialect) = dialect_from_label(ir.dialect) else {
1519 return;
1520 };
1521 let spans = structural_block_spans_for_source(ir.source_text.as_str(), dialect);
1522 for node in &mut ir.nodes {
1523 if node.deleted || node.source_span_start == node.source_span_end {
1524 node.block_span = None;
1525 node.owner_block_span = None;
1526 continue;
1527 }
1528 node.block_span = block_span_for_range(
1529 node.source_span_start,
1530 node.source_span_end,
1531 spans.as_slice(),
1532 );
1533 node.owner_block_span = owner_block_span_for_range(
1534 node.source_span_start,
1535 node.source_span_end,
1536 spans.as_slice(),
1537 );
1538 }
1539 ir.structural_block_spans = spans;
1540}
1541
1542fn dialect_from_label(label: &str) -> Option<StyleDialect> {
1543 match label {
1544 "css" => Some(StyleDialect::Css),
1545 "scss" => Some(StyleDialect::Scss),
1546 "sass" => Some(StyleDialect::Sass),
1547 "less" => Some(StyleDialect::Less),
1548 _ => None,
1549 }
1550}
1551
1552fn block_spans_for_ir_text(ir: &TransformIrV0, source: &str) -> Vec<IrBlockSpanV0> {
1553 dialect_from_label(ir.dialect)
1554 .map(|dialect| structural_block_spans_for_source(source, dialect))
1555 .unwrap_or_default()
1556}
1557
1558fn trimmed_prelude_span(source: &str, span: IrBlockSpanV0) -> Option<(usize, usize)> {
1559 let prelude = source.get(span.prelude_start..span.open_brace_start)?;
1560 let start = span.prelude_start + prelude.len().saturating_sub(prelude.trim_start().len());
1561 let end = span
1562 .open_brace_start
1563 .saturating_sub(prelude.len().saturating_sub(prelude.trim_end().len()));
1564 (start < end).then_some((start, end))
1565}
1566
1567fn block_span_for_rendered_start(
1568 source: &str,
1569 spans: &[IrBlockSpanV0],
1570 start: usize,
1571) -> Option<IrBlockSpanV0> {
1572 spans.iter().copied().find(|span| {
1573 trimmed_prelude_span(source, *span).is_some_and(|(prelude_start, _)| prelude_start == start)
1574 })
1575}
1576
1577const fn dialect_label(dialect: StyleDialect) -> &'static str {
1578 match dialect {
1579 StyleDialect::Css => "css",
1580 StyleDialect::Scss => "scss",
1581 StyleDialect::Sass => "sass",
1582 StyleDialect::Less => "less",
1583 }
1584}
1585
1586fn assign_parent_links(nodes: &mut [IrNodeV0]) {
1587 let mut open_ranges = Vec::<usize>::new();
1594 let mut parents = vec![None; nodes.len()];
1595 for index in 0..nodes.len() {
1596 while open_ranges.last().is_some_and(|candidate_index| {
1597 let candidate = &nodes[*candidate_index];
1598 let node = &nodes[index];
1599 candidate.source_span_start > node.source_span_start
1600 || candidate.source_span_end < node.source_span_end
1601 }) {
1602 open_ranges.pop();
1603 }
1604
1605 let same_range_as_top = open_ranges.last().is_some_and(|candidate_index| {
1606 let candidate = &nodes[*candidate_index];
1607 let node = &nodes[index];
1608 candidate.source_span_start == node.source_span_start
1609 && candidate.source_span_end == node.source_span_end
1610 });
1611 parents[index] = if same_range_as_top {
1612 open_ranges.iter().rev().nth(1).copied()
1613 } else {
1614 open_ranges.last().copied()
1615 };
1616 if !same_range_as_top {
1617 open_ranges.push(index);
1618 }
1619 }
1620
1621 for (index, parent) in parents.into_iter().enumerate() {
1622 nodes[index].parent = parent.map(IrNodeIdV0);
1623 }
1624 for index in 0..nodes.len() {
1625 if let Some(parent) = nodes[index].parent {
1626 nodes[parent.index()].children.push(IrNodeIdV0(index));
1627 }
1628 }
1629}
1630
1631mod sealed_metadata_refresh {
1632 use super::*;
1633
1634 fn refresh_transform_ir_metadata(ir: &mut TransformIrV0) {
1635 record_transform_ir_metadata_refresh();
1636 ir.indexes.take();
1637 ir.original_node_count = ir
1638 .nodes
1639 .iter()
1640 .filter(|node| {
1641 !node.deleted
1642 && ir
1643 .origins
1644 .get(node.origin_index)
1645 .is_some_and(NodeTextOriginV0::is_original)
1646 })
1647 .count();
1648 ir.synthesized_node_count = ir
1649 .nodes
1650 .iter()
1651 .filter(|node| {
1652 !node.deleted
1653 && ir
1654 .origins
1655 .get(node.origin_index)
1656 .is_some_and(|origin| !origin.is_original())
1657 })
1658 .count();
1659 }
1660
1661 pub fn materialize_transform_ir_printed_source(
1662 ir: &mut TransformIrV0,
1663 ) -> Result<String, TransformIrPrintErrorV0> {
1664 let printed = materialize_transform_ir_printed_source_without_metadata_refresh(ir)?;
1665 record_transform_ir_materialization();
1666 refresh_transform_ir_metadata(ir);
1667 Ok(printed)
1668 }
1669
1670 impl IrTransactionV0<'_> {
1671 pub fn commit(mut self) -> Result<(), IrTransactionErrorV0> {
1672 record_transform_ir_validation_cost(
1673 self.working.nodes.len(),
1674 self.changed_node_ids.len(),
1675 );
1676 validate_transaction_commit(self.working, &self.changed_node_ids, self.declared_region)
1677 .map_err(IrTransactionErrorV0::Validation)?;
1678 record_transform_ir_transaction_commit();
1679 refresh_transform_ir_metadata(self.working);
1680 self.working.ir_epoch = self.working.ir_epoch.saturating_add(1);
1681 self.committed = true;
1682 Ok(())
1683 }
1684 }
1685
1686 #[cfg(test)]
1687 mod tests {
1688 use super::*;
1689
1690 #[test]
1691 fn child_module_control_can_call_the_sealed_refresh() {
1692 let mut ir = lower_transform_ir_from_source(
1693 ".control { color: red; }",
1694 StyleDialect::Css,
1695 "control.css",
1696 );
1697 refresh_transform_ir_metadata(&mut ir);
1698 assert_eq!(
1699 ir.original_node_count + ir.synthesized_node_count,
1700 ir.nodes.len()
1701 );
1702 }
1703
1704 #[test]
1705 fn metadata_refreshes_are_conserved_across_commit_and_materialization() -> Result<(), String>
1706 {
1707 let mut ir = lower_transform_ir_from_source(
1708 ".control { color: red; }",
1709 StyleDialect::Css,
1710 "control.css",
1711 );
1712 reset_transform_ir_metadata_telemetry();
1713 let value = ir
1714 .nodes
1715 .iter()
1716 .find(|node| node.kind == IrNodeKindV0::Value)
1717 .map(|node| node.node_id)
1718 .ok_or_else(|| "fixture must contain a value".to_string())?;
1719 let source_byte_len = ir.source_byte_len;
1720 let mut transaction =
1721 IrTransactionV0::new(&mut ir, "control", IrEditRegionV0::full(source_byte_len));
1722 transaction
1723 .rewrite_value(value, "blue")
1724 .map_err(|error| format!("fixture mutation must succeed: {error:?}"))?;
1725 transaction
1726 .commit()
1727 .map_err(|error| format!("fixture commit must succeed: {error:?}"))?;
1728
1729 assert_eq!(
1730 transform_ir_metadata_telemetry_snapshot(),
1731 TransformIrMetadataTelemetryV0 {
1732 ir_metadata_refresh_count: 1,
1733 ir_transaction_commit_count: 1,
1734 ir_materialization_count: 0,
1735 ir_mutation_count: 1,
1736 }
1737 );
1738
1739 materialize_transform_ir_printed_source(&mut ir)
1740 .map_err(|error| format!("fixture materialization must succeed: {error:?}"))?;
1741 let telemetry = transform_ir_metadata_telemetry_snapshot();
1742 assert_eq!(
1743 telemetry,
1744 TransformIrMetadataTelemetryV0 {
1745 ir_metadata_refresh_count: 2,
1746 ir_transaction_commit_count: 1,
1747 ir_materialization_count: 1,
1748 ir_mutation_count: 1,
1749 }
1750 );
1751 assert!(telemetry.refresh_conservation_holds());
1752 Ok(())
1753 }
1754 }
1755}
1756
1757#[derive(Clone, Copy)]
1758enum TransactionValidationPassKindV0 {
1759 Whole(fn(&TransformIrV0) -> Result<(), IrTransactionValidationErrorV0>),
1760 ChangedWithRegion(
1761 fn(
1762 &TransformIrV0,
1763 &[IrNodeIdV0],
1764 IrEditRegionV0,
1765 ) -> Result<(), IrTransactionValidationErrorV0>,
1766 ),
1767 Changed(fn(&TransformIrV0, &[IrNodeIdV0]) -> Result<(), IrTransactionValidationErrorV0>),
1768}
1769
1770#[derive(Clone, Copy)]
1771struct TransactionValidationPassV0 {
1772 name: &'static str,
1773 kind: TransactionValidationPassKindV0,
1774}
1775
1776const TRANSACTION_VALIDATION_PASSES_V0: &[TransactionValidationPassV0] = &[
1777 TransactionValidationPassV0 {
1778 name: "no-dangling-nodes",
1779 kind: TransactionValidationPassKindV0::Whole(validate_no_dangling_nodes),
1780 },
1781 TransactionValidationPassV0 {
1782 name: "parent-child-links",
1783 kind: TransactionValidationPassKindV0::Whole(validate_parent_child_links),
1784 },
1785 TransactionValidationPassV0 {
1786 name: "declaration-ownership",
1787 kind: TransactionValidationPassKindV0::Whole(validate_declaration_ownership),
1788 },
1789 TransactionValidationPassV0 {
1790 name: "global-order-slots",
1791 kind: TransactionValidationPassKindV0::Whole(validate_global_order_slots),
1792 },
1793 TransactionValidationPassV0 {
1794 name: "provenance",
1795 kind: TransactionValidationPassKindV0::Whole(validate_provenance),
1796 },
1797 TransactionValidationPassV0 {
1798 name: "changed-nodes-inside-region",
1799 kind: TransactionValidationPassKindV0::ChangedWithRegion(
1800 validate_changed_nodes_inside_region,
1801 ),
1802 },
1803 TransactionValidationPassV0 {
1804 name: "changed-nodes-outside-parse-errors",
1805 kind: TransactionValidationPassKindV0::Changed(validate_changed_nodes_outside_parse_errors),
1806 },
1807];
1808
1809fn transaction_validation_pass_counts() -> (u64, u64) {
1810 TRANSACTION_VALIDATION_PASSES_V0
1811 .iter()
1812 .fold((0_u64, 0_u64), |(whole, delta), pass| match pass.kind {
1813 TransactionValidationPassKindV0::Whole(_) => (whole.saturating_add(1), delta),
1814 TransactionValidationPassKindV0::ChangedWithRegion(_)
1815 | TransactionValidationPassKindV0::Changed(_) => (whole, delta.saturating_add(1)),
1816 })
1817}
1818
1819fn validate_transaction_commit(
1820 ir: &TransformIrV0,
1821 changed_node_ids: &[IrNodeIdV0],
1822 declared_region: IrEditRegionV0,
1823) -> Result<(), IrTransactionValidationErrorV0> {
1824 for pass in TRANSACTION_VALIDATION_PASSES_V0 {
1825 debug_assert!(!pass.name.is_empty());
1826 match pass.kind {
1827 TransactionValidationPassKindV0::Whole(validate) => validate(ir)?,
1828 TransactionValidationPassKindV0::ChangedWithRegion(validate) => {
1829 validate(ir, changed_node_ids, declared_region)?
1830 }
1831 TransactionValidationPassKindV0::Changed(validate) => validate(ir, changed_node_ids)?,
1832 }
1833 }
1834 Ok(())
1835}
1836
1837fn validate_no_dangling_nodes(ir: &TransformIrV0) -> Result<(), IrTransactionValidationErrorV0> {
1838 for node in &ir.nodes {
1839 if let Some(parent) = node.parent
1840 && parent.index() >= ir.nodes.len()
1841 {
1842 return Err(IrTransactionValidationErrorV0::DanglingNode {
1843 node_index: node.node_id.index(),
1844 dangling_node_index: parent.index(),
1845 });
1846 }
1847 for child in &node.children {
1848 if child.index() >= ir.nodes.len() {
1849 return Err(IrTransactionValidationErrorV0::DanglingNode {
1850 node_index: node.node_id.index(),
1851 dangling_node_index: child.index(),
1852 });
1853 }
1854 }
1855 }
1856 Ok(())
1857}
1858
1859fn validate_parent_child_links(ir: &TransformIrV0) -> Result<(), IrTransactionValidationErrorV0> {
1860 for node in &ir.nodes {
1861 if let Some(parent) = node.parent {
1862 let parent_node = &ir.nodes[parent.index()];
1863 if parent == node.node_id || !parent_node.children.contains(&node.node_id) {
1864 return Err(IrTransactionValidationErrorV0::ParentChildLinkMismatch {
1865 node_index: node.node_id.index(),
1866 parent_index: parent.index(),
1867 });
1868 }
1869 }
1870 for child in &node.children {
1871 if ir.nodes[child.index()].parent != Some(node.node_id) {
1872 return Err(IrTransactionValidationErrorV0::ParentChildLinkMismatch {
1873 node_index: child.index(),
1874 parent_index: node.node_id.index(),
1875 });
1876 }
1877 }
1878 }
1879 Ok(())
1880}
1881
1882fn validate_declaration_ownership(
1883 ir: &TransformIrV0,
1884) -> Result<(), IrTransactionValidationErrorV0> {
1885 for node in &ir.nodes {
1886 if node.deleted || node.kind != IrNodeKindV0::Declaration {
1887 continue;
1888 }
1889 if !has_rule_owner(ir, node)
1890 && !has_icss_root_declaration_owner(ir, node)
1891 && !has_less_mixin_declaration_owner(ir, node)
1892 {
1893 return Err(
1894 IrTransactionValidationErrorV0::DeclarationWithoutRuleOwner {
1895 node_index: node.node_id.index(),
1896 },
1897 );
1898 }
1899 }
1900 Ok(())
1901}
1902
1903fn validate_global_order_slots(ir: &TransformIrV0) -> Result<(), IrTransactionValidationErrorV0> {
1904 let mut seen = BTreeSet::new();
1905 for node in ir.nodes.iter().filter(|node| !node.deleted) {
1906 if !seen.insert(node.global_order) {
1907 return Err(IrTransactionValidationErrorV0::DuplicateGlobalOrder {
1908 global_order: node.global_order,
1909 });
1910 }
1911 }
1912 Ok(())
1913}
1914
1915fn validate_provenance(ir: &TransformIrV0) -> Result<(), IrTransactionValidationErrorV0> {
1916 for node in ir.nodes.iter().filter(|node| !node.deleted) {
1917 let Some(origin) = ir.origins.get(node.origin_index) else {
1918 return Err(IrTransactionValidationErrorV0::MissingProvenance {
1919 node_index: node.node_id.index(),
1920 origin_index: node.origin_index,
1921 });
1922 };
1923 match origin {
1924 NodeTextOriginV0::Original {
1925 source_span_start,
1926 source_span_end,
1927 ..
1928 } => {
1929 if source_slice(
1930 ir,
1931 node.node_id.index(),
1932 *source_span_start,
1933 *source_span_end,
1934 )
1935 .is_err()
1936 {
1937 return Err(IrTransactionValidationErrorV0::MissingProvenance {
1938 node_index: node.node_id.index(),
1939 origin_index: node.origin_index,
1940 });
1941 }
1942 }
1943 NodeTextOriginV0::Synthesized { .. } => {
1944 if node.canonical_text.is_none() {
1945 return Err(IrTransactionValidationErrorV0::MissingProvenance {
1946 node_index: node.node_id.index(),
1947 origin_index: node.origin_index,
1948 });
1949 }
1950 }
1951 }
1952 }
1953 Ok(())
1954}
1955
1956fn validate_changed_nodes_inside_region(
1957 ir: &TransformIrV0,
1958 changed_node_ids: &[IrNodeIdV0],
1959 declared_region: IrEditRegionV0,
1960) -> Result<(), IrTransactionValidationErrorV0> {
1961 for node_id in changed_node_ids {
1962 let node = &ir.nodes[node_id.index()];
1963 if !declared_region.contains_span(node.source_span_start, node.source_span_end) {
1964 return Err(IrTransactionValidationErrorV0::EditOutsideDeclaredRegion {
1965 node_index: node.node_id.index(),
1966 region: declared_region,
1967 });
1968 }
1969 }
1970 Ok(())
1971}
1972
1973fn validate_changed_nodes_outside_parse_errors(
1974 ir: &TransformIrV0,
1975 changed_node_ids: &[IrNodeIdV0],
1976) -> Result<(), IrTransactionValidationErrorV0> {
1977 for node_id in changed_node_ids {
1978 let node = &ir.nodes[node_id.index()];
1979 if let Some(parse_error_span) = ir.parse_error_spans.iter().copied().find(|span| {
1980 spans_overlap(
1981 node.source_span_start,
1982 node.source_span_end,
1983 span.source_span_start,
1984 span.source_span_end,
1985 ) && !changed_node_preserves_parse_error_source(ir, node, *span)
1986 }) {
1987 return Err(IrTransactionValidationErrorV0::EditInsideParseErrorRegion {
1988 node_index: node.node_id.index(),
1989 parse_error_span,
1990 });
1991 }
1992 }
1993 Ok(())
1994}
1995
1996fn changed_node_preserves_parse_error_source(
1997 ir: &TransformIrV0,
1998 node: &IrNodeV0,
1999 parse_error_span: TransformIrParseErrorSpanV0,
2000) -> bool {
2001 if changed_node_deletes_structural_parse_error_region(node, parse_error_span) {
2002 return true;
2003 }
2004 if node.deleted
2005 || parse_error_span.source_span_start < node.source_span_start
2006 || parse_error_span.source_span_end > node.source_span_end
2007 {
2008 return false;
2009 }
2010
2011 let Some(canonical_text) = node.canonical_text.as_deref() else {
2012 return false;
2013 };
2014 let Some((context_start, context_end)) = parse_error_context_span(ir, node, parse_error_span)
2015 else {
2016 return false;
2017 };
2018 let Ok(parse_error_source) = source_slice(ir, node.node_id.index(), context_start, context_end)
2019 else {
2020 return false;
2021 };
2022
2023 !parse_error_source.is_empty() && canonical_text.contains(parse_error_source)
2024}
2025
2026fn changed_node_deletes_structural_parse_error_region(
2027 node: &IrNodeV0,
2028 parse_error_span: TransformIrParseErrorSpanV0,
2029) -> bool {
2030 node.deleted
2031 && node.canonical_text.as_deref().is_some_and(str::is_empty)
2032 && matches!(
2033 node.kind,
2034 IrNodeKindV0::StyleRule | IrNodeKindV0::AtRule | IrNodeKindV0::Declaration
2035 )
2036 && node.source_span_start <= parse_error_span.source_span_start
2037 && parse_error_span.source_span_end <= node.source_span_end
2038}
2039
2040fn parse_error_context_span(
2041 ir: &TransformIrV0,
2042 node: &IrNodeV0,
2043 parse_error_span: TransformIrParseErrorSpanV0,
2044) -> Option<(usize, usize)> {
2045 if parse_error_span.source_span_start > parse_error_span.source_span_end
2046 || parse_error_span.source_span_end > ir.source_text.len()
2047 {
2048 return None;
2049 }
2050 let bytes = ir.source_text.as_bytes();
2051 let mut start = parse_error_span.source_span_start;
2052 let mut end = parse_error_span.source_span_end;
2053
2054 while start > node.source_span_start && is_parse_error_context_byte(bytes[start - 1]) {
2055 start -= 1;
2056 }
2057 while end < node.source_span_end && is_parse_error_context_byte(bytes[end]) {
2058 end += 1;
2059 }
2060
2061 (start < end).then_some((start, end))
2062}
2063
2064const fn is_parse_error_context_byte(byte: u8) -> bool {
2065 byte.is_ascii_alphanumeric()
2066 || matches!(
2067 byte,
2068 b'-' | b'_' | b'.' | b'$' | b'#' | b'%' | b'@' | b'/' | b'\\'
2069 )
2070}
2071
2072fn has_rule_owner(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2073 let mut parent = node.parent;
2074 while let Some(parent_id) = parent {
2075 let parent_node = &ir.nodes[parent_id.index()];
2076 if matches!(
2077 parent_node.kind,
2078 IrNodeKindV0::StyleRule | IrNodeKindV0::AtRule | IrNodeKindV0::Selector
2079 ) {
2080 return true;
2081 }
2082 parent = parent_node.parent;
2083 }
2084 false
2085}
2086
2087fn has_icss_root_declaration_owner(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2088 if node.parent.is_some() {
2089 return false;
2090 }
2091 root_declaration_owner_prelude(ir, node)
2092 .is_some_and(|prelude| prelude == ":export" || prelude.starts_with(":import("))
2093}
2094
2095fn has_less_mixin_declaration_owner(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2096 ir.dialect == "less"
2097 && node.parent.is_none()
2098 && root_declaration_owner_prelude(ir, node).is_some_and(less_prelude_is_callable_mixin)
2099}
2100
2101fn root_declaration_owner_prelude<'source>(
2102 ir: &'source TransformIrV0,
2103 node: &IrNodeV0,
2104) -> Option<&'source str> {
2105 let span = node.owner_block_span?;
2106 ir.source_text
2107 .get(span.prelude_start..span.open_brace_start)
2108 .map(str::trim)
2109}
2110
2111fn less_prelude_is_callable_mixin(prelude: &str) -> bool {
2112 let bytes = prelude.as_bytes();
2113 let mut index = 0;
2114 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2115 index += 1;
2116 }
2117 if index >= bytes.len() || !matches!(bytes[index], b'.' | b'#') {
2118 return false;
2119 }
2120 index += 1;
2121 if index >= bytes.len() {
2122 return false;
2123 }
2124 while index < bytes.len() {
2125 match bytes[index] {
2126 byte if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') => {
2127 index += 1;
2128 }
2129 b'\\' => {
2130 index = index.saturating_add(2);
2131 }
2132 _ => break,
2133 }
2134 }
2135 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2136 index += 1;
2137 }
2138 bytes.get(index) == Some(&b'(')
2139}
2140
2141fn sorted_root_nodes(ir: &TransformIrV0) -> Vec<IrNodeIdV0> {
2142 let mut root_nodes = ir.root_nodes.clone();
2143 root_nodes.sort_by_key(|node_id| {
2144 let node = &ir.nodes[node_id.index()];
2145 (node.source_span_start, node.global_order)
2146 });
2147 root_nodes
2148}
2149
2150fn active_subtree_node_count(ir: &TransformIrV0, node_id: IrNodeIdV0) -> usize {
2151 let node = &ir.nodes[node_id.index()];
2152 if node.deleted {
2153 return 0;
2154 }
2155 1 + node
2156 .children
2157 .iter()
2158 .map(|child_id| active_subtree_node_count(ir, *child_id))
2159 .sum::<usize>()
2160}
2161
2162fn root_canonical_text_overrides(
2163 ir: &TransformIrV0,
2164 root_ids: &[IrNodeIdV0],
2165) -> Result<Vec<(IrNodeIdV0, String)>, IrTransactionErrorV0> {
2166 let mut overrides = Vec::with_capacity(root_ids.len());
2167 let mut cursor = 0usize;
2168 for (index, root_id) in root_ids.iter().copied().enumerate() {
2169 let node = &ir.nodes[root_id.index()];
2170 let end = root_ids
2171 .get(index + 1)
2172 .map(|next_root_id| ir.nodes[next_root_id.index()].source_span_start)
2173 .unwrap_or(ir.source_text.len());
2174 let start = cursor.min(node.source_span_start);
2175 let text = source_slice(ir, node.node_id.index(), start, end).map_err(|_| {
2176 IrTransactionErrorV0::InvalidSourceSpan {
2177 node_index: node.node_id.index(),
2178 source_span_start: start,
2179 source_span_end: end,
2180 }
2181 })?;
2182 overrides.push((root_id, text.to_string()));
2183 cursor = end;
2184 }
2185 Ok(overrides)
2186}
2187
2188fn canonical_text_override_for_node(
2189 overrides: &[(IrNodeIdV0, String)],
2190 node_id: IrNodeIdV0,
2191) -> Option<&str> {
2192 overrides
2193 .iter()
2194 .find_map(|(candidate_id, text)| (*candidate_id == node_id).then_some(text.as_str()))
2195}
2196
2197fn render_node_css(
2198 ir: &TransformIrV0,
2199 node_id: IrNodeIdV0,
2200) -> Result<String, TransformIrPrintErrorV0> {
2201 let node = &ir.nodes[node_id.index()];
2202 if node.deleted {
2203 return Ok(String::new());
2204 }
2205 if node.dirty {
2206 let Some(canonical_text) = &node.canonical_text else {
2207 return Err(TransformIrPrintErrorV0::MissingSynthesizedText {
2208 node_index: node.node_id.index(),
2209 });
2210 };
2211 return render_dirty_node_with_children(ir, node, canonical_text);
2212 }
2213
2214 render_original_node_with_children(ir, node)
2215}
2216
2217fn render_node_css_with_spans(
2218 ir: &TransformIrV0,
2219 node_id: IrNodeIdV0,
2220 output: &mut String,
2221 node_spans: &mut [Option<(usize, usize)>],
2222) -> Result<(), TransformIrPrintErrorV0> {
2223 let node = &ir.nodes[node_id.index()];
2224 let rendered_start = output.len();
2225 if node.deleted {
2226 assign_deleted_subtree_spans(ir, node_id, rendered_start, node_spans);
2227 return Ok(());
2228 }
2229
2230 if node.dirty {
2231 let Some(canonical_text) = &node.canonical_text else {
2232 return Err(TransformIrPrintErrorV0::MissingSynthesizedText {
2233 node_index: node.node_id.index(),
2234 });
2235 };
2236 render_dirty_node_with_children_and_spans(ir, node, canonical_text, output, node_spans)?;
2237 } else {
2238 render_original_node_with_children_and_spans(ir, node, output, node_spans)?;
2239 }
2240
2241 node_spans[node_id.index()] = Some((rendered_start, output.len()));
2242 Ok(())
2243}
2244
2245fn assign_deleted_subtree_spans(
2246 ir: &TransformIrV0,
2247 node_id: IrNodeIdV0,
2248 rendered_start: usize,
2249 node_spans: &mut [Option<(usize, usize)>],
2250) {
2251 node_spans[node_id.index()] = Some((rendered_start, rendered_start));
2252 for child_id in sorted_child_nodes(ir, &ir.nodes[node_id.index()]) {
2253 assign_deleted_subtree_spans(ir, child_id, rendered_start, node_spans);
2254 }
2255}
2256
2257fn deleted_subtree_flags(ir: &TransformIrV0) -> Vec<bool> {
2258 ir.nodes
2259 .iter()
2260 .map(|node| node.deleted || has_deleted_ancestor(ir, node))
2261 .collect()
2262}
2263
2264fn has_deleted_ancestor(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
2265 let mut parent = node.parent;
2266 while let Some(parent_id) = parent {
2267 let parent_node = &ir.nodes[parent_id.index()];
2268 if parent_node.deleted {
2269 return true;
2270 }
2271 parent = parent_node.parent;
2272 }
2273 false
2274}
2275
2276fn remap_parse_error_spans_after_materialization(
2277 ir: &TransformIrV0,
2278 printed_css: &str,
2279 materialized_spans: &[(usize, usize)],
2280 deleted_subtree_nodes: &[bool],
2281) -> Result<Vec<TransformIrParseErrorSpanV0>, TransformIrPrintErrorV0> {
2282 let mut remapped_spans = Vec::with_capacity(ir.parse_error_spans.len());
2283 for parse_error_span in ir.parse_error_spans.iter().copied() {
2284 if parse_error_span.source_span_start > parse_error_span.source_span_end
2285 || parse_error_span.source_span_end > ir.source_text.len()
2286 || !ir
2287 .source_text
2288 .is_char_boundary(parse_error_span.source_span_start)
2289 || !ir
2290 .source_text
2291 .is_char_boundary(parse_error_span.source_span_end)
2292 {
2293 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
2294 parser_error_count: ir.parser_error_count.max(ir.parse_error_spans.len()),
2295 });
2296 }
2297 let Some(container) = parse_error_container_node(ir, parse_error_span) else {
2298 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
2299 parser_error_count: ir.parser_error_count.max(ir.parse_error_spans.len()),
2300 });
2301 };
2302 if deleted_subtree_nodes[container.node_id.index()] {
2303 continue;
2304 }
2305 let Some((rendered_start, _)) = materialized_spans.get(container.node_id.index()).copied()
2306 else {
2307 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
2308 parser_error_count: ir.parser_error_count.max(ir.parse_error_spans.len()),
2309 });
2310 };
2311 let Some(remapped_span) =
2312 remap_parse_error_span_with_container(ir, container, parse_error_span, rendered_start)
2313 else {
2314 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
2315 parser_error_count: ir.parser_error_count.max(ir.parse_error_spans.len()),
2316 });
2317 };
2318 if remapped_span.source_span_start > remapped_span.source_span_end
2319 || remapped_span.source_span_end > printed_css.len()
2320 || !printed_css.is_char_boundary(remapped_span.source_span_start)
2321 || !printed_css.is_char_boundary(remapped_span.source_span_end)
2322 {
2323 if container.dirty {
2324 continue;
2325 }
2326 return Err(TransformIrPrintErrorV0::CannotMaterializeParseErrorSpans {
2327 parser_error_count: ir.parser_error_count.max(ir.parse_error_spans.len()),
2328 });
2329 }
2330 remapped_spans.push(remapped_span);
2331 }
2332 Ok(remapped_spans)
2333}
2334
2335fn parse_error_container_node(
2336 ir: &TransformIrV0,
2337 parse_error_span: TransformIrParseErrorSpanV0,
2338) -> Option<&IrNodeV0> {
2339 ir.nodes
2340 .iter()
2341 .filter(|node| {
2342 node.source_span_start <= parse_error_span.source_span_start
2343 && parse_error_span.source_span_end <= node.source_span_end
2344 })
2345 .min_by_key(|node| {
2346 (
2347 if node.dirty || node.deleted { 0 } else { 1 },
2348 node.source_span_len(),
2349 )
2350 })
2351}
2352
2353fn remap_parse_error_span_with_container(
2354 ir: &TransformIrV0,
2355 container: &IrNodeV0,
2356 parse_error_span: TransformIrParseErrorSpanV0,
2357 rendered_start: usize,
2358) -> Option<TransformIrParseErrorSpanV0> {
2359 let (relative_start, relative_end) = if container.dirty {
2360 remap_parse_error_relative_span_inside_dirty_node(ir, container, parse_error_span)?
2361 } else {
2362 (
2363 parse_error_span
2364 .source_span_start
2365 .checked_sub(container.source_span_start)?,
2366 parse_error_span
2367 .source_span_end
2368 .checked_sub(container.source_span_start)?,
2369 )
2370 };
2371 Some(TransformIrParseErrorSpanV0 {
2372 source_span_start: rendered_start.checked_add(relative_start)?,
2373 source_span_end: rendered_start.checked_add(relative_end)?,
2374 })
2375}
2376
2377fn remap_parse_error_relative_span_inside_dirty_node(
2378 ir: &TransformIrV0,
2379 node: &IrNodeV0,
2380 parse_error_span: TransformIrParseErrorSpanV0,
2381) -> Option<(usize, usize)> {
2382 if node.deleted
2383 || parse_error_span.source_span_start < node.source_span_start
2384 || parse_error_span.source_span_end > node.source_span_end
2385 {
2386 return None;
2387 }
2388 let canonical_text = node.canonical_text.as_deref()?;
2389 let projection = dirty_node_text_projection(ir, node, canonical_text).ok()?;
2390 let original_start = parse_error_span
2391 .source_span_start
2392 .checked_sub(node.source_span_start)?;
2393 let original_end = parse_error_span
2394 .source_span_end
2395 .checked_sub(node.source_span_start)?;
2396 if let (Some(projected_start), Some(projected_end)) = (
2397 project_dirty_node_original_offset(&projection, original_start),
2398 project_dirty_node_original_offset(&projection, original_end),
2399 ) {
2400 return Some((projected_start, projected_end));
2401 }
2402 let (context_start, context_end) = parse_error_context_span(ir, node, parse_error_span)?;
2403 let context_text = source_slice(ir, node.node_id.index(), context_start, context_end).ok()?;
2404 if context_text.is_empty() {
2405 return None;
2406 }
2407 let context_offset = canonical_text.find(context_text)?;
2408 let relative_start_in_context = parse_error_span
2409 .source_span_start
2410 .checked_sub(context_start)?;
2411 let relative_end_in_context = parse_error_span
2412 .source_span_end
2413 .checked_sub(context_start)?;
2414 Some((
2415 context_offset.checked_add(relative_start_in_context)?,
2416 context_offset.checked_add(relative_end_in_context)?,
2417 ))
2418}
2419
2420struct DirtyNodeTextProjectionV0 {
2421 original_replacement_start: usize,
2422 original_replacement_end: usize,
2423 rendered_replacement_end: usize,
2424}
2425
2426fn render_dirty_node_with_children(
2427 ir: &TransformIrV0,
2428 node: &IrNodeV0,
2429 canonical_text: &str,
2430) -> Result<String, TransformIrPrintErrorV0> {
2431 if node_has_zero_width_synthesized_span(ir, node) {
2432 return Ok(canonical_text.to_string());
2433 }
2434 let projection = dirty_node_text_projection(ir, node, canonical_text)?;
2435 let mut output = String::new();
2436 let mut cursor = 0;
2437 let mut child_was_composed = false;
2438
2439 for child_id in sorted_child_nodes(ir, node) {
2440 if child_id == node.node_id {
2441 continue;
2442 }
2443 if !node_subtree_has_mutation(ir, child_id) {
2444 continue;
2445 }
2446 let child = &ir.nodes[child_id.index()];
2447 let Some(child_start) = child
2448 .source_span_start
2449 .checked_sub(node.source_span_start)
2450 .and_then(|offset| project_dirty_node_original_offset(&projection, offset))
2451 else {
2452 return Ok(canonical_text.to_string());
2453 };
2454 let Some(child_end) = child
2455 .source_span_end
2456 .checked_sub(node.source_span_start)
2457 .and_then(|offset| project_dirty_node_original_offset(&projection, offset))
2458 else {
2459 return Ok(canonical_text.to_string());
2460 };
2461 if child_start < cursor
2462 || child_end < child_start
2463 || child_end > canonical_text.len()
2464 || !canonical_text.is_char_boundary(child_start)
2465 || !canonical_text.is_char_boundary(child_end)
2466 {
2467 return Ok(canonical_text.to_string());
2468 }
2469 output.push_str(&canonical_text[cursor..child_start]);
2470 output.push_str(render_node_css(ir, child_id)?.as_str());
2471 cursor = child_end;
2472 child_was_composed = true;
2473 }
2474
2475 if !child_was_composed {
2476 return Ok(canonical_text.to_string());
2477 }
2478 output.push_str(&canonical_text[cursor..]);
2479 Ok(output)
2480}
2481
2482fn render_dirty_node_with_children_and_spans(
2483 ir: &TransformIrV0,
2484 node: &IrNodeV0,
2485 canonical_text: &str,
2486 output: &mut String,
2487 node_spans: &mut [Option<(usize, usize)>],
2488) -> Result<(), TransformIrPrintErrorV0> {
2489 if node_has_zero_width_synthesized_span(ir, node) {
2490 let rendered_start = output.len();
2491 output.push_str(canonical_text);
2492 assign_rendered_subtree_spans(
2493 ir,
2494 node.node_id,
2495 rendered_start,
2496 rendered_start + canonical_text.len(),
2497 output,
2498 node_spans,
2499 )?;
2500 return Ok(());
2501 }
2502 let projection = dirty_node_text_projection(ir, node, canonical_text)?;
2503 let rendered_start = output.len();
2504 let mut cursor = 0;
2505 let mut child_was_composed = false;
2506
2507 for child_id in sorted_child_nodes(ir, node) {
2508 if child_id == node.node_id {
2509 continue;
2510 }
2511 let child = &ir.nodes[child_id.index()];
2512 let projected_child_start = child
2513 .source_span_start
2514 .checked_sub(node.source_span_start)
2515 .and_then(|offset| project_dirty_node_original_offset(&projection, offset));
2516 let projected_child_end = child
2517 .source_span_end
2518 .checked_sub(node.source_span_start)
2519 .and_then(|offset| project_dirty_node_original_offset(&projection, offset));
2520 let projected_offsets = projected_child_start.zip(projected_child_end);
2521 let direct_offsets = direct_child_replacement_offsets(ir, node, child, canonical_text);
2522 let rendered_offsets =
2523 rendered_child_offsets_in_dirty_node(ir, node, child, canonical_text, cursor)?;
2524 let prefer_rendered_offsets = rendered_offsets.is_some()
2525 && matches!(
2526 (node.kind, child.kind),
2527 (_, IrNodeKindV0::StyleRule)
2528 | (_, IrNodeKindV0::Selector)
2529 | (_, IrNodeKindV0::Declaration)
2530 | (IrNodeKindV0::Declaration, IrNodeKindV0::Value)
2531 );
2532 let selected_offsets = if prefer_rendered_offsets {
2533 rendered_offsets
2534 } else {
2535 projected_offsets.or(direct_offsets).or(rendered_offsets)
2536 };
2537 let used_direct_offsets =
2538 !prefer_rendered_offsets && projected_offsets.is_none() && direct_offsets.is_some();
2539 let used_rendered_offsets =
2540 selected_offsets == rendered_offsets && rendered_offsets.is_some();
2541 let Some((child_start, child_end)) = selected_offsets else {
2542 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
2543 node_index: node.node_id.index(),
2544 child_index: child.node_id.index(),
2545 });
2546 };
2547 if child_start < cursor
2548 || child_end < child_start
2549 || child_end > canonical_text.len()
2550 || !canonical_text.is_char_boundary(child_start)
2551 || !canonical_text.is_char_boundary(child_end)
2552 {
2553 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
2554 node_index: node.node_id.index(),
2555 child_index: child.node_id.index(),
2556 });
2557 }
2558 output.push_str(&canonical_text[cursor..child_start]);
2559 if node_subtree_has_mutation(ir, child_id) {
2560 render_node_css_with_spans(ir, child_id, output, node_spans)?;
2561 } else {
2562 output.push_str(&canonical_text[child_start..child_end]);
2563 if used_direct_offsets {
2564 assign_direct_original_subtree_spans(
2565 ir,
2566 child_id,
2567 rendered_start + child_start,
2568 child.source_span_start,
2569 node_spans,
2570 )?;
2571 } else if used_rendered_offsets {
2572 assign_rendered_subtree_spans(
2573 ir,
2574 child_id,
2575 rendered_start + child_start,
2576 rendered_start + child_end,
2577 output,
2578 node_spans,
2579 )?;
2580 } else {
2581 assign_projected_original_subtree_spans(
2582 ir,
2583 child_id,
2584 rendered_start,
2585 &projection,
2586 node.source_span_start,
2587 canonical_text,
2588 node_spans,
2589 )?;
2590 }
2591 }
2592 cursor = child_end;
2593 child_was_composed = true;
2594 }
2595
2596 if !child_was_composed {
2597 output.push_str(canonical_text);
2598 return Ok(());
2599 }
2600 output.push_str(&canonical_text[cursor..]);
2601 Ok(())
2602}
2603
2604fn rendered_child_offsets_in_dirty_node(
2605 ir: &TransformIrV0,
2606 node: &IrNodeV0,
2607 child: &IrNodeV0,
2608 canonical_text: &str,
2609 cursor: usize,
2610) -> Result<Option<(usize, usize)>, TransformIrPrintErrorV0> {
2611 if node.kind == IrNodeKindV0::StyleRule && child.kind == IrNodeKindV0::Selector {
2612 return Ok(style_rule_selector_offsets_for_child(
2613 ir,
2614 child,
2615 canonical_text,
2616 cursor,
2617 ));
2618 }
2619
2620 let rendered_child = render_node_css(ir, child.node_id)?;
2621 if rendered_child.is_empty() {
2622 return Ok(Some((cursor, cursor)));
2623 }
2624 Ok(
2625 find_rendered_child_after(canonical_text, rendered_child.as_str(), cursor)
2626 .or_else(|| {
2627 (child.kind == IrNodeKindV0::StyleRule)
2628 .then(|| {
2629 style_rule_offsets_by_nested_selector_chunk(
2630 ir,
2631 node,
2632 child,
2633 canonical_text,
2634 cursor,
2635 )
2636 })
2637 .flatten()
2638 })
2639 .or_else(|| {
2640 (child.kind == IrNodeKindV0::StyleRule)
2641 .then(|| style_rule_offsets_by_block(ir, child, canonical_text, cursor))
2642 .flatten()
2643 })
2644 .or_else(|| {
2645 (child.kind == IrNodeKindV0::AtRule)
2646 .then(|| at_rule_offsets_in_dirty_node(ir, node, child, canonical_text, cursor))
2647 .flatten()
2648 })
2649 .or_else(|| {
2650 (child.kind == IrNodeKindV0::StyleRule)
2651 .then(|| style_rule_offsets_by_selector(ir, child, canonical_text, cursor))
2652 .flatten()
2653 })
2654 .or_else(|| {
2655 (child.kind == IrNodeKindV0::Declaration)
2656 .then(|| declaration_offsets_by_property(ir, child, canonical_text, cursor))
2657 .flatten()
2658 }),
2659 )
2660}
2661
2662fn selector_is_css_module_scope_wrapper(ir: &TransformIrV0, child: &IrNodeV0) -> bool {
2663 let Some(selector_source) = ir
2664 .source_text
2665 .get(child.source_span_start..child.source_span_end)
2666 else {
2667 return false;
2668 };
2669 let selector = selector_source.trim();
2670 selector == ":local" || selector == ":global"
2671}
2672
2673fn style_rule_selector_offsets_for_child(
2674 ir: &TransformIrV0,
2675 child: &IrNodeV0,
2676 canonical_text: &str,
2677 cursor: usize,
2678) -> Option<(usize, usize)> {
2679 if selector_is_css_module_scope_wrapper(ir, child) {
2680 return Some((cursor, cursor));
2681 }
2682 let (selector_start, selector_end) = style_rule_selector_offsets(ir, canonical_text)?;
2683 if selector_end < cursor {
2684 return None;
2685 }
2686 let parent = child
2687 .parent
2688 .and_then(|parent_id| ir.nodes.get(parent_id.index()))?;
2689 let expected_selector = expanded_style_rule_selector(ir, parent)
2690 .or_else(|| style_rule_source_selector(ir, parent).map(str::to_string));
2691 let rendered_selector = canonical_text.get(selector_start..selector_end)?.trim();
2692 if let Some(expected_selector) = expected_selector {
2693 if rendered_selector == expected_selector {
2694 Some((selector_start, selector_end))
2695 } else if parent_has_style_rule_children(ir, parent) || cursor > selector_start {
2696 Some((cursor, cursor))
2697 } else {
2698 Some((selector_start, selector_end))
2699 }
2700 } else if render_node_css(ir, child.node_id)
2701 .ok()
2702 .is_some_and(|selector| selector.trim() == rendered_selector)
2703 {
2704 Some((selector_start, selector_end))
2705 } else {
2706 None
2707 }
2708}
2709
2710fn parent_has_style_rule_children(ir: &TransformIrV0, parent: &IrNodeV0) -> bool {
2711 parent.children.iter().any(|child_id| {
2712 ir.nodes
2713 .get(child_id.index())
2714 .is_some_and(|child| !child.deleted && child.kind == IrNodeKindV0::StyleRule)
2715 })
2716}
2717
2718fn style_rule_selector_offsets(ir: &TransformIrV0, canonical_text: &str) -> Option<(usize, usize)> {
2719 let brace = block_spans_for_ir_text(ir, canonical_text)
2720 .into_iter()
2721 .map(|span| span.open_brace_start)
2722 .min()?;
2723 if brace > 0 && canonical_text.is_char_boundary(brace) {
2724 Some((0, brace))
2725 } else {
2726 None
2727 }
2728}
2729
2730fn find_rendered_child_after(
2731 canonical_text: &str,
2732 rendered_child: &str,
2733 cursor: usize,
2734) -> Option<(usize, usize)> {
2735 let haystack = canonical_text.get(cursor..)?;
2736 let offset = haystack.find(rendered_child)?;
2737 let start = cursor.checked_add(offset)?;
2738 let end = start.checked_add(rendered_child.len())?;
2739 Some((start, end))
2740}
2741
2742fn style_rule_offsets_by_block(
2743 ir: &TransformIrV0,
2744 child: &IrNodeV0,
2745 canonical_text: &str,
2746 cursor: usize,
2747) -> Option<(usize, usize)> {
2748 let child_block = child.block_span?;
2749 let block = ir
2750 .source_text
2751 .get(child_block.open_brace_start..child_block.rule_end)?;
2752 let (block_rendered_start, block_rendered_end) =
2753 find_rendered_child_after(canonical_text, block, cursor)?;
2754 let rendered_blocks = block_spans_for_ir_text(ir, canonical_text);
2755 let rendered_block = rendered_blocks
2756 .iter()
2757 .copied()
2758 .find(|span| span.open_brace_start == block_rendered_start)?;
2759 let rule_start = rendered_block.prelude_start;
2760 let leading_trim = canonical_text.get(rule_start..block_rendered_start)?.len()
2761 - canonical_text
2762 .get(rule_start..block_rendered_start)?
2763 .trim_start()
2764 .len();
2765 Some((rule_start + leading_trim, block_rendered_end))
2766}
2767
2768fn style_rule_offsets_by_selector(
2769 ir: &TransformIrV0,
2770 child: &IrNodeV0,
2771 canonical_text: &str,
2772 cursor: usize,
2773) -> Option<(usize, usize)> {
2774 let child_block = child.block_span?;
2775 let selector = ir
2776 .source_text
2777 .get(child_block.prelude_start..child_block.open_brace_start)?
2778 .trim();
2779 if selector.is_empty() {
2780 return None;
2781 }
2782 let haystack = canonical_text.get(cursor..)?;
2783 let selector_offset = haystack.find(selector)?;
2784 let start = cursor.checked_add(selector_offset)?;
2785 let rendered_blocks = block_spans_for_ir_text(ir, canonical_text);
2786 let rendered_block = block_span_for_rendered_start(canonical_text, &rendered_blocks, start)?;
2787 Some((start, rendered_block.rule_end))
2788}
2789
2790fn style_rule_offsets_by_nested_selector_chunk(
2791 ir: &TransformIrV0,
2792 parent: &IrNodeV0,
2793 child: &IrNodeV0,
2794 canonical_text: &str,
2795 cursor: usize,
2796) -> Option<(usize, usize)> {
2797 let selector = expanded_style_rule_selector(ir, child)?;
2798 let start = find_rendered_child_after(canonical_text, selector.as_str(), cursor)?.0;
2799 let next_sibling_start = sorted_child_nodes(ir, parent)
2800 .into_iter()
2801 .map(|sibling_id| &ir.nodes[sibling_id.index()])
2802 .filter(|sibling| {
2803 sibling.kind == IrNodeKindV0::StyleRule
2804 && sibling.parent == child.parent
2805 && sibling.global_order > child.global_order
2806 })
2807 .filter_map(|sibling| {
2808 let sibling_selector = expanded_style_rule_selector(ir, sibling)?;
2809 find_rendered_child_after(
2810 canonical_text,
2811 sibling_selector.as_str(),
2812 start + selector.len(),
2813 )
2814 .map(|(sibling_start, _)| sibling_start)
2815 })
2816 .min();
2817 let raw_end = next_sibling_start.unwrap_or(canonical_text.len());
2818 let slice = canonical_text.get(start..raw_end)?;
2819 let end = raw_end.saturating_sub(slice.len().saturating_sub(slice.trim_end().len()));
2820 (start < end).then_some((start, end))
2821}
2822
2823fn expanded_style_rule_selector(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
2824 if node.kind != IrNodeKindV0::StyleRule {
2825 return None;
2826 }
2827 let selector = style_rule_source_selector(ir, node)?;
2828 let Some(parent_id) = node.parent else {
2829 return Some(selector.to_string());
2830 };
2831 let parent = &ir.nodes[parent_id.index()];
2832 let parent_selector = match parent.kind {
2833 IrNodeKindV0::StyleRule => expanded_style_rule_selector(ir, parent)?,
2834 IrNodeKindV0::AtRule => expanded_nest_at_rule_selector(ir, parent)
2835 .or_else(|| nearest_ancestor_style_rule_selector(ir, parent))?,
2836 _ => return Some(selector.to_string()),
2837 };
2838 expand_nested_selector_from_cst(parent_selector.as_str(), selector, StyleDialect::Scss)
2839 .or_else(|| Some(format!("{parent_selector} {selector}")))
2840}
2841
2842fn at_rule_offsets_in_dirty_node(
2843 ir: &TransformIrV0,
2844 parent: &IrNodeV0,
2845 child: &IrNodeV0,
2846 canonical_text: &str,
2847 cursor: usize,
2848) -> Option<(usize, usize)> {
2849 let child_block = child.block_span?;
2850 let prelude = ir
2851 .source_text
2852 .get(child_block.prelude_start..child_block.open_brace_start)?
2853 .trim();
2854 if prelude.is_empty() {
2855 return None;
2856 }
2857 if prelude.starts_with("@nest") {
2858 let selector = expanded_nest_at_rule_selector(ir, child)?;
2859 let start = find_rendered_child_after(canonical_text, selector.as_str(), cursor)?.0;
2860 let end = next_rendered_sibling_start(ir, parent, child, canonical_text, start)
2861 .unwrap_or(canonical_text.len());
2862 return trim_rendered_range_end(canonical_text, start, end);
2863 }
2864 let start = find_rendered_child_after(canonical_text, prelude, cursor)?.0;
2865 let rendered_blocks = block_spans_for_ir_text(ir, canonical_text);
2866 let rendered_block = block_span_for_rendered_start(canonical_text, &rendered_blocks, start)?;
2867 Some((start, rendered_block.rule_end))
2868}
2869
2870fn next_rendered_sibling_start(
2871 ir: &TransformIrV0,
2872 parent: &IrNodeV0,
2873 child: &IrNodeV0,
2874 canonical_text: &str,
2875 cursor: usize,
2876) -> Option<usize> {
2877 sorted_child_nodes(ir, parent)
2878 .into_iter()
2879 .map(|sibling_id| &ir.nodes[sibling_id.index()])
2880 .filter(|sibling| {
2881 sibling.parent == child.parent
2882 && sibling.global_order > child.global_order
2883 && !sibling.deleted
2884 })
2885 .filter_map(|sibling| rendered_node_start_hint(ir, sibling, canonical_text, cursor))
2886 .min()
2887}
2888
2889fn rendered_node_start_hint(
2890 ir: &TransformIrV0,
2891 node: &IrNodeV0,
2892 canonical_text: &str,
2893 cursor: usize,
2894) -> Option<usize> {
2895 match node.kind {
2896 IrNodeKindV0::StyleRule => {
2897 let selector = expanded_style_rule_selector(ir, node)
2898 .or_else(|| style_rule_source_selector(ir, node).map(str::to_string))?;
2899 find_rendered_child_after(canonical_text, selector.as_str(), cursor)
2900 .map(|(start, _)| start)
2901 }
2902 IrNodeKindV0::AtRule => {
2903 if let Some(selector) = expanded_nest_at_rule_selector(ir, node) {
2904 return find_rendered_child_after(canonical_text, selector.as_str(), cursor)
2905 .map(|(start, _)| start);
2906 }
2907 let block = node.block_span?;
2908 let prelude = ir
2909 .source_text
2910 .get(block.prelude_start..block.open_brace_start)?
2911 .trim();
2912 find_rendered_child_after(canonical_text, prelude, cursor).map(|(start, _)| start)
2913 }
2914 _ => None,
2915 }
2916}
2917
2918fn trim_rendered_range_end(
2919 canonical_text: &str,
2920 start: usize,
2921 end: usize,
2922) -> Option<(usize, usize)> {
2923 let slice = canonical_text.get(start..end)?;
2924 let trimmed_end = end.saturating_sub(slice.len().saturating_sub(slice.trim_end().len()));
2925 (start < trimmed_end).then_some((start, trimmed_end))
2926}
2927
2928fn expanded_nest_at_rule_selector(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
2929 if node.kind != IrNodeKindV0::AtRule {
2930 return None;
2931 }
2932 let block = node.block_span?;
2933 let prelude = ir
2934 .source_text
2935 .get(block.prelude_start..block.open_brace_start)?
2936 .trim();
2937 let nest_selector = prelude.strip_prefix("@nest")?.trim();
2938 if nest_selector.is_empty() {
2939 return None;
2940 }
2941 let ancestor_selector = nearest_ancestor_style_rule_selector(ir, node)?;
2942 expand_nested_selector_from_cst(
2943 ancestor_selector.as_str(),
2944 nest_selector,
2945 StyleDialect::Scss,
2946 )
2947 .or_else(|| Some(format!("{ancestor_selector} {nest_selector}")))
2948}
2949
2950fn nearest_ancestor_style_rule_selector(ir: &TransformIrV0, node: &IrNodeV0) -> Option<String> {
2951 let mut parent = node
2952 .parent
2953 .and_then(|parent_id| ir.nodes.get(parent_id.index()));
2954 while let Some(parent_node) = parent {
2955 if parent_node.kind == IrNodeKindV0::StyleRule {
2956 return expanded_style_rule_selector(ir, parent_node);
2957 }
2958 parent = parent_node
2959 .parent
2960 .and_then(|parent_id| ir.nodes.get(parent_id.index()));
2961 }
2962 None
2963}
2964
2965fn style_rule_source_selector<'source>(
2966 ir: &'source TransformIrV0,
2967 node: &IrNodeV0,
2968) -> Option<&'source str> {
2969 let block = node.block_span?;
2970 let selector = ir
2971 .source_text
2972 .get(block.prelude_start..block.open_brace_start)?
2973 .trim();
2974 (!selector.is_empty()).then_some(selector)
2975}
2976
2977fn dirty_node_text_projection(
2978 ir: &TransformIrV0,
2979 node: &IrNodeV0,
2980 canonical_text: &str,
2981) -> Result<DirtyNodeTextProjectionV0, TransformIrPrintErrorV0> {
2982 let original_text = source_slice(
2983 ir,
2984 node.node_id.index(),
2985 node.source_span_start,
2986 node.source_span_end,
2987 )?;
2988 let common_prefix_len = common_prefix_byte_len(original_text, canonical_text);
2989 let common_suffix_len =
2990 common_suffix_byte_len_after_prefix(original_text, canonical_text, common_prefix_len);
2991
2992 Ok(DirtyNodeTextProjectionV0 {
2993 original_replacement_start: common_prefix_len,
2994 original_replacement_end: original_text.len().saturating_sub(common_suffix_len),
2995 rendered_replacement_end: canonical_text.len().saturating_sub(common_suffix_len),
2996 })
2997}
2998
2999fn project_dirty_node_original_offset(
3000 projection: &DirtyNodeTextProjectionV0,
3001 original_offset: usize,
3002) -> Option<usize> {
3003 if original_offset <= projection.original_replacement_start {
3004 return Some(original_offset);
3005 }
3006 if original_offset >= projection.original_replacement_end {
3007 let delta = projection.rendered_replacement_end as isize
3008 - projection.original_replacement_end as isize;
3009 return apply_offset_delta(original_offset, delta);
3010 }
3011 None
3012}
3013
3014fn common_prefix_byte_len(left: &str, right: &str) -> usize {
3015 let mut byte_len = 0;
3016 for (left_char, right_char) in left.chars().zip(right.chars()) {
3017 if left_char != right_char {
3018 break;
3019 }
3020 byte_len += left_char.len_utf8();
3021 }
3022 byte_len
3023}
3024
3025fn common_suffix_byte_len_after_prefix(left: &str, right: &str, prefix_len: usize) -> usize {
3026 let mut byte_len = 0;
3027 for (left_char, right_char) in left[prefix_len..]
3028 .chars()
3029 .rev()
3030 .zip(right[prefix_len..].chars().rev())
3031 {
3032 if left_char != right_char {
3033 break;
3034 }
3035 byte_len += left_char.len_utf8();
3036 }
3037 byte_len
3038}
3039
3040fn apply_offset_delta(offset: usize, delta: isize) -> Option<usize> {
3041 if delta >= 0 {
3042 offset.checked_add(delta as usize)
3043 } else {
3044 offset.checked_sub((-delta) as usize)
3045 }
3046}
3047
3048fn node_subtree_has_mutation(ir: &TransformIrV0, node_id: IrNodeIdV0) -> bool {
3049 let node = &ir.nodes[node_id.index()];
3050 node.deleted
3051 || node.dirty
3052 || node
3053 .children
3054 .iter()
3055 .any(|child_id| node_subtree_has_mutation(ir, *child_id))
3056}
3057
3058fn direct_child_replacement_offsets(
3059 ir: &TransformIrV0,
3060 node: &IrNodeV0,
3061 child: &IrNodeV0,
3062 canonical_text: &str,
3063) -> Option<(usize, usize)> {
3064 let children = sorted_child_nodes(ir, node)
3065 .into_iter()
3066 .map(|child_id| &ir.nodes[child_id.index()])
3067 .filter(|candidate| !candidate.deleted)
3068 .collect::<Vec<_>>();
3069 let first_child = children.first()?;
3070 let last_child = children.last()?;
3071 let direct_source = ir
3072 .source_text
3073 .get(first_child.source_span_start..last_child.source_span_end)?;
3074 if direct_source.trim() != canonical_text.trim()
3075 || child.source_span_start < first_child.source_span_start
3076 || child.source_span_end > last_child.source_span_end
3077 {
3078 return None;
3079 }
3080 let direct_leading_trim = direct_source.len() - direct_source.trim_start().len();
3081 let canonical_leading_trim = canonical_text.len() - canonical_text.trim_start().len();
3082 let child_start_offset = child
3083 .source_span_start
3084 .checked_sub(first_child.source_span_start)?
3085 .checked_sub(direct_leading_trim)?;
3086 let child_end_offset = child
3087 .source_span_end
3088 .checked_sub(first_child.source_span_start)?
3089 .checked_sub(direct_leading_trim)?;
3090 let child_start = canonical_leading_trim.checked_add(child_start_offset)?;
3091 let child_end = canonical_leading_trim.checked_add(child_end_offset)?;
3092 if child_start <= child_end
3093 && child_end <= canonical_text.len()
3094 && canonical_text.is_char_boundary(child_start)
3095 && canonical_text.is_char_boundary(child_end)
3096 {
3097 Some((child_start, child_end))
3098 } else {
3099 None
3100 }
3101}
3102
3103fn assign_direct_original_subtree_spans(
3104 ir: &TransformIrV0,
3105 node_id: IrNodeIdV0,
3106 rendered_node_start: usize,
3107 original_node_start: usize,
3108 node_spans: &mut [Option<(usize, usize)>],
3109) -> Result<(), TransformIrPrintErrorV0> {
3110 let node = &ir.nodes[node_id.index()];
3111 let rendered_start = node
3112 .source_span_start
3113 .checked_sub(original_node_start)
3114 .and_then(|offset| rendered_node_start.checked_add(offset))
3115 .ok_or(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3116 node_index: node_id.index(),
3117 child_index: node_id.index(),
3118 })?;
3119 let rendered_end = node
3120 .source_span_end
3121 .checked_sub(original_node_start)
3122 .and_then(|offset| rendered_node_start.checked_add(offset))
3123 .ok_or(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3124 node_index: node_id.index(),
3125 child_index: node_id.index(),
3126 })?;
3127 if rendered_end < rendered_start {
3128 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3129 node_index: node_id.index(),
3130 child_index: node_id.index(),
3131 });
3132 }
3133 node_spans[node_id.index()] = Some((rendered_start, rendered_end));
3134
3135 for child_id in sorted_child_nodes(ir, node) {
3136 assign_direct_original_subtree_spans(
3137 ir,
3138 child_id,
3139 rendered_node_start,
3140 original_node_start,
3141 node_spans,
3142 )?;
3143 }
3144 Ok(())
3145}
3146
3147fn assign_rendered_subtree_spans(
3148 ir: &TransformIrV0,
3149 node_id: IrNodeIdV0,
3150 rendered_start: usize,
3151 rendered_end: usize,
3152 rendered_css: &str,
3153 node_spans: &mut [Option<(usize, usize)>],
3154) -> Result<(), TransformIrPrintErrorV0> {
3155 if rendered_end < rendered_start || rendered_end > rendered_css.len() {
3156 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3157 node_index: node_id.index(),
3158 child_index: node_id.index(),
3159 });
3160 }
3161 node_spans[node_id.index()] = Some((rendered_start, rendered_end));
3162
3163 let rendered_slice = &rendered_css[rendered_start..rendered_end];
3164 let mut cursor = 0;
3165 for child_id in sorted_child_nodes(ir, &ir.nodes[node_id.index()]) {
3166 let child = &ir.nodes[child_id.index()];
3167 let rendered_child = render_node_css(ir, child_id)?;
3168 let child_offsets =
3169 find_rendered_child_after(rendered_slice, rendered_child.as_str(), cursor)
3170 .or_else(|| {
3171 (child.kind == IrNodeKindV0::AtRule
3172 && !rendered_slice.contains(rendered_child.as_str()))
3173 .then_some((cursor, cursor))
3174 })
3175 .or_else(|| {
3176 (child.kind == IrNodeKindV0::StyleRule)
3177 .then(|| {
3178 style_rule_offsets_by_nested_selector_chunk(
3179 ir,
3180 &ir.nodes[node_id.index()],
3181 child,
3182 rendered_slice,
3183 cursor,
3184 )
3185 })
3186 .flatten()
3187 })
3188 .or_else(|| {
3189 (ir.nodes[node_id.index()].kind == IrNodeKindV0::StyleRule
3190 && child.kind == IrNodeKindV0::Selector)
3191 .then(|| {
3192 style_rule_selector_offsets_for_child(ir, child, rendered_slice, cursor)
3193 })
3194 .flatten()
3195 })
3196 .or_else(|| {
3197 (child.kind == IrNodeKindV0::Declaration)
3198 .then(|| declaration_offsets_by_property(ir, child, rendered_slice, cursor))
3199 .flatten()
3200 })
3201 .or_else(|| {
3202 (ir.nodes[node_id.index()].kind == IrNodeKindV0::Declaration
3203 && child.kind == IrNodeKindV0::Value)
3204 .then(|| declaration_value_offsets(rendered_slice))
3205 .flatten()
3206 })
3207 .or_else(|| {
3208 if ir.nodes[node_id.index()].kind == IrNodeKindV0::Selector
3209 && child.kind == IrNodeKindV0::Selector
3210 {
3211 Some(if cursor == 0 {
3212 (0, rendered_slice.len())
3213 } else {
3214 (cursor, cursor)
3215 })
3216 } else {
3217 None
3218 }
3219 });
3220 let Some((child_start, child_end)) = child_offsets else {
3221 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3222 node_index: node_id.index(),
3223 child_index: child_id.index(),
3224 });
3225 };
3226 assign_rendered_subtree_spans(
3227 ir,
3228 child_id,
3229 rendered_start + child_start,
3230 rendered_start + child_end,
3231 rendered_css,
3232 node_spans,
3233 )?;
3234 cursor = child_end;
3235 }
3236 Ok(())
3237}
3238
3239fn declaration_offsets_by_property(
3240 ir: &TransformIrV0,
3241 child: &IrNodeV0,
3242 rendered_slice: &str,
3243 cursor: usize,
3244) -> Option<(usize, usize)> {
3245 let child_source = ir
3246 .source_text
3247 .get(child.source_span_start..child.source_span_end)?;
3248 let property_end = child_source.find(':')?;
3249 let property = child_source.get(..property_end)?.trim();
3250 if property.is_empty() {
3251 return None;
3252 }
3253 let haystack = rendered_slice.get(cursor..)?;
3254 let property_offset = haystack.find(property)?;
3255 let start = cursor.checked_add(property_offset)?;
3256 let declaration_tail = rendered_slice.get(start..)?;
3257 let semicolon_offset = declaration_tail.find(';')?;
3258 let end = start.checked_add(semicolon_offset + 1)?;
3259 Some((start, end))
3260}
3261
3262fn declaration_value_offsets(rendered_slice: &str) -> Option<(usize, usize)> {
3263 let colon = rendered_slice.find(':')?;
3264 let semicolon = rendered_slice[colon..].find(';')?.checked_add(colon)?;
3265 let start = colon.checked_add(1)?;
3266 if start <= semicolon
3267 && rendered_slice.is_char_boundary(start)
3268 && rendered_slice.is_char_boundary(semicolon)
3269 {
3270 Some((start, semicolon))
3271 } else {
3272 None
3273 }
3274}
3275
3276fn render_original_node_with_children(
3277 ir: &TransformIrV0,
3278 node: &IrNodeV0,
3279) -> Result<String, TransformIrPrintErrorV0> {
3280 let mut output = String::new();
3281 let mut cursor = node.source_span_start;
3282 for child_id in sorted_child_nodes(ir, node) {
3283 let child = &ir.nodes[child_id.index()];
3284 if child.source_span_start < node.source_span_start
3285 || child.source_span_end > node.source_span_end
3286 || child.source_span_start < cursor
3287 {
3288 continue;
3289 }
3290 output.push_str(source_slice(
3291 ir,
3292 node.node_id.index(),
3293 cursor,
3294 child.source_span_start,
3295 )?);
3296 output.push_str(render_node_css(ir, child_id)?.as_str());
3297 cursor = child.source_span_end;
3298 }
3299 output.push_str(source_slice(
3300 ir,
3301 node.node_id.index(),
3302 cursor,
3303 node.source_span_end,
3304 )?);
3305 Ok(output)
3306}
3307
3308fn node_has_zero_width_synthesized_span(ir: &TransformIrV0, node: &IrNodeV0) -> bool {
3309 node.source_span_start == node.source_span_end
3310 && ir
3311 .origins
3312 .get(node.origin_index)
3313 .is_some_and(|origin| !origin.is_original())
3314}
3315
3316fn render_original_node_with_children_and_spans(
3317 ir: &TransformIrV0,
3318 node: &IrNodeV0,
3319 output: &mut String,
3320 node_spans: &mut [Option<(usize, usize)>],
3321) -> Result<(), TransformIrPrintErrorV0> {
3322 let mut cursor = node.source_span_start;
3323 for child_id in sorted_child_nodes(ir, node) {
3324 let child = &ir.nodes[child_id.index()];
3325 if child.source_span_start < node.source_span_start
3326 || child.source_span_end > node.source_span_end
3327 || child.source_span_start < cursor
3328 {
3329 continue;
3330 }
3331 output.push_str(source_slice(
3332 ir,
3333 node.node_id.index(),
3334 cursor,
3335 child.source_span_start,
3336 )?);
3337 render_node_css_with_spans(ir, child_id, output, node_spans)?;
3338 cursor = child.source_span_end;
3339 }
3340 output.push_str(source_slice(
3341 ir,
3342 node.node_id.index(),
3343 cursor,
3344 node.source_span_end,
3345 )?);
3346 Ok(())
3347}
3348
3349fn assign_projected_original_subtree_spans(
3350 ir: &TransformIrV0,
3351 node_id: IrNodeIdV0,
3352 rendered_parent_start: usize,
3353 projection: &DirtyNodeTextProjectionV0,
3354 original_parent_start: usize,
3355 canonical_text: &str,
3356 node_spans: &mut [Option<(usize, usize)>],
3357) -> Result<(), TransformIrPrintErrorV0> {
3358 let node = &ir.nodes[node_id.index()];
3359 let projected_start = node
3360 .source_span_start
3361 .checked_sub(original_parent_start)
3362 .and_then(|offset| project_dirty_node_original_offset(projection, offset))
3363 .and_then(|offset| rendered_parent_start.checked_add(offset));
3364 let projected_end = node
3365 .source_span_end
3366 .checked_sub(original_parent_start)
3367 .and_then(|offset| project_dirty_node_original_offset(projection, offset))
3368 .and_then(|offset| rendered_parent_start.checked_add(offset));
3369 let (rendered_start, rendered_end) = match (projected_start, projected_end) {
3370 (Some(rendered_start), Some(rendered_end)) => (rendered_start, rendered_end),
3371 _ => {
3372 let rendered_search_start = projection
3373 .original_replacement_start
3374 .min(projection.rendered_replacement_end)
3375 .min(canonical_text.len());
3376 if node.kind == IrNodeKindV0::Selector
3377 && let Some((selector_start, selector_end)) =
3378 style_rule_selector_offsets(ir, canonical_text)
3379 && let (Some(rendered_start), Some(rendered_end)) = (
3380 rendered_parent_start.checked_add(selector_start),
3381 rendered_parent_start.checked_add(selector_end),
3382 )
3383 {
3384 node_spans[node_id.index()] = Some((rendered_start, rendered_end));
3385 return Ok(());
3386 }
3387 if let Some(rendered_node_start) = rendered_original_subtree_start_in_parent_text(
3388 ir,
3389 node_id,
3390 canonical_text,
3391 rendered_search_start,
3392 )
3393 .and_then(|offset| rendered_parent_start.checked_add(offset))
3394 {
3395 assign_direct_original_subtree_spans(
3396 ir,
3397 node_id,
3398 rendered_node_start,
3399 node.source_span_start,
3400 node_spans,
3401 )?;
3402 return Ok(());
3403 }
3404 if let Some((rendered_node_start, rendered_node_end)) =
3405 rendered_expanded_style_rule_offsets_in_parent_text(
3406 ir,
3407 node_id,
3408 canonical_text,
3409 rendered_search_start,
3410 )
3411 {
3412 assign_rendered_subtree_spans_from_parent_text(
3413 ir,
3414 node_id,
3415 rendered_parent_start,
3416 rendered_node_start,
3417 rendered_node_end,
3418 canonical_text,
3419 node_spans,
3420 )?;
3421 return Ok(());
3422 }
3423 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3424 node_index: node_id.index(),
3425 child_index: node_id.index(),
3426 });
3427 }
3428 };
3429 if rendered_end < rendered_start
3430 || rendered_end.saturating_sub(rendered_parent_start) > canonical_text.len()
3431 {
3432 return Err(TransformIrPrintErrorV0::UnprojectableDirtyChild {
3433 node_index: node_id.index(),
3434 child_index: node_id.index(),
3435 });
3436 }
3437 node_spans[node_id.index()] = Some((rendered_start, rendered_end));
3438
3439 for child_id in sorted_child_nodes(ir, node) {
3440 assign_projected_original_subtree_spans(
3441 ir,
3442 child_id,
3443 rendered_parent_start,
3444 projection,
3445 original_parent_start,
3446 canonical_text,
3447 node_spans,
3448 )?;
3449 }
3450 Ok(())
3451}
3452
3453fn rendered_original_subtree_start_in_parent_text(
3454 ir: &TransformIrV0,
3455 node_id: IrNodeIdV0,
3456 canonical_text: &str,
3457 search_start: usize,
3458) -> Option<usize> {
3459 let rendered_node = render_node_css(ir, node_id).ok()?;
3460 if rendered_node.is_empty() {
3461 return Some(search_start);
3462 }
3463 find_rendered_child_after(canonical_text, rendered_node.as_str(), search_start)
3464 .map(|(rendered_start, _)| rendered_start)
3465}
3466
3467fn rendered_expanded_style_rule_offsets_in_parent_text(
3468 ir: &TransformIrV0,
3469 node_id: IrNodeIdV0,
3470 canonical_text: &str,
3471 search_start: usize,
3472) -> Option<(usize, usize)> {
3473 let node = &ir.nodes[node_id.index()];
3474 if node.kind != IrNodeKindV0::StyleRule {
3475 return None;
3476 }
3477 let selector = expanded_style_rule_selector(ir, node)?;
3478 let start = find_rendered_child_after(canonical_text, selector.as_str(), search_start)?.0;
3479 let rendered_blocks = block_spans_for_ir_text(ir, canonical_text);
3480 let rendered_block = block_span_for_rendered_start(canonical_text, &rendered_blocks, start)?;
3481 Some((start, rendered_block.rule_end))
3482}
3483
3484fn assign_rendered_subtree_spans_from_parent_text(
3485 ir: &TransformIrV0,
3486 node_id: IrNodeIdV0,
3487 rendered_parent_start: usize,
3488 rendered_start: usize,
3489 rendered_end: usize,
3490 canonical_text: &str,
3491 node_spans: &mut [Option<(usize, usize)>],
3492) -> Result<(), TransformIrPrintErrorV0> {
3493 let mut local_spans = vec![None; node_spans.len()];
3494 assign_rendered_subtree_spans(
3495 ir,
3496 node_id,
3497 rendered_start,
3498 rendered_end,
3499 canonical_text,
3500 local_spans.as_mut_slice(),
3501 )?;
3502 for (index, span) in local_spans.into_iter().enumerate() {
3503 let Some((local_start, local_end)) = span else {
3504 continue;
3505 };
3506 node_spans[index] = Some((
3507 rendered_parent_start + local_start,
3508 rendered_parent_start + local_end,
3509 ));
3510 }
3511 Ok(())
3512}
3513
3514fn sorted_child_nodes(ir: &TransformIrV0, node: &IrNodeV0) -> Vec<IrNodeIdV0> {
3515 let mut children = node.children.clone();
3516 children.sort_by_key(|child_id| {
3517 let child = &ir.nodes[child_id.index()];
3518 (child.source_span_start, child.global_order)
3519 });
3520 children
3521}
3522
3523fn insert_before_in_list(list: &mut Vec<IrNodeIdV0>, anchor_id: IrNodeIdV0, node_id: IrNodeIdV0) {
3524 let insert_index = list
3525 .iter()
3526 .position(|candidate| *candidate == anchor_id)
3527 .unwrap_or(list.len());
3528 list.insert(insert_index, node_id);
3529}
3530
3531const fn spans_overlap(
3532 left_start: usize,
3533 left_end: usize,
3534 right_start: usize,
3535 right_end: usize,
3536) -> bool {
3537 left_start < right_end && right_start < left_end
3538}
3539
3540fn build_indexes(nodes: &[IrNodeV0]) -> TransformIrIndexesV0 {
3541 let mut nodes_by_kind = BTreeMap::<IrNodeKindV0, Vec<IrNodeIdV0>>::new();
3542 for node in nodes.iter().filter(|node| !node.deleted) {
3543 nodes_by_kind
3544 .entry(node.kind)
3545 .or_default()
3546 .push(node.node_id);
3547 }
3548 let by_kind = nodes_by_kind
3549 .into_iter()
3550 .map(|(kind, node_ids)| TransformIrKindIndexV0 { kind, node_ids })
3551 .collect();
3552
3553 let mut nodes_by_parent = BTreeMap::<Option<IrNodeIdV0>, Vec<IrNodeIdV0>>::new();
3560 for node in nodes.iter().filter(|node| !node.deleted) {
3561 nodes_by_parent
3562 .entry(node.parent)
3563 .or_default()
3564 .push(node.node_id);
3565 }
3566 let by_parent = nodes_by_parent
3567 .into_iter()
3568 .map(|(parent, node_ids)| TransformIrParentIndexV0 { parent, node_ids })
3569 .collect();
3570
3571 TransformIrIndexesV0 { by_kind, by_parent }
3572}
3573
3574fn validate_node_origins(ir: &TransformIrV0) -> Result<(), TransformIrPrintErrorV0> {
3575 for node in &ir.nodes {
3576 let Some(origin) = ir.origins.get(node.origin_index) else {
3577 return Err(TransformIrPrintErrorV0::MissingNodeOrigin {
3578 node_index: node.node_id.index(),
3579 });
3580 };
3581 if let NodeTextOriginV0::Original {
3582 source_span_start,
3583 source_span_end,
3584 ..
3585 } = origin
3586 {
3587 source_slice(
3588 ir,
3589 node.node_id.index(),
3590 *source_span_start,
3591 *source_span_end,
3592 )?;
3593 }
3594 }
3595 Ok(())
3596}
3597
3598fn source_slice(
3599 ir: &TransformIrV0,
3600 node_index: usize,
3601 source_span_start: usize,
3602 source_span_end: usize,
3603) -> Result<&str, TransformIrPrintErrorV0> {
3604 if source_span_start > source_span_end
3605 || source_span_end > ir.source_text.len()
3606 || !ir.source_text.is_char_boundary(source_span_start)
3607 || !ir.source_text.is_char_boundary(source_span_end)
3608 {
3609 return Err(TransformIrPrintErrorV0::InvalidOriginalSpan {
3610 node_index,
3611 source_span_start,
3612 source_span_end,
3613 source_byte_len: ir.source_text.len(),
3614 });
3615 }
3616 Ok(&ir.source_text[source_span_start..source_span_end])
3617}
3618
3619const fn kind_order(kind: IrNodeKindV0) -> u8 {
3620 match kind {
3621 IrNodeKindV0::StyleRule => 0,
3622 IrNodeKindV0::AtRule => 1,
3623 IrNodeKindV0::Selector => 2,
3624 IrNodeKindV0::Declaration => 3,
3625 IrNodeKindV0::Value => 4,
3626 IrNodeKindV0::UrlValue => 5,
3627 }
3628}
3629
3630#[cfg(test)]
3631mod tests {
3632 use super::{
3633 IrEditRegionV0, IrNodeIdV0, IrNodeKindV0, IrTransactionErrorV0, IrTransactionV0,
3634 IrTransactionValidationErrorV0, NodeTextOriginV0, TRANSACTION_VALIDATION_PASSES_V0,
3635 TransformIrParseErrorSpanV0, TransformIrPrintErrorV0, build_indexes,
3636 expanded_style_rule_selector, has_less_mixin_declaration_owner,
3637 lower_transform_ir_from_source, materialize_transform_ir_printed_source,
3638 print_transform_ir_css, reset_transform_ir_transaction_cost_telemetry,
3639 summarize_transform_ir_identity_round_trip, transaction_validation_pass_counts,
3640 transform_ir_transaction_cost_telemetry_snapshot, validate_transaction_commit,
3641 };
3642 use omena_parser::StyleDialect;
3643 use std::collections::{BTreeMap, BTreeSet};
3644
3645 #[test]
3646 fn transform_ir_identity_round_trip_keeps_original_origins() -> Result<(), String> {
3647 let source = r#".card {
3648 color: red;
3649}
3650@media (min-width: 40rem) {
3651 .card { color: blue; }
3652}
3653"#;
3654 let summary =
3655 summarize_transform_ir_identity_round_trip(source, StyleDialect::Css, "fixture:card")
3656 .map_err(|err| format!("round trip should print: {err:?}"))?;
3657
3658 assert_eq!(
3659 summary.product,
3660 "omena-transform-cst.transform-ir-identity-round-trip"
3661 );
3662 assert!(summary.byte_identical);
3663 assert!(summary.all_nodes_original);
3664 assert_eq!(summary.synthesized_node_count, 0);
3665 assert_eq!(summary.printed_css, source);
3666 assert!(summary.node_count >= 5);
3667 Ok(())
3668 }
3669
3670 #[test]
3671 fn transform_ir_parent_stack_matches_the_interval_reference() {
3672 let source = r#"
3673@media (min-width: 40rem) {
3674 .card, .panel {
3675 color: rgb(1 2 3);
3676 background: url(./card.png);
3677 }
3678}
3679.outside { margin: 0; }
3680"#;
3681 let ir =
3682 lower_transform_ir_from_source(source, StyleDialect::Css, "parent-stack-reference.css");
3683
3684 for (index, node) in ir.nodes.iter().enumerate() {
3685 let expected = ir
3686 .nodes
3687 .iter()
3688 .enumerate()
3689 .filter(|(candidate_index, candidate)| {
3690 *candidate_index != index
3691 && candidate.source_span_start <= node.source_span_start
3692 && candidate.source_span_end >= node.source_span_end
3693 && candidate.source_span_len() > node.source_span_len()
3694 })
3695 .min_by_key(|(_, candidate)| candidate.source_span_len())
3696 .map(|(candidate_index, _)| IrNodeIdV0(candidate_index));
3697 assert_eq!(
3698 node.parent, expected,
3699 "stack and reference disagree for node {index}: {node:?}"
3700 );
3701 }
3702 }
3703
3704 #[test]
3705 fn transaction_validation_telemetry_is_derived_from_the_executed_registry() {
3706 let names = TRANSACTION_VALIDATION_PASSES_V0
3707 .iter()
3708 .map(|pass| pass.name)
3709 .collect::<Vec<_>>();
3710 eprintln!(
3711 "transactionValidationRegistry names={names:?} counts={:?}",
3712 transaction_validation_pass_counts()
3713 );
3714 assert_eq!(
3715 names,
3716 vec![
3717 "no-dangling-nodes",
3718 "parent-child-links",
3719 "declaration-ownership",
3720 "global-order-slots",
3721 "provenance",
3722 "changed-nodes-inside-region",
3723 "changed-nodes-outside-parse-errors",
3724 ],
3725 "adding or removing an executed pass must update the cost contract"
3726 );
3727 assert_eq!(transaction_validation_pass_counts(), (5, 2));
3728 }
3729
3730 #[test]
3731 fn ir_transaction_edit_log_snapshots_only_changed_nodes() -> Result<(), String> {
3732 let source = (0..96)
3733 .map(|index| format!(".rule-{index} {{ color: red; margin: {index}px; }}\n"))
3734 .collect::<String>();
3735 let mut ir = lower_transform_ir_from_source(
3736 source.as_str(),
3737 StyleDialect::Css,
3738 "transaction-cost.css",
3739 );
3740 let value = ir
3741 .nodes
3742 .iter()
3743 .find(|node| {
3744 node.kind == IrNodeKindV0::Value
3745 && ir
3746 .source_text()
3747 .get(node.source_span_start..node.source_span_end)
3748 .is_some_and(|text| text.trim() == "red")
3749 })
3750 .map(|node| node.node_id)
3751 .ok_or_else(|| "fixture must contain a value node".to_string())?;
3752 let node_count = ir.nodes.len();
3753 let source_byte_len = ir.source_byte_len;
3754 reset_transform_ir_transaction_cost_telemetry();
3755
3756 let mut transaction = IrTransactionV0::new(
3757 &mut ir,
3758 "single-value",
3759 IrEditRegionV0::full(source_byte_len),
3760 );
3761 transaction
3762 .rewrite_value(value, "blue")
3763 .map_err(|error| format!("rewrite should succeed: {error:?}"))?;
3764 transaction
3765 .commit()
3766 .map_err(|error| format!("commit should succeed: {error:?}"))?;
3767
3768 let telemetry = transform_ir_transaction_cost_telemetry_snapshot();
3769 assert_eq!(telemetry.transaction_count, 1);
3770 assert_eq!(telemetry.input_node_count, node_count as u64);
3771 assert_eq!(telemetry.snapshotted_node_count, 1);
3772 assert!(
3773 telemetry.shared_node_fraction() >= 0.99,
3774 "a one-node rewrite should preserve at least 99% of the arena without snapshots"
3775 );
3776 assert_eq!(telemetry.shifted_global_order_node_count, 0);
3777 assert_eq!(telemetry.whole_validation_pass_count, 5);
3778 assert_eq!(
3779 telemetry.whole_validation_outer_node_visit_count,
3780 (node_count as u64) * 5
3781 );
3782 assert_eq!(telemetry.delta_validation_pass_count, 2);
3783 assert_eq!(telemetry.delta_validation_node_visit_count, 2);
3784 let printed = print_transform_ir_css(&ir)
3785 .map_err(|error| format!("edited IR should print: {error:?}"))?;
3786 assert!(printed.contains("blue"), "printed={printed:?}");
3787 Ok(())
3788 }
3789
3790 #[test]
3791 fn dropped_ir_transaction_rolls_back_mutate_through_share() -> Result<(), String> {
3792 let source = ".card { color: red; }";
3793 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "rollback.css");
3794 let before = ir.clone();
3795 let value = ir
3796 .nodes
3797 .iter()
3798 .find(|node| node.kind == IrNodeKindV0::Value)
3799 .map(|node| node.node_id)
3800 .ok_or_else(|| "fixture must contain a value node".to_string())?;
3801 let source_byte_len = ir.source_byte_len;
3802 {
3803 let mut transaction = IrTransactionV0::new(
3804 &mut ir,
3805 "uncommitted",
3806 IrEditRegionV0::full(source_byte_len),
3807 );
3808 transaction
3809 .rewrite_value(value, "poisoned")
3810 .map_err(|error| format!("seed mutation should succeed: {error:?}"))?;
3811 }
3812
3813 assert_eq!(ir, before, "dropping an edit log must restore the base IR");
3814 assert_eq!(
3815 print_transform_ir_css(&ir)
3816 .map_err(|error| format!("rolled-back IR should print: {error:?}"))?,
3817 source
3818 );
3819 Ok(())
3820 }
3821
3822 #[test]
3823 fn ir_transaction_reports_the_remaining_global_order_suffix_cost() -> Result<(), String> {
3824 let source = (0..32)
3825 .map(|index| format!(".rule-{index} {{ color: red; }}\n"))
3826 .collect::<String>();
3827 let mut ir =
3828 lower_transform_ir_from_source(source.as_str(), StyleDialect::Css, "order-cost.css");
3829 let anchor = *ir
3830 .root_nodes
3831 .first()
3832 .ok_or_else(|| "fixture must contain a root".to_string())?;
3833 let original_node_count = ir.nodes.len();
3834 let source_byte_len = ir.source_byte_len;
3835 reset_transform_ir_transaction_cost_telemetry();
3836
3837 let mut transaction = IrTransactionV0::new(
3838 &mut ir,
3839 "insert-root",
3840 IrEditRegionV0::full(source_byte_len),
3841 );
3842 transaction
3843 .insert_before(anchor, IrNodeKindV0::AtRule, "@charset \"UTF-8\";")
3844 .map_err(|error| format!("insert should succeed: {error:?}"))?;
3845 transaction
3846 .commit()
3847 .map_err(|error| format!("insert commit should succeed: {error:?}"))?;
3848
3849 let telemetry = transform_ir_transaction_cost_telemetry_snapshot();
3850 assert_eq!(telemetry.snapshotted_node_count, 0);
3851 assert_eq!(
3852 telemetry.shifted_global_order_node_count, original_node_count as u64,
3853 "inserting at the first observable order slot still shifts the whole suffix"
3854 );
3855 assert_eq!(
3856 telemetry.whole_validation_outer_node_visit_count,
3857 ((original_node_count + 1) as u64) * 5
3858 );
3859 assert!(
3860 print_transform_ir_css(&ir)
3861 .map_err(|error| format!("inserted IR should print: {error:?}"))?
3862 .starts_with("@charset \"UTF-8\";")
3863 );
3864 Ok(())
3865 }
3866
3867 #[test]
3868 fn transform_ir_lowers_css_module_value_statements_as_at_rule_nodes() -> Result<(), String> {
3869 let source = r#"@value used: red; @value dead: blue; .button { color: used; }"#;
3870 let ir = lower_transform_ir_from_source(source, StyleDialect::Css, "css-module-values");
3871 let statement_start = source
3872 .find("@value dead")
3873 .ok_or_else(|| "fixture should contain dead @value".to_string())?;
3874 let statement_end = statement_start + "@value dead: blue;".len();
3875
3876 assert!(ir.nodes.iter().any(|node| {
3877 node.kind == IrNodeKindV0::AtRule
3878 && node.source_span_start == statement_start
3879 && node.source_span_end == statement_end
3880 }));
3881 assert_eq!(
3882 print_transform_ir_css(&ir).map_err(|err| format!("print should succeed: {err:?}"))?,
3883 source
3884 );
3885 Ok(())
3886 }
3887
3888 #[test]
3889 fn transform_ir_retains_non_node_structural_block_spans() {
3890 let source = "@keyframes fade { from { opacity: 0; } to { opacity: 1; } }";
3891 let ir = lower_transform_ir_from_source(source, StyleDialect::Css, "keyframe-block-spans");
3892 let preludes = ir
3893 .structural_block_spans()
3894 .iter()
3895 .filter_map(|span| source.get(span.prelude_start..span.open_brace_start))
3896 .map(str::trim)
3897 .collect::<Vec<_>>();
3898
3899 assert_eq!(preludes, vec!["@keyframes fade", "from", "to"]);
3900 }
3901
3902 #[test]
3903 fn css_module_value_statement_span_ignores_semicolons_inside_values() {
3904 let statement = r#"@value marker: "a;b";"#;
3905 let source = format!("{statement} .button {{ content: marker; }}");
3906 let ir = lower_transform_ir_from_source(
3907 source.as_str(),
3908 StyleDialect::Css,
3909 "css-module-value-span",
3910 );
3911 let statements = ir
3912 .nodes
3913 .iter()
3914 .filter(|node| node.kind == IrNodeKindV0::AtRule)
3915 .map(|node| &source[node.source_span_start..node.source_span_end])
3916 .collect::<Vec<_>>();
3917
3918 assert_eq!(statements, vec![statement]);
3919 }
3920
3921 #[test]
3922 fn transform_ir_lowers_url_values_as_typed_nodes() -> Result<(), String> {
3923 let source = r#".card { background-image: url("../img/icon.svg"); }"#;
3924 let ir = lower_transform_ir_from_source(source, StyleDialect::Css, "url-values");
3925 let url_start = source
3926 .find("url(")
3927 .ok_or_else(|| "fixture should contain url(...)".to_string())?;
3928 let url_end = url_start + r#"url("../img/icon.svg")"#.len();
3929 let url_node = ir
3930 .nodes
3931 .iter()
3932 .find(|node| {
3933 node.kind == IrNodeKindV0::UrlValue
3934 && node.source_span_start == url_start
3935 && node.source_span_end == url_end
3936 })
3937 .ok_or_else(|| "URL value should lower to a typed IR node".to_string())?;
3938
3939 assert!(ir.indexes().by_kind.iter().any(|index| {
3940 index.kind == IrNodeKindV0::UrlValue && index.node_ids.contains(&url_node.node_id)
3941 }));
3942 assert_eq!(
3943 print_transform_ir_css(&ir).map_err(|err| format!("print should succeed: {err:?}"))?,
3944 source
3945 );
3946 Ok(())
3947 }
3948
3949 #[test]
3950 fn transform_ir_indexes_structural_node_kinds() {
3951 let ir = lower_transform_ir_from_source(
3952 ".card { color: red; }\n@supports (display: grid) { .grid { display: grid; } }",
3953 StyleDialect::Css,
3954 "fixture:index",
3955 );
3956
3957 assert_eq!(ir.product, "omena-transform-cst.transform-ir");
3958 assert!(ir.all_nodes_original());
3959 assert_eq!(ir.original_node_count, ir.nodes.len());
3960 assert!(ir.root_nodes.iter().all(|node_id| {
3961 ir.nodes[node_id.index()].kind == IrNodeKindV0::StyleRule
3962 || ir.nodes[node_id.index()].kind == IrNodeKindV0::AtRule
3963 }));
3964 assert!(ir.indexes().by_kind.iter().any(|index| {
3965 index.kind == IrNodeKindV0::Declaration && !index.node_ids.is_empty()
3966 }));
3967 assert!(ir.origins.iter().all(NodeTextOriginV0::is_original));
3968 }
3969
3970 #[test]
3971 fn lazy_transform_ir_indexes_match_eager_indexes_and_ignore_force_state_in_equality() {
3972 for (fixture_id, source) in [
3973 ("plain", ".card { color: red; }"),
3974 (
3975 "nested",
3976 "@supports (display: grid) { .grid { display: grid; gap: 1rem; } }",
3977 ),
3978 (
3979 "multi-root",
3980 ".a { color: red; }\n.b { background: blue; }\n@media print { .c { color: black; } }",
3981 ),
3982 ] {
3983 let ir = lower_transform_ir_from_source(
3984 source,
3985 StyleDialect::Css,
3986 format!("index:{fixture_id}"),
3987 );
3988 let unforced_clone = ir.clone();
3989 assert_transform_ir_indexes_partition_active_nodes(&ir);
3990 assert_eq!(
3991 ir, unforced_clone,
3992 "forcing a derived index must not affect TransformIrV0 equality"
3993 );
3994 }
3995 }
3996
3997 #[test]
3998 fn transform_ir_mutation_invalidates_forced_lazy_indexes() -> Result<(), String> {
3999 let mut ir = lower_transform_ir_from_source(
4000 ".first { color: red; }\n.second { color: blue; }",
4001 StyleDialect::Css,
4002 "index-invalidation",
4003 );
4004 let _ = ir.indexes();
4005 let anchor = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4006 let source_byte_len = ir.source_byte_len;
4007 let mut transaction = IrTransactionV0::new(
4008 &mut ir,
4009 "index-invalidation",
4010 IrEditRegionV0::full(source_byte_len),
4011 );
4012 transaction
4013 .insert_before(
4014 anchor,
4015 IrNodeKindV0::StyleRule,
4016 ".inserted { color: green; }\n",
4017 )
4018 .map_err(|error| format!("insert should succeed: {error:?}"))?;
4019
4020 assert_eq!(
4021 transaction.working.indexes(),
4022 &build_indexes(&transaction.working.nodes),
4023 "the first read after mutation must rebuild the invalidated index"
4024 );
4025 Ok(())
4026 }
4027
4028 #[test]
4029 fn ir_transaction_commits_value_rewrite_through_printer() -> Result<(), String> {
4030 let mut ir =
4031 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "rewrite");
4032 assert_eq!(ir.ir_epoch(), 0);
4033 let value_id = first_node_id(&ir, IrNodeKindV0::Value)?;
4034 let region = IrEditRegionV0::full(ir.source_byte_len);
4035 let mut transaction = IrTransactionV0::new(&mut ir, "rewrite-value", region);
4036 transaction
4037 .rewrite_value(value_id, " blue")
4038 .map_err(|err| format!("rewrite value should be accepted: {err:?}"))?;
4039 transaction
4040 .commit()
4041 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4042
4043 assert!(!ir.all_nodes_original());
4044 assert_eq!(
4045 print_transform_ir_css(&ir)
4046 .map_err(|err| format!("mutated IR should print: {err:?}"))?,
4047 ".card { color: blue; }"
4048 );
4049 assert_eq!(ir.ir_epoch(), 1);
4050 Ok(())
4051 }
4052
4053 #[test]
4054 fn ir_epoch_advances_once_for_each_committed_transaction() -> Result<(), String> {
4055 let mut ir = lower_transform_ir_from_source(
4056 ".card { color: red; background: white; }",
4057 StyleDialect::Css,
4058 "epoch-currency",
4059 );
4060 assert_eq!(ir.ir_epoch(), 0);
4061
4062 let values = ir
4063 .nodes
4064 .iter()
4065 .filter(|node| node.kind == IrNodeKindV0::Value)
4066 .map(|node| node.node_id)
4067 .collect::<Vec<_>>();
4068 let first_value = *values
4069 .first()
4070 .ok_or_else(|| "fixture should contain a first value".to_string())?;
4071 let second_value = *values
4072 .get(1)
4073 .ok_or_else(|| "fixture should contain a second value".to_string())?;
4074
4075 let source_byte_len = ir.source_byte_len;
4076 let mut transaction = IrTransactionV0::new(
4077 &mut ir,
4078 "rewrite-values",
4079 IrEditRegionV0::full(source_byte_len),
4080 );
4081 transaction
4082 .rewrite_value(first_value, " blue")
4083 .map_err(|err| format!("first value rewrite should be accepted: {err:?}"))?;
4084 transaction
4085 .rewrite_value(second_value, " black")
4086 .map_err(|err| format!("second value rewrite should be accepted: {err:?}"))?;
4087 transaction
4088 .commit()
4089 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4090
4091 assert_eq!(ir.ir_epoch(), 1);
4092 let _ = ir.indexes();
4093 let serialized = serde_json::to_string(&ir)
4094 .map_err(|err| format!("IR serialization should succeed: {err:?}"))?;
4095 assert!(!serialized.contains("irEpoch"));
4096 assert!(!serialized.contains("ir_epoch"));
4097 assert!(!serialized.contains("blockSpan"));
4098 assert!(!serialized.contains("ownerBlockSpan"));
4099 assert!(!serialized.contains("structuralBlockSpans"));
4100 assert!(!serialized.contains("\"indexes\""));
4101
4102 let source_byte_len = ir.source_byte_len;
4103 let mut followup = IrTransactionV0::new(
4104 &mut ir,
4105 "rewrite-again",
4106 IrEditRegionV0::full(source_byte_len),
4107 );
4108 followup
4109 .rewrite_value(first_value, " green")
4110 .map_err(|err| format!("follow-up value rewrite should be accepted: {err:?}"))?;
4111 followup
4112 .commit()
4113 .map_err(|err| format!("follow-up transaction should commit: {err:?}"))?;
4114
4115 assert_eq!(ir.ir_epoch(), 2);
4116 Ok(())
4117 }
4118
4119 #[test]
4120 fn transform_ir_reconstruction_round_trip_exercises_dirty_node_spans() -> Result<(), String> {
4121 let mut ir = lower_transform_ir_from_source(
4122 ".card { color: red; background: white; }",
4123 StyleDialect::Css,
4124 "rewrite-spans",
4125 );
4126 let value_id = first_node_id(&ir, IrNodeKindV0::Value)?;
4127 let region = IrEditRegionV0::full(ir.source_byte_len);
4128 let mut transaction = IrTransactionV0::new(&mut ir, "rewrite-value", region);
4129 transaction
4130 .rewrite_value(value_id, " blue")
4131 .map_err(|err| format!("rewrite value should be accepted: {err:?}"))?;
4132 transaction
4133 .commit()
4134 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4135
4136 assert!(!ir.all_nodes_original());
4137 let printed = materialize_transform_ir_printed_source(&mut ir)
4138 .map_err(|err| format!("dirty-node materialization should succeed: {err:?}"))?;
4139 assert_eq!(printed, ".card { color: blue; background: white; }");
4140 assert!(ir.all_nodes_original());
4141 assert_eq!(ir.source_text(), printed);
4142 Ok(())
4143 }
4144
4145 #[test]
4146 fn materialized_transaction_rebases_source_spans_for_next_transaction() -> Result<(), String> {
4147 let mut ir =
4148 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "material");
4149 let value_id = first_node_id(&ir, IrNodeKindV0::Value)?;
4150 let region = IrEditRegionV0::full(ir.source_byte_len);
4151 let mut transaction = IrTransactionV0::new(&mut ir, "rewrite-value", region);
4152 transaction
4153 .rewrite_value(value_id, " blue")
4154 .map_err(|err| format!("first rewrite should be accepted: {err:?}"))?;
4155 transaction
4156 .commit()
4157 .map_err(|err| format!("first transaction should commit: {err:?}"))?;
4158
4159 let printed = materialize_transform_ir_printed_source(&mut ir)
4160 .map_err(|err| format!("materialization should succeed: {err:?}"))?;
4161 assert_eq!(printed, ".card { color: blue; }");
4162 assert_eq!(ir.source_text(), ".card { color: blue; }");
4163 assert!(ir.all_nodes_original());
4164 assert_eq!(ir.synthesized_node_count, 0);
4165 let value = &ir.nodes[value_id.index()];
4166 assert_eq!(
4167 &ir.source_text()[value.source_span_start..value.source_span_end],
4168 " blue"
4169 );
4170
4171 let mut transaction =
4172 IrTransactionV0::new(&mut ir, "rewrite-value-again", IrEditRegionV0::full(22));
4173 transaction
4174 .rewrite_value(value_id, " green")
4175 .map_err(|err| format!("second rewrite should use materialized spans: {err:?}"))?;
4176 transaction
4177 .commit()
4178 .map_err(|err| format!("second transaction should commit: {err:?}"))?;
4179
4180 assert_eq!(
4181 print_transform_ir_css(&ir)
4182 .map_err(|err| format!("second mutation should print: {err:?}"))?,
4183 ".card { color: green; }"
4184 );
4185 Ok(())
4186 }
4187
4188 #[test]
4189 fn materialization_rejects_shifted_typed_block_span() -> Result<(), String> {
4190 let source = "@scope (.card) { .title{color:red;} }";
4191 let canonical = "@scope (._card_x) { .title { color:red;} }";
4192 let base = lower_transform_ir_from_source(source, StyleDialect::Css, "typed-span-guard");
4193 let at_rule = first_node_id(&base, IrNodeKindV0::AtRule)?;
4194 let nested_rule = base
4195 .nodes
4196 .iter()
4197 .find(|node| node.kind == IrNodeKindV0::StyleRule && node.parent == Some(at_rule))
4198 .map(|node| node.node_id)
4199 .ok_or_else(|| "fixture should expose a nested style rule".to_string())?;
4200
4201 let mut control = base.clone();
4202 let mut control_transaction = IrTransactionV0::new(
4203 &mut control,
4204 "typed-span-control",
4205 IrEditRegionV0::full(source.len()),
4206 );
4207 control_transaction
4208 .replace_node(at_rule, canonical)
4209 .map_err(|error| format!("control rewrite should be accepted: {error:?}"))?;
4210 control_transaction
4211 .commit()
4212 .map_err(|error| format!("control transaction should commit: {error:?}"))?;
4213 assert_eq!(
4214 materialize_transform_ir_printed_source(&mut control)
4215 .map_err(|error| format!("control materialization should succeed: {error:?}"))?,
4216 canonical
4217 );
4218
4219 let mut shifted = base;
4220 let block = shifted.nodes[nested_rule.index()]
4221 .block_span
4222 .as_mut()
4223 .ok_or_else(|| "fixture should expose a typed block span".to_string())?;
4224 block.open_brace_start = block.open_brace_start.saturating_add(1);
4225
4226 let mut transaction = IrTransactionV0::new(
4227 &mut shifted,
4228 "typed-span-guard",
4229 IrEditRegionV0::full(source.len()),
4230 );
4231 transaction
4232 .replace_node(at_rule, canonical)
4233 .map_err(|error| format!("rule rewrite should be accepted: {error:?}"))?;
4234 transaction
4235 .commit()
4236 .map_err(|error| format!("transaction should commit: {error:?}"))?;
4237
4238 let materialized = materialize_transform_ir_printed_source(&mut shifted);
4239 assert!(matches!(
4240 materialized,
4241 Err(TransformIrPrintErrorV0::UnprojectableDirtyChild { .. })
4242 ));
4243 Ok(())
4244 }
4245
4246 #[test]
4247 fn materialized_deletion_rebases_deleted_subtree_spans() -> Result<(), String> {
4248 let source = ".dead { color: red; } .used { color: blue; }";
4249 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "delete-material");
4250 let deleted_rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4251 let deleted_descendants = ir.nodes[deleted_rule.index()].children.clone();
4252 let region = IrEditRegionV0::full(source.len());
4253 let mut transaction = IrTransactionV0::new(&mut ir, "delete-rule", region);
4254 transaction
4255 .delete_node(deleted_rule)
4256 .map_err(|err| format!("rule delete should be accepted: {err:?}"))?;
4257 transaction
4258 .commit()
4259 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4260
4261 let printed = materialize_transform_ir_printed_source(&mut ir)
4262 .map_err(|err| format!("deletion materialization should succeed: {err:?}"))?;
4263
4264 assert_eq!(printed, " .used { color: blue; }");
4265 assert_eq!(ir.source_text(), " .used { color: blue; }");
4266 assert!(ir.nodes[deleted_rule.index()].deleted);
4267 assert!(
4268 deleted_descendants
4269 .iter()
4270 .all(|node_id| ir.nodes[node_id.index()].deleted)
4271 );
4272 assert!(deleted_descendants.iter().all(|node_id| {
4273 ir.nodes[node_id.index()].source_span_start == ir.nodes[node_id.index()].source_span_end
4274 }));
4275 Ok(())
4276 }
4277
4278 #[test]
4279 fn ir_transaction_unwraps_wrapper_node_by_promoting_children() -> Result<(), String> {
4280 let source = "@media all { .used { color: blue; } }";
4281 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "unwrap-node");
4282 let wrapper = first_node_id(&ir, IrNodeKindV0::AtRule)?;
4283 let child = ir.nodes[wrapper.index()]
4284 .children
4285 .first()
4286 .copied()
4287 .ok_or_else(|| "wrapper should expose a child rule".to_string())?;
4288 let region = IrEditRegionV0::full(source.len());
4289 let mut transaction = IrTransactionV0::new(&mut ir, "unwrap-node", region);
4290 transaction
4291 .unwrap_node(wrapper)
4292 .map_err(|err| format!("wrapper unwrap should be accepted: {err:?}"))?;
4293 transaction
4294 .commit()
4295 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4296
4297 assert_eq!(ir.nodes[child.index()].parent, None);
4298 assert!(ir.root_nodes.contains(&child));
4299 assert_eq!(
4300 print_transform_ir_css(&ir)
4301 .map_err(|err| format!("unwrapped IR should print: {err:?}"))?,
4302 ".used { color: blue; }"
4303 );
4304 let printed = materialize_transform_ir_printed_source(&mut ir)
4305 .map_err(|err| format!("unwrap materialization should succeed: {err:?}"))?;
4306 assert_eq!(printed, ".used { color: blue; }");
4307 Ok(())
4308 }
4309
4310 #[test]
4311 fn lower_transform_ir_exposes_css_module_composes_declaration() {
4312 let source = ".button { composes: base utility from \"./base.css\"; color: red; }";
4313 let ir = lower_transform_ir_from_source(source, StyleDialect::Css, "composes-declaration");
4314
4315 assert!(ir.nodes.iter().any(|node| {
4316 node.kind == IrNodeKindV0::Declaration
4317 && &source[node.source_span_start..node.source_span_end]
4318 == "composes: base utility from \"./base.css\";"
4319 }));
4320 }
4321
4322 #[test]
4323 fn css_module_fact_spans_follow_syntax_across_delimiter_strings() {
4324 let composes = r#"composes: base from "./tokens;a.css";"#;
4325 let source = format!(".button {{ {composes} color: red; }}");
4326 let ir = lower_transform_ir_from_source(
4327 source.as_str(),
4328 StyleDialect::Css,
4329 "css-module-composes-span",
4330 );
4331 let composes_nodes = ir
4332 .nodes
4333 .iter()
4334 .filter(|node| {
4335 node.kind == IrNodeKindV0::Declaration
4336 && source[node.source_span_start..node.source_span_end].starts_with("composes")
4337 })
4338 .map(|node| &source[node.source_span_start..node.source_span_end])
4339 .collect::<Vec<_>>();
4340 assert_eq!(composes_nodes, vec![composes]);
4341
4342 let icss = r#":export { marker: "}"; next: blue; }"#;
4343 let ir = lower_transform_ir_from_source(icss, StyleDialect::Css, "icss-block-span");
4344 let icss_blocks = ir
4345 .nodes
4346 .iter()
4347 .filter(|node| node.kind == IrNodeKindV0::StyleRule)
4348 .map(|node| &icss[node.source_span_start..node.source_span_end])
4349 .collect::<Vec<_>>();
4350 assert_eq!(icss_blocks, vec![icss]);
4351 }
4352
4353 #[test]
4354 fn ir_transaction_exposes_replace_delete_and_insert_mutators() -> Result<(), String> {
4355 let mut ir = lower_transform_ir_from_source(
4356 ".card { color: red; }\n.tile { color: blue; }",
4357 StyleDialect::Css,
4358 "mutators",
4359 );
4360 let selector_id = first_node_id(&ir, IrNodeKindV0::Selector)?;
4361 let rule_id = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4362 let region = IrEditRegionV0::full(ir.source_byte_len);
4363 let mut transaction = IrTransactionV0::new(&mut ir, "mutator-smoke", region);
4364 transaction
4365 .replace_node(selector_id, ".panel")
4366 .map_err(|err| format!("replace node should be accepted: {err:?}"))?;
4367 transaction
4368 .insert_before(
4369 rule_id,
4370 IrNodeKindV0::StyleRule,
4371 ".inserted { color: green; }\n",
4372 )
4373 .map_err(|err| format!("insert before should be accepted: {err:?}"))?;
4374 transaction
4375 .delete_node(rule_id)
4376 .map_err(|err| format!("delete node should be accepted: {err:?}"))?;
4377 transaction
4378 .commit()
4379 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4380
4381 assert_eq!(ir.synthesized_node_count, 2);
4382 assert!(ir.nodes.iter().any(|node| node.deleted));
4383 Ok(())
4384 }
4385
4386 #[test]
4387 fn ir_transaction_inserts_ir_root_subtrees_before_anchor() -> Result<(), String> {
4388 let source = r#"@import "./tokens.css"; .button { color: var(--alias); }"#;
4389 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "import-graft");
4390 let replacement_ir = lower_transform_ir_from_source(
4391 ":root { --alias: var(--brand); --brand: red; }",
4392 StyleDialect::Css,
4393 "import-graft.inserted",
4394 );
4395 let anchor = first_node_id(&ir, IrNodeKindV0::AtRule)?;
4396 let mut transaction =
4397 IrTransactionV0::new(&mut ir, "import-inline", IrEditRegionV0::full(source.len()));
4398 let roots = transaction
4399 .insert_ir_roots_before(anchor, &replacement_ir)
4400 .map_err(|err| format!("subtree graft should be accepted: {err:?}"))?;
4401 transaction
4402 .delete_node(anchor)
4403 .map_err(|err| format!("anchor delete should be accepted: {err:?}"))?;
4404 transaction
4405 .commit()
4406 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4407
4408 assert_eq!(roots.len(), 1);
4409 assert!(ir.nodes.iter().any(|node| {
4410 !node.deleted
4411 && node.kind == IrNodeKindV0::Declaration
4412 && node
4413 .canonical_text
4414 .as_deref()
4415 .is_some_and(|text| text.contains("--alias"))
4416 }));
4417
4418 let printed = materialize_transform_ir_printed_source(&mut ir)
4419 .map_err(|err| format!("grafted IR should materialize: {err:?}"))?;
4420 assert_eq!(
4421 printed,
4422 ":root { --alias: var(--brand); --brand: red; } .button { color: var(--alias); }"
4423 );
4424 let alias_value = ir
4425 .nodes
4426 .iter()
4427 .find(|node| {
4428 !node.deleted
4429 && node.kind == IrNodeKindV0::Value
4430 && ir.source_text()[node.source_span_start..node.source_span_end].trim()
4431 == "var(--brand)"
4432 })
4433 .map(|node| node.node_id)
4434 .ok_or_else(|| "materialized graft should expose inserted value node".to_string())?;
4435 let mut followup = IrTransactionV0::new(
4436 &mut ir,
4437 "design-token-routing",
4438 IrEditRegionV0::full(printed.len()),
4439 );
4440 followup
4441 .rewrite_value(alias_value, " red")
4442 .map_err(|err| format!("inserted value rewrite should be accepted: {err:?}"))?;
4443 followup
4444 .commit()
4445 .map_err(|err| format!("follow-up transaction should commit: {err:?}"))?;
4446
4447 assert_eq!(
4448 print_transform_ir_css(&ir)
4449 .map_err(|err| format!("follow-up IR should print: {err:?}"))?,
4450 ":root { --alias: red; --brand: red; } .button { color: var(--alias); }"
4451 );
4452 Ok(())
4453 }
4454
4455 #[test]
4456 fn ir_transaction_preserves_inter_root_spacing_for_inserted_ir() -> Result<(), String> {
4457 let source = r#"@import "./components.css"; .app { color: green; }"#;
4458 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "multi-root-graft");
4459 let replacement_ir = lower_transform_ir_from_source(
4460 ".base { color: red; } .token { color: blue; }",
4461 StyleDialect::Css,
4462 "multi-root-graft.inserted",
4463 );
4464 let anchor = first_node_id(&ir, IrNodeKindV0::AtRule)?;
4465 let mut transaction =
4466 IrTransactionV0::new(&mut ir, "import-inline", IrEditRegionV0::full(source.len()));
4467 transaction
4468 .insert_ir_roots_before(anchor, &replacement_ir)
4469 .map_err(|err| format!("multi-root graft should be accepted: {err:?}"))?;
4470 transaction
4471 .delete_node(anchor)
4472 .map_err(|err| format!("anchor delete should be accepted: {err:?}"))?;
4473 transaction
4474 .commit()
4475 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4476
4477 let printed = materialize_transform_ir_printed_source(&mut ir)
4478 .map_err(|err| format!("multi-root graft should materialize: {err:?}"))?;
4479 assert_eq!(
4480 printed,
4481 ".base { color: red; } .token { color: blue; } .app { color: green; }"
4482 );
4483 Ok(())
4484 }
4485
4486 #[test]
4487 fn ir_transaction_replaces_node_across_consumed_sibling_span() -> Result<(), String> {
4488 let mut ir = lower_transform_ir_from_source(
4489 ".a { color: red; } .a { background: blue; }",
4490 StyleDialect::Css,
4491 "covering-span",
4492 );
4493 let rule_ids = ir
4494 .nodes
4495 .iter()
4496 .filter(|node| node.kind == IrNodeKindV0::StyleRule)
4497 .map(|node| node.node_id)
4498 .collect::<Vec<_>>();
4499 let first_rule = *rule_ids
4500 .first()
4501 .ok_or_else(|| "fixture should produce the first rule".to_string())?;
4502 let second_rule = *rule_ids
4503 .get(1)
4504 .ok_or_else(|| "fixture should produce the second rule".to_string())?;
4505 let span_start = ir.nodes[first_rule.index()].source_span_start;
4506 let span_end = ir.nodes[second_rule.index()].source_span_end;
4507 let region = IrEditRegionV0 {
4508 source_span_start: span_start,
4509 source_span_end: span_end,
4510 };
4511 let mut transaction = IrTransactionV0::new(&mut ir, "rule-merge", region);
4512 transaction
4513 .replace_node_covering_span(
4514 first_rule,
4515 ".a { color: red; background: blue; }",
4516 span_start,
4517 span_end,
4518 )
4519 .map_err(|err| format!("covering replacement should be accepted: {err:?}"))?;
4520 transaction
4521 .delete_node(second_rule)
4522 .map_err(|err| format!("covered sibling should be deletable: {err:?}"))?;
4523 transaction
4524 .commit()
4525 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4526
4527 assert_eq!(
4528 print_transform_ir_css(&ir)
4529 .map_err(|err| format!("mutated IR should print: {err:?}"))?,
4530 ".a { color: red; background: blue; }"
4531 );
4532 Ok(())
4533 }
4534
4535 #[test]
4536 fn ir_transaction_prints_dirty_child_inside_dirty_parent_when_spans_project()
4537 -> Result<(), String> {
4538 let source = "@scope (.card) { .title { color: red; } }";
4539 let mut ir = lower_transform_ir_from_source(source, StyleDialect::Css, "nested-dirty");
4540 let at_rule = first_node_id(&ir, IrNodeKindV0::AtRule)?;
4541 let nested_rule = ir
4542 .nodes
4543 .iter()
4544 .find(|node| node.kind == IrNodeKindV0::StyleRule && node.parent == Some(at_rule))
4545 .map(|node| node.node_id)
4546 .ok_or_else(|| "fixture should expose a nested style rule".to_string())?;
4547 let region = IrEditRegionV0::full(ir.source_byte_len);
4548 let mut transaction = IrTransactionV0::new(&mut ir, "nested-dirty", region);
4549 transaction
4550 .replace_node(at_rule, "@scope (._card_x) { .title { color: red; } }")
4551 .map_err(|err| format!("at-rule rewrite should be accepted: {err:?}"))?;
4552 transaction
4553 .replace_node(nested_rule, "._title_z{ color: red; }")
4554 .map_err(|err| format!("nested rule rewrite should be accepted: {err:?}"))?;
4555 transaction
4556 .commit()
4557 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4558
4559 assert_eq!(
4560 print_transform_ir_css(&ir)
4561 .map_err(|err| format!("mutated IR should print: {err:?}"))?,
4562 "@scope (._card_x) { ._title_z{ color: red; } }"
4563 );
4564 Ok(())
4565 }
4566
4567 #[test]
4568 fn materialized_nested_dirty_transaction_rebases_projected_child_span() -> Result<(), String> {
4569 let source = "@scope (.card) { .title { color: red; } }";
4570 let mut ir =
4571 lower_transform_ir_from_source(source, StyleDialect::Css, "nested-materialized");
4572 let at_rule = first_node_id(&ir, IrNodeKindV0::AtRule)?;
4573 let nested_rule = ir
4574 .nodes
4575 .iter()
4576 .find(|node| node.kind == IrNodeKindV0::StyleRule && node.parent == Some(at_rule))
4577 .map(|node| node.node_id)
4578 .ok_or_else(|| "fixture should expose a nested style rule".to_string())?;
4579 let mut transaction = IrTransactionV0::new(
4580 &mut ir,
4581 "nested-materialized",
4582 IrEditRegionV0::full(source.len()),
4583 );
4584 transaction
4585 .replace_node(at_rule, "@scope (._card_x) { .title { color: red; } }")
4586 .map_err(|err| format!("at-rule rewrite should be accepted: {err:?}"))?;
4587 transaction
4588 .replace_node(nested_rule, "._title_z{ color: red; }")
4589 .map_err(|err| format!("nested rule rewrite should be accepted: {err:?}"))?;
4590 transaction
4591 .commit()
4592 .map_err(|err| format!("transaction should commit: {err:?}"))?;
4593
4594 let printed = materialize_transform_ir_printed_source(&mut ir)
4595 .map_err(|err| format!("nested materialization should succeed: {err:?}"))?;
4596 assert_eq!(printed, "@scope (._card_x) { ._title_z{ color: red; } }");
4597 assert!(ir.all_nodes_original());
4598 let nested = &ir.nodes[nested_rule.index()];
4599 assert_eq!(
4600 &ir.source_text()[nested.source_span_start..nested.source_span_end],
4601 "._title_z{ color: red; }"
4602 );
4603 Ok(())
4604 }
4605
4606 #[test]
4607 fn materialized_nested_bem_transaction_rebases_expanded_selector_chunks() -> Result<(), String>
4608 {
4609 let source = r#".dashboard {
4610 &__card0 {
4611 color: red;
4612
4613 &--active {
4614 border-color: blue;
4615 }
4616 }
4617
4618 &__card1 {
4619 color: green;
4620 }
4621}"#;
4622 let mut ir =
4623 lower_transform_ir_from_source(source, StyleDialect::Scss, "nested-bem-materialized");
4624 let root = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4625 let canonical_text = ".dashboard__card0 { color: red; } .dashboard__card0--active { border-color: blue; } .dashboard__card1 { color: green; }";
4626 let mut transaction = IrTransactionV0::new(
4627 &mut ir,
4628 "nesting-unwrap",
4629 IrEditRegionV0::full(source.len()),
4630 );
4631 transaction
4632 .replace_node(root, canonical_text)
4633 .map_err(|err| format!("BEM nesting rewrite should be accepted: {err:?}"))?;
4634 transaction
4635 .commit()
4636 .map_err(|err| format!("BEM nesting transaction should commit: {err:?}"))?;
4637
4638 let printed = materialize_transform_ir_printed_source(&mut ir)
4639 .map_err(|err| format!("BEM nesting materialization should succeed: {err:?}"))?;
4640
4641 assert_eq!(printed, canonical_text);
4642 assert!(ir.all_nodes_original());
4643 assert!(ir.source_text().contains(".dashboard__card0--active"));
4644 Ok(())
4645 }
4646
4647 #[test]
4648 fn expanded_ir_selector_only_substitutes_cst_nesting_tokens() -> Result<(), String> {
4649 let source = r#".card {
4650 &[data-label="&"].icon\&tail { color: red; }
4651}"#;
4652 let ir =
4653 lower_transform_ir_from_source(source, StyleDialect::Scss, "selector-token-expansion");
4654 let nested = ir
4655 .nodes
4656 .iter()
4657 .filter(|node| node.kind == IrNodeKindV0::StyleRule)
4658 .nth(1)
4659 .ok_or_else(|| "nested style rule should be present".to_string())?;
4660
4661 assert_eq!(
4662 expanded_style_rule_selector(&ir, nested).as_deref(),
4663 Some(r#".card[data-label="&"].icon\&tail"#),
4664 );
4665 Ok(())
4666 }
4667
4668 #[test]
4669 fn materialized_nested_descendant_transaction_rebases_expanded_selector_chunks()
4670 -> Result<(), String> {
4671 let source = r#".component {
4672 &--tone-0 {
4673 @include elevation(1px);
4674
4675 .component__label0 {
4676 color: var(--tone-0);
4677 }
4678 }
4679}"#;
4680 let mut ir = lower_transform_ir_from_source(
4681 source,
4682 StyleDialect::Scss,
4683 "nested-descendant-materialized",
4684 );
4685 let root = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4686 let canonical_text = ".component--tone-0 .component__label0 { color: var(--tone-0); }";
4687 let mut transaction = IrTransactionV0::new(
4688 &mut ir,
4689 "nesting-unwrap",
4690 IrEditRegionV0::full(source.len()),
4691 );
4692 transaction
4693 .replace_node(root, canonical_text)
4694 .map_err(|err| format!("descendant nesting rewrite should be accepted: {err:?}"))?;
4695 transaction
4696 .commit()
4697 .map_err(|err| format!("descendant nesting transaction should commit: {err:?}"))?;
4698
4699 let printed = materialize_transform_ir_printed_source(&mut ir)
4700 .map_err(|err| format!("descendant nesting materialization should succeed: {err:?}"))?;
4701
4702 assert_eq!(printed, canonical_text);
4703 assert!(ir.all_nodes_original());
4704 assert!(
4705 ir.source_text()
4706 .contains(".component--tone-0 .component__label0")
4707 );
4708 Ok(())
4709 }
4710
4711 #[test]
4712 fn lower_transform_ir_preserves_less_rule_after_mixin_declaration() -> Result<(), String> {
4713 let source = ".space() when (isnumber($margin)) { padding: $margin; } .button { .space(); margin: 2px; }";
4714 let ir = lower_transform_ir_from_source(source, StyleDialect::Less, "less-mixin-rule");
4715 let button_rule = ir.nodes.iter().find(|node| {
4716 node.kind == IrNodeKindV0::StyleRule
4717 && source[node.source_span_start..node.source_span_end].starts_with(".button")
4718 });
4719
4720 assert!(
4721 button_rule.is_some(),
4722 "ordinary Less rule after mixin declaration should lower as a style-rule node"
4723 );
4724 Ok(())
4725 }
4726
4727 #[test]
4728 fn ir_transaction_rejects_dangling_nodes() -> Result<(), String> {
4729 let mut ir =
4730 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "dangling");
4731 ir.nodes[0].children.push(IrNodeIdV0(usize::MAX));
4732
4733 let err = validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4734 .err()
4735 .ok_or_else(|| "dangling child must fail validation".to_string())?;
4736
4737 assert_eq!(
4738 err,
4739 IrTransactionValidationErrorV0::DanglingNode {
4740 node_index: 0,
4741 dangling_node_index: usize::MAX,
4742 }
4743 );
4744 Ok(())
4745 }
4746
4747 #[test]
4748 fn ir_transaction_rejects_parent_child_mismatch() -> Result<(), String> {
4749 let mut ir =
4750 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "links");
4751 let child = first_node_id(&ir, IrNodeKindV0::Declaration)?;
4752 let parent = ir.nodes[child.index()]
4753 .parent
4754 .ok_or_else(|| "declaration should have a parent".to_string())?;
4755 ir.nodes[parent.index()]
4756 .children
4757 .retain(|candidate| *candidate != child);
4758
4759 let err = validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4760 .err()
4761 .ok_or_else(|| "parent/child mismatch must fail validation".to_string())?;
4762
4763 assert_eq!(
4764 err,
4765 IrTransactionValidationErrorV0::ParentChildLinkMismatch {
4766 node_index: child.index(),
4767 parent_index: parent.index(),
4768 }
4769 );
4770 Ok(())
4771 }
4772
4773 #[test]
4774 fn ir_transaction_rejects_declaration_without_rule_owner() -> Result<(), String> {
4775 let mut ir =
4776 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "owner");
4777 let declaration = first_node_id(&ir, IrNodeKindV0::Declaration)?;
4778 if let Some(parent) = ir.nodes[declaration.index()].parent {
4779 ir.nodes[parent.index()]
4780 .children
4781 .retain(|candidate| *candidate != declaration);
4782 }
4783 ir.nodes[declaration.index()].parent = None;
4784
4785 let err = validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4786 .err()
4787 .ok_or_else(|| "orphan declaration must fail validation".to_string())?;
4788
4789 assert_eq!(
4790 err,
4791 IrTransactionValidationErrorV0::DeclarationWithoutRuleOwner {
4792 node_index: declaration.index(),
4793 }
4794 );
4795 Ok(())
4796 }
4797
4798 #[test]
4799 fn ir_transaction_accepts_less_mixin_declaration_owned_root_declarations() -> Result<(), String>
4800 {
4801 let source = ".space() when (isnumber($margin)) { padding: $margin; }";
4802 let ir = lower_transform_ir_from_source(source, StyleDialect::Less, "less-mixin-owner");
4803
4804 validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4805 .map_err(|err| format!("Less mixin declaration contents should be owned: {err:?}"))?;
4806 Ok(())
4807 }
4808
4809 #[test]
4810 fn typed_block_ownership_accepts_supported_root_declarations() -> Result<(), String> {
4811 for (source, dialect) in [
4812 (":export { token: red; }", StyleDialect::Css),
4813 (
4814 ":import(\"./tokens.css\") { token: remote; }",
4815 StyleDialect::Css,
4816 ),
4817 (
4818 ".space() when (isnumber($margin)) { padding: $margin; }",
4819 StyleDialect::Less,
4820 ),
4821 (".card { color: red; }", StyleDialect::Css),
4822 ] {
4823 let ir = lower_transform_ir_from_source(source, dialect, "block-owner-oracle");
4824 validate_transaction_commit(&ir, &[], IrEditRegionV0::full(source.len()))
4825 .map_err(|error| format!("typed ownership rejected {source:?}: {error:?}"))?;
4826 }
4827 Ok(())
4828 }
4829
4830 #[test]
4831 fn typed_block_ownership_ignores_braces_inside_less_values() -> Result<(), String> {
4832 let source = ".space() { color: red; content: \"{\"; padding: 1px; }";
4833 let ir = lower_transform_ir_from_source(source, StyleDialect::Less, "typed-block-owner");
4834 let padding = ir
4835 .nodes
4836 .iter()
4837 .find(|node| {
4838 node.kind == IrNodeKindV0::Declaration
4839 && ir
4840 .source_text()
4841 .get(node.source_span_start..node.source_span_end)
4842 .is_some_and(|text| text.starts_with("padding"))
4843 })
4844 .ok_or_else(|| "fixture should expose the padding declaration".to_string())?;
4845
4846 assert!(has_less_mixin_declaration_owner(&ir, padding));
4847 Ok(())
4848 }
4849
4850 #[test]
4851 fn ir_transaction_rejects_duplicate_global_order() -> Result<(), String> {
4852 let mut ir =
4853 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "order");
4854 let duplicate_order = ir.nodes[0].global_order;
4855 ir.nodes[1].global_order = duplicate_order;
4856
4857 let err = validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4858 .err()
4859 .ok_or_else(|| "duplicate global order must fail validation".to_string())?;
4860
4861 assert_eq!(
4862 err,
4863 IrTransactionValidationErrorV0::DuplicateGlobalOrder {
4864 global_order: duplicate_order,
4865 }
4866 );
4867 Ok(())
4868 }
4869
4870 #[test]
4871 fn ir_transaction_rejects_missing_provenance() -> Result<(), String> {
4872 let mut ir = lower_transform_ir_from_source(
4873 ".card { color: red; }",
4874 StyleDialect::Css,
4875 "provenance",
4876 );
4877 ir.nodes[0].origin_index = usize::MAX;
4878
4879 let err = validate_transaction_commit(&ir, &[], IrEditRegionV0::full(ir.source_byte_len))
4880 .err()
4881 .ok_or_else(|| "missing provenance must fail validation".to_string())?;
4882
4883 assert_eq!(
4884 err,
4885 IrTransactionValidationErrorV0::MissingProvenance {
4886 node_index: 0,
4887 origin_index: usize::MAX,
4888 }
4889 );
4890 Ok(())
4891 }
4892
4893 #[test]
4894 fn ir_transaction_rejects_edits_outside_declared_region() -> Result<(), String> {
4895 let ir =
4896 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "region");
4897 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4898 let region = IrEditRegionV0 {
4899 source_span_start: ir.source_byte_len,
4900 source_span_end: ir.source_byte_len,
4901 };
4902
4903 let err = validate_transaction_commit(&ir, &[rule], region)
4904 .err()
4905 .ok_or_else(|| "outside-region edit must fail validation".to_string())?;
4906
4907 assert_eq!(
4908 err,
4909 IrTransactionValidationErrorV0::EditOutsideDeclaredRegion {
4910 node_index: rule.index(),
4911 region,
4912 }
4913 );
4914 Ok(())
4915 }
4916
4917 #[test]
4918 fn ir_transaction_rejects_edits_inside_parse_error_region() -> Result<(), String> {
4919 let mut ir = lower_transform_ir_from_source(
4920 ".card { color: red; }",
4921 StyleDialect::Css,
4922 "parse-error",
4923 );
4924 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4925 let parse_error_span = TransformIrParseErrorSpanV0 {
4926 source_span_start: ir.nodes[rule.index()].source_span_start,
4927 source_span_end: ir.nodes[rule.index()].source_span_end,
4928 };
4929 ir.parse_error_spans.push(parse_error_span);
4930
4931 let err =
4932 validate_transaction_commit(&ir, &[rule], IrEditRegionV0::full(ir.source_byte_len))
4933 .err()
4934 .ok_or_else(|| "parse-error edit must fail validation".to_string())?;
4935
4936 assert_eq!(
4937 err,
4938 IrTransactionValidationErrorV0::EditInsideParseErrorRegion {
4939 node_index: rule.index(),
4940 parse_error_span,
4941 }
4942 );
4943 Ok(())
4944 }
4945
4946 #[test]
4947 fn ir_transaction_allows_parent_rewrite_when_parse_error_source_is_preserved()
4948 -> Result<(), String> {
4949 let source = ".card { color: tokens.$accent; }";
4950 let mut ir =
4951 lower_transform_ir_from_source(source, StyleDialect::Scss, "preserved-parse-error");
4952 if ir.parser_error_count == 0 {
4953 return Err("fixture must expose a SCSS parse-error token".to_string());
4954 }
4955 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4956 let region = IrEditRegionV0::full(ir.source_byte_len);
4957 let canonical_text = ".card { color: tokens.$accent; background: red; }";
4958
4959 let mut transaction = IrTransactionV0::new(&mut ir, "preserve-parse-error", region);
4960 transaction
4961 .replace_node(rule, canonical_text)
4962 .map_err(|error| format!("{error:?}"))?;
4963 transaction.commit().map_err(|error| format!("{error:?}"))?;
4964
4965 assert_eq!(
4966 print_transform_ir_css(&ir).map_err(|error| format!("{error:?}"))?,
4967 canonical_text
4968 );
4969 let printed = materialize_transform_ir_printed_source(&mut ir).map_err(|error| {
4970 format!("materialization should preserve parse-error spans: {error:?}")
4971 })?;
4972 assert_eq!(printed, canonical_text);
4973 assert!(ir.parser_error_count > 0);
4974 assert!(!ir.parse_error_spans.is_empty());
4975 assert!(ir.parse_error_spans.iter().all(|span| {
4976 span.source_span_start <= span.source_span_end
4977 && span.source_span_end <= ir.source_text().len()
4978 && ir.source_text().is_char_boundary(span.source_span_start)
4979 && ir.source_text().is_char_boundary(span.source_span_end)
4980 }));
4981 Ok(())
4982 }
4983
4984 #[test]
4985 fn ir_transaction_rejects_parent_rewrite_when_parse_error_source_is_removed()
4986 -> Result<(), String> {
4987 let source = ".card { color: tokens.$accent; }";
4988 let mut ir =
4989 lower_transform_ir_from_source(source, StyleDialect::Scss, "removed-parse-error");
4990 let parse_error_span = ir
4991 .parse_error_spans
4992 .first()
4993 .copied()
4994 .ok_or_else(|| "fixture must expose a SCSS parse-error token".to_string())?;
4995 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
4996 let region = IrEditRegionV0::full(ir.source_byte_len);
4997 let mut transaction = IrTransactionV0::new(&mut ir, "remove-parse-error", region);
4998 transaction
4999 .replace_node(rule, ".card { color: blue; }")
5000 .map_err(|error| format!("{error:?}"))?;
5001
5002 let err = transaction
5003 .commit()
5004 .err()
5005 .ok_or_else(|| "parse-error removal must fail validation".to_string())?;
5006
5007 assert_eq!(
5008 err,
5009 IrTransactionErrorV0::Validation(
5010 IrTransactionValidationErrorV0::EditInsideParseErrorRegion {
5011 node_index: rule.index(),
5012 parse_error_span,
5013 }
5014 )
5015 );
5016 Ok(())
5017 }
5018
5019 #[test]
5020 fn ir_transaction_allows_structural_rule_deletion_with_parse_error_region() -> Result<(), String>
5021 {
5022 let source = ".dead { color: tokens.$accent; }\n.used { color: blue; }";
5023 let mut ir =
5024 lower_transform_ir_from_source(source, StyleDialect::Scss, "delete-parse-error");
5025 let parse_error_span = ir
5026 .parse_error_spans
5027 .first()
5028 .copied()
5029 .ok_or_else(|| "fixture must expose a SCSS parse-error token".to_string())?;
5030 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
5031 let region = IrEditRegionV0::full(ir.source_byte_len);
5032 let mut transaction = IrTransactionV0::new(&mut ir, "delete-parse-error-rule", region);
5033 transaction
5034 .delete_node(rule)
5035 .map_err(|error| format!("{error:?}"))?;
5036
5037 transaction.commit().map_err(|error| format!("{error:?}"))?;
5038
5039 assert!(
5040 print_transform_ir_css(&ir)
5041 .map_err(|error| format!("{error:?}"))?
5042 .contains(".used { color: blue; }")
5043 );
5044 assert!(
5045 parse_error_span.source_span_start >= ir.nodes[rule.index()].source_span_start
5046 && parse_error_span.source_span_end <= ir.nodes[rule.index()].source_span_end
5047 );
5048 let printed = materialize_transform_ir_printed_source(&mut ir)
5049 .map_err(|error| format!("deleted parse-error region should materialize: {error:?}"))?;
5050 assert_eq!(printed, "\n.used { color: blue; }");
5051 assert_eq!(ir.parser_error_count, 0);
5052 assert!(ir.parse_error_spans.is_empty());
5053 Ok(())
5054 }
5055
5056 #[test]
5057 fn ir_transaction_rejects_non_value_rewrite_value() -> Result<(), String> {
5058 let mut ir =
5059 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "kind");
5060 let rule = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
5061 let region = IrEditRegionV0::full(ir.source_byte_len);
5062 let mut transaction = IrTransactionV0::new(&mut ir, "rewrite-value", region);
5063 let err = transaction
5064 .rewrite_value(rule, "blue")
5065 .err()
5066 .ok_or_else(|| "non-value rewrite must fail".to_string())?;
5067
5068 assert_eq!(
5069 err,
5070 IrTransactionErrorV0::NodeKindMismatch {
5071 node_index: rule.index(),
5072 expected: IrNodeKindV0::Value,
5073 actual: IrNodeKindV0::StyleRule,
5074 }
5075 );
5076 Ok(())
5077 }
5078
5079 #[test]
5080 fn transform_ir_printer_rejects_invalid_original_span() -> Result<(), String> {
5081 let mut ir =
5082 lower_transform_ir_from_source(".card { color: red; }", StyleDialect::Css, "bad-span");
5083 let first_node = ir
5084 .nodes
5085 .first()
5086 .ok_or_else(|| "fixture should produce an IR node".to_string())?;
5087 let origin_index = first_node.origin_index;
5088 ir.origins[origin_index] = NodeTextOriginV0::Original {
5089 source_id: "bad-span".to_string(),
5090 source_span_start: 0,
5091 source_span_end: usize::MAX,
5092 };
5093
5094 let err = match print_transform_ir_css(&ir) {
5095 Ok(_) => return Err("invalid original span must fail printing".to_string()),
5096 Err(err) => err,
5097 };
5098
5099 assert_eq!(
5100 err,
5101 TransformIrPrintErrorV0::InvalidOriginalSpan {
5102 node_index: first_node.node_id.index(),
5103 source_span_start: 0,
5104 source_span_end: usize::MAX,
5105 source_byte_len: 21,
5106 }
5107 );
5108 Ok(())
5109 }
5110
5111 fn assert_transform_ir_indexes_partition_active_nodes(ir: &super::TransformIrV0) {
5112 let indexes = ir.indexes();
5113 let active_nodes = ir
5114 .nodes
5115 .iter()
5116 .filter(|node| !node.deleted)
5117 .collect::<Vec<_>>();
5118 eprintln!(
5119 "activeNodes={} parentGroups={} kindGroups={}",
5120 active_nodes.len(),
5121 indexes.by_parent.len(),
5122 indexes.by_kind.len(),
5123 );
5124
5125 let expected_parent_keys = active_nodes
5126 .iter()
5127 .map(|node| node.parent)
5128 .collect::<BTreeSet<_>>();
5129 let actual_parent_keys = indexes
5130 .by_parent
5131 .iter()
5132 .map(|group| group.parent)
5133 .collect::<BTreeSet<_>>();
5134 assert_eq!(
5135 actual_parent_keys, expected_parent_keys,
5136 "parent index keys must equal the distinct active-node parents"
5137 );
5138 let parent_occurrences = indexes
5139 .by_parent
5140 .iter()
5141 .flat_map(|group| group.node_ids.iter().copied())
5142 .fold(
5143 BTreeMap::<IrNodeIdV0, usize>::new(),
5144 |mut counts, node_id| {
5145 *counts.entry(node_id).or_default() += 1;
5146 counts
5147 },
5148 );
5149 assert_eq!(
5150 parent_occurrences.len(),
5151 active_nodes.len(),
5152 "every active node must appear in the parent partition"
5153 );
5154 for node in &active_nodes {
5155 assert_eq!(
5156 parent_occurrences.get(&node.node_id),
5157 Some(&1),
5158 "each active node must appear exactly once in the parent partition"
5159 );
5160 }
5161 for group in &indexes.by_parent {
5162 let expected = active_nodes
5163 .iter()
5164 .filter(|node| node.parent == group.parent)
5165 .map(|node| node.node_id)
5166 .collect::<BTreeSet<_>>();
5167 let actual = group.node_ids.iter().copied().collect::<BTreeSet<_>>();
5168 assert_eq!(
5169 actual, expected,
5170 "parent partition membership must match a direct node scan"
5171 );
5172 }
5173
5174 let expected_kind_keys = active_nodes
5175 .iter()
5176 .map(|node| node.kind)
5177 .collect::<BTreeSet<_>>();
5178 let actual_kind_keys = indexes
5179 .by_kind
5180 .iter()
5181 .map(|group| group.kind)
5182 .collect::<BTreeSet<_>>();
5183 assert_eq!(
5184 actual_kind_keys, expected_kind_keys,
5185 "kind index keys must equal the distinct active-node kinds"
5186 );
5187 let kind_occurrences = indexes
5188 .by_kind
5189 .iter()
5190 .flat_map(|group| group.node_ids.iter().copied())
5191 .fold(
5192 BTreeMap::<IrNodeIdV0, usize>::new(),
5193 |mut counts, node_id| {
5194 *counts.entry(node_id).or_default() += 1;
5195 counts
5196 },
5197 );
5198 assert_eq!(
5199 kind_occurrences.len(),
5200 active_nodes.len(),
5201 "every active node must appear in the kind partition"
5202 );
5203 for node in &active_nodes {
5204 assert_eq!(
5205 kind_occurrences.get(&node.node_id),
5206 Some(&1),
5207 "each active node must appear exactly once in the kind partition"
5208 );
5209 }
5210 for group in &indexes.by_kind {
5211 let expected = active_nodes
5212 .iter()
5213 .filter(|node| node.kind == group.kind)
5214 .map(|node| node.node_id)
5215 .collect::<BTreeSet<_>>();
5216 let actual = group.node_ids.iter().copied().collect::<BTreeSet<_>>();
5217 assert_eq!(
5218 actual, expected,
5219 "kind partition membership must match a direct node scan"
5220 );
5221 }
5222 }
5223
5224 #[test]
5225 fn transform_ir_mark_node_synthesized_invalidates_forced_lazy_indexes() -> Result<(), String> {
5226 let mut ir = lower_transform_ir_from_source(
5227 ".card { color: red; background: blue; }",
5228 StyleDialect::Css,
5229 "index-mark-node-synthesized-invalidation",
5230 );
5231 let _ = ir.indexes();
5232 let value_id = first_node_id(&ir, IrNodeKindV0::Value)?;
5233 let source_byte_len = ir.source_byte_len;
5234 let mut transaction = IrTransactionV0::new(
5235 &mut ir,
5236 "index-mark-node-synthesized-invalidation",
5237 IrEditRegionV0::full(source_byte_len),
5238 );
5239 transaction
5240 .delete_node(value_id)
5241 .map_err(|error| format!("delete should succeed: {error:?}"))?;
5242
5243 assert_transform_ir_indexes_partition_active_nodes(transaction.working);
5244 Ok(())
5245 }
5246
5247 #[test]
5248 fn transform_ir_insert_ir_roots_before_invalidates_forced_lazy_indexes() -> Result<(), String> {
5249 let mut ir = lower_transform_ir_from_source(
5250 ".anchor { color: red; }",
5251 StyleDialect::Css,
5252 "index-insert-ir-roots-invalidation",
5253 );
5254 let inserted_ir = lower_transform_ir_from_source(
5255 ".inserted { color: green; }",
5256 StyleDialect::Css,
5257 "index-insert-ir-roots-source",
5258 );
5259 let _ = ir.indexes();
5260 let anchor = first_node_id(&ir, IrNodeKindV0::StyleRule)?;
5261 let source_byte_len = ir.source_byte_len;
5262 let mut transaction = IrTransactionV0::new(
5263 &mut ir,
5264 "index-insert-ir-roots-invalidation",
5265 IrEditRegionV0::full(source_byte_len),
5266 );
5267 transaction
5268 .insert_ir_roots_before(anchor, &inserted_ir)
5269 .map_err(|error| format!("IR-root insertion should succeed: {error:?}"))?;
5270
5271 assert_transform_ir_indexes_partition_active_nodes(transaction.working);
5272 Ok(())
5273 }
5274
5275 fn first_node_id(ir: &super::TransformIrV0, kind: IrNodeKindV0) -> Result<IrNodeIdV0, String> {
5276 ir.nodes
5277 .iter()
5278 .find(|node| node.kind == kind)
5279 .map(|node| node.node_id)
5280 .ok_or_else(|| format!("missing node kind {kind:?}"))
5281 }
5282}