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