Skip to main content

cssforge_core/
engine.rs

1use crate::{
2    model::{
3        AnalysisStats, FileReport, Finding, PlanEntry, Proof, RuleId, Safety, SourceRange,
4        WorkspaceReport, WorkspaceSummary,
5    },
6    scanner::{
7        NodeKind, SourceNode, count_ascii_case_insensitive_outside_comments,
8        count_top_level_declarations, is_whitespace_only, scan_nodes,
9    },
10};
11use anyhow::{Context, Result};
12use lightningcss::stylesheet::{ParserOptions, StyleSheet};
13use similar::TextDiff;
14use std::{
15    collections::{HashMap, HashSet},
16    fs,
17    ops::Range,
18    path::{Path, PathBuf},
19};
20
21const SPEC_BASELINE: &str = "2026-08-17";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
24pub struct Specificity {
25    pub ids: usize,
26    pub classes: usize,
27    pub elements: usize,
28}
29
30pub fn calculate_specificity(selector: &str) -> Specificity {
31    let mut ids = 0;
32    let mut classes = 0;
33    let mut elements = 0;
34    let bytes = selector.as_bytes();
35    let mut i = 0;
36    let mut in_attr = false;
37
38    while i < bytes.len() {
39        let b = bytes[i];
40        if b == b'[' {
41            in_attr = true;
42            classes += 1;
43            i += 1;
44            continue;
45        }
46        if b == b']' {
47            in_attr = false;
48            i += 1;
49            continue;
50        }
51        if in_attr {
52            i += 1;
53            continue;
54        }
55
56        if b == b'#' {
57            ids += 1;
58            i += 1;
59            while i < bytes.len()
60                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
61            {
62                i += 1;
63            }
64            continue;
65        }
66
67        if b == b'.' {
68            classes += 1;
69            i += 1;
70            while i < bytes.len()
71                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
72            {
73                i += 1;
74            }
75            continue;
76        }
77
78        if b == b':' {
79            if i + 1 < bytes.len() && bytes[i + 1] == b':' {
80                elements += 1;
81                i += 2;
82                while i < bytes.len()
83                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
84                {
85                    i += 1;
86                }
87            } else {
88                let start_name = i + 1;
89                let mut end_name = start_name;
90                while end_name < bytes.len()
91                    && (bytes[end_name].is_ascii_alphanumeric() || bytes[end_name] == b'-')
92                {
93                    end_name += 1;
94                }
95                let pseudo_name = &selector[start_name..end_name];
96                if pseudo_name == "where" {
97                    if end_name < bytes.len()
98                        && bytes[end_name] == b'('
99                        && let Some(close_p) = find_matching_paren(selector, end_name)
100                    {
101                        i = close_p + 1;
102                        continue;
103                    }
104                } else if pseudo_name == "is" || pseudo_name == "not" || pseudo_name == "has" {
105                    if end_name < bytes.len()
106                        && bytes[end_name] == b'('
107                        && let Some(close_p) = find_matching_paren(selector, end_name)
108                    {
109                        let inner = &selector[end_name + 1..close_p];
110                        let max_inner = split_top_level_comma(inner)
111                            .into_iter()
112                            .map(|s| calculate_specificity(s.trim()))
113                            .max()
114                            .unwrap_or_default();
115                        ids += max_inner.ids;
116                        classes += max_inner.classes;
117                        elements += max_inner.elements;
118                        i = close_p + 1;
119                        continue;
120                    }
121                    classes += 1;
122                } else {
123                    classes += 1;
124                }
125                i = end_name;
126            }
127            continue;
128        }
129
130        if (b.is_ascii_alphabetic() || b == b'*')
131            && (i == 0
132                || bytes[i - 1].is_ascii_whitespace()
133                || bytes[i - 1] == b'>'
134                || bytes[i - 1] == b'+'
135                || bytes[i - 1] == b'~'
136                || bytes[i - 1] == b'|')
137        {
138            if b != b'*' {
139                elements += 1;
140            }
141            i += 1;
142            while i < bytes.len()
143                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
144            {
145                i += 1;
146            }
147            continue;
148        }
149
150        i += 1;
151    }
152
153    Specificity {
154        ids,
155        classes,
156        elements,
157    }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum RelationKind {
162    PseudoClass,
163    PseudoElement,
164    Attribute,
165    Compound,
166    Descendant,
167    Combinator,
168}
169
170impl RelationKind {
171    fn rule(self) -> RuleId {
172        match self {
173            Self::PseudoClass => RuleId::NestPseudoClass,
174            Self::PseudoElement => RuleId::NestPseudoElement,
175            Self::Attribute => RuleId::NestAttribute,
176            Self::Compound => RuleId::NestCompound,
177            Self::Descendant => RuleId::NestDescendant,
178            Self::Combinator => RuleId::NestCombinator,
179        }
180    }
181}
182
183#[derive(Debug, Clone)]
184enum ConditionalInner {
185    Direct {
186        body_range: Range<usize>,
187    },
188    Nested {
189        nested_selector: String,
190        body_range: Range<usize>,
191    },
192}
193
194#[derive(Debug, Clone)]
195enum ClusterChild {
196    Style {
197        node: SourceNode,
198        relation: RelationKind,
199        nested_selector: String,
200    },
201    Conditional {
202        node: SourceNode,
203        rule: RuleId,
204        inners: Vec<ConditionalInner>,
205    },
206}
207
208impl ClusterChild {
209    fn node(&self) -> &SourceNode {
210        match self {
211            Self::Style { node, .. } | Self::Conditional { node, .. } => node,
212        }
213    }
214
215    fn rule(&self) -> RuleId {
216        match self {
217            Self::Style { relation, .. } => relation.rule(),
218            Self::Conditional { rule, .. } => *rule,
219        }
220    }
221}
222
223pub fn analyze_file(path: &Path, enabled_rules: &[RuleId]) -> Result<FileReport> {
224    let source = fs::read_to_string(path)
225        .with_context(|| format!("failed to read CSS file {}", path.display()))?;
226    analyze_source(path.to_path_buf(), &source, enabled_rules)
227}
228
229pub fn analyze_workspace(
230    root: &Path,
231    files: &[PathBuf],
232    enabled_rules: &[RuleId],
233) -> Result<WorkspaceReport> {
234    let mut reports = Vec::with_capacity(files.len());
235    let mut next_id = 1usize;
236
237    for path in files {
238        let mut report = analyze_file(path, enabled_rules)?;
239        for plan in &mut report.plans {
240            plan.id = format!("T-{next_id:06}");
241            next_id += 1;
242        }
243        reports.push(report);
244    }
245
246    let mut summary = WorkspaceSummary {
247        files: reports.len(),
248        ..WorkspaceSummary::default()
249    };
250
251    for report in &reports {
252        if !report.parse_ok {
253            summary.parse_errors += 1;
254        }
255        summary.rules_analyzed +=
256            report.stats.top_level_style_rules + report.stats.top_level_at_rules;
257        for plan in &report.plans {
258            match plan.safety {
259                Safety::Safe => summary.safe += 1,
260                Safety::Review => summary.review += 1,
261                Safety::Unsafe => summary.unsafe_count += 1,
262                Safety::Unsupported => summary.unsupported += 1,
263                Safety::NoOp => summary.no_op += 1,
264            }
265            if plan
266                .warnings
267                .iter()
268                .any(|w| w.to_ascii_lowercase().contains("specificity"))
269            {
270                summary.specificity_sensitive += 1;
271            }
272            if plan.warnings.iter().any(|w| {
273                w.to_ascii_lowercase().contains("cascade")
274                    || w.to_ascii_lowercase().contains("source order")
275            }) {
276                summary.cascade_sensitive += 1;
277            }
278            if plan
279                .warnings
280                .iter()
281                .any(|w| w.to_ascii_lowercase().contains("layer"))
282            {
283                summary.layer_sensitive += 1;
284            }
285            if plan
286                .warnings
287                .iter()
288                .any(|w| w.to_ascii_lowercase().contains("scope"))
289            {
290                summary.scope_sensitive += 1;
291            }
292        }
293    }
294
295    Ok(WorkspaceReport {
296        tool_version: env!("CARGO_PKG_VERSION").to_string(),
297        spec_baseline: SPEC_BASELINE.to_string(),
298        root: root.to_path_buf(),
299        enabled_rules: enabled_rules.to_vec(),
300        files: reports,
301        summary,
302    })
303}
304
305fn analyze_source(path: PathBuf, source: &str, enabled_rules: &[RuleId]) -> Result<FileReport> {
306    // LightningCSS is a *validator*, not the planner. Unknown constructs
307    // (`::picker()`, `::checkmark`, `anchor-size()`) must not wipe plans:
308    // `scan_nodes` already sees those as ordinary style/at-rule blocks.
309    let parse_result = StyleSheet::parse(
310        source,
311        ParserOptions {
312            filename: path.display().to_string(),
313            error_recovery: true,
314            ..ParserOptions::default()
315        },
316    );
317
318    let parse_error = parse_result.err().map(|err| format!("{err:?}"));
319    let parse_ok = parse_error.is_none();
320    let nodes = scan_nodes(source, 0..source.len());
321    let stats = collect_stats(source, &nodes, parse_ok);
322    let mut findings = collect_findings(source, &nodes);
323
324    if let Some(error) = &parse_error {
325        findings.push(Finding {
326            safety: Safety::Unsupported,
327            title: "Semantic parse failed".into(),
328            detail: format!(
329                "{error} Planning continues from the structural scanner; review all plans."
330            ),
331        });
332    }
333
334    let mut plans = if !enabled_rules.is_empty() {
335        build_plans_recursive(&path, source, &nodes, enabled_rules)
336    } else {
337        Vec::new()
338    };
339    if !parse_ok {
340        for plan in &mut plans {
341            if plan.safety == Safety::Safe {
342                plan.safety = Safety::Review;
343            }
344            plan.warnings.push(
345                "Planned despite a LightningCSS parse error on this file; review required.".into(),
346            );
347        }
348    }
349
350    Ok(FileReport {
351        path,
352        parse_ok,
353        parse_error,
354        stats,
355        findings,
356        plans,
357    })
358}
359
360fn collect_stats(source: &str, nodes: &[SourceNode], parse_ok: bool) -> AnalysisStats {
361    let mut stats = AnalysisStats {
362        bytes: source.len(),
363        parse_errors: usize::from(!parse_ok),
364        important_declarations: count_ascii_case_insensitive_outside_comments(source, "!important"),
365        ..AnalysisStats::default()
366    };
367    let mut selector_counts: HashMap<String, usize> = HashMap::new();
368
369    for node in nodes {
370        match &node.kind {
371            NodeKind::Style => {
372                stats.top_level_style_rules += 1;
373                let selector = node.prelude(source).to_string();
374                *selector_counts.entry(selector).or_default() += 1;
375                if let Some(body) = node.body(source) {
376                    stats.declarations += count_top_level_declarations(body);
377                    stats.custom_properties += count_custom_properties(body);
378                }
379            }
380            NodeKind::AtBlock { name, .. } => {
381                stats.top_level_at_rules += 1;
382                match name.as_str() {
383                    "media" => stats.media_rules += 1,
384                    "supports" => stats.supports_rules += 1,
385                    "container" => stats.container_rules += 1,
386                    "layer" => stats.layer_rules += 1,
387                    "scope" => stats.scope_rules += 1,
388                    "starting-style" => stats.starting_style_rules += 1,
389                    _ => {}
390                }
391            }
392            NodeKind::AtStatement { .. } => stats.top_level_at_rules += 1,
393        }
394    }
395
396    stats.duplicate_selectors = selector_counts.values().filter(|&&count| count > 1).count();
397    stats
398}
399
400fn count_custom_properties(body: &str) -> usize {
401    body.lines()
402        .filter(|line| {
403            let trimmed = line.trim_start();
404            trimmed.starts_with("--") && trimmed.contains(':')
405        })
406        .count()
407}
408
409fn collect_findings(source: &str, nodes: &[SourceNode]) -> Vec<Finding> {
410    let mut findings = Vec::new();
411    let selectors: HashSet<String> = nodes
412        .iter()
413        .filter(|n| matches!(&n.kind, NodeKind::Style))
414        .map(|n| n.prelude(source).to_string())
415        .collect();
416
417    let mut selector_occurrences: HashMap<String, usize> = HashMap::new();
418
419    for node in nodes {
420        match &node.kind {
421            NodeKind::Style => {
422                let selector = node.prelude(source);
423                *selector_occurrences.entry(selector.to_string()).or_default() += 1;
424
425                if contains_top_level_comma(selector) {
426                    let branches = split_top_level_comma(selector);
427                    let specs: Vec<Specificity> = branches.iter().map(|b| calculate_specificity(b.trim())).collect();
428                    let has_mixed = specs.windows(2).any(|w| w[0] != w[1]);
429                    if has_mixed {
430                        findings.push(Finding {
431                            safety: Safety::Review,
432                            title: "Mixed-specificity selector list detected".into(),
433                            detail: format!("{selector}: contains branches with differing specificities; factoring into :is() or parent nesting would raise lower-specificity branches."),
434                        });
435                    } else {
436                        findings.push(Finding {
437                            safety: Safety::Review,
438                            title: "Selector list kept flat".into(),
439                            detail: format!("{selector}: parent selector lists require per-branch specificity proof before native nesting."),
440                        });
441                    }
442                }
443
444                if let Some(base) = bem_base_candidate(selector)
445                    && selectors.contains(base) {
446                        findings.push(Finding {
447                            safety: Safety::Unsupported,
448                            title: "BEM token concatenation is not native nesting".into(),
449                            detail: format!("{selector} resembles {base} + a BEM suffix; CSS nesting cannot safely generate &__element or &--modifier."),
450                        });
451                    }
452
453                if let Some(body) = node.body(source) {
454                    if body.trim().is_empty() {
455                        findings.push(Finding {
456                            safety: Safety::Review,
457                            title: "Empty rule block detected".into(),
458                            detail: format!("{selector} contains no declarations or nested rules."),
459                        });
460                    }
461
462                    let mut seen_props: HashMap<String, String> = HashMap::new();
463                    for line in body.lines() {
464                        let trimmed = line.trim();
465                        if trimmed.starts_with("/*") || trimmed.starts_with('*') || !trimmed.contains(':') {
466                            continue;
467                        }
468                        if let Some((prop, val)) = trimmed.split_once(':') {
469                            let prop = prop.trim().to_ascii_lowercase();
470                            let val = val.trim().trim_end_matches(';').trim().to_string();
471                            if let Some(prev_val) = seen_props.get(&prop) {
472                                if prev_val == &val {
473                                    findings.push(Finding {
474                                        safety: Safety::Review,
475                                        title: "Exact duplicate declaration detected".into(),
476                                        detail: format!("In {selector}: property '{prop}: {val}' is declared multiple times with identical value."),
477                                    });
478                                }
479                            } else {
480                                seen_props.insert(prop, val);
481                            }
482                        }
483                    }
484
485                    if selector.contains(" .") && !selector.contains(":has(") {
486                        findings.push(Finding {
487                            safety: Safety::Review,
488                            title: "Potential :has() relational candidate".into(),
489                            detail: format!("{selector}: parent-child descendant relationship could be expressed with :has() if container-targeting is intended (advisory)."),
490                        });
491                    }
492                }
493            }
494            NodeKind::AtBlock { name, .. } => match name.as_str() {
495                "layer" => findings.push(Finding {
496                    safety: Safety::Review,
497                    title: "Cascade layer context detected".into(),
498                    detail: "@layer participates in cascade ordering and reverses layer precedence for !important; automatic layer architecture is not applied.".into(),
499                }),
500                "scope" => findings.push(Finding {
501                    safety: Safety::Review,
502                    title: "Scope boundary detected".into(),
503                    detail: "@scope boundaries enforce doughnut scoping; scoping parameters require manual architect review.".into(),
504                }),
505                "container" => findings.push(Finding {
506                    safety: Safety::Review,
507                    title: "Container query context detected".into(),
508                    detail: "@container depends on eligible ancestor containers; media-to-container conversion is not inferred from CSS alone.".into(),
509                }),
510                "starting-style" => findings.push(Finding {
511                    safety: Safety::Review,
512                    title: "Starting-style context detected".into(),
513                    detail: "@starting-style is temporal transition state; this build never invents it from ordinary declarations.".into(),
514                }),
515                _ => {}
516            },
517            NodeKind::AtStatement { .. } => {}
518        }
519    }
520
521    for (selector, count) in selector_occurrences {
522        if count > 1 {
523            findings.push(Finding {
524                safety: Safety::Review,
525                title: "Duplicate selector in stylesheet".into(),
526                detail: format!("'{selector}' appears {count} times in the stylesheet; non-adjacent occurrences must not be merged across intervening rules."),
527            });
528        }
529    }
530
531    findings
532}
533
534fn bem_base_candidate(selector: &str) -> Option<&str> {
535    if let Some(pos) = selector.find("__") {
536        let base = &selector[..pos];
537        if !base.is_empty() && !base.contains(' ') {
538            return Some(base);
539        }
540    }
541    if let Some(pos) = selector.find("--") {
542        let base = &selector[..pos];
543        if !base.is_empty() && !base.contains(' ') {
544            return Some(base);
545        }
546    }
547    None
548}
549
550const TRANSPARENT_AT_RULES: &[&str] = &["layer", "scope", "media", "supports", "container"];
551
552fn build_plans_recursive(
553    path: &Path,
554    source: &str,
555    nodes: &[SourceNode],
556    enabled_rules: &[RuleId],
557) -> Vec<PlanEntry> {
558    let mut plans = build_plans(path, source, nodes, enabled_rules);
559
560    for node in nodes {
561        if let NodeKind::AtBlock { name, .. } = &node.kind
562            && TRANSPARENT_AT_RULES.contains(&name.as_str())
563        {
564            let is_covered = plans
565                .iter()
566                .any(|p| p.source_range.start <= node.start && node.end <= p.source_range.end);
567            if !is_covered && let Some(body_range) = &node.body_range {
568                let inner_nodes = scan_nodes(source, body_range.clone());
569                if !inner_nodes.is_empty() {
570                    let inner_plans =
571                        build_plans_recursive(path, source, &inner_nodes, enabled_rules);
572                    plans.extend(inner_plans);
573                }
574            }
575        }
576    }
577
578    let keep = select_disjoint_plan_indices(&plans);
579    keep.into_iter().map(|i| plans[i].clone()).collect()
580}
581
582fn build_plans(
583    path: &Path,
584    source: &str,
585    nodes: &[SourceNode],
586    enabled_rules: &[RuleId],
587) -> Vec<PlanEntry> {
588    let enabled: HashSet<RuleId> = enabled_rules.iter().copied().collect();
589    let mut plans = Vec::new();
590
591    // 1. Structural At-rule refactorings across top-level nodes
592    plan_merge_same_named_layers(path, source, nodes, &enabled, &mut plans);
593    plan_merge_adjacent_at_blocks(path, source, nodes, &enabled, &mut plans);
594    plan_gather_consecutive_conditions_by_selector(path, source, nodes, &enabled, &mut plans);
595    plan_merge_adjacent_identical_selectors(path, source, nodes, &enabled, &mut plans);
596    plan_gather_related_selector_rules(path, source, nodes, &enabled, &mut plans);
597    plan_nest_layer_by_selector(path, source, nodes, &enabled, &mut plans);
598    plan_merge_identical_rule_bodies(path, source, nodes, &enabled, &mut plans);
599    plan_factor_identical_states_with_is(path, source, nodes, &enabled, &mut plans);
600    plan_factor_multi_selector_cluster_with_is(path, source, nodes, &enabled, &mut plans);
601    plan_nest_in_place_adjacent_states(path, source, nodes, &enabled, &mut plans);
602
603    let mut i = 0usize;
604
605    while i < nodes.len() {
606        let parent = &nodes[i];
607
608        // ModernizeMediaRange on at-rules
609        if enabled.contains(&RuleId::ModernizeMediaRange)
610            && let NodeKind::AtBlock { name, .. } = &parent.kind
611            && (name == "media" || name == "container")
612        {
613            let prelude = parent.prelude(source);
614            if let Some(modernized) = modernize_media_query_str(prelude) {
615                plans.push(PlanEntry {
616                            id: String::new(),
617                            file: path.to_path_buf(),
618                            rules: vec![RuleId::ModernizeMediaRange],
619                            safety: Safety::Safe,
620                            source_range: SourceRange {
621                                start: parent.prelude_range.start,
622                                end: parent.prelude_range.end,
623                            },
624                            original: source[parent.prelude_range.clone()].to_string(),
625                            proposed: modernized,
626                            proof: Proof::safe_local(),
627                            warnings: Vec::new(),
628                            reason: "Modernize legacy media/container feature syntax to CSS Range Syntax (e.g. (width >= 800px)).".to_string(),
629                            selected: true,
630                        });
631            }
632        }
633
634        if !matches!(&parent.kind, NodeKind::Style) {
635            i += 1;
636            continue;
637        }
638
639        let parent_selector = parent.prelude(source);
640
641        // FactorSelectorList
642        if contains_top_level_comma(parent_selector) {
643            let parent_indent = line_indent(source, parent.start);
644            let unit = relative_indent_unit(source, parent);
645
646            if enabled.contains(&RuleId::FactorSelectorList)
647                && let Some(body_range) = &parent.body_range
648            {
649                let body = &source[body_range.clone()];
650                if let Some(mut factored) =
651                    factor_selector_list(parent_selector, body, &parent_indent, &unit)
652                {
653                    let branches: Vec<&str> = split_top_level_comma(parent_selector)
654                        .into_iter()
655                        .map(|s| s.trim())
656                        .collect();
657                    let base = branches[0];
658
659                    // Check if subsequent adjacent style rules share base (e.g. .notice:hover)
660                    let mut cursor = i + 1;
661                    let mut prev_end = parent.end;
662                    let mut extra_children = Vec::new();
663
664                    while cursor < nodes.len() {
665                        let next = &nodes[cursor];
666                        if !is_whitespace_only(source, prev_end..next.start) {
667                            break;
668                        }
669                        if matches!(&next.kind, NodeKind::Style)
670                            && let Some((rel, nested_sel)) =
671                                selector_relation(base, next.prelude(source))
672                            && enabled.contains(&rel.rule())
673                        {
674                            extra_children.push(ClusterChild::Style {
675                                node: next.clone(),
676                                relation: rel,
677                                nested_selector: nested_sel,
678                            });
679                            prev_end = next.end;
680                            cursor += 1;
681                            continue;
682                        }
683                        break;
684                    }
685
686                    let end_offset = if extra_children.is_empty() {
687                        parent.end
688                    } else {
689                        let nested_indent = format!("{parent_indent}{unit}");
690                        let inner_decl_indent = format!("{nested_indent}{unit}");
691                        let mut extra_rendered = String::new();
692
693                        for ch in &extra_children {
694                            if let ClusterChild::Style {
695                                node: ch_node,
696                                nested_selector,
697                                ..
698                            } = ch
699                            {
700                                extra_rendered.push('\n');
701                                extra_rendered.push_str(&nested_indent);
702                                extra_rendered.push_str(nested_selector.trim());
703                                extra_rendered.push_str(" {\n");
704                                if let Some(ch_body_range) = &ch_node.body_range {
705                                    for line in source[ch_body_range.clone()].lines() {
706                                        let trimmed = line.trim();
707                                        if !trimmed.is_empty() {
708                                            extra_rendered.push_str(&inner_decl_indent);
709                                            extra_rendered.push_str(&ensure_semicolon(trimmed));
710                                            extra_rendered.push('\n');
711                                        }
712                                    }
713                                }
714                                extra_rendered.push_str(&nested_indent);
715                                extra_rendered.push_str("}\n");
716                            }
717                        }
718
719                        if let Some(close_brace_pos) = factored.rfind('}') {
720                            factored.insert_str(close_brace_pos, &extra_rendered);
721                        }
722                        prev_end
723                    };
724
725                    plans.push(PlanEntry {
726                            id: String::new(),
727                            file: path.to_path_buf(),
728                            rules: vec![RuleId::FactorSelectorList],
729                            safety: Safety::Safe,
730                            source_range: SourceRange {
731                                start: parent.start,
732                                end: end_offset,
733                            },
734                            original: source[parent.start..end_offset].to_string(),
735                            proposed: factored,
736                            proof: Proof::safe_local(),
737                            warnings: Vec::new(),
738                            reason: "Factor comma-separated selectors sharing a common base element into nested form.".to_string(),
739                            selected: true,
740                        });
741                    i = cursor;
742                    continue;
743                }
744            }
745
746            if enabled.contains(&RuleId::ModernizeIs)
747                && let Some((factored_sel, uniform)) = factor_with_is(parent_selector)
748            {
749                plans.push(PlanEntry {
750                        id: String::new(),
751                        file: path.to_path_buf(),
752                        rules: vec![RuleId::ModernizeIs],
753                        safety: if uniform { Safety::Safe } else { Safety::Review },
754                        source_range: SourceRange {
755                            start: parent.prelude_range.start,
756                            end: parent.prelude_range.end,
757                        },
758                        original: source[parent.prelude_range.clone()].to_string(),
759                        proposed: factored_sel,
760                        proof: Proof {
761                            specificity_equivalent: uniform,
762                            ..Proof::safe_local()
763                        },
764                        warnings: if uniform { Vec::new() } else { vec!["Mixed branch specificity: :is() takes the specificity of its most specific argument.".into()] },
765                        reason: "Factor common selector prefix/suffix into :is(...) grouping.".to_string(),
766                        selected: true,
767                    });
768                i += 1;
769                continue;
770            }
771
772            if enabled.contains(&RuleId::ModernizeWhere)
773                && let Some(factored_where) = factor_with_where(parent_selector)
774            {
775                plans.push(PlanEntry {
776                        id: String::new(),
777                        file: path.to_path_buf(),
778                        rules: vec![RuleId::ModernizeWhere],
779                        safety: Safety::Review,
780                        source_range: SourceRange {
781                            start: parent.prelude_range.start,
782                            end: parent.prelude_range.end,
783                        },
784                        original: source[parent.prelude_range.clone()].to_string(),
785                        proposed: factored_where,
786                        proof: Proof {
787                            specificity_equivalent: false,
788                            ..Proof::safe_local()
789                        },
790                        warnings: vec!["Specificity zeroed to 0-0-0 by :where()".into()],
791                        reason: "Convert selector list to :where(...) for zero-specificity defaults (review required).".to_string(),
792                        selected: true,
793                    });
794                i += 1;
795                continue;
796            }
797
798            i += 1;
799            continue;
800        }
801
802        if parent_selector.contains("::") {
803            i += 1;
804            continue;
805        }
806
807        let mut children = Vec::new();
808        let mut cursor = i + 1;
809        let mut previous_end = parent.end;
810
811        while cursor < nodes.len() {
812            let node = &nodes[cursor];
813            if !is_whitespace_only(source, previous_end..node.start) {
814                break;
815            }
816
817            if matches!(&node.kind, NodeKind::Style)
818                && let Some((relation, nested_selector)) =
819                    selector_relation(parent_selector, node.prelude(source))
820                && enabled.contains(&relation.rule())
821            {
822                children.push(ClusterChild::Style {
823                    node: node.clone(),
824                    relation,
825                    nested_selector,
826                });
827                previous_end = node.end;
828                cursor += 1;
829                continue;
830            }
831
832            if let Some(child) = conditional_child(source, parent_selector, node, &enabled) {
833                previous_end = node.end;
834                children.push(child);
835                cursor += 1;
836                continue;
837            }
838
839            break;
840        }
841
842        if !children.is_empty() {
843            let last_end = children.last().expect("non-empty cluster").node().end;
844            let proposed = render_cluster(source, parent, &children);
845            let mut rules = Vec::new();
846            for child in &children {
847                let rule = child.rule();
848                if !rules.contains(&rule) {
849                    rules.push(rule);
850                }
851            }
852            plans.push(PlanEntry {
853                id: String::new(),
854                file: path.to_path_buf(),
855                rules,
856                safety: Safety::Safe,
857                source_range: SourceRange {
858                    start: parent.start,
859                    end: last_end,
860                },
861                original: source[parent.start..last_end].to_string(),
862                proposed,
863                proof: Proof::safe_local(),
864                warnings: Vec::new(),
865                reason: format!(
866                    "{} immediately adjacent rule(s) share the exact parent selector and can be nested without crossing comments or unrelated rules.",
867                    children.len()
868                ),
869                selected: true,
870            });
871            i = cursor;
872        } else {
873            if enabled.contains(&RuleId::ConsolidateNot) && matches!(&parent.kind, NodeKind::Style)
874            {
875                let prelude = parent.prelude(source);
876                if let Some((consolidated, _uniform)) = consolidate_not_in_selector(prelude) {
877                    plans.push(PlanEntry {
878                        id: String::new(),
879                        file: path.to_path_buf(),
880                        rules: vec![RuleId::ConsolidateNot],
881                        safety: Safety::Review,
882                        source_range: SourceRange {
883                            start: parent.prelude_range.start,
884                            end: parent.prelude_range.end,
885                        },
886                        original: source[parent.prelude_range.clone()].to_string(),
887                        proposed: consolidated,
888                        proof: Proof {
889                            specificity_equivalent: false,
890                            ..Proof::safe_local()
891                        },
892                        warnings: vec!["Specificity reduced: chained :not() has additive specificity; comma-separated :not() takes only the maximum argument specificity.".into()],
893                        reason: "Consolidate chained :not() selectors into a single comma-separated :not() list (review required for specificity drop).".to_string(),
894                        selected: true,
895                    });
896                }
897            }
898            i += 1;
899        }
900    }
901
902    plans
903}
904
905fn plan_merge_same_named_layers(
906    path: &Path,
907    source: &str,
908    nodes: &[SourceNode],
909    enabled: &HashSet<RuleId>,
910    plans: &mut Vec<PlanEntry>,
911) {
912    if !enabled.contains(&RuleId::MergeSameNamedLayer) {
913        return;
914    }
915    let mut layer_groups: HashMap<String, Vec<&SourceNode>> = HashMap::new();
916    for node in nodes {
917        if let NodeKind::AtBlock { name, .. } = &node.kind
918            && name == "layer"
919        {
920            let prelude = node.prelude(source).trim();
921            if let Some(layer_name) = prelude.strip_prefix("@layer") {
922                let layer_name = layer_name.trim();
923                if !layer_name.is_empty() && !layer_name.contains('{') {
924                    layer_groups
925                        .entry(layer_name.to_string())
926                        .or_default()
927                        .push(node);
928                }
929            }
930        }
931    }
932
933    let enabled_rules_vec: Vec<RuleId> = enabled.iter().copied().collect();
934
935    for (layer_name, blocks) in layer_groups {
936        if blocks.len() > 1 {
937            let first = blocks[0];
938            let parent_indent = line_indent(source, first.start);
939            let first_body_range = first.body_range.as_ref().unwrap();
940            let unit = detect_indent_unit(source, first_body_range.clone())
941                .unwrap_or_else(|| "  ".to_string());
942            let nested_indent = format!("{parent_indent}{unit}");
943
944            let mut merged_body = String::new();
945            for b in &blocks {
946                if let Some(body_range) = &b.body_range {
947                    let inner_nodes = scan_nodes(source, body_range.clone());
948                    let inner_plans =
949                        build_plans_recursive(path, source, &inner_nodes, &enabled_rules_vec);
950                    let body_text = &source[body_range.clone()];
951                    let modernized_body = if inner_plans.is_empty() {
952                        body_text.to_string()
953                    } else {
954                        let mut local_plans = Vec::new();
955                        for p in inner_plans {
956                            if p.source_range.start >= body_range.start
957                                && p.source_range.end <= body_range.end
958                            {
959                                let mut local_p = p.clone();
960                                local_p.source_range.start -= body_range.start;
961                                local_p.source_range.end -= body_range.start;
962                                local_plans.push(local_p);
963                            }
964                        }
965                        apply_selected_plans(body_text, &local_plans, true)
966                            .unwrap_or_else(|_| body_text.to_string())
967                    };
968
969                    for line in modernized_body.lines() {
970                        let trimmed = line.trim();
971                        if !trimmed.is_empty() {
972                            merged_body.push_str(&nested_indent);
973                            merged_body.push_str(&ensure_semicolon(trimmed));
974                            merged_body.push('\n');
975                        }
976                    }
977                }
978            }
979
980            let proposed_first =
981                format!("{parent_indent}@layer {layer_name} {{\n{merged_body}{parent_indent}}}");
982            plans.push(PlanEntry {
983                id: String::new(),
984                file: path.to_path_buf(),
985                rules: vec![RuleId::MergeSameNamedLayer],
986                safety: Safety::Safe,
987                source_range: SourceRange {
988                    start: first.start,
989                    end: first.end,
990                },
991                original: source[first.start..first.end].to_string(),
992                proposed: proposed_first,
993                proof: Proof::safe_local(),
994                warnings: Vec::new(),
995                reason: format!(
996                    "Consolidate {} separated blocks of @layer {} into first occurrence.",
997                    blocks.len(),
998                    layer_name
999                ),
1000                selected: true,
1001            });
1002
1003            for subsequent in &blocks[1..] {
1004                plans.push(PlanEntry {
1005                    id: String::new(),
1006                    file: path.to_path_buf(),
1007                    rules: vec![RuleId::MergeSameNamedLayer],
1008                    safety: Safety::Safe,
1009                    source_range: SourceRange {
1010                        start: subsequent.start,
1011                        end: subsequent.end,
1012                    },
1013                    original: source[subsequent.start..subsequent.end].to_string(),
1014                    proposed: String::new(),
1015                    proof: Proof::safe_local(),
1016                    warnings: Vec::new(),
1017                    reason: format!(
1018                        "Remove consolidated subsequent block of @layer {}.",
1019                        layer_name
1020                    ),
1021                    selected: true,
1022                });
1023            }
1024        }
1025    }
1026}
1027
1028fn plan_merge_adjacent_at_blocks(
1029    path: &Path,
1030    source: &str,
1031    nodes: &[SourceNode],
1032    enabled: &HashSet<RuleId>,
1033    plans: &mut Vec<PlanEntry>,
1034) {
1035    let mut i = 0;
1036    while i < nodes.len() {
1037        let first = &nodes[i];
1038        if let NodeKind::AtBlock { name, .. } = &first.kind {
1039            let rule = match name.as_str() {
1040                "media" => RuleId::MergeAdjacentMedia,
1041                "supports" => RuleId::MergeAdjacentSupports,
1042                "container" => RuleId::MergeAdjacentContainer,
1043                "scope" => RuleId::MergeIdenticalScope,
1044                "starting-style" => RuleId::MergeIdenticalStartingStyle,
1045                _ => {
1046                    i += 1;
1047                    continue;
1048                }
1049            };
1050
1051            if !enabled.contains(&rule) {
1052                i += 1;
1053                continue;
1054            }
1055
1056            let first_prelude = first.prelude(source).trim();
1057            let mut cluster = vec![first];
1058            let mut cursor = i + 1;
1059            let mut prev_end = first.end;
1060
1061            while cursor < nodes.len() {
1062                let next = &nodes[cursor];
1063                if !is_whitespace_only(source, prev_end..next.start) {
1064                    break;
1065                }
1066                if let NodeKind::AtBlock {
1067                    name: next_name, ..
1068                } = &next.kind
1069                    && next_name == name
1070                    && next.prelude(source).trim() == first_prelude
1071                {
1072                    cluster.push(next);
1073                    prev_end = next.end;
1074                    cursor += 1;
1075                    continue;
1076                }
1077                break;
1078            }
1079
1080            if cluster.len() > 1 {
1081                let last = cluster.last().unwrap();
1082                let parent_indent = line_indent(source, first.start);
1083                let first_body_range = first.body_range.as_ref().unwrap();
1084                let unit = detect_indent_unit(source, first_body_range.clone())
1085                    .unwrap_or_else(|| "  ".to_string());
1086                let nested_indent = format!("{parent_indent}{unit}");
1087
1088                let mut merged_body = String::new();
1089                for c in &cluster {
1090                    if let Some(body_range) = &c.body_range {
1091                        let body_text = &source[body_range.clone()];
1092                        for line in body_text.lines() {
1093                            let trimmed = line.trim();
1094                            if !trimmed.is_empty() {
1095                                merged_body.push_str(&nested_indent);
1096                                merged_body.push_str(&ensure_semicolon(trimmed));
1097                                merged_body.push('\n');
1098                            }
1099                        }
1100                    }
1101                }
1102
1103                let proposed =
1104                    format!("{parent_indent}{first_prelude} {{\n{merged_body}{parent_indent}}}");
1105                plans.push(PlanEntry {
1106                    id: String::new(),
1107                    file: path.to_path_buf(),
1108                    rules: vec![rule],
1109                    safety: Safety::Safe,
1110                    source_range: SourceRange {
1111                        start: first.start,
1112                        end: last.end,
1113                    },
1114                    original: source[first.start..last.end].to_string(),
1115                    proposed,
1116                    proof: Proof::safe_local(),
1117                    warnings: Vec::new(),
1118                    reason: format!(
1119                        "Merge {} adjacent identical {} blocks into a single block.",
1120                        cluster.len(),
1121                        first_prelude
1122                    ),
1123                    selected: true,
1124                });
1125                i = cursor;
1126                continue;
1127            }
1128        }
1129        i += 1;
1130    }
1131}
1132
1133fn plan_gather_consecutive_conditions_by_selector(
1134    path: &Path,
1135    source: &str,
1136    nodes: &[SourceNode],
1137    enabled: &HashSet<RuleId>,
1138    plans: &mut Vec<PlanEntry>,
1139) {
1140    if !enabled.contains(&RuleId::NestMedia) && !enabled.contains(&RuleId::NestSupports) {
1141        return;
1142    }
1143
1144    let mut i = 0;
1145    while i < nodes.len() {
1146        let first = &nodes[i];
1147        if let NodeKind::AtBlock { name, .. } = &first.kind
1148            && (name == "media" || name == "supports")
1149            && let Some(target_sel) = extract_single_style_selector(source, first)
1150        {
1151            let mut cluster = vec![first];
1152            let mut cursor = i + 1;
1153            let mut prev_end = first.end;
1154
1155            while cursor < nodes.len() {
1156                let next = &nodes[cursor];
1157                if !is_whitespace_only(source, prev_end..next.start) {
1158                    break;
1159                }
1160                if let NodeKind::AtBlock {
1161                    name: next_name, ..
1162                } = &next.kind
1163                    && (next_name == "media" || next_name == "supports")
1164                    && let Some(next_sel) = extract_single_style_selector(source, next)
1165                    && next_sel == target_sel
1166                {
1167                    cluster.push(next);
1168                    prev_end = next.end;
1169                    cursor += 1;
1170                    continue;
1171                }
1172                break;
1173            }
1174
1175            if cluster.len() > 1 {
1176                let last = cluster.last().unwrap();
1177                let parent_indent = line_indent(source, first.start);
1178                let first_body_range = first.body_range.as_ref().unwrap();
1179                let unit = detect_indent_unit(source, first_body_range.clone())
1180                    .unwrap_or_else(|| "  ".to_string());
1181                let nested_indent = format!("{parent_indent}{unit}");
1182                let inner_decl_indent = format!("{nested_indent}{unit}");
1183
1184                let mut body_out = String::new();
1185                for (idx, &c) in cluster.iter().enumerate() {
1186                    if idx > 0 {
1187                        body_out.push('\n');
1188                    }
1189                    let at_header = c.prelude(source).trim();
1190                    body_out.push_str(&nested_indent);
1191                    body_out.push_str(at_header);
1192                    body_out.push_str(" {\n");
1193
1194                    let c_body_range = c.body_range.as_ref().unwrap();
1195                    let inner_nodes = scan_nodes(source, c_body_range.clone());
1196                    for in_node in &inner_nodes {
1197                        if let Some(in_body_range) = &in_node.body_range {
1198                            for line in source[in_body_range.clone()].lines() {
1199                                let trimmed = line.trim();
1200                                if !trimmed.is_empty() {
1201                                    body_out.push_str(&inner_decl_indent);
1202                                    body_out.push_str(&ensure_semicolon(trimmed));
1203                                    body_out.push('\n');
1204                                }
1205                            }
1206                        }
1207                    }
1208
1209                    body_out.push_str(&nested_indent);
1210                    body_out.push_str("}\n");
1211                }
1212
1213                let proposed =
1214                    format!("{parent_indent}{target_sel} {{\n{body_out}{parent_indent}}}");
1215                plans.push(PlanEntry {
1216                            id: String::new(),
1217                            file: path.to_path_buf(),
1218                            rules: vec![RuleId::NestMedia, RuleId::NestSupports],
1219                            safety: Safety::Safe,
1220                            source_range: SourceRange {
1221                                start: first.start,
1222                                end: last.end,
1223                            },
1224                            original: source[first.start..last.end].to_string(),
1225                            proposed,
1226                            proof: Proof::safe_local(),
1227                            warnings: Vec::new(),
1228                            reason: format!(
1229                                "Gather {} consecutive condition blocks targeting '{}' into a single component rule.",
1230                                cluster.len(),
1231                                target_sel
1232                            ),
1233                            selected: true,
1234                        });
1235                i = cursor;
1236                continue;
1237            }
1238        }
1239        i += 1;
1240    }
1241}
1242
1243fn extract_single_style_selector<'a>(source: &'a str, at_node: &SourceNode) -> Option<&'a str> {
1244    let body_range = at_node.body_range.as_ref()?;
1245    let inner_nodes = scan_nodes(source, body_range.clone());
1246    if inner_nodes.len() == 1 && matches!(&inner_nodes[0].kind, NodeKind::Style) {
1247        Some(inner_nodes[0].prelude(source).trim())
1248    } else {
1249        None
1250    }
1251}
1252
1253fn plan_nest_in_place_adjacent_states(
1254    path: &Path,
1255    source: &str,
1256    nodes: &[SourceNode],
1257    enabled: &HashSet<RuleId>,
1258    plans: &mut Vec<PlanEntry>,
1259) {
1260    if !enabled.contains(&RuleId::NestPseudoClass) {
1261        return;
1262    }
1263
1264    let mut i = 0;
1265    while i < nodes.len() {
1266        let first = &nodes[i];
1267        if matches!(&first.kind, NodeKind::Style) {
1268            let first_sel = first.prelude(source).trim();
1269            if let Some(base) = extract_base_target(first_sel) {
1270                let mut cluster = vec![first];
1271                let mut cursor = i + 1;
1272                let mut prev_end = first.end;
1273
1274                while cursor < nodes.len() {
1275                    let next = &nodes[cursor];
1276                    if !is_whitespace_only(source, prev_end..next.start) {
1277                        break;
1278                    }
1279                    if matches!(&next.kind, NodeKind::Style) {
1280                        let next_sel = next.prelude(source).trim();
1281                        if let Some(next_base) = extract_base_target(next_sel)
1282                            && next_base == base
1283                        {
1284                            cluster.push(next);
1285                            prev_end = next.end;
1286                            cursor += 1;
1287                            continue;
1288                        }
1289                    }
1290                    break;
1291                }
1292
1293                if cluster.len() > 1 {
1294                    let last = cluster.last().unwrap();
1295                    let parent_indent = line_indent(source, first.start);
1296                    let first_body_range = first.body_range.as_ref().unwrap();
1297                    let unit = detect_indent_unit(source, first_body_range.clone())
1298                        .unwrap_or_else(|| "  ".to_string());
1299                    let nested_indent = format!("{parent_indent}{unit}");
1300                    let inner_decl_indent = format!("{nested_indent}{unit}");
1301
1302                    let mut out = format!("{parent_indent}{base} {{\n");
1303                    for (idx, &c) in cluster.iter().enumerate() {
1304                        if idx > 0 {
1305                            out.push('\n');
1306                        }
1307                        let c_sel = c.prelude(source).trim();
1308                        let remainder = &c_sel[base.len()..];
1309                        let nested_sel = if remainder.starts_with(':')
1310                            || remainder.starts_with('[')
1311                            || remainder.starts_with('.')
1312                            || remainder.starts_with('#')
1313                        {
1314                            format!("&{remainder}")
1315                        } else {
1316                            remainder.trim().to_string()
1317                        };
1318
1319                        out.push_str(&nested_indent);
1320                        out.push_str(&nested_sel);
1321                        out.push_str(" {\n");
1322
1323                        if let Some(c_body_range) = &c.body_range {
1324                            for line in source[c_body_range.clone()].lines() {
1325                                let trimmed = line.trim();
1326                                if !trimmed.is_empty() {
1327                                    out.push_str(&inner_decl_indent);
1328                                    out.push_str(&ensure_semicolon(trimmed));
1329                                    out.push('\n');
1330                                }
1331                            }
1332                        }
1333
1334                        out.push_str(&nested_indent);
1335                        out.push_str("}\n");
1336                    }
1337
1338                    out.push_str(&parent_indent);
1339                    out.push('}');
1340
1341                    plans.push(PlanEntry {
1342                        id: String::new(),
1343                        file: path.to_path_buf(),
1344                        rules: vec![RuleId::NestPseudoClass, RuleId::NestAttribute],
1345                        safety: Safety::Safe,
1346                        source_range: SourceRange {
1347                            start: first.start,
1348                            end: last.end,
1349                        },
1350                        original: source[first.start..last.end].to_string(),
1351                        proposed: out,
1352                        proof: Proof::safe_local(),
1353                        warnings: Vec::new(),
1354                        reason: format!(
1355                            "Nest {} adjacent state rules for '{}' in place without moving.",
1356                            cluster.len(),
1357                            base
1358                        ),
1359                        selected: true,
1360                    });
1361                    i = cursor;
1362                    continue;
1363                }
1364            }
1365        }
1366        i += 1;
1367    }
1368}
1369
1370fn extract_base_target(selector: &str) -> Option<&str> {
1371    if contains_top_level_comma(selector) {
1372        return None;
1373    }
1374    if let Some(pos) = selector.find(':')
1375        && pos > 0
1376        && !selector[pos..].starts_with("::")
1377    {
1378        let base = &selector[..pos];
1379        if !base.is_empty() {
1380            return Some(base);
1381        }
1382    }
1383    if let Some(pos) = selector.find('[')
1384        && pos > 0
1385    {
1386        let base = &selector[..pos];
1387        if !base.is_empty() {
1388            return Some(base);
1389        }
1390    }
1391    None
1392}
1393
1394fn parse_rule_body_items(body_str: &str) -> (Vec<String>, Vec<String>) {
1395    let mut declarations = Vec::new();
1396    let mut nested_rules = Vec::new();
1397
1398    let mut depth = 0usize;
1399    let mut current_block = String::new();
1400    let mut current_decl = String::new();
1401    let mut in_comment = false;
1402    let bytes = body_str.as_bytes();
1403    let mut i = 0;
1404
1405    while i < bytes.len() {
1406        if in_comment {
1407            current_decl.push(bytes[i] as char);
1408            current_block.push(bytes[i] as char);
1409            if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
1410                current_decl.push('/');
1411                current_block.push('/');
1412                i += 2;
1413                in_comment = false;
1414                continue;
1415            }
1416            i += 1;
1417            continue;
1418        }
1419
1420        if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
1421            in_comment = true;
1422            current_decl.push('/');
1423            current_decl.push('*');
1424            current_block.push('/');
1425            current_block.push('*');
1426            i += 2;
1427            continue;
1428        }
1429
1430        let b = bytes[i];
1431        if b == b'{' {
1432            depth += 1;
1433            if depth == 1 {
1434                current_block = current_decl.clone();
1435                current_decl.clear();
1436            }
1437            current_block.push('{');
1438            i += 1;
1439            continue;
1440        } else if b == b'}' {
1441            if depth > 0 {
1442                depth -= 1;
1443                current_block.push('}');
1444                if depth == 0 {
1445                    let trimmed = current_block.trim().to_string();
1446                    if !trimmed.is_empty() {
1447                        nested_rules.push(trimmed);
1448                    }
1449                    current_block.clear();
1450                    current_decl.clear();
1451                }
1452            }
1453            i += 1;
1454            continue;
1455        }
1456
1457        if depth > 0 {
1458            current_block.push(b as char);
1459        } else {
1460            if b == b';' {
1461                current_decl.push(';');
1462                let trimmed = current_decl.trim().to_string();
1463                if !trimmed.is_empty() {
1464                    declarations.push(trimmed);
1465                }
1466                current_decl.clear();
1467            } else if b == b'\n' {
1468                let trimmed = current_decl.trim();
1469                // Selector-list continuations (`&:hover,` / `&::before,`) contain `:`
1470                // but are not declarations — they must stay attached to the `{` that follows.
1471                if !trimmed.is_empty()
1472                    && trimmed.contains(':')
1473                    && !trimmed.ends_with('{')
1474                    && !trimmed.ends_with(',')
1475                {
1476                    let rest = body_str[i + 1..].trim_start();
1477                    if !rest.starts_with('{') {
1478                        declarations.push(trimmed.to_string());
1479                        current_decl.clear();
1480                    } else {
1481                        current_decl.push('\n');
1482                    }
1483                } else {
1484                    current_decl.push('\n');
1485                }
1486            } else {
1487                current_decl.push(b as char);
1488            }
1489        }
1490        i += 1;
1491    }
1492
1493    let trailing_decl = current_decl.trim().to_string();
1494    if !trailing_decl.is_empty() && trailing_decl.contains(':') {
1495        declarations.push(ensure_semicolon(&trailing_decl).into_owned());
1496    }
1497
1498    (declarations, nested_rules)
1499}
1500
1501fn is_ident_continue(c: char) -> bool {
1502    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '\\'
1503}
1504
1505/// MDN "Appending the `&` nesting selector": reverse the context so the
1506/// parent appears as a suffix (`&`) of the nested selector.
1507///
1508/// `.featured .card` → `.featured &`
1509/// `.featured.card`  → `.featured&`
1510/// `:not(.card)`     → `:not(&)`
1511/// `.foo > .card`    → `.foo > &`
1512fn appended_nesting_selector_raw(base: &str, candidate_sel: &str) -> Option<String> {
1513    let base = base.trim();
1514    let candidate_sel = candidate_sel.trim();
1515    if base.is_empty() || candidate_sel == base || contains_top_level_comma(candidate_sel) {
1516        return None;
1517    }
1518
1519    const FNS: &[&str] = &[":not(", ":is(", ":where(", ":has("];
1520    for fn_name in FNS {
1521        let wrapped = format!("{fn_name}{base})");
1522        if candidate_sel == wrapped {
1523            return Some(format!("{fn_name}&)"));
1524        }
1525        if let Some(prefix) = candidate_sel.strip_suffix(wrapped.as_str())
1526            && !prefix.is_empty()
1527        {
1528            let prev = prefix.chars().last()?;
1529            if is_ident_continue(prev)
1530                || prev == '.'
1531                || prev == '#'
1532                || prev == ']'
1533                || prev == ')'
1534                || prev == '*'
1535            {
1536                return Some(format!("{prefix}{fn_name}&)"));
1537            }
1538        }
1539    }
1540
1541    if !candidate_sel.ends_with(base) {
1542        return None;
1543    }
1544    let prefix = &candidate_sel[..candidate_sel.len() - base.len()];
1545    if prefix.is_empty() {
1546        return None;
1547    }
1548    let prev = prefix.chars().last()?;
1549    let first_of_base = base.chars().next()?;
1550
1551    if prev.is_whitespace() || matches!(prev, '>' | '+' | '~') {
1552        return Some(format!("{prefix}&"));
1553    }
1554
1555    // Compound join: `.featured.card`, `div.card` — the `.`/`#`/`[`/`:` of
1556    // `base` starts a new simple selector, not a mid-ident substring.
1557    if matches!(first_of_base, '.' | '#' | '[' | ':')
1558        && (is_ident_continue(prev) || prev == ']' || prev == ')' || prev == '*')
1559    {
1560        return Some(format!("{prefix}&"));
1561    }
1562
1563    None
1564}
1565
1566fn appended_nesting_selector(base: &str, candidate_sel: &str) -> Option<String> {
1567    let nested = appended_nesting_selector_raw(base, candidate_sel)?;
1568    // Refuse a nest that would be interpreted as a descendant. `&` anywhere
1569    // (including `:not(&)`) marks the selector as non-relative.
1570    selector_contains_nesting_amp(&nested).then_some(nested)
1571}
1572
1573fn appended_selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
1574    let nested = appended_nesting_selector(parent, child)?;
1575    let before_amp = nested.strip_suffix('&').unwrap_or(nested.as_str());
1576    let kind = if nested.contains(":not(")
1577        || nested.contains(":is(")
1578        || nested.contains(":where(")
1579        || nested.contains(":has(")
1580    {
1581        RelationKind::PseudoClass
1582    } else if before_amp.contains('>') || before_amp.contains('+') || before_amp.contains('~') {
1583        RelationKind::Combinator
1584    } else if nested.ends_with('&')
1585        && before_amp
1586            .chars()
1587            .last()
1588            .is_some_and(|c| !c.is_whitespace())
1589    {
1590        RelationKind::Compound
1591    } else {
1592        RelationKind::Descendant
1593    };
1594    Some((kind, nested))
1595}
1596
1597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1598enum RelatedKind {
1599    Prefix,
1600    Appended,
1601}
1602
1603fn prefix_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1604    if candidate_sel == base || !candidate_sel.starts_with(base) {
1605        return None;
1606    }
1607    let rem_raw = &candidate_sel[base.len()..];
1608    let rem = rem_raw.trim_start();
1609    if rem.is_empty() {
1610        return None;
1611    }
1612    // Only attach '&' when the suffix is directly touching the base (no whitespace).
1613    // e.g. `.foo:hover` → `&:hover` but `.foo :not(*)` → `:not(*)` (descendant, no &).
1614    let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1615    if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#')
1616    {
1617        if directly_attached {
1618            return Some(format!("&{rem}"));
1619        } else {
1620            return Some(rem.to_string());
1621        }
1622    }
1623    if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1624        let first_char = &rem[..1];
1625        let rest = rem[1..].trim_start();
1626        return Some(format!("{first_char} {rest}"));
1627    }
1628    if rem_raw.starts_with(' ') {
1629        return Some(rem.to_string());
1630    }
1631    None
1632}
1633
1634fn classify_related_nested_one(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1635    if candidate_sel == base || contains_top_level_comma(candidate_sel) {
1636        return None;
1637    }
1638    if let Some(rel) = prefix_related_nested_selector(base, candidate_sel) {
1639        return Some((RelatedKind::Prefix, rel));
1640    }
1641    appended_nesting_selector(base, candidate_sel).map(|rel| (RelatedKind::Appended, rel))
1642}
1643
1644fn classify_related_comma_list(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1645    let parts: Vec<&str> = split_top_level_comma(candidate_sel)
1646        .into_iter()
1647        .map(str::trim)
1648        .filter(|s| !s.is_empty())
1649        .collect();
1650    if parts.len() < 2 {
1651        return None;
1652    }
1653    let mut rels = Vec::with_capacity(parts.len());
1654    let mut all_prefix = true;
1655    for part in parts {
1656        if part == base {
1657            rels.push("&".to_string());
1658            continue;
1659        }
1660        let (kind, rel) = classify_related_nested_one(base, part)?;
1661        if kind != RelatedKind::Prefix {
1662            all_prefix = false;
1663        }
1664        rels.push(rel);
1665    }
1666    let kind = if all_prefix {
1667        RelatedKind::Prefix
1668    } else {
1669        RelatedKind::Appended
1670    };
1671    Some((kind, rels.join(", ")))
1672}
1673
1674fn classify_related_nested(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1675    if candidate_sel == base {
1676        return None;
1677    }
1678    if contains_top_level_comma(candidate_sel) {
1679        return classify_related_comma_list(base, candidate_sel);
1680    }
1681    classify_related_nested_one(base, candidate_sel)
1682}
1683
1684fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1685    classify_related_nested(base, candidate_sel).map(|(_, rel)| rel)
1686}
1687
1688fn is_weak_gather_base(base: &str) -> bool {
1689    matches!(base.trim(), "*" | "html" | "body" | ":root" | ":host")
1690}
1691
1692/// CSS Nesting: if a nested selector contains `&` anywhere (including inside
1693/// `:not()`, `:is()`, `:where()`, `:has()`), it is a *non-relative* selector.
1694/// The parent is not implicitly prepended as a descendant.
1695/// `.parent { .child:not(&) {} }` → `.child:not(:is(.parent))`, not
1696/// `.parent .child:not(.parent)`.
1697fn selector_contains_nesting_amp(selector: &str) -> bool {
1698    let bytes = selector.as_bytes();
1699    let mut i = 0;
1700    let mut quote: Option<u8> = None;
1701    let mut escaped = false;
1702    while i < bytes.len() {
1703        let b = bytes[i];
1704        if let Some(q) = quote {
1705            if escaped {
1706                escaped = false;
1707            } else if b == b'\\' {
1708                escaped = true;
1709            } else if b == q {
1710                quote = None;
1711            }
1712            i += 1;
1713            continue;
1714        }
1715        match b {
1716            b'\'' | b'"' => quote = Some(b),
1717            b'&' => return true,
1718            _ => {}
1719        }
1720        i += 1;
1721    }
1722    false
1723}
1724
1725/// Last `/* … */` before `node_start` if only whitespace follows it.
1726fn leading_block_comment(source: &str, node_start: usize) -> Option<(usize, &str)> {
1727    let before = source.get(..node_start)?;
1728    let start = before.rfind("/*")?;
1729    let close_rel = source.get(start + 2..node_start)?.find("*/")?;
1730    let end = start + 2 + close_rel + 2;
1731    if !source[end..node_start]
1732        .bytes()
1733        .all(|b| b.is_ascii_whitespace())
1734    {
1735        return None;
1736    }
1737    Some((start, source[start..end].trim_end()))
1738}
1739
1740fn is_simple_compound_selector(sel: &str) -> bool {
1741    let sel = sel.trim();
1742    if sel.is_empty()
1743        || sel.starts_with('&')
1744        || sel.starts_with('+')
1745        || sel.starts_with('>')
1746        || sel.starts_with('~')
1747        || contains_top_level_comma(sel)
1748    {
1749        return false;
1750    }
1751    let mut paren = 0usize;
1752    let mut brack = 0usize;
1753    for c in sel.chars() {
1754        match c {
1755            '(' => paren += 1,
1756            ')' => paren = paren.saturating_sub(1),
1757            '[' => brack += 1,
1758            ']' => brack = brack.saturating_sub(1),
1759            _ if paren == 0
1760                && brack == 0
1761                && (c.is_whitespace() || matches!(c, '+' | '>' | '~')) =>
1762            {
1763                return false;
1764            }
1765            _ => {}
1766        }
1767    }
1768    true
1769}
1770
1771fn first_compound_stripped(sel: &str) -> Option<&str> {
1772    let sel = sel.trim();
1773    if sel.is_empty() || sel.starts_with('&') {
1774        return None;
1775    }
1776    let mut paren = 0usize;
1777    let mut brack = 0usize;
1778    let mut end = sel.len();
1779    for (i, c) in sel.char_indices() {
1780        match c {
1781            '(' => paren += 1,
1782            ')' => paren = paren.saturating_sub(1),
1783            '[' => brack += 1,
1784            ']' => brack = brack.saturating_sub(1),
1785            _ if paren == 0
1786                && brack == 0
1787                && (c.is_whitespace() || matches!(c, '+' | '>' | '~' | ',')) =>
1788            {
1789                end = i;
1790                break;
1791            }
1792            _ => {}
1793        }
1794    }
1795    let head = sel[..end].trim();
1796    if head.is_empty() || head.starts_with(':') || head.starts_with('[') {
1797        return None;
1798    }
1799    paren = 0;
1800    brack = 0;
1801    for (i, c) in head.char_indices() {
1802        match c {
1803            '(' => paren += 1,
1804            ')' => paren = paren.saturating_sub(1),
1805            '[' if paren == 0 && i > 0 => return Some(&head[..i]),
1806            ':' if paren == 0 && brack == 0 && i > 0 => return Some(&head[..i]),
1807            _ => {}
1808        }
1809    }
1810    Some(head)
1811}
1812
1813fn style_body_weight(source: &str, node: &SourceNode) -> usize {
1814    node.body(source)
1815        .map(|b| b.lines().filter(|l| !l.trim().is_empty()).count())
1816        .unwrap_or(0)
1817}
1818
1819/// Pick a single gather home for `sel`.
1820/// Exact existing rules win by specificity; prefix beats appended on a tie;
1821/// virtual stripped compounds (`.skip-link` from `.skip-link:hover`) are last resort.
1822fn assign_gather_home<'a>(
1823    sel: &str,
1824    exact_homes: &[&'a str],
1825    home_weight: &HashMap<&'a str, usize>,
1826) -> Option<&'a str> {
1827    #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1828    struct Rank {
1829        spec: Specificity,
1830        is_prefix: bool,
1831        weight: usize,
1832        len: usize,
1833    }
1834
1835    let mut best: Option<(Rank, &'a str)> = None;
1836    let consider =
1837        |best: &mut Option<(Rank, &'a str)>, home: &'a str, kind: Option<RelatedKind>| {
1838            let is_prefix = matches!(kind, None | Some(RelatedKind::Prefix));
1839            if matches!(kind, Some(RelatedKind::Appended)) && is_weak_gather_base(home) {
1840                return;
1841            }
1842            let rank = Rank {
1843                spec: calculate_specificity(home),
1844                is_prefix,
1845                weight: home_weight.get(home).copied().unwrap_or(0),
1846                len: home.len(),
1847            };
1848            if best.as_ref().is_none_or(|(cur, _)| rank > *cur) {
1849                *best = Some((rank, home));
1850            }
1851        };
1852
1853    for &home in exact_homes {
1854        if sel == home || home_weight.get(home).copied().unwrap_or(0) == 0 {
1855            continue;
1856        }
1857        if let Some((kind, _)) = classify_related_nested(home, sel) {
1858            consider(&mut best, home, Some(kind));
1859        }
1860    }
1861    if best.is_some() {
1862        return best.map(|(_, home)| home);
1863    }
1864    // No existing rule claimed this selector — fall back to a virtual
1865    // stripped compound so `.skip-link:hover` + `.skip-link:focus` can
1866    // still wrap under a synthesized `.skip-link`.
1867    for &home in exact_homes {
1868        if sel == home || home_weight.get(home).copied().unwrap_or(0) != 0 {
1869            continue;
1870        }
1871        if let Some((kind, _)) = classify_related_nested(home, sel) {
1872            consider(&mut best, home, Some(kind));
1873        }
1874    }
1875    best.map(|(_, home)| home)
1876}
1877
1878const GATHERABLE_CONDITIONALS: &[&str] = &["media", "supports", "container", "starting-style"];
1879/// `@layer` / `@scope` are NOT gather containers. Walking into them and
1880/// dumping members into the first home moves declarations across cascade
1881/// layers (e.g. `:root` in `@layer base` into `@layer tokens`).
1882const GATHER_SCAN_CONTAINERS: &[&str] = &[];
1883
1884fn is_strong_gather_home(sel: &str) -> bool {
1885    let sel = sel.trim();
1886    sel.starts_with('.')
1887        || sel.starts_with('#')
1888        || sel.starts_with('[')
1889        || (sel.starts_with(':') && !sel.starts_with("::"))
1890}
1891
1892fn is_absorbable_descendant(base: &str, sel: &str) -> bool {
1893    let sel = sel.trim();
1894    if sel.is_empty() || sel == base {
1895        return false;
1896    }
1897    if contains_top_level_comma(sel) {
1898        return split_top_level_comma(sel)
1899            .into_iter()
1900            .map(str::trim)
1901            .filter(|s| !s.is_empty())
1902            .all(|part| is_absorbable_descendant(base, part));
1903    }
1904    if extract_related_nested_selector(base, sel).is_some() {
1905        return true;
1906    }
1907    let head = first_compound_stripped(sel).unwrap_or(sel);
1908    if is_weak_gather_base(head) {
1909        return false;
1910    }
1911    if is_strong_gather_home(head) && head != first_compound_stripped(base).unwrap_or(base) {
1912        return false;
1913    }
1914    is_simple_compound_selector(sel) || first_compound_stripped(sel).is_some()
1915}
1916
1917fn can_nest_under_gather_home(base: &str, sel: &str) -> bool {
1918    sel == base
1919        || extract_related_nested_selector(base, sel).is_some()
1920        || is_absorbable_descendant(base, sel)
1921}
1922
1923enum GatherMember {
1924    Style(SourceNode),
1925    Conditional {
1926        at_node: SourceNode,
1927        inner: SourceNode,
1928        delete_whole_at_block: bool,
1929    },
1930}
1931
1932impl GatherMember {
1933    fn outer_start(&self) -> usize {
1934        match self {
1935            Self::Style(n) => n.start,
1936            Self::Conditional { at_node, .. } => at_node.start,
1937        }
1938    }
1939
1940    fn outer_end(&self) -> usize {
1941        match self {
1942            Self::Style(n) => n.end,
1943            Self::Conditional { at_node, .. } => at_node.end,
1944        }
1945    }
1946}
1947
1948fn extend_with_trailing_newline(source: &str, end: usize) -> usize {
1949    if source[end..].starts_with("\r\n") {
1950        end + 2
1951    } else if source[end..].starts_with('\n') {
1952        end + 1
1953    } else {
1954        end
1955    }
1956}
1957
1958fn push_relative_body_as_nest(rel_sel: &str, body_str: &str, all_nested_rules: &mut Vec<String>) {
1959    let (decls, nested) = parse_rule_body_items(body_str);
1960    if nested.is_empty() {
1961        let mut rel_body = String::new();
1962        for d in &decls {
1963            rel_body.push_str(&format!("{}\n", ensure_semicolon(d)));
1964        }
1965        all_nested_rules.push(format!("{rel_sel} {{\n    {rel_body}}}"));
1966    } else {
1967        let mut rel_body_lines = Vec::new();
1968        for d in &decls {
1969            rel_body_lines.push(format!("    {}", ensure_semicolon(d)));
1970        }
1971        for nr in &nested {
1972            rel_body_lines.push(nr.clone());
1973        }
1974        let rel_body = rel_body_lines.join("\n");
1975        all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
1976    }
1977}
1978
1979fn push_style_member_into_merge(
1980    first_sel: &str,
1981    cand_sel: &str,
1982    body_str: &str,
1983    all_decls: &mut Vec<String>,
1984    all_nested_rules: &mut Vec<String>,
1985) {
1986    if cand_sel == first_sel {
1987        let (decls, nested) = parse_rule_body_items(body_str);
1988        all_decls.extend(decls);
1989        all_nested_rules.extend(nested);
1990    } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
1991        push_relative_body_as_nest(&rel_sel, body_str, all_nested_rules);
1992    } else if is_absorbable_descendant(first_sel, cand_sel) {
1993        push_relative_body_as_nest(cand_sel, body_str, all_nested_rules);
1994    }
1995}
1996
1997fn format_conditional_group_into_lines(
1998    first_sel: &str,
1999    at_header: &str,
2000    inners: &[&SourceNode],
2001    source: &str,
2002    nested_indent: &str,
2003    unit: &str,
2004) -> Vec<String> {
2005    let level2 = format!("{nested_indent}{unit}");
2006    let mut direct_decls = Vec::new();
2007    let mut nested = Vec::new();
2008
2009    for inner in inners {
2010        let Some(body_range) = &inner.body_range else {
2011            continue;
2012        };
2013        let inner_sel = inner.prelude(source).trim();
2014        let inner_body = &source[body_range.clone()];
2015        if inner_sel == first_sel {
2016            let (decls, nrs) = parse_rule_body_items(inner_body);
2017            direct_decls.extend(decls);
2018            nested.extend(nrs);
2019        } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, inner_sel) {
2020            push_relative_body_as_nest(&rel_sel, inner_body, &mut nested);
2021        }
2022    }
2023
2024    let nested = factor_related_nested_rules(merge_same_prelude_nests(nested));
2025    let mut lines = Vec::new();
2026    lines.push(format!("{nested_indent}{at_header} {{"));
2027    for d in &direct_decls {
2028        lines.push(format!("{level2}{}", ensure_semicolon(d)));
2029    }
2030    for nr in &nested {
2031        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2032            let item = NestedRuleItem {
2033                comment: nested_rule_comment_prefix(nr),
2034                sel,
2035                body: nested_rule_inner(nr),
2036            };
2037            for line in render_item_indented(&item, &level2, unit).lines() {
2038                lines.push(line.to_string());
2039            }
2040        } else {
2041            for line in nr.lines() {
2042                let trimmed = line.trim();
2043                if !trimmed.is_empty() {
2044                    lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2045                }
2046            }
2047        }
2048    }
2049    lines.push(format!("{nested_indent}}}"));
2050    lines
2051}
2052
2053fn format_conditional_into_lines(
2054    first_sel: &str,
2055    at_header: &str,
2056    inner_sel: &str,
2057    inner_body: &str,
2058    nested_indent: &str,
2059    unit: &str,
2060) -> Vec<String> {
2061    if inner_sel != first_sel {
2062        return Vec::new();
2063    }
2064    let (decls, nested) = parse_rule_body_items(inner_body);
2065    let level2 = format!("{nested_indent}{unit}");
2066    let mut lines = Vec::new();
2067    lines.push(format!("{nested_indent}{at_header} {{"));
2068    for d in &decls {
2069        lines.push(format!("{level2}{}", ensure_semicolon(d)));
2070    }
2071    for nr in &nested {
2072        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2073            let item = NestedRuleItem {
2074                comment: nested_rule_comment_prefix(nr),
2075                sel,
2076                body: nested_rule_inner(nr),
2077            };
2078            for line in render_item_indented(&item, &level2, unit).lines() {
2079                lines.push(line.to_string());
2080            }
2081        } else {
2082            for line in nr.lines() {
2083                let trimmed = line.trim();
2084                if trimmed.is_empty() {
2085                    lines.push(String::new());
2086                } else {
2087                    lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2088                }
2089            }
2090        }
2091    }
2092    lines.push(format!("{nested_indent}}}"));
2093    lines
2094}
2095
2096fn nested_rule_prelude(nr: &str) -> Option<&str> {
2097    let mut i = 0usize;
2098    while i < nr.len() {
2099        while i < nr.len() && nr.as_bytes()[i].is_ascii_whitespace() {
2100            i += 1;
2101        }
2102        if nr[i..].starts_with("/*") {
2103            i += nr[i..].find("*/")? + 2;
2104            continue;
2105        }
2106        if nr[i..].starts_with("//") {
2107            i += nr[i..].find('\n').map(|n| n + 1).unwrap_or(nr.len() - i);
2108            continue;
2109        }
2110        break;
2111    }
2112    let rest = nr.get(i..)?;
2113    let brace = rest.find('{')?;
2114    let head = rest[..brace].trim();
2115    if head.is_empty() { None } else { Some(head) }
2116}
2117
2118fn nested_rule_inner(nr: &str) -> String {
2119    let lines: Vec<&str> = nr.lines().collect();
2120    let open = lines.iter().position(|line| {
2121        let t = line.trim();
2122        t.ends_with('{') && !t.starts_with("/*") && !t.starts_with("//")
2123    });
2124    let Some(open) = open else {
2125        return String::new();
2126    };
2127    if lines.len() <= open + 2 {
2128        return String::new();
2129    }
2130    lines[open + 1..lines.len() - 1]
2131        .iter()
2132        .map(|l| l.trim_end())
2133        .collect::<Vec<_>>()
2134        .join("\n")
2135}
2136
2137fn nested_rule_comment_prefix(nr: &str) -> String {
2138    let mut out = String::new();
2139    for line in nr.lines() {
2140        let t = line.trim();
2141        if t.is_empty() || t.starts_with("/*") || t.starts_with("//") {
2142            if !out.is_empty() {
2143                out.push('\n');
2144            }
2145            out.push_str(t);
2146        } else {
2147            break;
2148        }
2149    }
2150    out
2151}
2152
2153/// Collapse `&.flash { a }` + `&.flash { @media { b } }` into one nest.
2154fn merge_same_prelude_nests(rules: Vec<String>) -> Vec<String> {
2155    let mut order: Vec<String> = Vec::new();
2156    let mut merged: HashMap<String, String> = HashMap::new();
2157    let mut comments: HashMap<String, String> = HashMap::new();
2158    let mut leftovers = Vec::new();
2159
2160    for nr in rules {
2161        let Some(prelude) = nested_rule_prelude(&nr).map(str::to_string) else {
2162            leftovers.push(nr);
2163            continue;
2164        };
2165        let inner = nested_rule_inner(&nr);
2166        let prefix = nested_rule_comment_prefix(&nr);
2167        if let Some(existing) = merged.get_mut(&prelude) {
2168            if !existing.is_empty() && !inner.is_empty() {
2169                existing.push('\n');
2170            }
2171            existing.push_str(&inner);
2172        } else {
2173            order.push(prelude.clone());
2174            merged.insert(prelude.clone(), inner);
2175            if !prefix.is_empty() {
2176                comments.insert(prelude, prefix);
2177            }
2178        }
2179    }
2180
2181    let mut out: Vec<String> = order
2182        .into_iter()
2183        .map(|prelude| {
2184            let inner = merged.remove(&prelude).unwrap_or_default();
2185            let comment = comments.remove(&prelude).unwrap_or_default();
2186            let head = if comment.is_empty() {
2187                String::new()
2188            } else {
2189                format!("{comment}\n")
2190            };
2191            if inner.is_empty() {
2192                format!("{head}{prelude} {{}}")
2193            } else {
2194                format!("{head}{prelude} {{\n{inner}\n}}")
2195            }
2196        })
2197        .collect();
2198    out.extend(leftovers);
2199    out
2200}
2201
2202fn conditional_as_nested_rule(
2203    first_sel: &str,
2204    at_header: &str,
2205    inner_sel: &str,
2206    inner_body: &str,
2207) -> Option<String> {
2208    if inner_sel == first_sel {
2209        return None;
2210    }
2211    let rel_sel = extract_related_nested_selector(first_sel, inner_sel)?;
2212    let (decls, nested) = parse_rule_body_items(inner_body);
2213    let mut at_inner = String::new();
2214    for d in &decls {
2215        at_inner.push_str("        ");
2216        at_inner.push_str(&ensure_semicolon(d));
2217        at_inner.push('\n');
2218    }
2219    for nr in &nested {
2220        for line in nr.lines() {
2221            let trimmed = line.trim();
2222            if !trimmed.is_empty() {
2223                at_inner.push_str("        ");
2224                at_inner.push_str(&ensure_semicolon(trimmed));
2225                at_inner.push('\n');
2226            }
2227        }
2228    }
2229    Some(format!(
2230        "{rel_sel} {{\n    {at_header} {{\n{at_inner}    }}\n}}"
2231    ))
2232}
2233
2234#[derive(Debug, Clone)]
2235struct NestedRuleItem {
2236    comment: String,
2237    sel: String,
2238    body: String,
2239}
2240
2241fn render_nested_rule_item(item: &NestedRuleItem) -> String {
2242    let head = if item.comment.is_empty() {
2243        String::new()
2244    } else {
2245        format!("{}\n", item.comment)
2246    };
2247    if item.body.trim().is_empty() {
2248        format!("{head}{} {{}}", item.sel)
2249    } else {
2250        format!("{head}{} {{\n{}\n}}", item.sel, item.body)
2251    }
2252}
2253
2254fn append_child_rule(parent: &mut NestedRuleItem, rel_sel: &str, child_body: &str) {
2255    let child = NestedRuleItem {
2256        comment: String::new(),
2257        sel: rel_sel.to_string(),
2258        body: child_body.to_string(),
2259    };
2260    if !parent.body.is_empty() {
2261        parent.body.push('\n');
2262    }
2263    parent.body.push_str(&render_nested_rule_item(&child));
2264}
2265
2266fn split_relative_combinator(sel: &str) -> Option<(String, String)> {
2267    let mut paren = 0usize;
2268    let mut brack = 0usize;
2269    for (i, c) in sel.char_indices() {
2270        match c {
2271            '(' => paren += 1,
2272            ')' => paren = paren.saturating_sub(1),
2273            '[' => brack += 1,
2274            ']' => brack = brack.saturating_sub(1),
2275            _ if paren == 0 && brack == 0 => {
2276                if c.is_whitespace() {
2277                    let right = sel[i..].trim_start();
2278                    let left = sel[..i].trim();
2279                    if left.is_empty() || right.is_empty() {
2280                        return None;
2281                    }
2282                    return Some((left.to_string(), right.to_string()));
2283                }
2284                if matches!(c, '+' | '>' | '~') && i > 0 {
2285                    let right = sel[i + c.len_utf8()..].trim_start();
2286                    let left = sel[..i].trim();
2287                    if left.is_empty() || right.is_empty() {
2288                        return None;
2289                    }
2290                    return Some((left.to_string(), format!("{c} {right}")));
2291                }
2292            }
2293            _ => {}
2294        }
2295    }
2296    None
2297}
2298
2299fn is_at_rule_prelude(sel: &str) -> bool {
2300    sel.trim_start().starts_with('@')
2301}
2302
2303fn synthesizable_parent(sel: &str) -> Option<(String, String)> {
2304    let sel = sel.trim();
2305    if sel.is_empty() || sel == "&" || is_at_rule_prelude(sel) {
2306        return None;
2307    }
2308    if contains_top_level_comma(sel) {
2309        let parts: Vec<&str> = split_top_level_comma(sel)
2310            .into_iter()
2311            .map(str::trim)
2312            .filter(|s| !s.is_empty())
2313            .collect();
2314        if parts.len() < 2 {
2315            return None;
2316        }
2317        let mut parent: Option<String> = None;
2318        let mut children = Vec::new();
2319        for part in parts {
2320            let (p, c) = synthesizable_parent(part)?;
2321            match &parent {
2322                Some(existing) if existing != &p => return None,
2323                None => parent = Some(p),
2324                _ => {}
2325            }
2326            children.push(c);
2327        }
2328        return Some((parent?, children.join(", ")));
2329    }
2330    split_relative_combinator(sel)
2331}
2332
2333fn nest_items_under_existing(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2334    let n = items.len();
2335    if n < 2 {
2336        return items;
2337    }
2338
2339    let mut parent_of: Vec<Option<usize>> = vec![None; n];
2340    for i in 0..n {
2341        let mut best: Option<(usize, usize)> = None;
2342        for j in 0..n {
2343            if i == j {
2344                continue;
2345            }
2346            // Only prefix relations. Appended `&` under an already-relative
2347            // sibling (`.select-arrow` ← `&:open .select-arrow`) would turn
2348            // `.custom-select:open .select-arrow` into `.select-arrow:open &`.
2349            if matches!(
2350                classify_related_nested(&items[j].sel, &items[i].sel),
2351                Some((RelatedKind::Prefix, _))
2352            ) {
2353                let len = items[j].sel.len();
2354                if best.is_none_or(|(_, l)| len >= l) {
2355                    best = Some((j, len));
2356                }
2357            }
2358        }
2359        if let Some((j, _)) = best {
2360            parent_of[i] = Some(j);
2361        }
2362    }
2363
2364    // Only nest under roots so we do not have to rebuild intermediate parents.
2365    for i in 0..n {
2366        if let Some(j) = parent_of[i]
2367            && parent_of[j].is_some()
2368        {
2369            parent_of[i] = None;
2370        }
2371    }
2372
2373    let mut out = Vec::new();
2374    let mut out_idx = vec![None; n];
2375    for i in 0..n {
2376        if parent_of[i].is_none() {
2377            out_idx[i] = Some(out.len());
2378            out.push(items[i].clone());
2379        }
2380    }
2381    for i in 0..n {
2382        if let Some(j) = parent_of[i]
2383            && let Some(out_j) = out_idx[j]
2384            && let Some((RelatedKind::Prefix, rel)) =
2385                classify_related_nested(&out[out_j].sel, &items[i].sel)
2386        {
2387            append_child_rule(&mut out[out_j], &rel, &items[i].body);
2388        }
2389    }
2390    out
2391}
2392
2393fn wrap_shared_virtual_parents(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2394    #[derive(Clone)]
2395    enum Bucket {
2396        Atomic(NestedRuleItem),
2397        Shared {
2398            parent: String,
2399            originals: Vec<NestedRuleItem>,
2400            children: Vec<NestedRuleItem>,
2401        },
2402    }
2403
2404    let mut buckets: Vec<Bucket> = Vec::new();
2405    let mut shared_at: HashMap<String, usize> = HashMap::new();
2406
2407    for item in items {
2408        match synthesizable_parent(&item.sel) {
2409            Some((parent, child)) => {
2410                if let Some(&idx) = shared_at.get(&parent) {
2411                    if let Bucket::Shared {
2412                        originals,
2413                        children,
2414                        ..
2415                    } = &mut buckets[idx]
2416                    {
2417                        originals.push(item.clone());
2418                        children.push(NestedRuleItem {
2419                            comment: item.comment.clone(),
2420                            sel: child,
2421                            body: item.body,
2422                        });
2423                    }
2424                } else {
2425                    shared_at.insert(parent.clone(), buckets.len());
2426                    buckets.push(Bucket::Shared {
2427                        parent,
2428                        originals: vec![item.clone()],
2429                        children: vec![NestedRuleItem {
2430                            comment: item.comment.clone(),
2431                            sel: child,
2432                            body: item.body,
2433                        }],
2434                    });
2435                }
2436            }
2437            None => buckets.push(Bucket::Atomic(item)),
2438        }
2439    }
2440
2441    let mut out = Vec::new();
2442    for bucket in buckets {
2443        match bucket {
2444            Bucket::Atomic(item) => out.push(item),
2445            Bucket::Shared {
2446                parent,
2447                originals,
2448                children,
2449            } => {
2450                if children.len() >= 2 {
2451                    let body = children
2452                        .iter()
2453                        .map(render_nested_rule_item)
2454                        .collect::<Vec<_>>()
2455                        .join("\n");
2456                    out.push(NestedRuleItem {
2457                        comment: String::new(),
2458                        sel: parent,
2459                        body,
2460                    });
2461                } else {
2462                    out.extend(originals);
2463                }
2464            }
2465        }
2466    }
2467    out
2468}
2469
2470fn factor_related_nested_rules(rules: Vec<String>) -> Vec<String> {
2471    let mut items = Vec::new();
2472    let mut leftovers = Vec::new();
2473    for nr in rules {
2474        if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2475            items.push(NestedRuleItem {
2476                comment: nested_rule_comment_prefix(&nr),
2477                sel,
2478                body: nested_rule_inner(&nr),
2479            });
2480        } else {
2481            leftovers.push(nr);
2482        }
2483    }
2484
2485    for _ in 0..8 {
2486        let before = items.len();
2487        items = nest_items_under_existing(items);
2488        items = wrap_shared_virtual_parents(items);
2489        if items.len() == before {
2490            break;
2491        }
2492    }
2493
2494    let mut out: Vec<String> = items.iter().map(render_nested_rule_item).collect();
2495    out.extend(leftovers);
2496    out
2497}
2498
2499fn line_start(source: &str, pos: usize) -> usize {
2500    source[..pos.min(source.len())]
2501        .rfind('\n')
2502        .map(|i| i + 1)
2503        .unwrap_or(0)
2504}
2505
2506fn relative_indent_unit(source: &str, node: &SourceNode) -> String {
2507    let parent = line_indent(source, node.start);
2508    let Some(body) = node.body_range.clone() else {
2509        return "    ".to_string();
2510    };
2511    match detect_indent_unit(source, body) {
2512        Some(raw) => raw
2513            .strip_prefix(parent.as_str())
2514            .filter(|rest| !rest.is_empty())
2515            .unwrap_or("    ")
2516            .to_string(),
2517        None => "    ".to_string(),
2518    }
2519}
2520
2521fn squeeze_excess_blank_lines(s: &str) -> String {
2522    let ends_nl = s.ends_with('\n');
2523    let mut out = String::new();
2524    let mut blank_run = 0usize;
2525    for line in s.lines() {
2526        if line.trim().is_empty() {
2527            blank_run += 1;
2528            if blank_run > 1 {
2529                continue;
2530            }
2531            out.push('\n');
2532        } else {
2533            blank_run = 0;
2534            out.push_str(line);
2535            out.push('\n');
2536        }
2537    }
2538    if !ends_nl && out.ends_with('\n') {
2539        out.pop();
2540    }
2541    out
2542}
2543
2544fn render_item_indented(item: &NestedRuleItem, indent: &str, unit: &str) -> String {
2545    let mut out = String::new();
2546    if !item.comment.is_empty() {
2547        for line in item.comment.lines() {
2548            out.push_str(indent);
2549            out.push_str(line.trim());
2550            out.push('\n');
2551        }
2552    }
2553    out.push_str(indent);
2554    out.push_str(&item.sel);
2555    out.push_str(" {\n");
2556    let inner = format!("{indent}{unit}");
2557    let (decls, nested) = parse_rule_body_items(&item.body);
2558    for d in decls {
2559        out.push_str(&inner);
2560        out.push_str(&ensure_semicolon(&d));
2561        out.push('\n');
2562    }
2563    for nr in nested {
2564        if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2565            let child = NestedRuleItem {
2566                comment: nested_rule_comment_prefix(&nr),
2567                sel,
2568                body: nested_rule_inner(&nr),
2569            };
2570            out.push_str(&render_item_indented(&child, &inner, unit));
2571            out.push('\n');
2572        }
2573    }
2574    out.push_str(indent);
2575    out.push('}');
2576    out
2577}
2578
2579fn leftover_style_replacements(
2580    source: &str,
2581    nodes: &[SourceNode],
2582    span_start: usize,
2583    span_end: usize,
2584    cluster_starts: &HashSet<usize>,
2585) -> Vec<(usize, usize, String)> {
2586    let leftovers: Vec<&SourceNode> = nodes
2587        .iter()
2588        .filter(|n| {
2589            matches!(n.kind, NodeKind::Style)
2590                && n.start >= span_start
2591                && n.end <= span_end
2592                && !cluster_starts.contains(&n.start)
2593        })
2594        .collect();
2595
2596    let mut reps = Vec::new();
2597    let mut i = 0;
2598    while i < leftovers.len() {
2599        let parent = leftovers[i];
2600        let parent_sel = parent.prelude(source);
2601        if contains_top_level_comma(parent_sel) || parent_sel.contains("::") {
2602            i += 1;
2603            continue;
2604        }
2605        let mut children = Vec::new();
2606        let mut j = i + 1;
2607        let mut prev_end = parent.end;
2608        while j < leftovers.len() {
2609            let next = leftovers[j];
2610            if !is_whitespace_only(source, prev_end..next.start) {
2611                break;
2612            }
2613            if let Some((relation, nested_selector)) =
2614                selector_relation(parent_sel, next.prelude(source))
2615            {
2616                children.push(ClusterChild::Style {
2617                    node: (*next).clone(),
2618                    relation,
2619                    nested_selector,
2620                });
2621                prev_end = next.end;
2622                j += 1;
2623                continue;
2624            }
2625            break;
2626        }
2627        if !children.is_empty() {
2628            let last_end = children.last().expect("non-empty").node().end;
2629            reps.push((
2630                parent.start,
2631                last_end,
2632                render_cluster(source, parent, &children),
2633            ));
2634            i = j;
2635        } else {
2636            i += 1;
2637        }
2638    }
2639    reps
2640}
2641
2642fn apply_range_replacements(
2643    source: &str,
2644    start: usize,
2645    end: usize,
2646    replacements: &[(usize, usize, String)],
2647) -> String {
2648    let mut reps: Vec<(usize, usize, &str)> = replacements
2649        .iter()
2650        .map(|(s, e, t)| (*s, *e, t.as_str()))
2651        .filter(|(s, e, _)| *s >= start && *e <= end && *s <= *e)
2652        .collect();
2653    reps.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
2654    let mut out = String::new();
2655    let mut cur = start;
2656    for (s, e, text) in reps {
2657        if s < cur {
2658            continue;
2659        }
2660        if s > cur {
2661            out.push_str(&source[cur..s]);
2662        }
2663        out.push_str(text);
2664        cur = e;
2665    }
2666    if cur < end {
2667        out.push_str(&source[cur..end]);
2668    }
2669    out
2670}
2671
2672fn is_gather_delete(plan: &PlanEntry) -> bool {
2673    plan.proposed.is_empty() && plan.rules.contains(&RuleId::GatherRelatedSelectorRules)
2674}
2675
2676fn gather_target_key(plan: &PlanEntry) -> Option<String> {
2677    if !plan.rules.contains(&RuleId::GatherRelatedSelectorRules) {
2678        return None;
2679    }
2680    let start = plan.reason.find("for '")? + 5;
2681    let rest = plan.reason.get(start..)?;
2682    let end = rest.find('\'')?;
2683    Some(rest[..end].to_string())
2684}
2685
2686fn ranges_overlap(a: &SourceRange, b: &SourceRange) -> bool {
2687    a.start < b.end && b.start < a.end
2688}
2689
2690fn select_disjoint_plan_indices(plans: &[PlanEntry]) -> Vec<usize> {
2691    let mut primary: Vec<usize> = (0..plans.len())
2692        .filter(|&i| !is_gather_delete(&plans[i]))
2693        .collect();
2694    primary.sort_by(|&i, &j| {
2695        plans[i]
2696            .source_range
2697            .start
2698            .cmp(&plans[j].source_range.start)
2699            .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2700    });
2701
2702    let mut kept = Vec::new();
2703    let mut last_end = 0usize;
2704    for i in primary {
2705        if plans[i].source_range.start >= last_end {
2706            last_end = plans[i].source_range.end;
2707            kept.push(i);
2708        }
2709    }
2710
2711    let mut kept_gather_keys = HashSet::new();
2712    for &i in &kept {
2713        if !plans[i].proposed.is_empty()
2714            && let Some(key) = gather_target_key(&plans[i])
2715        {
2716            kept_gather_keys.insert((plans[i].file.clone(), key));
2717        }
2718    }
2719
2720    let mut deletes: Vec<usize> = (0..plans.len())
2721        .filter(|&i| is_gather_delete(&plans[i]))
2722        .collect();
2723    deletes.sort_by(|&i, &j| {
2724        plans[i]
2725            .source_range
2726            .start
2727            .cmp(&plans[j].source_range.start)
2728    });
2729
2730    for i in deletes {
2731        let Some(key) = gather_target_key(&plans[i]) else {
2732            continue;
2733        };
2734        if !kept_gather_keys.contains(&(plans[i].file.clone(), key)) {
2735            continue;
2736        }
2737        if kept
2738            .iter()
2739            .any(|&k| ranges_overlap(&plans[k].source_range, &plans[i].source_range))
2740        {
2741            continue;
2742        }
2743        kept.push(i);
2744    }
2745
2746    kept.sort_by(|&i, &j| {
2747        plans[i]
2748            .source_range
2749            .start
2750            .cmp(&plans[j].source_range.start)
2751            .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2752    });
2753    kept
2754}
2755
2756fn format_merged_rule(
2757    first_sel: &str,
2758    parent_indent: &str,
2759    unit: &str,
2760    cluster: &[GatherMember],
2761    source: &str,
2762) -> String {
2763    let nested_indent = format!("{parent_indent}{unit}");
2764
2765    let mut all_decls = Vec::new();
2766    let mut all_nested_rules = Vec::new();
2767    let mut absorbed_nests = Vec::new();
2768    let mut conditional_lines = Vec::new();
2769
2770    for member in cluster {
2771        let GatherMember::Style(c) = member else {
2772            continue;
2773        };
2774        if let Some(body_range) = &c.body_range {
2775            let cand_sel = c.prelude(source).trim();
2776            let body_str = &source[body_range.clone()];
2777            let before_len = all_nested_rules.len();
2778            push_style_member_into_merge(
2779                first_sel,
2780                cand_sel,
2781                body_str,
2782                &mut all_decls,
2783                &mut all_nested_rules,
2784            );
2785            if all_nested_rules.len() > before_len
2786                && let Some((_, cmt)) = leading_block_comment(source, c.start)
2787                && let Some(last) = all_nested_rules.last_mut()
2788            {
2789                *last = format!("{cmt}\n{last}");
2790            }
2791        }
2792    }
2793
2794    let mut cond_order: Vec<usize> = Vec::new();
2795    let mut cond_groups: HashMap<usize, (&SourceNode, Vec<&SourceNode>)> = HashMap::new();
2796    for member in cluster {
2797        let GatherMember::Conditional { at_node, inner, .. } = member else {
2798            continue;
2799        };
2800        cond_groups
2801            .entry(at_node.start)
2802            .and_modify(|(_, inners)| inners.push(inner))
2803            .or_insert_with(|| {
2804                cond_order.push(at_node.start);
2805                (at_node, vec![inner])
2806            });
2807    }
2808
2809    for key in cond_order {
2810        let Some((at_node, inners)) = cond_groups.remove(&key) else {
2811            continue;
2812        };
2813        let at_header = at_node.prelude(source).trim();
2814        let mut related = Vec::new();
2815        let mut absorbed = Vec::new();
2816        for inner in inners {
2817            let sel = inner.prelude(source).trim();
2818            if sel == first_sel || extract_related_nested_selector(first_sel, sel).is_some() {
2819                related.push(inner);
2820            } else {
2821                absorbed.push(inner);
2822            }
2823        }
2824
2825        if !related.is_empty() {
2826            let invert_group = related.len() >= 2
2827                || related
2828                    .iter()
2829                    .any(|inner| inner.prelude(source).trim() == first_sel);
2830            if invert_group {
2831                let extra = format_conditional_group_into_lines(
2832                    first_sel,
2833                    at_header,
2834                    &related,
2835                    source,
2836                    &nested_indent,
2837                    unit,
2838                );
2839                if !conditional_lines.is_empty() && !extra.is_empty() {
2840                    conditional_lines.push(String::new());
2841                }
2842                conditional_lines.extend(extra);
2843            } else {
2844                for inner in &related {
2845                    if let Some(body_range) = &inner.body_range {
2846                        let inner_sel = inner.prelude(source).trim();
2847                        let inner_body = &source[body_range.clone()];
2848                        if let Some(nr) =
2849                            conditional_as_nested_rule(first_sel, at_header, inner_sel, inner_body)
2850                        {
2851                            all_nested_rules.push(nr);
2852                        } else {
2853                            let extra = format_conditional_into_lines(
2854                                first_sel,
2855                                at_header,
2856                                inner_sel,
2857                                inner_body,
2858                                &nested_indent,
2859                                unit,
2860                            );
2861                            if !conditional_lines.is_empty() && !extra.is_empty() {
2862                                conditional_lines.push(String::new());
2863                            }
2864                            conditional_lines.extend(extra);
2865                        }
2866                    }
2867                }
2868            }
2869        }
2870
2871        for inner in absorbed {
2872            if let Some(body_range) = &inner.body_range {
2873                push_style_member_into_merge(
2874                    first_sel,
2875                    inner.prelude(source).trim(),
2876                    &source[body_range.clone()],
2877                    &mut all_decls,
2878                    &mut absorbed_nests,
2879                );
2880            }
2881        }
2882    }
2883
2884    let all_nested_rules = factor_related_nested_rules(merge_same_prelude_nests(all_nested_rules));
2885    let absorbed_nests = factor_related_nested_rules(merge_same_prelude_nests(absorbed_nests));
2886
2887    let mut body_lines = Vec::new();
2888
2889    for d in &all_decls {
2890        body_lines.push(format!("{nested_indent}{}", ensure_semicolon(d)));
2891    }
2892
2893    if !all_decls.is_empty() && !all_nested_rules.is_empty() {
2894        body_lines.push(String::new());
2895    }
2896
2897    for (idx, nr) in all_nested_rules.iter().enumerate() {
2898        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2899            let item = NestedRuleItem {
2900                comment: nested_rule_comment_prefix(nr),
2901                sel,
2902                body: nested_rule_inner(nr),
2903            };
2904            body_lines.push(render_item_indented(&item, &nested_indent, unit));
2905        } else {
2906            for line in nr.lines() {
2907                let trimmed = line.trim();
2908                if trimmed.is_empty() {
2909                    body_lines.push(String::new());
2910                } else {
2911                    body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
2912                }
2913            }
2914        }
2915
2916        if idx < all_nested_rules.len() - 1 {
2917            body_lines.push(String::new());
2918        }
2919    }
2920
2921    if !conditional_lines.is_empty() {
2922        if !body_lines.is_empty() {
2923            body_lines.push(String::new());
2924        }
2925        body_lines.extend(conditional_lines);
2926    }
2927
2928    if !absorbed_nests.is_empty() {
2929        if !body_lines.is_empty() {
2930            body_lines.push(String::new());
2931        }
2932        for (idx, nr) in absorbed_nests.iter().enumerate() {
2933            if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2934                let item = NestedRuleItem {
2935                    comment: nested_rule_comment_prefix(nr),
2936                    sel,
2937                    body: nested_rule_inner(nr),
2938                };
2939                body_lines.push(render_item_indented(&item, &nested_indent, unit));
2940            } else {
2941                for line in nr.lines() {
2942                    let trimmed = line.trim();
2943                    if trimmed.is_empty() {
2944                        body_lines.push(String::new());
2945                    } else {
2946                        body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
2947                    }
2948                }
2949            }
2950            if idx < absorbed_nests.len() - 1 {
2951                body_lines.push(String::new());
2952            }
2953        }
2954    }
2955
2956    let body_content = body_lines.join("\n");
2957    format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
2958}
2959
2960fn plan_merge_adjacent_identical_selectors(
2961    path: &Path,
2962    source: &str,
2963    nodes: &[SourceNode],
2964    enabled: &HashSet<RuleId>,
2965    plans: &mut Vec<PlanEntry>,
2966) {
2967    if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
2968        return;
2969    }
2970    let mut i = 0;
2971    while i < nodes.len() {
2972        let first = &nodes[i];
2973        if matches!(&first.kind, NodeKind::Style) {
2974            let first_sel = first.prelude(source).trim();
2975            let mut cluster = vec![first];
2976            let mut cursor = i + 1;
2977            let mut prev_end = first.end;
2978
2979            while cursor < nodes.len() {
2980                let next = &nodes[cursor];
2981                if !is_whitespace_only(source, prev_end..next.start) {
2982                    break;
2983                }
2984                if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
2985                {
2986                    cluster.push(next);
2987                    prev_end = next.end;
2988                    cursor += 1;
2989                    continue;
2990                }
2991                break;
2992            }
2993
2994            if cluster.len() > 1 {
2995                let last = cluster.last().unwrap();
2996                let parent_indent = line_indent(source, first.start);
2997                let first_body_range = first.body_range.as_ref().unwrap();
2998                let unit = detect_indent_unit(source, first_body_range.clone())
2999                    .unwrap_or_else(|| "    ".to_string());
3000
3001                let members: Vec<GatherMember> = cluster
3002                    .iter()
3003                    .map(|n| GatherMember::Style((*n).clone()))
3004                    .collect();
3005                let proposed =
3006                    format_merged_rule(first_sel, &parent_indent, &unit, &members, source);
3007
3008                plans.push(PlanEntry {
3009                    id: String::new(),
3010                    file: path.to_path_buf(),
3011                    rules: vec![RuleId::MergeAdjacentIdenticalSelector],
3012                    safety: Safety::Safe,
3013                    source_range: SourceRange {
3014                        start: first.start,
3015                        end: last.end,
3016                    },
3017                    original: source[first.start..last.end].to_string(),
3018                    proposed,
3019                    proof: Proof::safe_local(),
3020                    warnings: Vec::new(),
3021                    reason: format!(
3022                        "Merge {} adjacent identical selector rules for '{}' into a single block.",
3023                        cluster.len(),
3024                        first_sel
3025                    ),
3026                    selected: true,
3027                });
3028                i = cursor;
3029                continue;
3030            }
3031        }
3032        i += 1;
3033    }
3034}
3035
3036fn note_gather_home<'s>(
3037    source: &'s str,
3038    node: &SourceNode,
3039    exact_homes: &mut Vec<&'s str>,
3040    home_weight: &mut HashMap<&'s str, usize>,
3041) {
3042    let sel = node.prelude(source).trim();
3043    let core = first_compound_stripped(sel).unwrap_or(sel);
3044    if is_simple_compound_selector(sel) && core == sel && !sel.contains("::") {
3045        if !exact_homes.contains(&sel) {
3046            exact_homes.push(sel);
3047        }
3048        *home_weight.entry(sel).or_insert(0) += style_body_weight(source, node);
3049    }
3050    if let Some(stripped) = first_compound_stripped(sel)
3051        && !exact_homes.contains(&stripped)
3052        && is_simple_compound_selector(stripped)
3053        && !stripped.contains("::")
3054    {
3055        exact_homes.push(stripped);
3056        home_weight.entry(stripped).or_insert(0);
3057    }
3058}
3059
3060fn collect_gather_homes<'s>(
3061    source: &'s str,
3062    nodes: &[SourceNode],
3063    exact_homes: &mut Vec<&'s str>,
3064    home_weight: &mut HashMap<&'s str, usize>,
3065) {
3066    for node in nodes {
3067        if matches!(&node.kind, NodeKind::Style) {
3068            note_gather_home(source, node, exact_homes, home_weight);
3069        } else if let NodeKind::AtBlock { name, .. } = &node.kind
3070            && GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3071            && let Some(body_range) = &node.body_range
3072        {
3073            let inner = scan_nodes(source, body_range.clone());
3074            collect_gather_homes(source, &inner, exact_homes, home_weight);
3075        }
3076    }
3077}
3078
3079fn should_group_at_block(
3080    name: &str,
3081    source: &str,
3082    style_inners: &[&SourceNode],
3083    exact_homes: &[&str],
3084    home_weight: &HashMap<&str, usize>,
3085) -> bool {
3086    if style_inners.len() < 3 {
3087        return false;
3088    }
3089    let mut assigned: HashMap<&str, usize> = HashMap::new();
3090    let mut unsafe_extract = false;
3091    for inner in style_inners {
3092        let sel = inner.prelude(source).trim();
3093        match assign_gather_home(sel, exact_homes, home_weight) {
3094            Some(home) => {
3095                *assigned.entry(home).or_insert(0) += 1;
3096                if home != sel
3097                    && extract_related_nested_selector(home, sel).is_none()
3098                    && !is_absorbable_descendant(home, sel)
3099                {
3100                    unsafe_extract = true;
3101                }
3102            }
3103            None => {
3104                if contains_top_level_comma(sel)
3105                    && exact_homes
3106                        .iter()
3107                        .any(|home| extract_related_nested_selector(home, sel).is_some())
3108                {
3109                    // rewriteable comma list — do not force-group
3110                } else {
3111                    *assigned.entry("").or_insert(0) += 1;
3112                    unsafe_extract = true;
3113                }
3114            }
3115        }
3116    }
3117    let strong: Vec<(&str, usize)> = assigned
3118        .iter()
3119        .filter(|(home, _)| !home.is_empty() && is_strong_gather_home(home))
3120        .map(|(h, c)| (*h, *c))
3121        .collect();
3122    let total = style_inners.len();
3123    let dominant = strong.iter().any(|(_, c)| *c * 3 >= total * 2);
3124
3125    if name == "media" || name == "container" {
3126        return unsafe_extract || strong.len() >= 2;
3127    }
3128    // @supports / @starting-style: invert when one component owns the block.
3129    if strong.len() >= 2 && !dominant {
3130        return true;
3131    }
3132    unsafe_extract && strong.is_empty()
3133}
3134
3135fn mark_grouped_at_blocks(
3136    source: &str,
3137    nodes: &[SourceNode],
3138    exact_homes: &[&str],
3139    home_weight: &HashMap<&str, usize>,
3140    grouped: &mut HashSet<usize>,
3141) {
3142    for node in nodes {
3143        if let NodeKind::AtBlock { name, .. } = &node.kind
3144            && let Some(body_range) = &node.body_range
3145        {
3146            let inner_nodes = scan_nodes(source, body_range.clone());
3147            if GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3148                let style_inners: Vec<&SourceNode> = inner_nodes
3149                    .iter()
3150                    .filter(|n| matches!(&n.kind, NodeKind::Style))
3151                    .collect();
3152                if should_group_at_block(name, source, &style_inners, exact_homes, home_weight) {
3153                    grouped.insert(node.start);
3154                }
3155            }
3156            if GATHERABLE_CONDITIONALS.contains(&name.as_str())
3157                || GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3158            {
3159                mark_grouped_at_blocks(source, &inner_nodes, exact_homes, home_weight, grouped);
3160            }
3161        }
3162    }
3163}
3164
3165fn style_belongs_to_home(
3166    source: &str,
3167    node: &SourceNode,
3168    base: &str,
3169    exact_homes: &[&str],
3170    home_weight: &HashMap<&str, usize>,
3171) -> bool {
3172    let sel = node.prelude(source).trim();
3173    sel == base || assign_gather_home(sel, exact_homes, home_weight) == Some(base)
3174}
3175
3176fn collect_gather_cluster(
3177    source: &str,
3178    nodes: &[SourceNode],
3179    base: &str,
3180    exact_homes: &[&str],
3181    home_weight: &HashMap<&str, usize>,
3182    grouped_at_blocks: &HashSet<usize>,
3183    cluster: &mut Vec<GatherMember>,
3184) {
3185    for node in nodes {
3186        if matches!(&node.kind, NodeKind::Style) {
3187            if style_belongs_to_home(source, node, base, exact_homes, home_weight) {
3188                cluster.push(GatherMember::Style(node.clone()));
3189            }
3190        } else if let NodeKind::AtBlock { name, .. } = &node.kind {
3191            let Some(body_range) = &node.body_range else {
3192                continue;
3193            };
3194            let inner_nodes = scan_nodes(source, body_range.clone());
3195            if GATHER_SCAN_CONTAINERS.contains(&name.as_str()) {
3196                collect_gather_cluster(
3197                    source,
3198                    &inner_nodes,
3199                    base,
3200                    exact_homes,
3201                    home_weight,
3202                    grouped_at_blocks,
3203                    cluster,
3204                );
3205                continue;
3206            }
3207            if grouped_at_blocks.contains(&node.start) {
3208                continue;
3209            }
3210            if !GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3211                continue;
3212            }
3213            let style_inners: Vec<&SourceNode> = inner_nodes
3214                .iter()
3215                .filter(|n| matches!(n.kind, NodeKind::Style))
3216                .collect();
3217            let owned = style_inners
3218                .iter()
3219                .filter(|inner| {
3220                    style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3221                })
3222                .count();
3223            let dominate = !style_inners.is_empty() && owned * 3 >= style_inners.len() * 2;
3224            let related: Vec<SourceNode> = inner_nodes
3225                .iter()
3226                .filter(|inner| {
3227                    matches!(&inner.kind, NodeKind::Style) && {
3228                        let sel = inner.prelude(source).trim();
3229                        style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3230                            || (dominate && is_absorbable_descendant(base, sel))
3231                    }
3232                })
3233                .cloned()
3234                .collect();
3235            if !related.is_empty() {
3236                let delete_whole_at_block = related.len() == inner_nodes.len()
3237                    || (dominate
3238                        && style_inners.iter().all(|inner| {
3239                            let sel = inner.prelude(source).trim();
3240                            style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3241                                || is_absorbable_descendant(base, sel)
3242                        }));
3243                for inner in related {
3244                    cluster.push(GatherMember::Conditional {
3245                        at_node: node.clone(),
3246                        inner,
3247                        delete_whole_at_block,
3248                    });
3249                }
3250            }
3251        }
3252    }
3253}
3254
3255fn enclosing_container_end(
3256    source: &str,
3257    nodes: &[SourceNode],
3258    first: &SourceNode,
3259) -> Option<usize> {
3260    fn find(source: &str, nodes: &[SourceNode], first: &SourceNode) -> Option<usize> {
3261        for node in nodes {
3262            if let NodeKind::AtBlock { name, .. } = &node.kind
3263                && let Some(body_range) = &node.body_range
3264                && (GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3265                    || GATHERABLE_CONDITIONALS.contains(&name.as_str()))
3266                && first.start >= body_range.start
3267                && first.end <= body_range.end
3268            {
3269                let inner = scan_nodes(source, body_range.clone());
3270                if let Some(deeper) = find(source, &inner, first) {
3271                    return Some(deeper);
3272                }
3273                return Some(body_range.end);
3274            }
3275        }
3276        None
3277    }
3278    find(source, nodes, first)
3279}
3280
3281fn named_layer_ident(prelude: &str) -> Option<String> {
3282    let rest = prelude
3283        .trim()
3284        .strip_prefix("@layer")
3285        .unwrap_or(prelude)
3286        .trim();
3287    if rest.is_empty() || contains_top_level_comma(rest) {
3288        return None;
3289    }
3290    let ident: String = rest
3291        .chars()
3292        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3293        .collect();
3294    if ident.is_empty() { None } else { Some(ident) }
3295}
3296
3297#[derive(Clone)]
3298struct LayeredExactHit {
3299    selector: String,
3300    layer_path: Vec<String>,
3301    style: SourceNode,
3302    layer: SourceNode,
3303}
3304
3305fn collect_layered_exact_hits(
3306    source: &str,
3307    nodes: &[SourceNode],
3308    path: &[String],
3309    current_layer: Option<&SourceNode>,
3310    out: &mut Vec<LayeredExactHit>,
3311) {
3312    for node in nodes {
3313        if matches!(node.kind, NodeKind::Style) {
3314            if let Some(layer) = current_layer
3315                && !path.is_empty()
3316            {
3317                let sel = node.prelude(source).trim();
3318                if !sel.is_empty() && !contains_top_level_comma(sel) {
3319                    out.push(LayeredExactHit {
3320                        selector: sel.to_string(),
3321                        layer_path: path.to_vec(),
3322                        style: node.clone(),
3323                        layer: layer.clone(),
3324                    });
3325                }
3326            }
3327            continue;
3328        }
3329        if let NodeKind::AtBlock { name, .. } = &node.kind
3330            && name == "layer"
3331            && let Some(body_range) = &node.body_range
3332            && let Some(ident) = named_layer_ident(node.prelude(source))
3333        {
3334            let mut child = path.to_vec();
3335            child.push(ident);
3336            let inner = scan_nodes(source, body_range.clone());
3337            collect_layered_exact_hits(source, &inner, &child, Some(node), out);
3338        }
3339    }
3340}
3341
3342fn unwrap_style_block_body(block: &str) -> String {
3343    let open = match block.find('{') {
3344        Some(i) => i + 1,
3345        None => return block.to_string(),
3346    };
3347    let close = match block.rfind('}') {
3348        Some(i) => i,
3349        None => return block[open..].to_string(),
3350    };
3351    if close <= open {
3352        return String::new();
3353    }
3354    block[open..close].trim().to_string()
3355}
3356
3357fn reindent_owned(text: &str, indent: &str) -> Vec<String> {
3358    let lines: Vec<&str> = text.lines().collect();
3359    let min_pad = lines
3360        .iter()
3361        .filter_map(|l| {
3362            if l.trim().is_empty() {
3363                None
3364            } else {
3365                Some(l.len() - l.trim_start().len())
3366            }
3367        })
3368        .min()
3369        .unwrap_or(0);
3370    lines
3371        .into_iter()
3372        .map(|l| {
3373            if l.trim().is_empty() {
3374                String::new()
3375            } else {
3376                let rest = if l.len() >= min_pad {
3377                    &l[min_pad..]
3378                } else {
3379                    l.trim_start()
3380                };
3381                format!("{indent}{rest}")
3382            }
3383        })
3384        .collect()
3385}
3386
3387fn layer_body_cluster_for_selector(
3388    source: &str,
3389    layer: &SourceNode,
3390    selector: &str,
3391    exact_homes: &[&str],
3392    home_weight: &HashMap<&str, usize>,
3393) -> Vec<GatherMember> {
3394    let Some(body_range) = &layer.body_range else {
3395        return Vec::new();
3396    };
3397    let inner = scan_nodes(source, body_range.clone());
3398    let mut cluster = Vec::new();
3399    for node in &inner {
3400        if matches!(node.kind, NodeKind::Style) && node.prelude(source).trim() == selector {
3401            cluster.push(GatherMember::Style(node.clone()));
3402        } else if let NodeKind::AtBlock { name, .. } = &node.kind
3403            && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3404            && let Some(at_body) = &node.body_range
3405        {
3406            let at_inners = scan_nodes(source, at_body.clone());
3407            let related: Vec<SourceNode> = at_inners
3408                .iter()
3409                .filter(|n| {
3410                    matches!(n.kind, NodeKind::Style)
3411                        && (n.prelude(source).trim() == selector
3412                            || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3413                })
3414                .cloned()
3415                .collect();
3416            if related.is_empty() {
3417                continue;
3418            }
3419            let delete_whole = related.len() == at_inners.len();
3420            for inner_style in related {
3421                cluster.push(GatherMember::Conditional {
3422                    at_node: node.clone(),
3423                    inner: inner_style,
3424                    delete_whole_at_block: delete_whole,
3425                });
3426            }
3427        }
3428    }
3429    cluster
3430}
3431
3432fn layer_contains_only_selector_cluster(
3433    source: &str,
3434    layer: &SourceNode,
3435    selector: &str,
3436    exact_homes: &[&str],
3437    home_weight: &HashMap<&str, usize>,
3438) -> bool {
3439    let Some(body_range) = &layer.body_range else {
3440        return false;
3441    };
3442    let inner = scan_nodes(source, body_range.clone());
3443    if inner.is_empty() {
3444        return false;
3445    }
3446    inner.iter().all(|node| {
3447        if matches!(node.kind, NodeKind::Style) {
3448            return node.prelude(source).trim() == selector
3449                || style_belongs_to_home(source, node, selector, exact_homes, home_weight);
3450        }
3451        if let NodeKind::AtBlock { name, .. } = &node.kind
3452            && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3453            && let Some(at_body) = &node.body_range
3454        {
3455            let at_inners = scan_nodes(source, at_body.clone());
3456            return !at_inners.is_empty()
3457                && at_inners.iter().all(|n| {
3458                    matches!(n.kind, NodeKind::Style)
3459                        && (n.prelude(source).trim() == selector
3460                            || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3461                });
3462        }
3463        false
3464    })
3465}
3466
3467fn intervening_unlayered(
3468    nodes: &[SourceNode],
3469    first_layer_start: usize,
3470    last_layer_end: usize,
3471) -> bool {
3472    nodes.iter().any(|n| {
3473        n.start > first_layer_start
3474            && n.end < last_layer_end
3475            && match &n.kind {
3476                NodeKind::Style => true,
3477                NodeKind::AtBlock { name, .. } => name != "layer",
3478                NodeKind::AtStatement { name, .. } => name != "layer",
3479            }
3480    })
3481}
3482
3483fn plan_nest_layer_by_selector(
3484    path: &Path,
3485    source: &str,
3486    nodes: &[SourceNode],
3487    enabled: &HashSet<RuleId>,
3488    plans: &mut Vec<PlanEntry>,
3489) {
3490    if !enabled.contains(&RuleId::NestLayerBySelector) {
3491        return;
3492    }
3493
3494    let mut hits = Vec::new();
3495    collect_layered_exact_hits(source, nodes, &[], None, &mut hits);
3496    if hits.is_empty() {
3497        return;
3498    }
3499
3500    let mut exact_homes: Vec<&str> = Vec::new();
3501    let mut home_weight: HashMap<&str, usize> = HashMap::new();
3502    collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3503
3504    let mut by_sel: HashMap<String, Vec<LayeredExactHit>> = HashMap::new();
3505    for hit in hits {
3506        by_sel.entry(hit.selector.clone()).or_default().push(hit);
3507    }
3508
3509    for (selector, mut group) in by_sel {
3510        group.sort_by_key(|h| h.style.start);
3511        let mut seen_paths: Vec<Vec<String>> = Vec::new();
3512        for hit in &group {
3513            if !seen_paths.iter().any(|p| p == &hit.layer_path) {
3514                seen_paths.push(hit.layer_path.clone());
3515            }
3516        }
3517        if seen_paths.len() < 2 {
3518            continue;
3519        }
3520
3521        let first_layer = &group[0].layer;
3522        let last_layer = &group.last().unwrap().layer;
3523        if intervening_unlayered(nodes, first_layer.start, last_layer.end) {
3524            continue;
3525        }
3526
3527        let parent_indent = String::new();
3528        let unit = "    ".to_string();
3529        let mut layer_blocks = Vec::new();
3530        let mut deletes: Vec<(usize, usize, usize)> = Vec::new();
3531        let mut deleted_layers = HashSet::new();
3532
3533        for path in &seen_paths {
3534            let Some(hit) = group.iter().find(|h| &h.layer_path == path) else {
3535                continue;
3536            };
3537            let cluster = layer_body_cluster_for_selector(
3538                source,
3539                &hit.layer,
3540                &selector,
3541                &exact_homes,
3542                &home_weight,
3543            );
3544            if cluster.is_empty() {
3545                continue;
3546            }
3547            let merged = format_merged_rule(&selector, &parent_indent, &unit, &cluster, source);
3548            let body = unwrap_style_block_body(&merged);
3549            let header = format!("@layer {}", path.join("."));
3550            layer_blocks.push((header, body));
3551
3552            if layer_contains_only_selector_cluster(
3553                source,
3554                &hit.layer,
3555                &selector,
3556                &exact_homes,
3557                &home_weight,
3558            ) {
3559                if deleted_layers.insert(hit.layer.start) {
3560                    deletes.push((
3561                        hit.layer.start,
3562                        extend_with_trailing_newline(source, hit.layer.end),
3563                        hit.layer.start,
3564                    ));
3565                }
3566            } else {
3567                for member in &cluster {
3568                    match member {
3569                        GatherMember::Style(n) => {
3570                            let start = leading_block_comment(source, n.start)
3571                                .map(|(s, _)| s)
3572                                .unwrap_or(n.start);
3573                            deletes.push((
3574                                start,
3575                                extend_with_trailing_newline(source, n.end),
3576                                n.start,
3577                            ));
3578                        }
3579                        GatherMember::Conditional {
3580                            at_node,
3581                            inner,
3582                            delete_whole_at_block,
3583                        } => {
3584                            if *delete_whole_at_block {
3585                                deletes.push((
3586                                    at_node.start,
3587                                    extend_with_trailing_newline(source, at_node.end),
3588                                    at_node.start,
3589                                ));
3590                            } else {
3591                                deletes.push((
3592                                    inner.start,
3593                                    extend_with_trailing_newline(source, inner.end),
3594                                    inner.start,
3595                                ));
3596                            }
3597                        }
3598                    }
3599                }
3600            }
3601        }
3602
3603        if layer_blocks.len() < 2 {
3604            continue;
3605        }
3606
3607        let first_layer_only_ours = layer_contains_only_selector_cluster(
3608            source,
3609            first_layer,
3610            &selector,
3611            &exact_homes,
3612            &home_weight,
3613        );
3614        let insert_at = first_layer.start;
3615        let replace_end = if first_layer_only_ours {
3616            extend_with_trailing_newline(source, first_layer.end)
3617        } else {
3618            insert_at
3619        };
3620        deletes.retain(|(start, end, _)| !(*start == insert_at && *end == replace_end));
3621        let nested_indent = unit.clone();
3622        let inner_indent = format!("{unit}{unit}");
3623        let mut hoisted = format!("{selector} {{\n");
3624        for (i, (header, body)) in layer_blocks.iter().enumerate() {
3625            if i > 0 {
3626                hoisted.push('\n');
3627            }
3628            hoisted.push_str(&nested_indent);
3629            hoisted.push_str(header);
3630            hoisted.push_str(" {\n");
3631            if !body.trim().is_empty() {
3632                for line in reindent_owned(body, &inner_indent) {
3633                    hoisted.push_str(&line);
3634                    hoisted.push('\n');
3635                }
3636            }
3637            hoisted.push_str(&nested_indent);
3638            hoisted.push_str("}\n");
3639        }
3640        hoisted.push('}');
3641        if first_layer_only_ours {
3642            hoisted.push('\n');
3643        } else {
3644            hoisted.push('\n');
3645            hoisted.push('\n');
3646        }
3647
3648        plans.push(PlanEntry {
3649            id: String::new(),
3650            file: path.to_path_buf(),
3651            rules: vec![RuleId::NestLayerBySelector],
3652            safety: Safety::Review,
3653            source_range: SourceRange {
3654                start: insert_at,
3655                end: replace_end,
3656            },
3657            original: if first_layer_only_ours {
3658                source[insert_at..replace_end].to_string()
3659            } else {
3660                String::new()
3661            },
3662            proposed: hoisted,
3663            proof: Proof {
3664                selector_set_equivalent: true,
3665                specificity_equivalent: true,
3666                cascade_context_equivalent: true,
3667                source_order_equivalent: false,
3668                layer_equivalent: true,
3669                scope_equivalent: true,
3670                declarations_exact: true,
3671                important_exact: true,
3672            },
3673            warnings: vec![format!(
3674                "Hoisted '{}' out of {} named layers so nested @layer blocks do not create child layers.",
3675                selector,
3676                layer_blocks.len()
3677            )],
3678            reason: format!(
3679                "Nest {} named layers under shared selector '{}'.",
3680                layer_blocks.len(),
3681                selector
3682            ),
3683            selected: true,
3684        });
3685
3686        for (start, end, line_at) in deletes {
3687            plans.push(PlanEntry {
3688                id: String::new(),
3689                file: path.to_path_buf(),
3690                rules: vec![RuleId::NestLayerBySelector],
3691                safety: Safety::Review,
3692                source_range: SourceRange { start, end },
3693                original: source[start..end].to_string(),
3694                proposed: String::new(),
3695                proof: Proof::safe_local(),
3696                warnings: Vec::new(),
3697                reason: format!(
3698                    "Remove layer-local '{}' after nesting layers at line {}.",
3699                    selector,
3700                    line_number(source, line_at)
3701                ),
3702                selected: true,
3703            });
3704        }
3705    }
3706}
3707
3708fn plan_gather_related_selector_rules(
3709    path: &Path,
3710    source: &str,
3711    nodes: &[SourceNode],
3712    enabled: &HashSet<RuleId>,
3713    plans: &mut Vec<PlanEntry>,
3714) {
3715    if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
3716        return;
3717    }
3718
3719    let mut exact_homes: Vec<&str> = Vec::new();
3720    let mut home_weight: HashMap<&str, usize> = HashMap::new();
3721    collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3722
3723    let mut grouped_at_blocks: HashSet<usize> = HashSet::new();
3724    mark_grouped_at_blocks(
3725        source,
3726        nodes,
3727        &exact_homes,
3728        &home_weight,
3729        &mut grouped_at_blocks,
3730    );
3731
3732    for base in exact_homes.clone() {
3733        let mut cluster: Vec<GatherMember> = Vec::new();
3734        collect_gather_cluster(
3735            source,
3736            nodes,
3737            base,
3738            &exact_homes,
3739            &home_weight,
3740            &grouped_at_blocks,
3741            &mut cluster,
3742        );
3743
3744        if cluster.len() > 1 {
3745            let mut is_non_adjacent = false;
3746            for window in cluster.windows(2) {
3747                let prev_end = window[0].outer_end();
3748                let next_start = window[1].outer_start();
3749                if next_start < prev_end {
3750                    continue;
3751                }
3752                if !is_whitespace_only(source, prev_end..next_start) {
3753                    is_non_adjacent = true;
3754                    break;
3755                }
3756            }
3757
3758            let first_style = cluster.iter().find_map(|m| match m {
3759                GatherMember::Style(n) => Some(n),
3760                GatherMember::Conditional { .. } => None,
3761            });
3762
3763            let Some(first) = first_style else {
3764                continue;
3765            };
3766            let first_sel = first.prelude(source).trim();
3767            let has_non_style = cluster
3768                .iter()
3769                .any(|m| matches!(m, GatherMember::Conditional { .. }));
3770            let should_gather = is_non_adjacent || first_sel != base || has_non_style;
3771            if !should_gather {
3772                continue;
3773            }
3774
3775            let cluster_safe = cluster.iter().all(|member| {
3776                let sel = match member {
3777                    GatherMember::Style(n) => n.prelude(source).trim(),
3778                    GatherMember::Conditional { inner, .. } => inner.prelude(source).trim(),
3779                };
3780                can_nest_under_gather_home(base, sel)
3781            });
3782            if !cluster_safe {
3783                continue;
3784            }
3785            let parent_indent = line_indent(source, first.start);
3786            let unit = relative_indent_unit(source, first);
3787
3788            let mut merged = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
3789
3790            let style_members: Vec<&SourceNode> = cluster
3791                .iter()
3792                .filter_map(|m| match m {
3793                    GatherMember::Style(n) => Some(n),
3794                    GatherMember::Conditional { .. } => None,
3795                })
3796                .collect();
3797            let container_end = enclosing_container_end(source, nodes, first);
3798            let (local_styles, remote_styles): (Vec<&SourceNode>, Vec<&SourceNode>) =
3799                style_members.iter().copied().partition(|sec| {
3800                    if let Some(limit) = container_end {
3801                        sec.end <= limit
3802                    } else {
3803                        nodes.iter().any(|n| n.start == sec.start)
3804                    }
3805                });
3806            let last_local = local_styles.last().copied().unwrap_or(first);
3807            let first_comment = leading_block_comment(source, first.start);
3808            let span_start =
3809                line_start(source, first_comment.map(|(s, _)| s).unwrap_or(first.start));
3810            let mut span_end = extend_with_trailing_newline(source, last_local.end);
3811            for member in &cluster {
3812                let GatherMember::Conditional {
3813                    at_node,
3814                    delete_whole_at_block,
3815                    ..
3816                } = member
3817                else {
3818                    continue;
3819                };
3820                if *delete_whole_at_block
3821                    && at_node.start >= span_start
3822                    && (container_end.is_none_or(|limit| at_node.end <= limit))
3823                {
3824                    span_end = span_end.max(extend_with_trailing_newline(source, at_node.end));
3825                }
3826            }
3827            if let Some((_, cmt)) = first_comment {
3828                merged = format!("{parent_indent}{cmt}\n{merged}");
3829            }
3830
3831            let mut replacements = vec![(span_start, first.end, merged)];
3832            let mut cluster_starts = HashSet::new();
3833            for sec in &local_styles {
3834                cluster_starts.insert(sec.start);
3835            }
3836            for sec in local_styles.iter().skip(1) {
3837                let start = leading_block_comment(source, sec.start)
3838                    .map(|(s, _)| s)
3839                    .unwrap_or(sec.start);
3840                let end = extend_with_trailing_newline(source, sec.end);
3841                replacements.push((start, end, String::new()));
3842            }
3843            let mut replaced_at = HashSet::new();
3844            for member in &cluster {
3845                let GatherMember::Conditional {
3846                    at_node,
3847                    delete_whole_at_block,
3848                    ..
3849                } = member
3850                else {
3851                    continue;
3852                };
3853                if *delete_whole_at_block
3854                    && at_node.start >= span_start
3855                    && at_node.end <= span_end
3856                    && replaced_at.insert(at_node.start)
3857                {
3858                    replacements.push((
3859                        at_node.start,
3860                        extend_with_trailing_newline(source, at_node.end),
3861                        String::new(),
3862                    ));
3863                }
3864            }
3865            replacements.extend(leftover_style_replacements(
3866                source,
3867                nodes,
3868                span_start,
3869                span_end,
3870                &cluster_starts,
3871            ));
3872            let proposed = squeeze_excess_blank_lines(&apply_range_replacements(
3873                source,
3874                span_start,
3875                span_end,
3876                &replacements,
3877            ));
3878
3879            plans.push(PlanEntry {
3880                id: String::new(),
3881                file: path.to_path_buf(),
3882                rules: vec![RuleId::GatherRelatedSelectorRules],
3883                safety: Safety::Review,
3884                source_range: SourceRange {
3885                    start: span_start,
3886                    end: span_end,
3887                },
3888                original: source[span_start..span_end].to_string(),
3889                proposed,
3890                proof: Proof {
3891                    selector_set_equivalent: true,
3892                    specificity_equivalent: true,
3893                    cascade_context_equivalent: false,
3894                    source_order_equivalent: false,
3895                    layer_equivalent: remote_styles.is_empty(),
3896                    scope_equivalent: true,
3897                    declarations_exact: true,
3898                    important_exact: true,
3899                },
3900                warnings: vec![format!(
3901                    "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
3902                    cluster.len(),
3903                    base
3904                )],
3905                reason: format!(
3906                    "Gather {} related rules for '{}' into the canonical first selector block.",
3907                    cluster.len(),
3908                    base
3909                ),
3910                selected: true,
3911            });
3912
3913            let mut deleted_at_blocks = HashSet::new();
3914            for sec in &remote_styles {
3915                let start = leading_block_comment(source, sec.start)
3916                    .map(|(s, _)| s)
3917                    .unwrap_or(sec.start);
3918                let end = extend_with_trailing_newline(source, sec.end);
3919                plans.push(PlanEntry {
3920                    id: String::new(),
3921                    file: path.to_path_buf(),
3922                    rules: vec![RuleId::GatherRelatedSelectorRules],
3923                    safety: Safety::Review,
3924                    source_range: SourceRange { start, end },
3925                    original: source[start..end].to_string(),
3926                    proposed: String::new(),
3927                    proof: Proof::safe_local(),
3928                    warnings: Vec::new(),
3929                    reason: format!(
3930                        "Remove non-adjacent gathered rule for '{}' at line {}.",
3931                        base,
3932                        line_number(source, sec.start)
3933                    ),
3934                    selected: true,
3935                });
3936            }
3937            for member in &cluster {
3938                let GatherMember::Conditional {
3939                    at_node,
3940                    inner,
3941                    delete_whole_at_block,
3942                } = member
3943                else {
3944                    continue;
3945                };
3946                let (sec_start, sec_end, line_at) = if *delete_whole_at_block {
3947                    if !deleted_at_blocks.insert(at_node.start) {
3948                        continue;
3949                    }
3950                    (
3951                        at_node.start,
3952                        extend_with_trailing_newline(source, at_node.end),
3953                        at_node.start,
3954                    )
3955                } else {
3956                    (
3957                        inner.start,
3958                        extend_with_trailing_newline(source, inner.end),
3959                        inner.start,
3960                    )
3961                };
3962
3963                plans.push(PlanEntry {
3964                    id: String::new(),
3965                    file: path.to_path_buf(),
3966                    rules: vec![RuleId::GatherRelatedSelectorRules],
3967                    safety: Safety::Review,
3968                    source_range: SourceRange {
3969                        start: sec_start,
3970                        end: sec_end,
3971                    },
3972                    original: source[sec_start..sec_end].to_string(),
3973                    proposed: String::new(),
3974                    proof: Proof::safe_local(),
3975                    warnings: Vec::new(),
3976                    reason: format!(
3977                        "Remove non-adjacent gathered rule for '{}' at line {}.",
3978                        base,
3979                        line_number(source, line_at)
3980                    ),
3981                    selected: true,
3982                });
3983            }
3984        }
3985    }
3986}
3987
3988fn plan_factor_identical_states_with_is(
3989    path: &Path,
3990    source: &str,
3991    nodes: &[SourceNode],
3992    enabled: &HashSet<RuleId>,
3993    plans: &mut Vec<PlanEntry>,
3994) {
3995    if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
3996        return;
3997    }
3998    let mut i = 0;
3999    while i < nodes.len() {
4000        let first = &nodes[i];
4001        if matches!(&first.kind, NodeKind::Style) {
4002            let first_sel = first.prelude(source).trim();
4003            if let Some(colon_pos) = first_sel.find(':')
4004                && !first_sel[colon_pos..].starts_with("::")
4005            {
4006                let base = &first_sel[..colon_pos];
4007                if !base.is_empty() && !base.contains(' ') {
4008                    let first_body = first.body(source).unwrap_or("").trim();
4009                    let mut cluster = vec![first];
4010                    let mut cursor = i + 1;
4011                    let mut prev_end = first.end;
4012
4013                    while cursor < nodes.len() {
4014                        let next = &nodes[cursor];
4015                        if !is_whitespace_only(source, prev_end..next.start) {
4016                            break;
4017                        }
4018                        if matches!(&next.kind, NodeKind::Style) {
4019                            let next_sel = next.prelude(source).trim();
4020                            if next_sel.starts_with(base)
4021                                && next_sel[base.len()..].starts_with(':')
4022                                && !next_sel[base.len()..].starts_with("::")
4023                                && next.body(source).unwrap_or("").trim() == first_body
4024                            {
4025                                cluster.push(next);
4026                                prev_end = next.end;
4027                                cursor += 1;
4028                                continue;
4029                            }
4030                        }
4031                        break;
4032                    }
4033
4034                    if cluster.len() > 1 {
4035                        let last = cluster.last().unwrap();
4036                        let pseudos: Vec<&str> = cluster
4037                            .iter()
4038                            .map(|c| {
4039                                let s = c.prelude(source).trim();
4040                                &s[base.len()..]
4041                            })
4042                            .collect();
4043                        let is_inner = pseudos.join(", ");
4044                        let parent_indent = line_indent(source, first.start);
4045                        let first_body_range = first.body_range.as_ref().unwrap();
4046                        let unit = detect_indent_unit(source, first_body_range.clone())
4047                            .unwrap_or_else(|| "  ".to_string());
4048                        let nested_indent = format!("{parent_indent}{unit}");
4049                        let inner_decl_indent = format!("{nested_indent}{unit}");
4050
4051                        let mut decls = String::new();
4052                        for line in source[first_body_range.clone()].lines() {
4053                            let trimmed = line.trim();
4054                            if !trimmed.is_empty() {
4055                                decls.push_str(&inner_decl_indent);
4056                                decls.push_str(&ensure_semicolon(trimmed));
4057                                decls.push('\n');
4058                            }
4059                        }
4060
4061                        let proposed = format!(
4062                            "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
4063                        );
4064                        plans.push(PlanEntry {
4065                            id: String::new(),
4066                            file: path.to_path_buf(),
4067                            rules: vec![RuleId::FactorIdenticalStatesWithIs],
4068                            safety: Safety::Safe,
4069                            source_range: SourceRange {
4070                                start: first.start,
4071                                end: last.end,
4072                            },
4073                            original: source[first.start..last.end].to_string(),
4074                            proposed,
4075                            proof: Proof::safe_local(),
4076                            warnings: Vec::new(),
4077                            reason: format!(
4078                                "Factor {} identical state rules for '{}' into &:is({}) form.",
4079                                cluster.len(),
4080                                base,
4081                                is_inner
4082                            ),
4083                            selected: true,
4084                        });
4085                        i = cursor;
4086                        continue;
4087                    }
4088                }
4089            }
4090        }
4091        i += 1;
4092    }
4093}
4094
4095fn plan_factor_multi_selector_cluster_with_is(
4096    path: &Path,
4097    source: &str,
4098    nodes: &[SourceNode],
4099    enabled: &HashSet<RuleId>,
4100    plans: &mut Vec<PlanEntry>,
4101) {
4102    if !enabled.contains(&RuleId::ModernizeIs) {
4103        return;
4104    }
4105
4106    let mut i = 0;
4107    while i < nodes.len() {
4108        let first = &nodes[i];
4109        if matches!(&first.kind, NodeKind::Style) {
4110            let first_sel = first.prelude(source).trim();
4111            if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
4112                let mut cluster = vec![(first, first_suffix)];
4113                let mut cursor = i + 1;
4114                let mut prev_end = first.end;
4115
4116                while cursor < nodes.len() {
4117                    let next = &nodes[cursor];
4118                    if !is_whitespace_only(source, prev_end..next.start) {
4119                        break;
4120                    }
4121                    if matches!(&next.kind, NodeKind::Style) {
4122                        let next_sel = next.prelude(source).trim();
4123                        if let Some((next_prefixes, next_suffix)) =
4124                            extract_multi_branch_pattern(next_sel)
4125                            && next_prefixes == base_prefixes
4126                        {
4127                            cluster.push((next, next_suffix));
4128                            prev_end = next.end;
4129                            cursor += 1;
4130                            continue;
4131                        }
4132                    }
4133                    break;
4134                }
4135
4136                if cluster.len() > 1 {
4137                    let (last_node, _) = cluster.last().unwrap();
4138                    let parent_indent = line_indent(source, first.start);
4139                    let first_body_range = first.body_range.as_ref().unwrap();
4140                    let unit = detect_indent_unit(source, first_body_range.clone())
4141                        .unwrap_or_else(|| "  ".to_string());
4142                    let nested_indent = format!("{parent_indent}{unit}");
4143                    let inner_decl_indent = format!("{nested_indent}{unit}");
4144
4145                    let is_header = format!(":is({})", base_prefixes.join(", "));
4146                    let mut out = format!("{parent_indent}{is_header} {{\n");
4147                    let mut has_direct_decls = false;
4148
4149                    // 1. Direct declarations from base rules (suffix == None)
4150                    for &(c_node, ref suffix) in &cluster {
4151                        if suffix.is_none()
4152                            && let Some(c_body_range) = &c_node.body_range
4153                        {
4154                            for line in source[c_body_range.clone()].lines() {
4155                                let trimmed = line.trim();
4156                                if !trimmed.is_empty() {
4157                                    out.push_str(&nested_indent);
4158                                    out.push_str(&ensure_semicolon(trimmed));
4159                                    out.push('\n');
4160                                    has_direct_decls = true;
4161                                }
4162                            }
4163                        }
4164                    }
4165
4166                    // 2. Nested child rules (suffix == Some(sub_sel))
4167                    for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
4168                        if let Some(sub_sel) = suffix {
4169                            if has_direct_decls || c_idx > 0 {
4170                                out.push('\n');
4171                            }
4172                            out.push_str(&nested_indent);
4173                            out.push_str(sub_sel);
4174                            out.push_str(" {\n");
4175                            if let Some(c_body_range) = &c_node.body_range {
4176                                for line in source[c_body_range.clone()].lines() {
4177                                    let trimmed = line.trim();
4178                                    if !trimmed.is_empty() {
4179                                        out.push_str(&inner_decl_indent);
4180                                        out.push_str(&ensure_semicolon(trimmed));
4181                                        out.push('\n');
4182                                    }
4183                                }
4184                            }
4185                            out.push_str(&nested_indent);
4186                            out.push_str("}\n");
4187                        }
4188                    }
4189
4190                    out.push_str(&parent_indent);
4191                    out.push('}');
4192
4193                    let specificities: Vec<Specificity> = base_prefixes
4194                        .iter()
4195                        .map(|p| calculate_specificity(p))
4196                        .collect();
4197                    let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
4198
4199                    plans.push(PlanEntry {
4200                        id: String::new(),
4201                        file: path.to_path_buf(),
4202                        rules: vec![RuleId::ModernizeIs],
4203                        safety: Safety::Safe,
4204                        source_range: SourceRange {
4205                            start: first.start,
4206                            end: last_node.end,
4207                        },
4208                        original: source[first.start..last_node.end].to_string(),
4209                        proposed: out,
4210                        proof: Proof::safe_local(),
4211                        warnings: if uniform { Vec::new() } else { vec!["Notice: :is() takes the specificity of its most specific argument.".into()] },
4212                        reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
4213                        selected: true,
4214                    });
4215                    i = cursor;
4216                    continue;
4217                }
4218            }
4219        }
4220        i += 1;
4221    }
4222}
4223
4224fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
4225    let branches: Vec<&str> = split_top_level_comma(selector)
4226        .into_iter()
4227        .map(|s| s.trim())
4228        .collect();
4229    if branches.len() < 2 {
4230        return None;
4231    }
4232    if branches.iter().any(|b| b.contains("::")) {
4233        return None;
4234    }
4235
4236    let first = branches[0];
4237    if let Some(space_pos) = first.rfind(' ') {
4238        let suffix = &first[space_pos..];
4239        if branches.iter().all(|b| b.ends_with(suffix)) {
4240            let prefixes: Vec<String> = branches
4241                .iter()
4242                .map(|b| b[..b.len() - suffix.len()].trim().to_string())
4243                .collect();
4244            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4245                return Some((prefixes, Some(suffix.trim().to_string())));
4246            }
4247        }
4248    }
4249
4250    if branches
4251        .iter()
4252        .all(|b| is_valid_selector_token(b) && !b.contains(' '))
4253    {
4254        let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
4255        return Some((prefixes, None));
4256    }
4257
4258    None
4259}
4260
4261fn plan_merge_identical_rule_bodies(
4262    path: &Path,
4263    source: &str,
4264    nodes: &[SourceNode],
4265    enabled: &HashSet<RuleId>,
4266    plans: &mut Vec<PlanEntry>,
4267) {
4268    if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
4269        return;
4270    }
4271    let mut i = 0;
4272    while i < nodes.len() {
4273        let first = &nodes[i];
4274        if matches!(&first.kind, NodeKind::Style) {
4275            let first_body = first.body(source).unwrap_or("").trim();
4276            if !first_body.is_empty() {
4277                let mut cluster = vec![first];
4278                let mut cursor = i + 1;
4279                let mut prev_end = first.end;
4280
4281                while cursor < nodes.len() {
4282                    let next = &nodes[cursor];
4283                    if !is_whitespace_only(source, prev_end..next.start) {
4284                        break;
4285                    }
4286                    if matches!(&next.kind, NodeKind::Style)
4287                        && next.body(source).unwrap_or("").trim() == first_body
4288                    {
4289                        cluster.push(next);
4290                        prev_end = next.end;
4291                        cursor += 1;
4292                        continue;
4293                    }
4294                    break;
4295                }
4296
4297                if cluster.len() > 1 {
4298                    let last = cluster.last().unwrap();
4299                    let selectors: Vec<&str> =
4300                        cluster.iter().map(|c| c.prelude(source).trim()).collect();
4301                    let parent_indent = line_indent(source, first.start);
4302                    let first_body_range = first.body_range.as_ref().unwrap();
4303                    let unit = detect_indent_unit(source, first_body_range.clone())
4304                        .unwrap_or_else(|| "  ".to_string());
4305                    let nested_indent = format!("{parent_indent}{unit}");
4306
4307                    let mut decls = String::new();
4308                    for line in source[first_body_range.clone()].lines() {
4309                        let trimmed = line.trim();
4310                        if !trimmed.is_empty() {
4311                            decls.push_str(&nested_indent);
4312                            decls.push_str(&ensure_semicolon(trimmed));
4313                            decls.push('\n');
4314                        }
4315                    }
4316
4317                    let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
4318                    let proposed =
4319                        format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
4320                    plans.push(PlanEntry {
4321                        id: String::new(),
4322                        file: path.to_path_buf(),
4323                        rules: vec![RuleId::MergeIdenticalRuleBodies],
4324                        safety: Safety::Safe,
4325                        source_range: SourceRange {
4326                            start: first.start,
4327                            end: last.end,
4328                        },
4329                        original: source[first.start..last.end].to_string(),
4330                        proposed,
4331                        proof: Proof::safe_local(),
4332                        warnings: Vec::new(),
4333                        reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
4334                        selected: true,
4335                    });
4336                    i = cursor;
4337                    continue;
4338                }
4339            }
4340        }
4341        i += 1;
4342    }
4343}
4344
4345pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
4346    let bytes = selector.as_bytes();
4347    let mut parts = Vec::new();
4348    let mut last = 0;
4349    let mut parens = 0usize;
4350    let mut brackets = 0usize;
4351    let mut quote: Option<u8> = None;
4352    let mut escaped = false;
4353    let mut i = 0usize;
4354
4355    while i < bytes.len() {
4356        let b = bytes[i];
4357        if let Some(q) = quote {
4358            if escaped {
4359                escaped = false;
4360            } else if b == b'\\' {
4361                escaped = true;
4362            } else if b == q {
4363                quote = None;
4364            }
4365            i += 1;
4366            continue;
4367        }
4368        match b {
4369            b'\'' | b'"' => quote = Some(b),
4370            b'(' => parens += 1,
4371            b')' => parens = parens.saturating_sub(1),
4372            b'[' => brackets += 1,
4373            b']' => brackets = brackets.saturating_sub(1),
4374            b',' if parens == 0 && brackets == 0 => {
4375                parts.push(&selector[last..i]);
4376                last = i + 1;
4377            }
4378            _ => {}
4379        }
4380        i += 1;
4381    }
4382    if last < selector.len() {
4383        parts.push(&selector[last..]);
4384    }
4385    parts
4386}
4387
4388pub fn factor_selector_list(
4389    selector: &str,
4390    body: &str,
4391    indent: &str,
4392    unit: &str,
4393) -> Option<String> {
4394    let branches: Vec<&str> = split_top_level_comma(selector)
4395        .into_iter()
4396        .map(|s| s.trim())
4397        .collect();
4398    if branches.len() < 2 {
4399        return None;
4400    }
4401    let base = branches[0];
4402    if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
4403        return None;
4404    }
4405
4406    let mut inner_selectors = Vec::new();
4407    for &branch in &branches {
4408        if branch == base {
4409            inner_selectors.push("&".to_string());
4410        } else {
4411            let rel = branch.strip_prefix(base)?;
4412            if rel.starts_with("::")
4413                || rel.starts_with(':')
4414                || rel.starts_with('[')
4415                || rel.starts_with('.')
4416                || rel.starts_with('#')
4417            {
4418                inner_selectors.push(format!("&{rel}"));
4419            } else {
4420                let trimmed = rel.strip_prefix(' ')?;
4421                inner_selectors.push(trimmed.trim_start().to_string());
4422            }
4423        }
4424    }
4425
4426    let nested_indent = format!("{indent}{unit}");
4427    let inner_decl_indent = format!("{nested_indent}{unit}");
4428    let mut out = String::new();
4429    out.push_str(base);
4430    out.push_str(" {\n");
4431    out.push_str(&nested_indent);
4432    out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
4433    out.push_str(" {\n");
4434    for line in body.lines() {
4435        let trimmed = line.trim();
4436        if !trimmed.is_empty() {
4437            out.push_str(&inner_decl_indent);
4438            out.push_str(&ensure_semicolon(trimmed));
4439            out.push('\n');
4440        }
4441    }
4442    out.push_str(&nested_indent);
4443    out.push_str("}\n");
4444    out.push_str(indent);
4445    out.push('}');
4446    Some(out)
4447}
4448
4449pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
4450    let branches: Vec<&str> = split_top_level_comma(selector)
4451        .into_iter()
4452        .map(|s| s.trim())
4453        .filter(|s| !s.is_empty())
4454        .collect();
4455    if branches.len() < 2 {
4456        return None;
4457    }
4458
4459    if branches.iter().any(|b| b.contains("::")) {
4460        return None;
4461    }
4462
4463    let specificities: Vec<Specificity> =
4464        branches.iter().map(|b| calculate_specificity(b)).collect();
4465    let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4466
4467    let first = branches[0];
4468
4469    // Suffix alternatives (e.g. .alpha .title, #hero .title -> :is(.alpha, #hero) .title)
4470    if let Some(space_pos) = first.rfind(' ') {
4471        let suffix = &first[space_pos..];
4472        if branches.iter().all(|b| b.ends_with(suffix)) {
4473            let prefixes: Vec<&str> = branches
4474                .iter()
4475                .map(|b| b[..b.len() - suffix.len()].trim())
4476                .collect();
4477            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4478                let is_inner = prefixes.join(", ");
4479                return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
4480            }
4481        }
4482    }
4483
4484    // Descendant alternatives (e.g. .card .title, .card .subtitle -> .card :is(.title, .subtitle))
4485    if let Some(space_pos) = first.rfind(' ') {
4486        let prefix = &first[..=space_pos];
4487        if branches.iter().all(|b| b.starts_with(prefix)) {
4488            let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
4489            if suffixes.iter().all(|s| is_valid_selector_token(s)) {
4490                let is_inner = suffixes.join(", ");
4491                return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
4492            }
4493        }
4494    }
4495
4496    // Pseudo-class alternatives (e.g. .button:hover, .button:focus -> .button:is(:hover, :focus))
4497    if let Some(colon_pos) = first.find(':') {
4498        let base = &first[..colon_pos];
4499        if !base.is_empty()
4500            && !base.contains(' ')
4501            && branches.iter().all(|b| {
4502                b.starts_with(base)
4503                    && b[base.len()..].starts_with(':')
4504                    && !b[base.len()..].starts_with("::")
4505            })
4506        {
4507            let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4508            if pseudos
4509                .iter()
4510                .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
4511            {
4512                let is_inner = pseudos.join(", ");
4513                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4514            }
4515        }
4516    }
4517
4518    // Attribute alternatives
4519    if let Some(bracket_pos) = first.find('[') {
4520        let base = &first[..bracket_pos];
4521        if !base.is_empty()
4522            && !base.contains(' ')
4523            && branches
4524                .iter()
4525                .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
4526        {
4527            let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4528            if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
4529                let is_inner = attrs.join(", ");
4530                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4531            }
4532        }
4533    }
4534
4535    None
4536}
4537
4538fn is_valid_selector_token(s: &str) -> bool {
4539    if s.is_empty() {
4540        return false;
4541    }
4542    let first = s.chars().next().unwrap();
4543    first == '.'
4544        || first == '#'
4545        || first == '['
4546        || first == ':'
4547        || first.is_ascii_alphabetic()
4548        || first == '*'
4549        || first == '>'
4550        || first == '+'
4551        || first == '~'
4552}
4553
4554pub fn factor_with_where(selector: &str) -> Option<String> {
4555    let branches: Vec<&str> = split_top_level_comma(selector)
4556        .into_iter()
4557        .map(|s| s.trim())
4558        .collect();
4559    if branches.len() < 2 {
4560        return None;
4561    }
4562    if branches.iter().any(|b| b.contains("::")) {
4563        return None;
4564    }
4565    Some(format!(":where({})", branches.join(", ")))
4566}
4567
4568pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
4569    let mut result = prelude.to_string();
4570    let mut changed = false;
4571
4572    // Range: (min-width: 400px) and (max-width: 800px) -> (400px <= width <= 800px)
4573    if let (Some((min_raw, min_val)), Some((max_raw, max_val))) = (
4574        extract_media_feature_and_raw(&result, "min-width"),
4575        extract_media_feature_and_raw(&result, "max-width"),
4576    ) {
4577        let pattern = format!("{min_raw} and {max_raw}");
4578        let replacement = format!("({min_val} <= width <= {max_val})");
4579        if result.contains(&pattern) {
4580            result = result.replace(&pattern, &replacement);
4581            changed = true;
4582        }
4583    }
4584
4585    // Single: (min-width: 800px) -> (width >= 800px)
4586    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-width") {
4587        let replacement = format!("(width >= {val})");
4588        result = result.replacen(&raw, &replacement, 1);
4589        changed = true;
4590    }
4591
4592    // Single: (max-width: 800px) -> (width <= 800px)
4593    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-width") {
4594        let replacement = format!("(width <= {val})");
4595        result = result.replacen(&raw, &replacement, 1);
4596        changed = true;
4597    }
4598
4599    // Single: (min-height: 400px) -> (height >= 400px)
4600    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-height") {
4601        let replacement = format!("(height >= {val})");
4602        result = result.replacen(&raw, &replacement, 1);
4603        changed = true;
4604    }
4605
4606    // Single: (max-height: 400px) -> (height <= 400px)
4607    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-height") {
4608        let replacement = format!("(height <= {val})");
4609        result = result.replacen(&raw, &replacement, 1);
4610        changed = true;
4611    }
4612
4613    if changed { Some(result) } else { None }
4614}
4615
4616fn extract_media_feature_and_raw(source: &str, feature: &str) -> Option<(String, String)> {
4617    let feat_idx = source.find(feature)?;
4618    let open_paren = source[..feat_idx].rfind('(')?;
4619    if source[open_paren..feat_idx].contains(')') {
4620        return None;
4621    }
4622    let colon_rel = source[feat_idx + feature.len()..].find(':')?;
4623    let colon_idx = feat_idx + feature.len() + colon_rel;
4624    let close_paren_rel = source[colon_idx..].find(')')?;
4625    let close_paren = colon_idx + close_paren_rel;
4626
4627    let raw = source[open_paren..=close_paren].to_string();
4628    let val = source[colon_idx + 1..close_paren].trim().to_string();
4629    Some((raw, val))
4630}
4631
4632fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
4633    let parent = parent.trim();
4634    let child = child.trim();
4635    if parent.is_empty()
4636        || child.is_empty()
4637        || contains_top_level_comma(parent)
4638        || contains_top_level_comma(child)
4639        || parent.contains("::")
4640        || child == parent
4641    {
4642        return None;
4643    }
4644
4645    if !child.starts_with(parent) {
4646        if is_weak_gather_base(parent) {
4647            return None;
4648        }
4649        return appended_selector_relation(parent, child);
4650    }
4651
4652    let remainder = &child[parent.len()..];
4653    let trimmed = remainder.trim_start();
4654    if trimmed.is_empty() {
4655        return None;
4656    }
4657
4658    let (relation, nested_selector) = if remainder.starts_with("::") {
4659        (RelationKind::PseudoElement, format!("&{remainder}"))
4660    } else if remainder.starts_with(':') {
4661        (RelationKind::PseudoClass, format!("&{remainder}"))
4662    } else if remainder.starts_with('[') {
4663        (RelationKind::Attribute, format!("&{remainder}"))
4664    } else if remainder.starts_with('.') || remainder.starts_with('#') {
4665        (RelationKind::Compound, format!("&{remainder}"))
4666    } else if let Some(first_char) = trimmed
4667        .chars()
4668        .next()
4669        .filter(|c| *c == '>' || *c == '+' || *c == '~')
4670    {
4671        let after_comb = trimmed[first_char.len_utf8()..].trim();
4672        if after_comb == parent {
4673            (RelationKind::Combinator, format!("{first_char} {parent}"))
4674        } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
4675            (
4676                RelationKind::Combinator,
4677                format!("{first_char} {parent}{after_parent}"),
4678            )
4679        } else {
4680            (
4681                RelationKind::Combinator,
4682                format!("{first_char} {after_comb}"),
4683            )
4684        }
4685    } else if remainder
4686        .as_bytes()
4687        .first()
4688        .is_some_and(|b| b.is_ascii_whitespace())
4689    {
4690        let descendant = remainder.trim();
4691        (RelationKind::Descendant, descendant.to_string())
4692    } else {
4693        return None;
4694    };
4695
4696    Some((relation, nested_selector))
4697}
4698
4699fn conditional_child(
4700    source: &str,
4701    parent_selector: &str,
4702    node: &SourceNode,
4703    enabled: &HashSet<RuleId>,
4704) -> Option<ClusterChild> {
4705    let (name, rule) = match &node.kind {
4706        NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
4707        NodeKind::AtBlock { name, .. } if name == "supports" => {
4708            (name.as_str(), RuleId::NestSupports)
4709        }
4710        NodeKind::AtBlock { name, .. } if name == "container" => {
4711            (name.as_str(), RuleId::NestContainer)
4712        }
4713        NodeKind::AtBlock { name, .. } if name == "starting-style" => {
4714            (name.as_str(), RuleId::NestStartingStyle)
4715        }
4716        _ => return None,
4717    };
4718    if !enabled.contains(&rule) {
4719        return None;
4720    }
4721
4722    let body_range = node.body_range.clone()?;
4723    let inner_nodes = scan_nodes(source, body_range.clone());
4724    if inner_nodes.is_empty() {
4725        return None;
4726    }
4727
4728    let mut inners = Vec::new();
4729    for inner in &inner_nodes {
4730        if !matches!(&inner.kind, NodeKind::Style) {
4731            return None;
4732        }
4733        let inner_prelude = inner.prelude(source);
4734        let inner_body = inner.body_range.clone()?;
4735        if inner_prelude == parent_selector.trim() {
4736            inners.push(ConditionalInner::Direct {
4737                body_range: inner_body,
4738            });
4739        } else if let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude) {
4740            inners.push(ConditionalInner::Nested {
4741                nested_selector: nested_sel,
4742                body_range: inner_body,
4743            });
4744        } else {
4745            return None;
4746        }
4747    }
4748
4749    debug_assert!(
4750        name == "media" || name == "supports" || name == "container" || name == "starting-style"
4751    );
4752    Some(ClusterChild::Conditional {
4753        node: node.clone(),
4754        rule,
4755        inners,
4756    })
4757}
4758
4759pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
4760    if !selector.contains(":not(") {
4761        return None;
4762    }
4763    let mut result = String::new();
4764    let mut i = 0;
4765    let bytes = selector.as_bytes();
4766    let mut changed = false;
4767    let mut uniform_specificity = true;
4768
4769    while i < bytes.len() {
4770        if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
4771            let mut args = Vec::new();
4772            let mut current_end = i;
4773
4774            while current_end + 5 <= bytes.len()
4775                && &selector[current_end..current_end + 5] == ":not("
4776            {
4777                let open = current_end + 4;
4778                if let Some(close) = find_matching_paren(selector, open) {
4779                    let arg = selector[open + 1..close].trim();
4780                    args.push(arg);
4781                    current_end = close + 1;
4782                } else {
4783                    break;
4784                }
4785            }
4786
4787            if args.len() > 1 {
4788                changed = true;
4789                let specs: Vec<Specificity> =
4790                    args.iter().map(|a| calculate_specificity(a)).collect();
4791                if specs.windows(2).any(|w| w[0] != w[1]) {
4792                    uniform_specificity = false;
4793                }
4794
4795                result.push_str(":not(");
4796                result.push_str(&args.join(", "));
4797                result.push(')');
4798                i = current_end;
4799                continue;
4800            }
4801        }
4802        let ch = selector[i..].chars().next().unwrap();
4803        result.push(ch);
4804        i += ch.len_utf8();
4805    }
4806
4807    if changed {
4808        Some((result, uniform_specificity))
4809    } else {
4810        None
4811    }
4812}
4813
4814fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
4815    let bytes = source.as_bytes();
4816    let mut depth = 1usize;
4817    let mut i = open + 1;
4818    let mut quote: Option<u8> = None;
4819    let mut escaped = false;
4820
4821    while i < bytes.len() {
4822        let b = bytes[i];
4823        if let Some(q) = quote {
4824            if escaped {
4825                escaped = false;
4826            } else if b == b'\\' {
4827                escaped = true;
4828            } else if b == q {
4829                quote = None;
4830            }
4831            i += 1;
4832            continue;
4833        }
4834
4835        match b {
4836            b'\'' | b'"' => quote = Some(b),
4837            b'(' => depth += 1,
4838            b')' => {
4839                depth -= 1;
4840                if depth == 0 {
4841                    return Some(i);
4842                }
4843            }
4844            _ => {}
4845        }
4846        i += 1;
4847    }
4848    None
4849}
4850
4851#[derive(Debug, Clone)]
4852struct HierarchicalRule {
4853    relative_selector: String,
4854    body_lines: Vec<String>,
4855    sub_rules: Vec<HierarchicalRule>,
4856    conditional_header: Option<String>,
4857}
4858
4859fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
4860    let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
4861    let parent_indent = line_indent(source, parent.start);
4862    let unit = relative_indent_unit(source, parent);
4863    let nested_indent = format!("{parent_indent}{unit}");
4864
4865    let mut out = String::new();
4866    let open = parent_body_range.start - 1;
4867    out.push_str(&source[parent.start..=open]);
4868
4869    let parent_body = &source[parent_body_range.clone()];
4870    let trimmed_body = parent_body.trim();
4871    if !trimmed_body.is_empty() {
4872        out.push('\n');
4873        for line in parent_body.lines() {
4874            let trimmed_line = line.trim();
4875            if !trimmed_line.is_empty() {
4876                out.push_str(&nested_indent);
4877                out.push_str(&ensure_semicolon(trimmed_line));
4878                out.push('\n');
4879            }
4880        }
4881    }
4882
4883    // Build hierarchical rules
4884    let mut root_rules: Vec<HierarchicalRule> = Vec::new();
4885
4886    for child in children {
4887        match child {
4888            ClusterChild::Style {
4889                node,
4890                nested_selector,
4891                ..
4892            } => {
4893                let mut body_lines = Vec::new();
4894                if let Some(body_range) = &node.body_range {
4895                    for line in source[body_range.clone()].lines() {
4896                        let trimmed = line.trim();
4897                        if !trimmed.is_empty() {
4898                            body_lines.push(trimmed.to_string());
4899                        }
4900                    }
4901                }
4902                insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
4903            }
4904            ClusterChild::Conditional { node, inners, .. } => {
4905                let header = node.prelude(source).trim().to_string();
4906                let mut cond_sub_rules = Vec::new();
4907                for inner in inners {
4908                    match inner {
4909                        ConditionalInner::Direct { body_range } => {
4910                            let mut lines = Vec::new();
4911                            for line in source[body_range.clone()].lines() {
4912                                let trimmed = line.trim();
4913                                if !trimmed.is_empty() {
4914                                    lines.push(trimmed.to_string());
4915                                }
4916                            }
4917                            cond_sub_rules.push(HierarchicalRule {
4918                                relative_selector: String::new(),
4919                                body_lines: lines,
4920                                sub_rules: Vec::new(),
4921                                conditional_header: None,
4922                            });
4923                        }
4924                        ConditionalInner::Nested {
4925                            nested_selector,
4926                            body_range,
4927                        } => {
4928                            let mut lines = Vec::new();
4929                            for line in source[body_range.clone()].lines() {
4930                                let trimmed = line.trim();
4931                                if !trimmed.is_empty() {
4932                                    lines.push(trimmed.to_string());
4933                                }
4934                            }
4935                            cond_sub_rules.push(HierarchicalRule {
4936                                relative_selector: nested_selector.trim().to_string(),
4937                                body_lines: lines,
4938                                sub_rules: Vec::new(),
4939                                conditional_header: None,
4940                            });
4941                        }
4942                    }
4943                }
4944                root_rules.push(HierarchicalRule {
4945                    relative_selector: String::new(),
4946                    body_lines: Vec::new(),
4947                    sub_rules: cond_sub_rules,
4948                    conditional_header: Some(header),
4949                });
4950            }
4951        }
4952    }
4953
4954    for rule in &root_rules {
4955        out.push('\n');
4956        render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
4957    }
4958
4959    out.push_str(&parent_indent);
4960    out.push('}');
4961    out
4962}
4963
4964fn insert_hierarchical_style(
4965    root_rules: &mut Vec<HierarchicalRule>,
4966    selector: &str,
4967    body_lines: Vec<String>,
4968) {
4969    if let Some(last_rule) = root_rules.last_mut()
4970        && last_rule.conditional_header.is_none()
4971        && !last_rule.relative_selector.is_empty()
4972    {
4973        let parent_sel = &last_rule.relative_selector;
4974        if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
4975            insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
4976            return;
4977        }
4978    }
4979
4980    root_rules.push(HierarchicalRule {
4981        relative_selector: selector.to_string(),
4982        body_lines,
4983        sub_rules: Vec::new(),
4984        conditional_header: None,
4985    });
4986}
4987
4988fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
4989    let parent = parent.trim();
4990    let child = child.trim();
4991    if child == parent || !child.starts_with(parent) {
4992        return None;
4993    }
4994    let remainder = &child[parent.len()..];
4995    let trimmed = remainder.trim_start();
4996    if trimmed.is_empty() {
4997        return None;
4998    }
4999
5000    if remainder.starts_with("::")
5001        || remainder.starts_with(':')
5002        || remainder.starts_with('[')
5003        || remainder.starts_with('.')
5004        || remainder.starts_with('#')
5005    {
5006        Some(format!("&{remainder}"))
5007    } else if let Some(first_char) = trimmed
5008        .chars()
5009        .next()
5010        .filter(|c| *c == '>' || *c == '+' || *c == '~')
5011    {
5012        let after_comb = trimmed[first_char.len_utf8()..].trim();
5013        Some(format!("{first_char} {after_comb}"))
5014    } else if remainder
5015        .as_bytes()
5016        .first()
5017        .is_some_and(|b| b.is_ascii_whitespace())
5018    {
5019        Some(trimmed.to_string())
5020    } else {
5021        None
5022    }
5023}
5024
5025fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
5026    let inner_indent = format!("{indent}{unit}");
5027
5028    if let Some(header) = &rule.conditional_header {
5029        out.push_str(indent);
5030        out.push_str(header);
5031        out.push_str(" {\n");
5032        for (idx, sub) in rule.sub_rules.iter().enumerate() {
5033            if idx > 0 {
5034                out.push('\n');
5035            }
5036            if sub.relative_selector.is_empty() {
5037                for line in &sub.body_lines {
5038                    out.push_str(&inner_indent);
5039                    out.push_str(&ensure_semicolon(line));
5040                    out.push('\n');
5041                }
5042            } else {
5043                render_hierarchical_rule(out, sub, &inner_indent, unit);
5044            }
5045        }
5046        out.push_str(indent);
5047        out.push_str("}\n");
5048    } else {
5049        out.push_str(indent);
5050        out.push_str(&rule.relative_selector);
5051        out.push_str(" {\n");
5052
5053        for line in &rule.body_lines {
5054            out.push_str(&inner_indent);
5055            out.push_str(&ensure_semicolon(line));
5056            out.push('\n');
5057        }
5058
5059        for sub in &rule.sub_rules {
5060            out.push('\n');
5061            render_hierarchical_rule(out, sub, &inner_indent, unit);
5062        }
5063
5064        out.push_str(indent);
5065        out.push_str("}\n");
5066    }
5067}
5068
5069fn line_indent(source: &str, offset: usize) -> String {
5070    let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
5071    source[line_start..offset]
5072        .chars()
5073        .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
5074        .collect()
5075}
5076
5077/// Ensure a CSS declaration line ends with `;`.
5078/// Only adds the semicolon when the line looks like a property declaration
5079/// (contains `:`, does not already end with `;`, `{`, or `}`, and is not a comment).
5080fn ensure_semicolon(line: &str) -> std::borrow::Cow<'_, str> {
5081    let trimmed = line.trim_end();
5082    if trimmed.ends_with(';')
5083        || trimmed.ends_with('{')
5084        || trimmed.ends_with('}')
5085        || trimmed.ends_with(',')
5086        || trimmed.starts_with("//")
5087        || trimmed.starts_with("/*")
5088        || !trimmed.contains(':')
5089    {
5090        return std::borrow::Cow::Borrowed(line);
5091    }
5092    std::borrow::Cow::Owned(format!("{trimmed};"))
5093}
5094
5095fn line_number(source: &str, offset: usize) -> usize {
5096    source[..offset.min(source.len())].lines().count()
5097}
5098
5099fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
5100    for line in source[body].lines() {
5101        if line.trim().is_empty() {
5102            continue;
5103        }
5104        let indent: String = line
5105            .chars()
5106            .take_while(|c| *c == ' ' || *c == '\t')
5107            .collect();
5108        if !indent.is_empty() {
5109            return Some(indent);
5110        }
5111    }
5112    None
5113}
5114
5115fn contains_top_level_comma(selector: &str) -> bool {
5116    let bytes = selector.as_bytes();
5117    let mut parens = 0usize;
5118    let mut brackets = 0usize;
5119    let mut quote: Option<u8> = None;
5120    let mut escaped = false;
5121    let mut i = 0usize;
5122    while i < bytes.len() {
5123        let b = bytes[i];
5124        if let Some(q) = quote {
5125            if escaped {
5126                escaped = false;
5127            } else if b == b'\\' {
5128                escaped = true;
5129            } else if b == q {
5130                quote = None;
5131            }
5132            i += 1;
5133            continue;
5134        }
5135        match b {
5136            b'\'' | b'"' => quote = Some(b),
5137            b'(' => parens += 1,
5138            b')' => parens = parens.saturating_sub(1),
5139            b'[' => brackets += 1,
5140            b']' => brackets = brackets.saturating_sub(1),
5141            b',' if parens == 0 && brackets == 0 => return true,
5142            _ => {}
5143        }
5144        i += 1;
5145    }
5146    false
5147}
5148
5149pub fn apply_selected_plans(
5150    source: &str,
5151    plans: &[PlanEntry],
5152    include_review: bool,
5153) -> Result<String> {
5154    let selected: Vec<&PlanEntry> = plans
5155        .iter()
5156        .filter(|plan| {
5157            plan.selected
5158                && (plan.safety == Safety::Safe
5159                    || (include_review && plan.safety == Safety::Review))
5160        })
5161        .collect();
5162    let owned: Vec<PlanEntry> = selected.into_iter().cloned().collect();
5163    let keep = select_disjoint_plan_indices(&owned);
5164    let mut non_overlapping: Vec<&PlanEntry> = keep.iter().map(|&i| &owned[i]).collect();
5165    non_overlapping.sort_by(|a, b| {
5166        a.source_range
5167            .start
5168            .cmp(&b.source_range.start)
5169            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
5170    });
5171
5172    let mut output = source.to_string();
5173    for plan in non_overlapping.into_iter().rev() {
5174        if plan.source_range.start <= output.len()
5175            && plan.source_range.end <= output.len()
5176            && plan.source_range.start <= plan.source_range.end
5177        {
5178            output.replace_range(
5179                plan.source_range.start..plan.source_range.end,
5180                &plan.proposed,
5181            );
5182        }
5183    }
5184    Ok(output)
5185}
5186
5187pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
5188    TextDiff::from_lines(old, new)
5189        .unified_diff()
5190        .header(old_name, new_name)
5191        .to_string()
5192}
5193
5194#[cfg(test)]
5195mod tests {
5196    use super::*;
5197
5198    fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
5199        analyze_source(PathBuf::from("test.css"), css, rules)
5200            .unwrap()
5201            .plans
5202    }
5203
5204    #[test]
5205    fn nests_adjacent_pseudo_and_descendant_rules() {
5206        let css = ".card {\n  color: red;\n}\n.card:hover {\n  color: blue !important;\n}\n.card .title {\n  font-weight: 700;\n}\n";
5207        let plans = plan(css, &RuleId::ALL);
5208        assert_eq!(plans.len(), 1);
5209        let output = apply_selected_plans(css, &plans, false).unwrap();
5210        assert!(output.contains("&:hover"));
5211        assert!(output.contains(".title"));
5212        assert!(output.contains("color: blue !important;"));
5213    }
5214
5215    #[test]
5216    fn nests_exact_full_modernize_example() {
5217        let original = r#".card {
5218  color: #222;
5219  padding: 1rem;
5220}
5221.card:hover {
5222  color: #111 !important;
5223}
5224.card::before {
5225  content: "";
5226}
5227.card[data-active] {
5228  border-color: currentColor;
5229}
5230.card.featured {
5231  box-shadow: 0 0 0 1px currentColor;
5232}
5233.card .title {
5234  font-weight: 700;
5235}
5236.card > .body {
5237  min-width: 0;
5238}
5239.card + .card {
5240  margin-top: 1rem;
5241}
5242@media (width >= 48rem) {
5243  .card {
5244    padding: 1.5rem;
5245  }
5246}
5247@supports (display: grid) {
5248  .card {
5249    display: grid;
5250  }
5251}
5252"#;
5253
5254        let expected = r#".card {
5255  color: #222;
5256  padding: 1rem;
5257
5258  &:hover {
5259    color: #111 !important;
5260  }
5261
5262  &::before {
5263    content: "";
5264  }
5265
5266  &[data-active] {
5267    border-color: currentColor;
5268  }
5269
5270  &.featured {
5271    box-shadow: 0 0 0 1px currentColor;
5272  }
5273
5274  .title {
5275    font-weight: 700;
5276  }
5277
5278  > .body {
5279    min-width: 0;
5280  }
5281
5282  + .card {
5283    margin-top: 1rem;
5284  }
5285
5286  @media (width >= 48rem) {
5287    padding: 1.5rem;
5288  }
5289
5290  @supports (display: grid) {
5291    display: grid;
5292  }
5293}"#;
5294
5295        let plans = plan(
5296            original,
5297            &[
5298                RuleId::NestPseudoClass,
5299                RuleId::NestPseudoElement,
5300                RuleId::NestAttribute,
5301                RuleId::NestCompound,
5302                RuleId::NestDescendant,
5303                RuleId::NestCombinator,
5304                RuleId::NestMedia,
5305                RuleId::NestSupports,
5306            ],
5307        );
5308        assert_eq!(plans.len(), 1);
5309        let output = apply_selected_plans(original, &plans, false).unwrap();
5310        assert_eq!(output.trim(), expected.trim());
5311    }
5312
5313    #[test]
5314    fn factors_selector_list_sharing_base() {
5315        let css = ".marker,\n.marker::before,\n.marker::after {\n  box-sizing: border-box;\n}\n";
5316        let plans = plan(css, &[RuleId::FactorSelectorList]);
5317        assert_eq!(plans.len(), 1);
5318        let output = apply_selected_plans(css, &plans, false).unwrap();
5319        assert!(output.contains(".marker {"));
5320        assert!(output.contains("&,"));
5321        assert!(output.contains("&::before,"));
5322        assert!(output.contains("&::after {"));
5323        assert!(output.contains("box-sizing: border-box;"));
5324    }
5325
5326    #[test]
5327    fn modernizes_is_with_uniform_specificity() {
5328        let css = ".button:hover, .button:focus, .button:active {\n  color: blue;\n}\n";
5329        let plans = plan(css, &[RuleId::ModernizeIs]);
5330        assert_eq!(plans.len(), 1);
5331        let output = apply_selected_plans(css, &plans, false).unwrap();
5332        assert!(output.contains(".button:is(:hover, :focus, :active)"));
5333    }
5334
5335    #[test]
5336    fn modernizes_media_range_syntax() {
5337        let css = "@media (min-width: 800px) {\n  .card { padding: 2rem; }\n}\n";
5338        let plans = plan(css, &[RuleId::ModernizeMediaRange]);
5339        assert_eq!(plans.len(), 1);
5340        let output = apply_selected_plans(css, &plans, false).unwrap();
5341        assert!(output.contains("@media (width >= 800px)"));
5342    }
5343
5344    #[test]
5345    fn consolidates_not_selectors() {
5346        let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n  border: 1px solid gray;\n}\n";
5347        let plans = plan(css, &[RuleId::ConsolidateNot]);
5348        assert_eq!(plans.len(), 1);
5349        assert_eq!(plans[0].safety, Safety::Review);
5350        let output = apply_selected_plans(css, &plans, true).unwrap();
5351        assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
5352    }
5353
5354    #[test]
5355    fn refuses_subtoken_is_factoring_false_positive() {
5356        let css = ".same-specificity-a,\n.same-specificity-b {\n  color: black;\n}\n";
5357        let plans = plan(css, &[RuleId::ModernizeIs]);
5358        assert!(plans.is_empty());
5359    }
5360
5361    #[test]
5362    fn modernizes_descendant_is_alternatives() {
5363        let css = ".card .title, .card .subtitle, .card .description {\n  color: black;\n}\n";
5364        let plans = plan(css, &[RuleId::ModernizeIs]);
5365        assert_eq!(plans.len(), 1);
5366        let output = apply_selected_plans(css, &plans, false).unwrap();
5367        assert!(output.contains(".card :is(.title, .subtitle, .description)"));
5368    }
5369
5370    #[test]
5371    fn modernizes_suffix_is_alternatives() {
5372        let css = ".alpha .title,\n#hero .title {\n  color: rebeccapurple;\n}\n";
5373        let plans = plan(css, &[RuleId::ModernizeIs]);
5374        assert_eq!(plans.len(), 1);
5375        assert_eq!(plans[0].safety, Safety::Review);
5376        let output = apply_selected_plans(css, &plans, true).unwrap();
5377        assert!(output.contains(":is(.alpha, #hero) .title"));
5378    }
5379
5380    #[test]
5381    fn factors_multi_selector_cluster_with_is_and_nesting() {
5382        let css = r#".alpha .title,
5383#hero .title {
5384  color: rebeccapurple;
5385}
5386
5387.alpha .subtitle,
5388#hero .subtitle {
5389  color: slateblue;
5390}
5391
5392.alpha,
5393#hero {
5394  border-color: currentColor;
5395}
5396"#;
5397        let plans = plan(css, &[RuleId::ModernizeIs]);
5398        assert_eq!(plans.len(), 1);
5399        let output = apply_selected_plans(css, &plans, true).unwrap();
5400        assert!(output.contains(":is(.alpha, #hero) {"));
5401        assert!(output.contains("border-color: currentColor;"));
5402        assert!(output.contains(".title {"));
5403        assert!(output.contains("color: rebeccapurple;"));
5404        assert!(output.contains(".subtitle {"));
5405        assert!(output.contains("color: slateblue;"));
5406    }
5407
5408    #[test]
5409    fn refuses_bem_token_concatenation() {
5410        let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
5411        let plans = plan(css, &RuleId::ALL);
5412        assert!(plans.is_empty());
5413    }
5414
5415    #[test]
5416    fn merges_same_named_layer_blocks() {
5417        let css = "@layer overrides {\n  .layered-card {\n    color: darkgreen;\n  }\n}\n\n@layer overrides {\n  .layer-important {\n    color: orange !important;\n  }\n}\n";
5418        let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
5419        assert_eq!(plans.len(), 2);
5420        let output = apply_selected_plans(css, &plans, false).unwrap();
5421        assert!(output.contains("@layer overrides {"));
5422        assert!(output.contains(".layered-card {"));
5423        assert!(output.contains(".layer-important {"));
5424    }
5425
5426    #[test]
5427    fn merges_adjacent_media_queries() {
5428        let css = "@media (width >= 48rem) {\n  .card {\n    padding: 2rem;\n  }\n}\n\n@media (width >= 48rem) {\n  .panel {\n    padding: 2rem;\n  }\n}\n";
5429        let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
5430        assert_eq!(plans.len(), 1);
5431        let output = apply_selected_plans(css, &plans, false).unwrap();
5432        assert!(output.contains("@media (width >= 48rem) {"));
5433        assert!(output.contains(".card {"));
5434        assert!(output.contains(".panel {"));
5435    }
5436
5437    #[test]
5438    fn merges_adjacent_supports_queries() {
5439        let css = "@supports (display: grid) {\n  .card {\n    display: grid;\n  }\n}\n\n@supports (display: grid) {\n  .panel {\n    display: grid;\n  }\n}\n";
5440        let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
5441        assert_eq!(plans.len(), 1);
5442        let output = apply_selected_plans(css, &plans, false).unwrap();
5443        assert!(output.contains("@supports (display: grid) {"));
5444        assert!(output.contains(".card {"));
5445        assert!(output.contains(".panel {"));
5446    }
5447
5448    #[test]
5449    fn merges_adjacent_identical_selectors() {
5450        let css = ".card {\n  color: black;\n}\n\n.card {\n  padding: 1rem;\n}\n";
5451        let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
5452        assert_eq!(plans.len(), 1);
5453        let output = apply_selected_plans(css, &plans, false).unwrap();
5454        assert!(output.contains(".card {"));
5455        assert!(output.contains("color: black;"));
5456        assert!(output.contains("padding: 1rem;"));
5457    }
5458
5459    #[test]
5460    fn merges_identical_rule_bodies() {
5461        let css = ".card:hover {\n  color: red;\n}\n\n.panel:hover {\n  color: red;\n}\n";
5462        let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
5463        assert_eq!(plans.len(), 1);
5464        let output = apply_selected_plans(css, &plans, false).unwrap();
5465        assert!(output.contains(".card:hover,"));
5466        assert!(output.contains(".panel:hover {"));
5467        assert!(output.contains("color: red;"));
5468    }
5469
5470    #[test]
5471    fn factors_identical_states_with_is() {
5472        let css = ".card:hover {\n  background: silver;\n}\n\n.card:focus {\n  background: silver;\n}\n\n.card:focus-visible {\n  background: silver;\n}\n";
5473        let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
5474        assert_eq!(plans.len(), 1);
5475        let output = apply_selected_plans(css, &plans, false).unwrap();
5476        assert!(output.contains(".card {"));
5477        assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
5478        assert!(output.contains("background: silver;"));
5479    }
5480
5481    #[test]
5482    fn nests_multi_level_tree_hierarchy() {
5483        let css = r#".tree {
5484  display: grid;
5485  gap: 0.5rem;
5486}
5487.tree .node {
5488  position: relative;
5489}
5490.tree .node .label {
5491  display: flex;
5492}
5493.tree .node .label:hover {
5494  color: var(--accent);
5495}
5496.tree .node > .children {
5497  margin-inline-start: 1.25rem;
5498}
5499.tree .node > .children > .node + .node {
5500  margin-block-start: 0.25rem;
5501}
5502"#;
5503        let plans = plan(
5504            css,
5505            &[
5506                RuleId::NestDescendant,
5507                RuleId::NestCombinator,
5508                RuleId::NestPseudoClass,
5509            ],
5510        );
5511        assert_eq!(plans.len(), 1);
5512        let output = apply_selected_plans(css, &plans, false).unwrap();
5513        assert!(output.contains(".node {"));
5514        assert!(output.contains(".label {"));
5515        assert!(output.contains("&:hover {"));
5516        assert!(output.contains("> .children {"));
5517        assert!(output.contains("> .node + .node {"));
5518    }
5519
5520    #[test]
5521    fn nests_in_place_input_states() {
5522        let css = "input:user-invalid {\n  border-color: crimson;\n}\ninput:user-valid {\n  border-color: seagreen;\n}\ninput:placeholder-shown {\n  color: gray;\n}\n";
5523        let plans = plan(css, &[RuleId::NestPseudoClass]);
5524        assert_eq!(plans.len(), 1);
5525        let output = apply_selected_plans(css, &plans, false).unwrap();
5526        assert!(output.contains("input {"));
5527        assert!(output.contains("&:user-invalid {"));
5528        assert!(output.contains("&:user-valid {"));
5529        assert!(output.contains("&:placeholder-shown {"));
5530    }
5531
5532    #[test]
5533    fn gathers_consecutive_conditions_by_selector() {
5534        let css = "@media (width >= 30rem) {\n  .responsive-grid {\n    gap: 1rem;\n  }\n}\n\n@media (width >= 80rem) {\n  .responsive-grid {\n    gap: 2rem;\n  }\n}\n";
5535        let plans = plan(css, &[RuleId::NestMedia]);
5536        assert_eq!(plans.len(), 1);
5537        let output = apply_selected_plans(css, &plans, false).unwrap();
5538        assert!(output.contains(".responsive-grid {"));
5539        assert!(output.contains("@media (width >= 30rem) {"));
5540        assert!(output.contains("@media (width >= 80rem) {"));
5541    }
5542
5543    #[test]
5544    fn factors_selector_list_with_adjacent_hover() {
5545        let css = ".notice,\n.notice::before,\n.notice::after {\n  color: currentColor;\n}\n\n.notice:hover {\n  background: color-mix(in srgb, currentColor 8%, transparent);\n}\n";
5546        let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
5547        assert_eq!(plans.len(), 1);
5548        let output = apply_selected_plans(css, &plans, false).unwrap();
5549        assert!(output.contains(".notice {"));
5550        assert!(output.contains("&,"));
5551        assert!(output.contains("&::before,"));
5552        assert!(output.contains("&::after {"));
5553        assert!(output.contains("&:hover {"));
5554        assert!(output.contains("background: color-mix"));
5555    }
5556
5557    #[test]
5558    fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
5559        let css = r#".skip-link {
5560    position: absolute;
5561    inset-block-start: -48px;
5562    inset-inline-start: 1rem;
5563    z-index: 10000000000;
5564    background: var(--bg-color);
5565    color: var(--text-color);
5566    border: 1px solid var(--border-color);
5567    border-radius: 0.5rem;
5568    padding: 0.55rem 0.8rem;
5569    text-decoration: none;
5570    font-weight: 700;
5571    transition: inset-block-start 0.2s ease;
5572
5573    &:focus-visible {
5574        inset-block-start: 0.75rem;
5575    }
5576}
5577
5578.unrelated-rule {
5579    color: red;
5580}
5581
5582.skip-link {
5583    font: optional;
5584
5585    &::after {
5586        content: '';
5587    }
5588
5589    :not(*) & {
5590        all: unset
5591    }
5592}
5593"#;
5594        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5595        assert_eq!(plans.len(), 1);
5596        let output = apply_selected_plans(css, &plans, true).unwrap();
5597        assert!(output.contains("font: optional;"));
5598    }
5599
5600    #[test]
5601    fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
5602        let css = r#".skip-link {
5603    position: absolute;
5604    inset-block-start: -48px;
5605    inset-inline-start: 1rem;
5606    z-index: 10000000000;
5607    background: var(--bg-color);
5608    color: var(--text-color);
5609    border: 1px solid var(--border-color);
5610    border-radius: 0.5rem;
5611    padding: 0.55rem 0.8rem;
5612    text-decoration: none;
5613    font-weight: 700;
5614    transition: inset-block-start 0.2s ease;
5615
5616    &:focus-visible {
5617        inset-block-start: 0.75rem;
5618    }
5619}
5620
5621.unrelated {
5622    color: red;
5623}
5624
5625.skip-link {
5626    font: optional;
5627
5628    &::after {
5629        content: '';
5630    }
5631
5632    :not(*) & {
5633        all: unset
5634    }
5635}
5636
5637.skip-link+* {
5638    display: block;
5639}
5640
5641.skip-link::backdrop {
5642    background-color: gray;
5643}
5644
5645.skip-link:has(*) {
5646    color: #27ca3f;
5647}
5648"#;
5649        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5650        assert_eq!(plans.len(), 1);
5651        let output = apply_selected_plans(css, &plans, true).unwrap();
5652        assert!(output.contains("font: optional;"));
5653        assert!(output.contains("+ * {"));
5654        assert!(output.contains("&::backdrop {"));
5655        assert!(output.contains("&:has(*) {"));
5656    }
5657
5658    #[test]
5659    fn test_modernizes_media_range_syntax_no_whitespace() {
5660        let input = "@media (max-width:60rem) { .content { width: 100%; } }";
5661        let plans = plan(input, &[RuleId::ModernizeMediaRange]);
5662        assert_eq!(plans.len(), 1);
5663        let output = apply_selected_plans(input, &plans, true).unwrap();
5664        assert!(output.contains("(width <= 60rem)"));
5665    }
5666
5667    #[test]
5668    fn plans_continue_when_lightningcss_rejects_picker_pseudo() {
5669        // `::picker()` is valid CSS but LightningCSS rejects it. Planning must
5670        // still run from the structural scanner so siblings can nest/modernize.
5671        let css = r#".custom-select {
5672    appearance: none;
5673}
5674
5675.custom-select:hover {
5676    border-color: teal;
5677}
5678
5679.custom-select::picker(select) {
5680    background: white;
5681}
5682
5683.custom-select::picker(select)::-webkit-scrollbar {
5684    width: 6px;
5685}
5686
5687@media (max-width: 768px) {
5688    .custom-select {
5689        inline-size: 100%;
5690    }
5691}
5692"#;
5693        let report = analyze_source(PathBuf::from("picker.css"), css, &RuleId::ALL).unwrap();
5694        eprintln!(
5695            "parse_ok={} err={:?} plans={}",
5696            report.parse_ok,
5697            report.parse_error,
5698            report.plans.len()
5699        );
5700        for p in &report.plans {
5701            eprintln!("  {:?} {:?}", p.rules, p.reason);
5702        }
5703        assert!(
5704            !report.plans.is_empty(),
5705            "must still emit plans (parse_ok={:?} err={:?}): findings={:?}",
5706            report.parse_ok,
5707            report.parse_error,
5708            report.findings
5709        );
5710        let output = apply_selected_plans(css, &report.plans, true).unwrap();
5711        assert!(
5712            output.contains("&:hover") || output.contains("&::picker"),
5713            "custom-select relatives should nest: {output}"
5714        );
5715        assert!(
5716            output.contains("(width <= 768px)") || output.contains("inline-size: 100%"),
5717            "media-range or nest should still apply: {output}"
5718        );
5719    }
5720
5721    #[test]
5722    fn nesting_adds_semicolon_to_last_declaration_without_semicolon() {
5723        // .tabpanel body ends with `display: block !important` (no `;`)
5724        // After nesting the combinator, the declaration must get a `;` inserted
5725        // so it doesn't run together with the opening `{` of the nested rule.
5726        let css = r#".tabpanel {
5727  display: block !important
5728}
5729
5730.tabpanel+.tabpanel {
5731  margin-block-start: .5rem
5732}
5733"#;
5734        let rules = &[
5735            RuleId::NestCombinator,
5736            RuleId::NestDescendant,
5737            RuleId::NestPseudoClass,
5738            RuleId::NestPseudoElement,
5739        ];
5740        let plans = plan(css, rules);
5741        assert!(!plans.is_empty(), "expected at least one nesting plan");
5742        let output = apply_selected_plans(css, &plans, true).unwrap();
5743        // Must NOT contain the malformed concatenation
5744        assert!(
5745            !output.contains("!important+"),
5746            "semicolon missing before nested rule: {output}"
5747        );
5748        // Must contain properly terminated declaration
5749        assert!(
5750            output.contains("!important;") || output.contains("!important\n"),
5751            "declaration should end with ';': {output}"
5752        );
5753        // Nested rule selector must appear on its own
5754        assert!(
5755            output.contains("+ .tabpanel {") || output.contains("+.tabpanel {"),
5756            "nested combinator rule missing: {output}"
5757        );
5758    }
5759
5760    #[test]
5761    fn gather_related_selector_rules_adds_semicolon_to_declarations_without_semicolon() {
5762        // Declarations without trailing `;` must get one inserted when gathered
5763        // into the canonical block so they don't corrupt nested rule opening braces.
5764        let css = r#".skip-link {
5765    position: absolute;
5766    font-weight: 700
5767}
5768
5769.unrelated { color: red }
5770
5771.skip-link:focus {
5772    outline: 2px solid currentColor
5773}
5774
5775.skip-link+* {
5776    display: block
5777}
5778"#;
5779        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5780        assert!(!plans.is_empty(), "expected gather plan");
5781        let output = apply_selected_plans(css, &plans, true).unwrap();
5782        // No declaration should run together with `{`
5783        assert!(
5784            !output.contains("700\n    &") && !output.contains("700&") && !output.contains("700{"),
5785            "semicolon missing before nested pseudo/combinator: {output}"
5786        );
5787        assert!(
5788            !output.contains("currentColor\n    + *") && !output.contains("currentColor{"),
5789            "semicolon missing before nested combinator: {output}"
5790        );
5791        // All gathered declarations must end with `;`
5792        assert!(
5793            output.contains("font-weight: 700;"),
5794            "missing ';' after font-weight: {output}"
5795        );
5796        assert!(
5797            output.contains("position: absolute;"),
5798            "missing ';' after position: {output}"
5799        );
5800    }
5801
5802    #[test]
5803    fn gathers_non_adjacent_related_rule_inside_media_query() {
5804        let css = r#".skip-link {
5805    position: absolute;
5806    font-weight: 700;
5807
5808    &:focus-visible {
5809        inset-block-start: 0.75rem;
5810    }
5811}
5812
5813.unrelated {
5814    color: red;
5815}
5816
5817@media (width <= 1024px) {
5818    .skip-link :not(*) {
5819        position: inherit
5820    }
5821}
5822"#;
5823        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5824        assert!(
5825            !plans.is_empty(),
5826            "expected gather plan for media-wrapped related rule"
5827        );
5828        let output = apply_selected_plans(css, &plans, true).unwrap();
5829        assert!(
5830            !output.contains(".skip-link :not(*)"),
5831            "flat media descendant should be gathered: {output}"
5832        );
5833        assert!(
5834            output.contains(":not(*) {"),
5835            "descendant :not(*) should nest under .skip-link: {output}"
5836        );
5837        assert!(
5838            output.contains("@media (width <= 1024px) {"),
5839            "media query should nest inside :not(*): {output}"
5840        );
5841        assert!(
5842            output.contains("position: inherit"),
5843            "declaration should be preserved: {output}"
5844        );
5845        let not_pos = output.find(":not(*) {").expect(":not(*)");
5846        let media_pos = output.find("@media (width <= 1024px) {").expect("@media");
5847        assert!(
5848            media_pos > not_pos,
5849            "media must nest inside :not(*), not the reverse: {output}"
5850        );
5851    }
5852
5853    #[test]
5854    fn gathers_exact_parent_inside_media_as_nested_at_rule() {
5855        let css = r#".skip-link {
5856    position: absolute;
5857}
5858
5859.unrelated { color: red }
5860
5861@media (width <= 600px) {
5862    .skip-link {
5863        display: none
5864    }
5865}
5866"#;
5867        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5868        let output = apply_selected_plans(css, &plans, true).unwrap();
5869        assert!(output.contains("@media (width <= 600px) {"), "{output}");
5870        assert!(output.contains("display: none;"), "{output}");
5871        assert!(
5872            !output.contains("@media (width <= 600px) {\n    .skip-link"),
5873            "should invert to .skip-link {{ @media }}: {output}"
5874        );
5875    }
5876
5877    #[test]
5878    fn gather_media_leaves_unrelated_siblings_in_place() {
5879        let css = r#".skip-link {
5880    position: absolute;
5881}
5882
5883.unrelated { color: red }
5884
5885@media (width <= 1024px) {
5886    .skip-link :not(*) {
5887        position: inherit
5888    }
5889    .other {
5890        color: blue
5891    }
5892}
5893"#;
5894        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5895        let output = apply_selected_plans(css, &plans, true).unwrap();
5896        assert!(output.contains(":not(*) {"), "{output}");
5897        assert!(
5898            output.contains(".other") && output.contains("color: blue"),
5899            "unrelated sibling must stay in the media block: {output}"
5900        );
5901    }
5902
5903    #[test]
5904    fn nests_appended_nesting_selector_descendant() {
5905        // MDN: .featured .card → .card { .featured & { ... } }
5906        let css = ".card {\n  color: red;\n}\n.featured .card {\n  border: 1px solid;\n}\n";
5907        let plans = plan(css, &[RuleId::NestDescendant]);
5908        assert_eq!(plans.len(), 1);
5909        let output = apply_selected_plans(css, &plans, false).unwrap();
5910        assert!(
5911            output.contains(".featured & {"),
5912            "expected appended &: {output}"
5913        );
5914        assert!(output.contains("border: 1px solid;"), "{output}");
5915    }
5916
5917    #[test]
5918    fn nests_appended_nesting_selector_not() {
5919        // MDN-adjacent: :not(.card) → .card { :not(&) { ... } }
5920        let css = ".card {\n  color: red;\n}\n:not(.card) {\n  display: none;\n}\n";
5921        let plans = plan(css, &[RuleId::NestPseudoClass]);
5922        assert_eq!(plans.len(), 1);
5923        let output = apply_selected_plans(css, &plans, false).unwrap();
5924        assert!(output.contains(":not(&) {"), "expected :not(&): {output}");
5925        assert!(output.contains("display: none;"), "{output}");
5926    }
5927
5928    #[test]
5929    fn nests_appended_compound_selector() {
5930        let css = ".card {\n  color: red;\n}\n.featured.card {\n  font-weight: 700;\n}\n";
5931        let plans = plan(css, &[RuleId::NestCompound]);
5932        assert_eq!(plans.len(), 1);
5933        let output = apply_selected_plans(css, &plans, false).unwrap();
5934        assert!(
5935            output.contains(".featured& {"),
5936            "expected compound appended &: {output}"
5937        );
5938    }
5939
5940    #[test]
5941    fn gathers_non_adjacent_appended_nesting_selector() {
5942        let css = r#".card {
5943    color: red;
5944}
5945
5946.unrelated { color: blue }
5947
5948.featured .card {
5949    border: 1px solid
5950}
5951
5952:not(.card) {
5953    display: none
5954}
5955"#;
5956        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5957        assert!(!plans.is_empty());
5958        let output = apply_selected_plans(css, &plans, true).unwrap();
5959        assert!(output.contains(".featured & {"), "{output}");
5960        assert!(output.contains(":not(&) {"), "{output}");
5961        assert!(!output.contains(".featured .card"), "{output}");
5962        assert!(!output.contains(":not(.card)"), "{output}");
5963    }
5964
5965    #[test]
5966    fn gather_does_not_nest_selector_list_under_one_branch() {
5967        // `A, B { shared }` must not become `B { A, & { shared } }` —
5968        // that turns A into a descendant of B.
5969        let css = r#"::view-transition-old(root),
5970::view-transition-new(root) {
5971    position: absolute;
5972    inset: 0;
5973    animation: 0.55s ease-in-out both;
5974}
5975
5976.unrelated { color: red }
5977
5978::view-transition-old(root) {
5979    animation-name: fadeOut;
5980}
5981
5982::view-transition-new(root) {
5983    animation-name: fadeIn;
5984}
5985"#;
5986        let plans = plan(
5987            css,
5988            &[
5989                RuleId::GatherRelatedSelectorRules,
5990                RuleId::FactorSelectorList,
5991            ],
5992        );
5993        let output = apply_selected_plans(css, &plans, true).unwrap();
5994        assert!(
5995            !output.contains("::view-transition-old(root),\n    &")
5996                && !output.contains("::view-transition-old(root),\n    & {")
5997                && !output.contains("::view-transition-old(root),\n        &"),
5998            "selector list must not nest under one branch: {output}"
5999        );
6000        assert!(
6001            output.contains("::view-transition-old(root)")
6002                && output.contains("::view-transition-new(root)")
6003                && output.contains("animation-name: fadeOut")
6004                && output.contains("animation-name: fadeIn"),
6005            "both branches and their unique decls must remain: {output}"
6006        );
6007        assert!(
6008            output.contains("position: absolute"),
6009            "shared declarations must be kept: {output}"
6010        );
6011    }
6012
6013    #[test]
6014    fn gather_nests_not_focus_visible_as_non_relative_amp() {
6015        // `&` anywhere (here inside `:not()`) makes the nest non-relative:
6016        // `:focus-visible { .skip-link:focus:not(&) }` ≡
6017        // `.skip-link:focus:not(:is(:focus-visible))` — no descendant combinator.
6018        assert!(selector_contains_nesting_amp(".skip-link:focus:not(&)"));
6019        assert!(!selector_contains_nesting_amp(".skip-link:focus"));
6020
6021        let css = r#":focus-visible {
6022    outline: 2px solid blue;
6023}
6024
6025.unrelated { color: red }
6026
6027/* Preserve visible focus for skip-link activation (any modality). */
6028.skip-link:focus:not(:focus-visible) {
6029    outline: 2px solid blue;
6030}
6031"#;
6032        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6033        let output = apply_selected_plans(css, &plans, true).unwrap();
6034        assert!(
6035            output.contains(".skip-link:focus:not(&)"),
6036            "should nest as non-relative :not(&): {output}"
6037        );
6038        assert!(
6039            !output.contains(":focus-visible .skip-link")
6040                && !output.contains(":focus-visible  .skip-link"),
6041            "must not insert a descendant combinator: {output}"
6042        );
6043        assert!(
6044            output.contains("Preserve visible focus for skip-link activation"),
6045            "leading comment must move with the gathered rule: {output}"
6046        );
6047        let cmt = output.find("Preserve visible focus").expect("comment");
6048        let nest = output.find(".skip-link:focus:not(&)").expect("nest");
6049        assert!(
6050            cmt < nest,
6051            "comment should precede the nested rule: {output}"
6052        );
6053    }
6054
6055    #[test]
6056    fn gather_nests_skip_link_focus_under_skip_link_not_focus_visible() {
6057        let css = r#":focus-visible {
6058    outline: 2px solid blue;
6059}
6060
6061.skip-link {
6062    position: fixed;
6063}
6064
6065.unrelated { color: red }
6066
6067.skip-link:focus:not(:focus-visible) {
6068    outline: 2px solid blue;
6069}
6070"#;
6071        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6072        let output = apply_selected_plans(css, &plans, true).unwrap();
6073        assert!(
6074            output.contains("&:focus:not(:focus-visible)"),
6075            "should nest under .skip-link: {output}"
6076        );
6077        assert!(
6078            !output.contains(".skip-link:focus:not(&)"),
6079            "must not nest under :focus-visible: {output}"
6080        );
6081    }
6082
6083    #[test]
6084    fn gather_merges_same_nested_selector_from_style_and_media() {
6085        let css = r#".demo-out-text {
6086    font-size: 1.5rem;
6087
6088    &.flash {
6089        animation: demo-out-flash .4s ease;
6090    }
6091}
6092
6093.unrelated { color: red }
6094
6095@media (prefers-reduced-motion: reduce) {
6096    .demo-out-text.flash {
6097        animation: none
6098    }
6099}
6100"#;
6101        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6102        let output = apply_selected_plans(css, &plans, true).unwrap();
6103        let flash_opens = output.matches("&.flash {").count();
6104        assert_eq!(
6105            flash_opens, 1,
6106            "duplicate &.flash nests should merge: {output}"
6107        );
6108        assert!(output.contains("animation: demo-out-flash"), "{output}");
6109        assert!(
6110            output.contains("@media (prefers-reduced-motion: reduce)"),
6111            "{output}"
6112        );
6113        assert!(output.contains("animation: none"), "{output}");
6114    }
6115
6116    #[test]
6117    fn gather_prefers_existing_specific_home_over_universal_append() {
6118        // `.skip-link + *` and `.skip-link:has(*)` can also be written as
6119        // `*` { `.skip-link+&` / `.skip-link:has(&)` }. The existing `.skip-link`
6120        // rule is the stronger home — do not duplicate into `*`.
6121        let css = r#".skip-link {
6122    position: absolute;
6123    font-weight: 700;
6124}
6125
6126* {
6127    margin: 0;
6128}
6129
6130.unrelated { color: red }
6131
6132.skip-link+* {
6133    display: block
6134}
6135
6136.skip-link:has(*) {
6137    color: #27ca3f
6138}
6139"#;
6140        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6141        let output = apply_selected_plans(css, &plans, true).unwrap();
6142        assert!(
6143            output.contains("+ * {") || output.contains("+* {"),
6144            "combinator should nest under .skip-link: {output}"
6145        );
6146        assert!(
6147            output.contains("&:has(*) {"),
6148            ":has(*) should nest under .skip-link: {output}"
6149        );
6150        assert!(
6151            !output.contains(".skip-link+&")
6152                && !output.contains(".skip-link + &")
6153                && !output.contains(".skip-link:has(&)"),
6154            "must not append the same rules into *: {output}"
6155        );
6156        let star = output.find("\n* {").or_else(|| output.find("* {"));
6157        if let Some(star_at) = star {
6158            let after_star = &output[star_at..];
6159            let star_body = after_star.split('}').next().unwrap_or(after_star);
6160            assert!(
6161                !star_body.contains("skip-link"),
6162                "* must not absorb skip-link rules: {output}"
6163            );
6164        }
6165    }
6166
6167    #[test]
6168    fn gather_appends_only_when_no_prefix_home_exists() {
6169        let css = r#".card {
6170    color: red;
6171}
6172
6173.unrelated { color: blue }
6174
6175.featured .card {
6176    border: 1px solid
6177}
6178"#;
6179        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6180        let output = apply_selected_plans(css, &plans, true).unwrap();
6181        assert!(output.contains(".featured & {"), "{output}");
6182        assert!(!output.contains(".featured .card"), "{output}");
6183    }
6184
6185    #[test]
6186    fn gather_keeps_supports_block_with_comma_list_and_mixed_homes() {
6187        let css = r#".custom-select {
6188    color: navy;
6189}
6190
6191.unrelated { color: red }
6192
6193@supports (appearance: base-select) {
6194    .custom-select,
6195    .custom-select::picker(select) {
6196        appearance: base-select;
6197    }
6198    .custom-select button { display: flex }
6199    selectedcontent { display: flex }
6200    .custom-select option { padding: 1rem }
6201}
6202"#;
6203        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6204        let output = apply_selected_plans(css, &plans, true).unwrap();
6205        assert!(
6206            output.contains("appearance: base-select"),
6207            "comma-list body must not be dropped: {output}"
6208        );
6209        assert!(
6210            output.contains("selectedcontent") && output.contains(".custom-select button")
6211                || output.contains("button {"),
6212            "mixed @supports inners must remain: {output}"
6213        );
6214    }
6215
6216    #[test]
6217    fn gather_keeps_busy_mixed_media_grouped() {
6218        let css = r#".nav-links { display: flex; }
6219.hamburger-menu { display: none; }
6220.nav-controls { gap: 1rem; }
6221
6222.unrelated { color: red }
6223
6224@media (width <= 1024px) {
6225    .nav-links { display: none }
6226    .hamburger-menu { display: flex }
6227    .nav-controls { margin-inline-start: auto }
6228}
6229"#;
6230        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6231        let output = apply_selected_plans(css, &plans, true).unwrap();
6232        assert!(
6233            output.contains("@media (width <= 1024px)"),
6234            "mixed media with 3+ selectors should stay grouped: {output}"
6235        );
6236        assert!(
6237            !output.contains(".nav-links {\n    display: flex;\n\n    @media")
6238                && !output.contains(".hamburger-menu {\n    display: none;\n\n    @media"),
6239            "should not explode a busy media query into each parent: {output}"
6240        );
6241    }
6242
6243    #[test]
6244    fn appended_nesting_does_not_match_ident_suffix() {
6245        let css = ".card {\n  color: red;\n}\n.mycard {\n  color: blue;\n}\n";
6246        let plans = plan(css, &[RuleId::NestCompound, RuleId::NestDescendant]);
6247        assert!(
6248            plans.is_empty(),
6249            ".mycard must not nest under .card: {:?}",
6250            plans.iter().map(|p| &p.proposed).collect::<Vec<_>>()
6251        );
6252    }
6253
6254    #[test]
6255    fn gather_preserves_nested_selector_lists() {
6256        // `&::before,` contains `:` but is a selector-list continuation, not a declaration.
6257        let css = r#"* {
6258    margin: 0;
6259}
6260
6261.unrelated { color: red }
6262
6263@media (prefers-reduced-motion: reduce) {
6264    * {
6265        &,
6266        &::before,
6267        &::after {
6268            animation-duration: 0.001ms !important
6269        }
6270    }
6271}
6272"#;
6273        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6274        let output = apply_selected_plans(css, &plans, true).unwrap();
6275        assert!(
6276            !output.contains("&::before,\n        ;")
6277                && !output.contains("&::before,;")
6278                && !output.contains("&,\n        ;")
6279                && !output.contains("&,;"),
6280            "selector-list comma must not become a declaration terminator: {output}"
6281        );
6282        assert!(
6283            output.contains("&::before,") && output.contains("&::after {"),
6284            "compound pseudo selector list must stay intact: {output}"
6285        );
6286        let before = output.find("&::before,").expect("before");
6287        let after = output.find("&::after {").expect("after");
6288        let between = &output[before..after];
6289        assert!(
6290            !between.contains(';'),
6291            "no semicolon between selector-list items: {between:?} in {output}"
6292        );
6293    }
6294
6295    #[test]
6296    fn gather_keeps_every_custom_select_chrome_rule() {
6297        let css = r#".custom-select {
6298    color: navy;
6299}
6300
6301@supports (appearance: base-select) {
6302    .custom-select,
6303    .custom-select::picker(select) {
6304        appearance: base-select;
6305    }
6306    .custom-select button {
6307        display: flex;
6308    }
6309    .custom-select button:hover {
6310        border-color: teal;
6311    }
6312    .custom-select:focus button,
6313    .custom-select button:focus-visible {
6314        outline: none;
6315    }
6316    .custom-select .select-arrow {
6317        color: gray;
6318    }
6319    .custom-select:open .select-arrow {
6320        rotate: -180deg;
6321    }
6322    .custom-select:open button {
6323        border-color: teal;
6324    }
6325    selectedcontent {
6326        display: flex;
6327    }
6328    selectedcontent .opt-icon {
6329        display: none;
6330    }
6331    .custom-select::picker(select) {
6332        background: white;
6333    }
6334    .custom-select:not(:open)::picker(select) {
6335        opacity: 0;
6336    }
6337    .custom-select option {
6338        padding: 1rem;
6339    }
6340    .custom-select .dropdown-search-container {
6341        position: sticky;
6342    }
6343    .custom-select .select-search {
6344        inline-size: 100%;
6345    }
6346    .custom-select option .opt-icon {
6347        color: gray;
6348    }
6349    .custom-select option:hover .opt-icon,
6350    .custom-select option:checked .opt-icon {
6351        color: teal;
6352    }
6353    .custom-select::picker-icon {
6354        display: none;
6355    }
6356}
6357"#;
6358        let rules: Vec<RuleId> = RuleId::ALL
6359            .iter()
6360            .copied()
6361            .filter(|r| *r != RuleId::ModernizeWhere)
6362            .collect();
6363        let plans = plan(css, &rules);
6364        let output = apply_selected_plans(css, &plans, true).unwrap();
6365        for needle in [
6366            "appearance: base-select",
6367            "display: flex",
6368            "border-color: teal",
6369            "outline: none",
6370            "color: gray",
6371            "rotate: -180deg",
6372            "selectedcontent",
6373            "display: none",
6374            "background: white",
6375            "opacity: 0",
6376            "padding: 1rem",
6377            "position: sticky",
6378            "inline-size: 100%",
6379            "::picker-icon",
6380        ] {
6381            assert!(
6382                output.contains(needle),
6383                "lost `{needle}` after apply:\n{output}"
6384            );
6385        }
6386        assert!(
6387            output.contains("button") && output.contains("&:hover"),
6388            "button:hover should nest as button {{ &:hover }}:\n{output}"
6389        );
6390        assert!(
6391            !output.contains("&:open &") && !output.contains("&:focus &"),
6392            "must not rewrite :open/:focus descendants as appended &:\n{output}"
6393        );
6394        assert!(
6395            output.contains("&:open")
6396                && (output.contains("&:open {")
6397                    || output.contains("&:open .select-arrow")
6398                    || output.contains("&:open button")),
6399            ":open children should group under .custom-select:\n{output}"
6400        );
6401        assert!(
6402            output.contains("&:focus button") || output.contains("button:focus-visible"),
6403            "focus comma-list must stay as relative branches:\n{output}"
6404        );
6405        assert!(
6406            output.contains("selectedcontent")
6407                && (output.contains(".opt-icon") || output.contains("& .opt-icon")),
6408            "selectedcontent leftover must remain:\n{output}"
6409        );
6410        assert!(
6411            output.contains(".custom-select")
6412                && output.contains("@supports (appearance: base-select)"),
6413            "supports should invert under .custom-select:\n{output}"
6414        );
6415        let home = output.find(".custom-select {").expect("home");
6416        let supports = output
6417            .find("@supports (appearance: base-select)")
6418            .expect("supports");
6419        assert!(
6420            supports > home,
6421            "appearance supports should sit inside .custom-select:\n{output}"
6422        );
6423        assert!(
6424            !output.contains("@supports (appearance: base-select) {\n    .custom-select"),
6425            "must not leave a wrapper .custom-select inside @supports:\n{output}"
6426        );
6427    }
6428
6429    #[test]
6430    fn gather_folds_unlayered_picker_into_layered_home() {
6431        let css = r#"@layer components {
6432    .custom-select {
6433        color: navy;
6434        &:is(:hover) { color: teal; }
6435    }
6436}
6437
6438.custom-select::picker(select) {
6439    scrollbar-width: thin;
6440}
6441.custom-select::picker(select)::-webkit-scrollbar {
6442    width: 6px;
6443}
6444"#;
6445        let plans = plan(
6446            css,
6447            &[
6448                RuleId::GatherRelatedSelectorRules,
6449                RuleId::NestLayerBySelector,
6450            ],
6451        );
6452        let output = apply_selected_plans(css, &plans, true).unwrap();
6453        assert!(
6454            output.contains("@layer components") && output.contains(".custom-select"),
6455            "layered home must stay in its layer:\n{output}"
6456        );
6457        assert!(
6458            output.contains(".custom-select::picker(select)")
6459                || output.contains("&::picker(select)"),
6460            "picker chrome must remain:\n{output}"
6461        );
6462        let layer_at = output.find("@layer components").expect("layer");
6463        let picker_flat = output.find(".custom-select::picker(select)");
6464        if let Some(picker_at) = picker_flat {
6465            assert!(
6466                picker_at < layer_at || !output[layer_at..].contains("scrollbar-width"),
6467                "unlayered picker must not be dumped into @layer components:\n{output}"
6468            );
6469        }
6470    }
6471
6472    #[test]
6473    fn gather_does_not_dump_root_from_base_into_tokens() {
6474        let css = r#"@layer tokens {
6475    :root {
6476        color-scheme: light dark;
6477    }
6478}
6479
6480@layer base {
6481    :root {
6482        scroll-behavior: smooth;
6483    }
6484    body { margin: 0; }
6485}
6486"#;
6487        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6488        let output = apply_selected_plans(css, &plans, true).unwrap();
6489        assert!(
6490            output.contains("@layer tokens") && output.contains("@layer base"),
6491            "both layers must remain:\n{output}"
6492        );
6493        let tokens = output.split("@layer base").next().unwrap_or(&output);
6494        assert!(
6495            !tokens.contains("scroll-behavior"),
6496            "base :root decls must not move into tokens:\n{output}"
6497        );
6498        assert!(
6499            output.contains("scroll-behavior: smooth"),
6500            "base :root decls must survive:\n{output}"
6501        );
6502    }
6503
6504    #[test]
6505    fn nest_layer_by_selector_preserves_layer_identity() {
6506        let css = r#"@layer tokens {
6507    :root {
6508        color-scheme: light dark;
6509    }
6510}
6511
6512@layer base {
6513    :root {
6514        scroll-behavior: smooth;
6515    }
6516    body { margin: 0; }
6517}
6518"#;
6519        let plans = plan(css, &[RuleId::NestLayerBySelector]);
6520        let output = apply_selected_plans(css, &plans, true).unwrap();
6521        assert!(
6522            output.contains(":root {")
6523                && output.contains("@layer tokens {")
6524                && output.contains("@layer base {"),
6525            "should hoist :root and nest named layers:\n{output}"
6526        );
6527        assert!(
6528            output.contains("color-scheme: light dark")
6529                && output.contains("scroll-behavior: smooth")
6530                && output.contains("body"),
6531            "all declarations must survive:\n{output}"
6532        );
6533        // Must not create child layers like tokens.base
6534        assert!(
6535            !output.contains("@layer tokens {\n    :root")
6536                || output.find(":root {").unwrap() < output.find("@layer tokens {").unwrap_or(0)
6537                || output.matches("@layer tokens").count() >= 1,
6538            "{output}"
6539        );
6540        let root_at = output.find(":root {").expect("root");
6541        let tokens_inner = output[root_at..]
6542            .find("@layer tokens {")
6543            .expect("nested tokens");
6544        let base_inner = output[root_at..]
6545            .find("@layer base {")
6546            .expect("nested base");
6547        assert!(
6548            tokens_inner < base_inner,
6549            "layer order tokens then base must be preserved:\n{output}"
6550        );
6551        assert!(
6552            output.contains("body {") || output.contains("body{"),
6553            "unrelated base rules stay in @layer base:\n{output}"
6554        );
6555    }
6556
6557    #[test]
6558    fn nest_layer_blocks_are_not_factored_into_anonymous_layer() {
6559        // Second-pass gather of two :root blocks must not turn
6560        // `@layer tokens { }` + `@layer base { }` into
6561        // `@layer { tokens {} base {} }`.
6562        let css = r#":root {
6563    @layer tokens {
6564        color-scheme: light dark;
6565    }
6566
6567    @layer base {
6568        scroll-behavior: smooth;
6569    }
6570}
6571
6572:root {
6573    scrollbar-width: thin;
6574}
6575"#;
6576        let plans = plan(
6577            css,
6578            &[
6579                RuleId::GatherRelatedSelectorRules,
6580                RuleId::NestLayerBySelector,
6581            ],
6582        );
6583        let output = apply_selected_plans(css, &plans, true).unwrap();
6584        assert!(
6585            output.contains("@layer tokens") && output.contains("@layer base"),
6586            "named layers must stay named:\n{output}"
6587        );
6588        assert!(
6589            !output.contains("@layer {\n        tokens")
6590                && !output.contains("@layer {\n    tokens")
6591                && !output.contains("@layer {\n        base"),
6592            "must not wrap layer names as type selectors:\n{output}"
6593        );
6594        assert!(
6595            output.contains("color-scheme: light dark")
6596                && output.contains("scroll-behavior: smooth")
6597                && output.contains("scrollbar-width: thin"),
6598            "declarations must survive:\n{output}"
6599        );
6600    }
6601
6602    #[test]
6603    fn nest_layer_skips_when_unlayered_rule_intervenes() {
6604        let css = r#"@layer base {
6605    .container { color: red; }
6606}
6607
6608.some-rule { color: blue; }
6609
6610@layer layout {
6611    .container { color: green; }
6612}
6613"#;
6614        let plans = plan(css, &[RuleId::NestLayerBySelector]);
6615        let output = apply_selected_plans(css, &plans, true).unwrap();
6616        assert!(
6617            output.contains("@layer base")
6618                && output.contains("@layer layout")
6619                && output.contains(".some-rule"),
6620            "intervening unlayered rule blocks the hoist:\n{output}"
6621        );
6622        assert!(
6623            !output.contains(".container {\n    @layer base"),
6624            "must not move layout across .some-rule:\n{output}"
6625        );
6626    }
6627
6628    #[test]
6629    fn gather_factors_compact_descendants_under_option_card() {
6630        let css = r#".option-card.compact .option-label {
6631    padding: 1rem;
6632}
6633
6634.option-card.compact .option-icon {
6635    inline-size: 24px;
6636}
6637
6638.unrelated { color: red }
6639
6640.option-card {
6641    position: relative;
6642    cursor: pointer;
6643}
6644"#;
6645        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6646        let output = apply_selected_plans(css, &plans, true).unwrap();
6647        assert!(
6648            output.contains("&.compact")
6649                && output.contains(".option-label")
6650                && output.contains(".option-icon"),
6651            "compact descendants should nest under .option-card:\n{output}"
6652        );
6653        assert!(
6654            output.contains("&.compact {")
6655                && output.contains(".option-label {")
6656                && output.contains(".option-icon {"),
6657            "shared &.compact prefix should be factored:\n{output}"
6658        );
6659        assert!(output.contains("position: relative"), "{output}");
6660        assert!(output.contains("padding: 1rem"), "{output}");
6661        assert!(output.contains("inline-size: 24px"), "{output}");
6662    }
6663
6664    #[test]
6665    fn gather_does_not_orphan_deletes_when_shorter_nest_wins() {
6666        let css = r#".custom-select button {
6667    display: flex;
6668}
6669.custom-select button:hover {
6670    color: red;
6671}
6672
6673.unrelated { color: blue }
6674
6675.custom-select::picker(select) {
6676    background: white;
6677}
6678.custom-select::picker-icon {
6679    display: none;
6680}
6681"#;
6682        let rules = &[
6683            RuleId::NestPseudoClass,
6684            RuleId::NestDescendant,
6685            RuleId::GatherRelatedSelectorRules,
6686        ];
6687        let plans = plan(css, rules);
6688        let output = apply_selected_plans(css, &plans, true).unwrap();
6689        assert!(
6690            output.contains("background: white") && output.contains("display: none"),
6691            "picker chrome must survive mixed nest+gather:\n{output}"
6692        );
6693        assert!(
6694            output.contains("display: flex") && output.contains("color: red"),
6695            "button rules must survive:\n{output}"
6696        );
6697    }
6698}