Skip to main content

omena_transform_cst/
transform_ir.rs

1//! Transform IR: node arena with epochs, edit transactions, and identity round-trip printing.
2
3use 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    /// Retained for compatibility, but read by no product code, absent from
269    /// the wire, excluded from equality, and scheduled for removal in 6.0.0.
270    #[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
791// The public materializer remains a deliberate indirect route to the sealed
792// refresh. A parent mutator could call it and thereby refresh transitively, so
793// the visibility boundary prevents direct refresh calls but is not airtight
794// against moving mutation work behind this public operation.
795pub 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 = &copy_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    // Both indexes are partitions of the same active-node domain.
3284    // Building each map from the node stream makes group keys emerge from
3285    // observed values, omits empty groups, and prevents deleted nodes from
3286    // surviving in either projection. Tests independently reconstruct the
3287    // expected key sets and memberships instead of calling this function.
3288    //
3289    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}