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