1use omena_parser::{
4 ParsedCssModuleComposesFactKind, ParsedCssModuleValueFactKind, ParsedIcssFactKind,
5 ParsedStyleFacts, StyleDialect, TypedCstNode, facts_from_cst, parse_only,
6};
7use omena_syntax::{SyntaxKind, SyntaxNode};
8use serde::Serialize;
9use std::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 = ©_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}