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