Skip to main content

omena_transform_cst/
transform_ir.rs

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