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) {
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 nested = appended_nesting_selector_raw(base, candidate_sel)?;
1677    // Refuse a nest that would be interpreted as a descendant. `&` anywhere
1678    // (including `:not(&)`) marks the selector as non-relative.
1679    selector_contains_nesting_amp(&nested).then_some(nested)
1680}
1681
1682fn appended_selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
1683    let nested = appended_nesting_selector(parent, child)?;
1684    let before_amp = nested.strip_suffix('&').unwrap_or(nested.as_str());
1685    let kind = if nested.contains(":not(")
1686        || nested.contains(":is(")
1687        || nested.contains(":where(")
1688        || nested.contains(":has(")
1689    {
1690        RelationKind::PseudoClass
1691    } else if before_amp.contains('>') || before_amp.contains('+') || before_amp.contains('~') {
1692        RelationKind::Combinator
1693    } else if nested.ends_with('&')
1694        && before_amp
1695            .chars()
1696            .last()
1697            .is_some_and(|c| !c.is_whitespace())
1698    {
1699        RelationKind::Compound
1700    } else {
1701        RelationKind::Descendant
1702    };
1703    Some((kind, nested))
1704}
1705
1706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1707enum RelatedKind {
1708    Prefix,
1709    Appended,
1710}
1711
1712/// Render a combinator as one complete native CSS nested selector.
1713///
1714/// A combinator may be used directly in a nested selector (`> .child`), but
1715/// it must not become a rule on its own (`> { ... }`). Keeping `&` attached
1716/// here makes every construction path obey that invariant.
1717fn explicit_relative_combinator(combinator: char, selector: &str) -> String {
1718    format!("& {combinator} {}", selector.trim())
1719}
1720
1721fn starts_with_explicit_combinator(selector: &str) -> bool {
1722    let Some(rest) = selector.trim().strip_prefix('&') else {
1723        return false;
1724    };
1725    rest.trim_start()
1726        .chars()
1727        .next()
1728        .is_some_and(|c| matches!(c, '>' | '+' | '~'))
1729}
1730
1731fn prefix_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1732    if candidate_sel == base || !candidate_sel.starts_with(base) {
1733        return None;
1734    }
1735    let rem_raw = &candidate_sel[base.len()..];
1736    let rem = rem_raw.trim_start();
1737    if rem.is_empty() {
1738        return None;
1739    }
1740    // Only attach '&' when the suffix is directly touching the base (no whitespace).
1741    // e.g. `.foo:hover` → `&:hover` but `.foo :not(*)` → `:not(*)` (descendant, no &).
1742    let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1743    if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#')
1744    {
1745        if directly_attached {
1746            return Some(format!("&{rem}"));
1747        } else {
1748            return Some(rem.to_string());
1749        }
1750    }
1751    if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1752        let first_char = rem.chars().next()?;
1753        let rest = rem[1..].trim_start();
1754        return Some(explicit_relative_combinator(first_char, rest));
1755    }
1756    if rem_raw.starts_with(' ') {
1757        return Some(rem.to_string());
1758    }
1759    None
1760}
1761
1762fn classify_related_nested_one(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1763    if candidate_sel == base || contains_top_level_comma(candidate_sel) {
1764        return None;
1765    }
1766    if let Some(rel) = prefix_related_nested_selector(base, candidate_sel) {
1767        return Some((RelatedKind::Prefix, rel));
1768    }
1769    appended_nesting_selector(base, candidate_sel).map(|rel| (RelatedKind::Appended, rel))
1770}
1771
1772fn classify_related_comma_list(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1773    let parts: Vec<&str> = split_top_level_comma(candidate_sel)
1774        .into_iter()
1775        .map(str::trim)
1776        .filter(|s| !s.is_empty())
1777        .collect();
1778    if parts.len() < 2 {
1779        return None;
1780    }
1781    let mut rels = Vec::with_capacity(parts.len());
1782    let mut all_prefix = true;
1783    for part in parts {
1784        if part == base {
1785            rels.push("&".to_string());
1786            continue;
1787        }
1788        let (kind, rel) = classify_related_nested_one(base, part)?;
1789        if kind != RelatedKind::Prefix {
1790            all_prefix = false;
1791        }
1792        rels.push(rel);
1793    }
1794    let kind = if all_prefix {
1795        RelatedKind::Prefix
1796    } else {
1797        RelatedKind::Appended
1798    };
1799    Some((kind, rels.join(", ")))
1800}
1801
1802fn classify_related_nested(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1803    if candidate_sel == base {
1804        return None;
1805    }
1806    if contains_top_level_comma(candidate_sel) {
1807        return classify_related_comma_list(base, candidate_sel);
1808    }
1809    classify_related_nested_one(base, candidate_sel)
1810}
1811
1812fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1813    classify_related_nested(base, candidate_sel).map(|(_, rel)| rel)
1814}
1815
1816fn is_weak_gather_base(base: &str) -> bool {
1817    matches!(base.trim(), "*" | "html" | "body" | ":root" | ":host")
1818}
1819
1820/// CSS Nesting: if a nested selector contains `&` anywhere (including inside
1821/// `:not()`, `:is()`, `:where()`, `:has()`), it is a *non-relative* selector.
1822/// The parent is not implicitly prepended as a descendant.
1823/// `.parent { .child:not(&) {} }` → `.child:not(:is(.parent))`, not
1824/// `.parent .child:not(.parent)`.
1825fn selector_contains_nesting_amp(selector: &str) -> bool {
1826    let bytes = selector.as_bytes();
1827    let mut i = 0;
1828    let mut quote: Option<u8> = None;
1829    let mut escaped = false;
1830    while i < bytes.len() {
1831        let b = bytes[i];
1832        if let Some(q) = quote {
1833            if escaped {
1834                escaped = false;
1835            } else if b == b'\\' {
1836                escaped = true;
1837            } else if b == q {
1838                quote = None;
1839            }
1840            i += 1;
1841            continue;
1842        }
1843        match b {
1844            b'\'' | b'"' => quote = Some(b),
1845            b'&' => return true,
1846            _ => {}
1847        }
1848        i += 1;
1849    }
1850    false
1851}
1852
1853/// Last `/* … */` before `node_start` if only whitespace follows it.
1854fn leading_block_comment(source: &str, node_start: usize) -> Option<(usize, &str)> {
1855    let before = source.get(..node_start)?;
1856    let start = before.rfind("/*")?;
1857    let close_rel = source.get(start + 2..node_start)?.find("*/")?;
1858    let end = start + 2 + close_rel + 2;
1859    if !source[end..node_start]
1860        .bytes()
1861        .all(|b| b.is_ascii_whitespace())
1862    {
1863        return None;
1864    }
1865    Some((start, source[start..end].trim_end()))
1866}
1867
1868fn is_simple_compound_selector(sel: &str) -> bool {
1869    let sel = sel.trim();
1870    if sel.is_empty()
1871        || sel.starts_with('&')
1872        || sel.starts_with('+')
1873        || sel.starts_with('>')
1874        || sel.starts_with('~')
1875        || contains_top_level_comma(sel)
1876    {
1877        return false;
1878    }
1879    let mut paren = 0usize;
1880    let mut brack = 0usize;
1881    for c in sel.chars() {
1882        match c {
1883            '(' => paren += 1,
1884            ')' => paren = paren.saturating_sub(1),
1885            '[' => brack += 1,
1886            ']' => brack = brack.saturating_sub(1),
1887            _ if paren == 0
1888                && brack == 0
1889                && (c.is_whitespace() || matches!(c, '+' | '>' | '~')) =>
1890            {
1891                return false;
1892            }
1893            _ => {}
1894        }
1895    }
1896    true
1897}
1898
1899fn first_compound_stripped(sel: &str) -> Option<&str> {
1900    let sel = sel.trim();
1901    if sel.is_empty() || sel.starts_with('&') {
1902        return None;
1903    }
1904    let mut paren = 0usize;
1905    let mut brack = 0usize;
1906    let mut end = sel.len();
1907    for (i, c) in sel.char_indices() {
1908        match c {
1909            '(' => paren += 1,
1910            ')' => paren = paren.saturating_sub(1),
1911            '[' => brack += 1,
1912            ']' => brack = brack.saturating_sub(1),
1913            _ if paren == 0
1914                && brack == 0
1915                && (c.is_whitespace() || matches!(c, '+' | '>' | '~' | ',')) =>
1916            {
1917                end = i;
1918                break;
1919            }
1920            _ => {}
1921        }
1922    }
1923    let head = sel[..end].trim();
1924    if head.is_empty() || head.starts_with(':') || head.starts_with('[') {
1925        return None;
1926    }
1927    paren = 0;
1928    brack = 0;
1929    for (i, c) in head.char_indices() {
1930        match c {
1931            '(' => paren += 1,
1932            ')' => paren = paren.saturating_sub(1),
1933            '[' if paren == 0 && i > 0 => return Some(&head[..i]),
1934            ':' if paren == 0 && brack == 0 && i > 0 => return Some(&head[..i]),
1935            _ => {}
1936        }
1937    }
1938    Some(head)
1939}
1940
1941fn style_body_weight(source: &str, node: &SourceNode) -> usize {
1942    node.body(source)
1943        .map(|b| b.lines().filter(|l| !l.trim().is_empty()).count())
1944        .unwrap_or(0)
1945}
1946
1947/// Pick a single gather home for `sel`.
1948/// Exact existing rules win by specificity; prefix beats appended on a tie;
1949/// virtual stripped compounds (`.skip-link` from `.skip-link:hover`) are last resort.
1950fn assign_gather_home<'a>(
1951    sel: &str,
1952    exact_homes: &[&'a str],
1953    home_weight: &HashMap<&'a str, usize>,
1954) -> Option<&'a str> {
1955    #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1956    struct Rank {
1957        spec: Specificity,
1958        is_prefix: bool,
1959        weight: usize,
1960        len: usize,
1961    }
1962
1963    let mut best: Option<(Rank, &'a str)> = None;
1964    let consider =
1965        |best: &mut Option<(Rank, &'a str)>, home: &'a str, kind: Option<RelatedKind>| {
1966            let is_prefix = matches!(kind, None | Some(RelatedKind::Prefix));
1967            if matches!(kind, Some(RelatedKind::Appended)) && is_weak_gather_base(home) {
1968                return;
1969            }
1970            let rank = Rank {
1971                spec: calculate_specificity(home),
1972                is_prefix,
1973                weight: home_weight.get(home).copied().unwrap_or(0),
1974                len: home.len(),
1975            };
1976            if best.as_ref().is_none_or(|(cur, _)| rank > *cur) {
1977                *best = Some((rank, home));
1978            }
1979        };
1980
1981    for &home in exact_homes {
1982        if sel == home || home_weight.get(home).copied().unwrap_or(0) == 0 {
1983            continue;
1984        }
1985        if let Some((kind, _)) = classify_related_nested(home, sel) {
1986            consider(&mut best, home, Some(kind));
1987        }
1988    }
1989    if best.is_some() {
1990        return best.map(|(_, home)| home);
1991    }
1992    // No existing rule claimed this selector — fall back to a virtual
1993    // stripped compound so `.skip-link:hover` + `.skip-link:focus` can
1994    // still wrap under a synthesized `.skip-link`.
1995    for &home in exact_homes {
1996        if sel == home || home_weight.get(home).copied().unwrap_or(0) != 0 {
1997            continue;
1998        }
1999        if let Some((kind, _)) = classify_related_nested(home, sel) {
2000            consider(&mut best, home, Some(kind));
2001        }
2002    }
2003    best.map(|(_, home)| home)
2004}
2005
2006const GATHERABLE_CONDITIONALS: &[&str] = &["media", "supports", "container", "starting-style"];
2007/// `@layer` / `@scope` are NOT gather containers. Walking into them and
2008/// dumping members into the first home moves declarations across cascade
2009/// layers (e.g. `:root` in `@layer base` into `@layer tokens`).
2010const GATHER_SCAN_CONTAINERS: &[&str] = &[];
2011
2012fn is_strong_gather_home(sel: &str) -> bool {
2013    let sel = sel.trim();
2014    sel.starts_with('.')
2015        || sel.starts_with('#')
2016        || sel.starts_with('[')
2017        || (sel.starts_with(':') && !sel.starts_with("::"))
2018}
2019
2020fn is_absorbable_descendant(base: &str, sel: &str) -> bool {
2021    let sel = sel.trim();
2022    if sel.is_empty() || sel == base {
2023        return false;
2024    }
2025    if contains_top_level_comma(sel) {
2026        return split_top_level_comma(sel)
2027            .into_iter()
2028            .map(str::trim)
2029            .filter(|s| !s.is_empty())
2030            .all(|part| is_absorbable_descendant(base, part));
2031    }
2032    if extract_related_nested_selector(base, sel).is_some() {
2033        return true;
2034    }
2035    let head = first_compound_stripped(sel).unwrap_or(sel);
2036    if is_weak_gather_base(head) {
2037        return false;
2038    }
2039    if is_strong_gather_home(head) && head != first_compound_stripped(base).unwrap_or(base) {
2040        return false;
2041    }
2042    is_simple_compound_selector(sel) || first_compound_stripped(sel).is_some()
2043}
2044
2045fn can_nest_under_gather_home(base: &str, sel: &str) -> bool {
2046    if selector_contains_nesting_amp(sel) {
2047        return false;
2048    }
2049    sel == base
2050        || extract_related_nested_selector(base, sel).is_some()
2051        || is_absorbable_descendant(base, sel)
2052}
2053
2054enum GatherMember {
2055    Style(SourceNode),
2056    Conditional {
2057        at_node: SourceNode,
2058        inner: SourceNode,
2059        delete_whole_at_block: bool,
2060    },
2061}
2062
2063impl GatherMember {
2064    fn outer_start(&self) -> usize {
2065        match self {
2066            Self::Style(n) => n.start,
2067            Self::Conditional { at_node, .. } => at_node.start,
2068        }
2069    }
2070
2071    fn outer_end(&self) -> usize {
2072        match self {
2073            Self::Style(n) => n.end,
2074            Self::Conditional { at_node, .. } => at_node.end,
2075        }
2076    }
2077}
2078
2079fn extend_with_trailing_newline(source: &str, end: usize) -> usize {
2080    if source[end..].starts_with("\r\n") {
2081        end + 2
2082    } else if source[end..].starts_with('\n') {
2083        end + 1
2084    } else {
2085        end
2086    }
2087}
2088
2089fn push_relative_body_as_nest(rel_sel: &str, body_str: &str, all_nested_rules: &mut Vec<String>) {
2090    let (decls, nested) = parse_rule_body_items(body_str);
2091    if nested.is_empty() {
2092        let mut rel_body = String::new();
2093        for d in &decls {
2094            rel_body.push_str(&format!("{}\n", ensure_semicolon(d)));
2095        }
2096        all_nested_rules.push(format!("{rel_sel} {{\n    {rel_body}}}"));
2097    } else {
2098        let mut rel_body_lines = Vec::new();
2099        for d in &decls {
2100            rel_body_lines.push(format!("    {}", ensure_semicolon(d)));
2101        }
2102        for nr in &nested {
2103            rel_body_lines.push(nr.clone());
2104        }
2105        let rel_body = rel_body_lines.join("\n");
2106        all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
2107    }
2108}
2109
2110fn push_style_member_into_merge(
2111    first_sel: &str,
2112    cand_sel: &str,
2113    body_str: &str,
2114    all_decls: &mut Vec<String>,
2115    all_nested_rules: &mut Vec<String>,
2116) {
2117    if cand_sel == first_sel {
2118        let (decls, nested) = parse_rule_body_items(body_str);
2119        all_decls.extend(decls);
2120        all_nested_rules.extend(nested);
2121    } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
2122        push_relative_body_as_nest(&rel_sel, body_str, all_nested_rules);
2123    } else if is_absorbable_descendant(first_sel, cand_sel) {
2124        push_relative_body_as_nest(cand_sel, body_str, all_nested_rules);
2125    }
2126}
2127
2128fn format_conditional_group_into_lines(
2129    first_sel: &str,
2130    at_header: &str,
2131    inners: &[&SourceNode],
2132    source: &str,
2133    nested_indent: &str,
2134    unit: &str,
2135) -> Vec<String> {
2136    let level2 = format!("{nested_indent}{unit}");
2137    let mut direct_decls = Vec::new();
2138    let mut nested = Vec::new();
2139
2140    for inner in inners {
2141        let Some(body_range) = &inner.body_range else {
2142            continue;
2143        };
2144        let inner_sel = inner.prelude(source).trim();
2145        let inner_body = &source[body_range.clone()];
2146        if inner_sel == first_sel {
2147            let (decls, nrs) = parse_rule_body_items(inner_body);
2148            direct_decls.extend(decls);
2149            nested.extend(nrs);
2150        } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, inner_sel) {
2151            push_relative_body_as_nest(&rel_sel, inner_body, &mut nested);
2152        }
2153    }
2154
2155    let nested = factor_related_nested_rules(merge_same_prelude_nests(nested));
2156    let mut lines = Vec::new();
2157    lines.push(format!("{nested_indent}{at_header} {{"));
2158    for d in &direct_decls {
2159        lines.push(format!("{level2}{}", ensure_semicolon(d)));
2160    }
2161    for nr in &nested {
2162        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2163            let item = NestedRuleItem {
2164                comment: nested_rule_comment_prefix(nr),
2165                sel,
2166                body: nested_rule_inner(nr),
2167            };
2168            for line in render_item_indented(&item, &level2, unit).lines() {
2169                lines.push(line.to_string());
2170            }
2171        } else {
2172            for line in nr.lines() {
2173                let trimmed = line.trim();
2174                if !trimmed.is_empty() {
2175                    lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2176                }
2177            }
2178        }
2179    }
2180    lines.push(format!("{nested_indent}}}"));
2181    lines
2182}
2183
2184fn format_conditional_into_lines(
2185    first_sel: &str,
2186    at_header: &str,
2187    inner_sel: &str,
2188    inner_body: &str,
2189    nested_indent: &str,
2190    unit: &str,
2191) -> Vec<String> {
2192    if inner_sel != first_sel {
2193        return Vec::new();
2194    }
2195    let (decls, nested) = parse_rule_body_items(inner_body);
2196    let level2 = format!("{nested_indent}{unit}");
2197    let mut lines = Vec::new();
2198    lines.push(format!("{nested_indent}{at_header} {{"));
2199    for d in &decls {
2200        lines.push(format!("{level2}{}", ensure_semicolon(d)));
2201    }
2202    for nr in &nested {
2203        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2204            let item = NestedRuleItem {
2205                comment: nested_rule_comment_prefix(nr),
2206                sel,
2207                body: nested_rule_inner(nr),
2208            };
2209            for line in render_item_indented(&item, &level2, unit).lines() {
2210                lines.push(line.to_string());
2211            }
2212        } else {
2213            for line in nr.lines() {
2214                let trimmed = line.trim();
2215                if trimmed.is_empty() {
2216                    lines.push(String::new());
2217                } else {
2218                    lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2219                }
2220            }
2221        }
2222    }
2223    lines.push(format!("{nested_indent}}}"));
2224    lines
2225}
2226
2227fn nested_rule_prelude(nr: &str) -> Option<&str> {
2228    let mut i = 0usize;
2229    while i < nr.len() {
2230        while i < nr.len() && nr.as_bytes()[i].is_ascii_whitespace() {
2231            i += 1;
2232        }
2233        if nr[i..].starts_with("/*") {
2234            i += nr[i..].find("*/")? + 2;
2235            continue;
2236        }
2237        if nr[i..].starts_with("//") {
2238            i += nr[i..].find('\n').map(|n| n + 1).unwrap_or(nr.len() - i);
2239            continue;
2240        }
2241        break;
2242    }
2243    let rest = nr.get(i..)?;
2244    let brace = rest.find('{')?;
2245    let head = rest[..brace].trim();
2246    if head.is_empty() { None } else { Some(head) }
2247}
2248
2249fn nested_rule_inner(nr: &str) -> String {
2250    let lines: Vec<&str> = nr.lines().collect();
2251    let open = lines.iter().position(|line| {
2252        let t = line.trim();
2253        t.ends_with('{') && !t.starts_with("/*") && !t.starts_with("//")
2254    });
2255    let Some(open) = open else {
2256        return String::new();
2257    };
2258    if lines.len() <= open + 2 {
2259        return String::new();
2260    }
2261    lines[open + 1..lines.len() - 1]
2262        .iter()
2263        .map(|l| l.trim_end())
2264        .collect::<Vec<_>>()
2265        .join("\n")
2266}
2267
2268fn nested_rule_comment_prefix(nr: &str) -> String {
2269    let mut out = String::new();
2270    for line in nr.lines() {
2271        let t = line.trim();
2272        if t.is_empty() || t.starts_with("/*") || t.starts_with("//") {
2273            if !out.is_empty() {
2274                out.push('\n');
2275            }
2276            out.push_str(t);
2277        } else {
2278            break;
2279        }
2280    }
2281    out
2282}
2283
2284/// Collapse `&.flash { a }` + `&.flash { @media { b } }` into one nest.
2285fn merge_same_prelude_nests(rules: Vec<String>) -> Vec<String> {
2286    let mut order: Vec<String> = Vec::new();
2287    let mut merged: HashMap<String, String> = HashMap::new();
2288    let mut comments: HashMap<String, String> = HashMap::new();
2289    let mut leftovers = Vec::new();
2290
2291    for nr in rules {
2292        let Some(prelude) = nested_rule_prelude(&nr).map(str::to_string) else {
2293            leftovers.push(nr);
2294            continue;
2295        };
2296        let inner = nested_rule_inner(&nr);
2297        let prefix = nested_rule_comment_prefix(&nr);
2298        if let Some(existing) = merged.get_mut(&prelude) {
2299            if !existing.is_empty() && !inner.is_empty() {
2300                existing.push('\n');
2301            }
2302            existing.push_str(&inner);
2303        } else {
2304            order.push(prelude.clone());
2305            merged.insert(prelude.clone(), inner);
2306            if !prefix.is_empty() {
2307                comments.insert(prelude, prefix);
2308            }
2309        }
2310    }
2311
2312    let mut out: Vec<String> = order
2313        .into_iter()
2314        .map(|prelude| {
2315            let inner = merged.remove(&prelude).unwrap_or_default();
2316            let comment = comments.remove(&prelude).unwrap_or_default();
2317            let head = if comment.is_empty() {
2318                String::new()
2319            } else {
2320                format!("{comment}\n")
2321            };
2322            if inner.is_empty() {
2323                format!("{head}{prelude} {{}}")
2324            } else {
2325                format!("{head}{prelude} {{\n{inner}\n}}")
2326            }
2327        })
2328        .collect();
2329    out.extend(leftovers);
2330    out
2331}
2332
2333fn conditional_as_nested_rule(
2334    first_sel: &str,
2335    at_header: &str,
2336    inner_sel: &str,
2337    inner_body: &str,
2338) -> Option<String> {
2339    if inner_sel == first_sel {
2340        return None;
2341    }
2342    let rel_sel = extract_related_nested_selector(first_sel, inner_sel)?;
2343    let (decls, nested) = parse_rule_body_items(inner_body);
2344    let mut at_inner = String::new();
2345    for d in &decls {
2346        at_inner.push_str("        ");
2347        at_inner.push_str(&ensure_semicolon(d));
2348        at_inner.push('\n');
2349    }
2350    for nr in &nested {
2351        for line in nr.lines() {
2352            let trimmed = line.trim();
2353            if !trimmed.is_empty() {
2354                at_inner.push_str("        ");
2355                at_inner.push_str(&ensure_semicolon(trimmed));
2356                at_inner.push('\n');
2357            }
2358        }
2359    }
2360    Some(format!(
2361        "{rel_sel} {{\n    {at_header} {{\n{at_inner}    }}\n}}"
2362    ))
2363}
2364
2365#[derive(Debug, Clone)]
2366struct NestedRuleItem {
2367    comment: String,
2368    sel: String,
2369    body: String,
2370}
2371
2372fn render_nested_rule_item(item: &NestedRuleItem) -> String {
2373    let head = if item.comment.is_empty() {
2374        String::new()
2375    } else {
2376        format!("{}\n", item.comment)
2377    };
2378    if item.body.trim().is_empty() {
2379        format!("{head}{} {{}}", item.sel)
2380    } else {
2381        format!("{head}{} {{\n{}\n}}", item.sel, item.body)
2382    }
2383}
2384
2385fn append_child_rule(parent: &mut NestedRuleItem, rel_sel: &str, child_body: &str) {
2386    let child = NestedRuleItem {
2387        comment: String::new(),
2388        sel: rel_sel.to_string(),
2389        body: child_body.to_string(),
2390    };
2391    if !parent.body.is_empty() {
2392        parent.body.push('\n');
2393    }
2394    parent.body.push_str(&render_nested_rule_item(&child));
2395}
2396
2397fn split_relative_combinator(sel: &str) -> Option<(String, String)> {
2398    let mut paren = 0usize;
2399    let mut brack = 0usize;
2400    for (i, c) in sel.char_indices() {
2401        match c {
2402            '(' => paren += 1,
2403            ')' => paren = paren.saturating_sub(1),
2404            '[' => brack += 1,
2405            ']' => brack = brack.saturating_sub(1),
2406            _ if paren == 0 && brack == 0 => {
2407                if c.is_whitespace() {
2408                    let right = sel[i..].trim_start();
2409                    let left = sel[..i].trim();
2410                    if left.is_empty() || right.is_empty() {
2411                        return None;
2412                    }
2413                    return Some((left.to_string(), right.to_string()));
2414                }
2415                if matches!(c, '+' | '>' | '~') && i > 0 {
2416                    let right = sel[i + c.len_utf8()..].trim_start();
2417                    let left = sel[..i].trim();
2418                    if left.is_empty() || right.is_empty() {
2419                        return None;
2420                    }
2421                    return Some((left.to_string(), explicit_relative_combinator(c, right)));
2422                }
2423            }
2424            _ => {}
2425        }
2426    }
2427    None
2428}
2429
2430fn is_at_rule_prelude(sel: &str) -> bool {
2431    sel.trim_start().starts_with('@')
2432}
2433
2434fn synthesizable_parent(sel: &str) -> Option<(String, String)> {
2435    let sel = sel.trim();
2436    // A selector containing `&` is already expressed in native nesting
2437    // context. Treating its leading `&` as a synthetic parent would split a
2438    // valid `& > .child` into the invalid `& { > .child { ... } }` shape.
2439    if sel.is_empty()
2440        || sel == "&"
2441        || starts_with_explicit_combinator(sel)
2442        || is_at_rule_prelude(sel)
2443    {
2444        return None;
2445    }
2446    if contains_top_level_comma(sel) {
2447        let parts: Vec<&str> = split_top_level_comma(sel)
2448            .into_iter()
2449            .map(str::trim)
2450            .filter(|s| !s.is_empty())
2451            .collect();
2452        if parts.len() < 2 {
2453            return None;
2454        }
2455        let mut parent: Option<String> = None;
2456        let mut children = Vec::new();
2457        for part in parts {
2458            let (p, c) = synthesizable_parent(part)?;
2459            match &parent {
2460                Some(existing) if existing != &p => return None,
2461                None => parent = Some(p),
2462                _ => {}
2463            }
2464            children.push(c);
2465        }
2466        return Some((parent?, children.join(", ")));
2467    }
2468    split_relative_combinator(sel)
2469}
2470
2471fn nest_items_under_existing(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2472    let n = items.len();
2473    if n < 2 {
2474        return items;
2475    }
2476
2477    let mut parent_of: Vec<Option<usize>> = vec![None; n];
2478    for i in 0..n {
2479        let mut best: Option<(usize, usize)> = None;
2480        for j in 0..n {
2481            if i == j {
2482                continue;
2483            }
2484            // Only prefix relations. Appended `&` under an already-relative
2485            // sibling (`.select-arrow` ← `&:open .select-arrow`) would turn
2486            // `.custom-select:open .select-arrow` into `.select-arrow:open &`.
2487            if matches!(
2488                classify_related_nested(&items[j].sel, &items[i].sel),
2489                Some((RelatedKind::Prefix, _))
2490            ) {
2491                let len = items[j].sel.len();
2492                if best.is_none_or(|(_, l)| len >= l) {
2493                    best = Some((j, len));
2494                }
2495            }
2496        }
2497        if let Some((j, _)) = best {
2498            parent_of[i] = Some(j);
2499        }
2500    }
2501
2502    // Only nest under roots so we do not have to rebuild intermediate parents.
2503    for i in 0..n {
2504        if let Some(j) = parent_of[i]
2505            && parent_of[j].is_some()
2506        {
2507            parent_of[i] = None;
2508        }
2509    }
2510
2511    let mut out = Vec::new();
2512    let mut out_idx = vec![None; n];
2513    for i in 0..n {
2514        if parent_of[i].is_none() {
2515            out_idx[i] = Some(out.len());
2516            out.push(items[i].clone());
2517        }
2518    }
2519    for i in 0..n {
2520        if let Some(j) = parent_of[i]
2521            && let Some(out_j) = out_idx[j]
2522            && let Some((RelatedKind::Prefix, rel)) =
2523                classify_related_nested(&out[out_j].sel, &items[i].sel)
2524        {
2525            append_child_rule(&mut out[out_j], &rel, &items[i].body);
2526        }
2527    }
2528    out
2529}
2530
2531fn wrap_shared_virtual_parents(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2532    #[derive(Clone)]
2533    enum Bucket {
2534        Atomic(NestedRuleItem),
2535        Shared {
2536            parent: String,
2537            originals: Vec<NestedRuleItem>,
2538            children: Vec<NestedRuleItem>,
2539        },
2540    }
2541
2542    let mut buckets: Vec<Bucket> = Vec::new();
2543    let mut shared_at: HashMap<String, usize> = HashMap::new();
2544
2545    for item in items {
2546        match synthesizable_parent(&item.sel) {
2547            Some((parent, child)) => {
2548                if let Some(&idx) = shared_at.get(&parent) {
2549                    if let Bucket::Shared {
2550                        originals,
2551                        children,
2552                        ..
2553                    } = &mut buckets[idx]
2554                    {
2555                        originals.push(item.clone());
2556                        children.push(NestedRuleItem {
2557                            comment: item.comment.clone(),
2558                            sel: child,
2559                            body: item.body,
2560                        });
2561                    }
2562                } else {
2563                    shared_at.insert(parent.clone(), buckets.len());
2564                    buckets.push(Bucket::Shared {
2565                        parent,
2566                        originals: vec![item.clone()],
2567                        children: vec![NestedRuleItem {
2568                            comment: item.comment.clone(),
2569                            sel: child,
2570                            body: item.body,
2571                        }],
2572                    });
2573                }
2574            }
2575            None => buckets.push(Bucket::Atomic(item)),
2576        }
2577    }
2578
2579    let mut out = Vec::new();
2580    for bucket in buckets {
2581        match bucket {
2582            Bucket::Atomic(item) => out.push(item),
2583            Bucket::Shared {
2584                parent,
2585                originals,
2586                children,
2587            } => {
2588                if children.len() >= 2 {
2589                    let body = children
2590                        .iter()
2591                        .map(render_nested_rule_item)
2592                        .collect::<Vec<_>>()
2593                        .join("\n");
2594                    out.push(NestedRuleItem {
2595                        comment: String::new(),
2596                        sel: parent,
2597                        body,
2598                    });
2599                } else {
2600                    out.extend(originals);
2601                }
2602            }
2603        }
2604    }
2605    out
2606}
2607
2608fn factor_related_nested_rules(rules: Vec<String>) -> Vec<String> {
2609    let mut items = Vec::new();
2610    let mut leftovers = Vec::new();
2611    for nr in rules {
2612        if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2613            items.push(NestedRuleItem {
2614                comment: nested_rule_comment_prefix(&nr),
2615                sel,
2616                body: nested_rule_inner(&nr),
2617            });
2618        } else {
2619            leftovers.push(nr);
2620        }
2621    }
2622
2623    for _ in 0..8 {
2624        let before = items.len();
2625        items = nest_items_under_existing(items);
2626        items = wrap_shared_virtual_parents(items);
2627        if items.len() == before {
2628            break;
2629        }
2630    }
2631
2632    let mut out: Vec<String> = items.iter().map(render_nested_rule_item).collect();
2633    out.extend(leftovers);
2634    out
2635}
2636
2637fn line_start(source: &str, pos: usize) -> usize {
2638    source[..pos.min(source.len())]
2639        .rfind('\n')
2640        .map(|i| i + 1)
2641        .unwrap_or(0)
2642}
2643
2644fn relative_indent_unit(source: &str, node: &SourceNode) -> String {
2645    let parent = line_indent(source, node.start);
2646    let Some(body) = node.body_range.clone() else {
2647        return "    ".to_string();
2648    };
2649    match detect_indent_unit(source, body) {
2650        Some(raw) => raw
2651            .strip_prefix(parent.as_str())
2652            .filter(|rest| !rest.is_empty())
2653            .unwrap_or("    ")
2654            .to_string(),
2655        None => "    ".to_string(),
2656    }
2657}
2658
2659fn squeeze_excess_blank_lines(s: &str) -> String {
2660    let ends_nl = s.ends_with('\n');
2661    let mut out = String::new();
2662    let mut blank_run = 0usize;
2663    for line in s.lines() {
2664        if line.trim().is_empty() {
2665            blank_run += 1;
2666            if blank_run > 1 {
2667                continue;
2668            }
2669            out.push('\n');
2670        } else {
2671            blank_run = 0;
2672            out.push_str(line);
2673            out.push('\n');
2674        }
2675    }
2676    if !ends_nl && out.ends_with('\n') {
2677        out.pop();
2678    }
2679    out
2680}
2681
2682fn render_item_indented(item: &NestedRuleItem, indent: &str, unit: &str) -> String {
2683    let mut out = String::new();
2684    if !item.comment.is_empty() {
2685        for line in item.comment.lines() {
2686            out.push_str(indent);
2687            out.push_str(line.trim());
2688            out.push('\n');
2689        }
2690    }
2691    out.push_str(indent);
2692    out.push_str(&item.sel);
2693    out.push_str(" {\n");
2694    let inner = format!("{indent}{unit}");
2695    let (decls, nested) = parse_rule_body_items(&item.body);
2696    for d in decls {
2697        out.push_str(&inner);
2698        out.push_str(&ensure_semicolon(&d));
2699        out.push('\n');
2700    }
2701    for nr in nested {
2702        if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2703            let child = NestedRuleItem {
2704                comment: nested_rule_comment_prefix(&nr),
2705                sel,
2706                body: nested_rule_inner(&nr),
2707            };
2708            out.push_str(&render_item_indented(&child, &inner, unit));
2709            out.push('\n');
2710        }
2711    }
2712    out.push_str(indent);
2713    out.push('}');
2714    out
2715}
2716
2717fn leftover_style_replacements(
2718    source: &str,
2719    nodes: &[SourceNode],
2720    span_start: usize,
2721    span_end: usize,
2722    cluster_starts: &HashSet<usize>,
2723) -> Vec<(usize, usize, String)> {
2724    let leftovers: Vec<&SourceNode> = nodes
2725        .iter()
2726        .filter(|n| {
2727            matches!(n.kind, NodeKind::Style)
2728                && n.start >= span_start
2729                && n.end <= span_end
2730                && !cluster_starts.contains(&n.start)
2731        })
2732        .collect();
2733
2734    let mut reps = Vec::new();
2735    let mut i = 0;
2736    while i < leftovers.len() {
2737        let parent = leftovers[i];
2738        let parent_sel = parent.prelude(source);
2739        if contains_top_level_comma(parent_sel) || parent_sel.contains("::") {
2740            i += 1;
2741            continue;
2742        }
2743        let mut children = Vec::new();
2744        let mut j = i + 1;
2745        let mut prev_end = parent.end;
2746        while j < leftovers.len() {
2747            let next = leftovers[j];
2748            if !is_whitespace_only(source, prev_end..next.start) {
2749                break;
2750            }
2751            if let Some((relation, nested_selector)) =
2752                selector_relation(parent_sel, next.prelude(source))
2753            {
2754                children.push(ClusterChild::Style {
2755                    node: (*next).clone(),
2756                    relation,
2757                    nested_selector,
2758                });
2759                prev_end = next.end;
2760                j += 1;
2761                continue;
2762            }
2763            break;
2764        }
2765        if !children.is_empty() {
2766            let last_end = children.last().expect("non-empty").node().end;
2767            reps.push((
2768                parent.start,
2769                last_end,
2770                render_cluster(source, parent, &children),
2771            ));
2772            i = j;
2773        } else {
2774            i += 1;
2775        }
2776    }
2777    reps
2778}
2779
2780fn apply_range_replacements(
2781    source: &str,
2782    start: usize,
2783    end: usize,
2784    replacements: &[(usize, usize, String)],
2785) -> String {
2786    let mut reps: Vec<(usize, usize, &str)> = replacements
2787        .iter()
2788        .map(|(s, e, t)| (*s, *e, t.as_str()))
2789        .filter(|(s, e, _)| *s >= start && *e <= end && *s <= *e)
2790        .collect();
2791    reps.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
2792    let mut out = String::new();
2793    let mut cur = start;
2794    for (s, e, text) in reps {
2795        if s < cur {
2796            continue;
2797        }
2798        if s > cur {
2799            out.push_str(&source[cur..s]);
2800        }
2801        out.push_str(text);
2802        cur = e;
2803    }
2804    if cur < end {
2805        out.push_str(&source[cur..end]);
2806    }
2807    out
2808}
2809
2810fn is_gather_delete(plan: &PlanEntry) -> bool {
2811    plan.proposed.is_empty() && plan.rules.contains(&RuleId::GatherRelatedSelectorRules)
2812}
2813
2814fn gather_target_key(plan: &PlanEntry) -> Option<String> {
2815    if !plan.rules.contains(&RuleId::GatherRelatedSelectorRules) {
2816        return None;
2817    }
2818    let start = plan.reason.find("for '")? + 5;
2819    let rest = plan.reason.get(start..)?;
2820    let end = rest.find('\'')?;
2821    Some(rest[..end].to_string())
2822}
2823
2824fn ranges_overlap(a: &SourceRange, b: &SourceRange) -> bool {
2825    a.start < b.end && b.start < a.end
2826}
2827
2828fn select_disjoint_plan_indices(plans: &[PlanEntry]) -> Vec<usize> {
2829    let mut primary: Vec<usize> = (0..plans.len())
2830        .filter(|&i| !is_gather_delete(&plans[i]))
2831        .collect();
2832    primary.sort_by(|&i, &j| {
2833        plans[i]
2834            .source_range
2835            .start
2836            .cmp(&plans[j].source_range.start)
2837            .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2838    });
2839
2840    let mut kept = Vec::new();
2841    let mut last_end = 0usize;
2842    for i in primary {
2843        if plans[i].source_range.start >= last_end {
2844            last_end = plans[i].source_range.end;
2845            kept.push(i);
2846        }
2847    }
2848
2849    let mut kept_gather_keys = HashSet::new();
2850    for &i in &kept {
2851        if !plans[i].proposed.is_empty()
2852            && let Some(key) = gather_target_key(&plans[i])
2853        {
2854            kept_gather_keys.insert((plans[i].file.clone(), key));
2855        }
2856    }
2857
2858    let mut deletes: Vec<usize> = (0..plans.len())
2859        .filter(|&i| is_gather_delete(&plans[i]))
2860        .collect();
2861    deletes.sort_by(|&i, &j| {
2862        plans[i]
2863            .source_range
2864            .start
2865            .cmp(&plans[j].source_range.start)
2866    });
2867
2868    for i in deletes {
2869        let Some(key) = gather_target_key(&plans[i]) else {
2870            continue;
2871        };
2872        if !kept_gather_keys.contains(&(plans[i].file.clone(), key)) {
2873            continue;
2874        }
2875        if kept
2876            .iter()
2877            .any(|&k| ranges_overlap(&plans[k].source_range, &plans[i].source_range))
2878        {
2879            continue;
2880        }
2881        kept.push(i);
2882    }
2883
2884    kept.sort_by(|&i, &j| {
2885        plans[i]
2886            .source_range
2887            .start
2888            .cmp(&plans[j].source_range.start)
2889            .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2890    });
2891    kept
2892}
2893
2894fn format_merged_rule(
2895    first_sel: &str,
2896    parent_indent: &str,
2897    unit: &str,
2898    cluster: &[GatherMember],
2899    source: &str,
2900) -> String {
2901    let nested_indent = format!("{parent_indent}{unit}");
2902
2903    let mut all_decls = Vec::new();
2904    let mut all_nested_rules = Vec::new();
2905    let mut absorbed_nests = Vec::new();
2906    let mut conditional_lines = Vec::new();
2907
2908    for member in cluster {
2909        let GatherMember::Style(c) = member else {
2910            continue;
2911        };
2912        if let Some(body_range) = &c.body_range {
2913            let cand_sel = c.prelude(source).trim();
2914            let body_str = &source[body_range.clone()];
2915            let before_len = all_nested_rules.len();
2916            push_style_member_into_merge(
2917                first_sel,
2918                cand_sel,
2919                body_str,
2920                &mut all_decls,
2921                &mut all_nested_rules,
2922            );
2923            if all_nested_rules.len() > before_len
2924                && let Some((_, cmt)) = leading_block_comment(source, c.start)
2925                && let Some(last) = all_nested_rules.last_mut()
2926            {
2927                *last = format!("{cmt}\n{last}");
2928            }
2929        }
2930    }
2931
2932    let mut cond_order: Vec<usize> = Vec::new();
2933    let mut cond_groups: HashMap<usize, (&SourceNode, Vec<&SourceNode>)> = HashMap::new();
2934    for member in cluster {
2935        let GatherMember::Conditional { at_node, inner, .. } = member else {
2936            continue;
2937        };
2938        cond_groups
2939            .entry(at_node.start)
2940            .and_modify(|(_, inners)| inners.push(inner))
2941            .or_insert_with(|| {
2942                cond_order.push(at_node.start);
2943                (at_node, vec![inner])
2944            });
2945    }
2946
2947    for key in cond_order {
2948        let Some((at_node, inners)) = cond_groups.remove(&key) else {
2949            continue;
2950        };
2951        let at_header = at_node.prelude(source).trim();
2952        let mut related = Vec::new();
2953        let mut absorbed = Vec::new();
2954        for inner in inners {
2955            let sel = inner.prelude(source).trim();
2956            if sel == first_sel || extract_related_nested_selector(first_sel, sel).is_some() {
2957                related.push(inner);
2958            } else {
2959                absorbed.push(inner);
2960            }
2961        }
2962
2963        if !related.is_empty() {
2964            let invert_group = related.len() >= 2
2965                || related
2966                    .iter()
2967                    .any(|inner| inner.prelude(source).trim() == first_sel);
2968            if invert_group {
2969                let extra = format_conditional_group_into_lines(
2970                    first_sel,
2971                    at_header,
2972                    &related,
2973                    source,
2974                    &nested_indent,
2975                    unit,
2976                );
2977                if !conditional_lines.is_empty() && !extra.is_empty() {
2978                    conditional_lines.push(String::new());
2979                }
2980                conditional_lines.extend(extra);
2981            } else {
2982                for inner in &related {
2983                    if let Some(body_range) = &inner.body_range {
2984                        let inner_sel = inner.prelude(source).trim();
2985                        let inner_body = &source[body_range.clone()];
2986                        if let Some(nr) =
2987                            conditional_as_nested_rule(first_sel, at_header, inner_sel, inner_body)
2988                        {
2989                            all_nested_rules.push(nr);
2990                        } else {
2991                            let extra = format_conditional_into_lines(
2992                                first_sel,
2993                                at_header,
2994                                inner_sel,
2995                                inner_body,
2996                                &nested_indent,
2997                                unit,
2998                            );
2999                            if !conditional_lines.is_empty() && !extra.is_empty() {
3000                                conditional_lines.push(String::new());
3001                            }
3002                            conditional_lines.extend(extra);
3003                        }
3004                    }
3005                }
3006            }
3007        }
3008
3009        for inner in absorbed {
3010            if let Some(body_range) = &inner.body_range {
3011                push_style_member_into_merge(
3012                    first_sel,
3013                    inner.prelude(source).trim(),
3014                    &source[body_range.clone()],
3015                    &mut all_decls,
3016                    &mut absorbed_nests,
3017                );
3018            }
3019        }
3020    }
3021
3022    let all_nested_rules = factor_related_nested_rules(merge_same_prelude_nests(all_nested_rules));
3023    let absorbed_nests = factor_related_nested_rules(merge_same_prelude_nests(absorbed_nests));
3024
3025    let mut body_lines = Vec::new();
3026
3027    for d in &all_decls {
3028        body_lines.push(format!("{nested_indent}{}", ensure_semicolon(d)));
3029    }
3030
3031    if !all_decls.is_empty() && !all_nested_rules.is_empty() {
3032        body_lines.push(String::new());
3033    }
3034
3035    for (idx, nr) in all_nested_rules.iter().enumerate() {
3036        if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
3037            let item = NestedRuleItem {
3038                comment: nested_rule_comment_prefix(nr),
3039                sel,
3040                body: nested_rule_inner(nr),
3041            };
3042            body_lines.push(render_item_indented(&item, &nested_indent, unit));
3043        } else {
3044            for line in nr.lines() {
3045                let trimmed = line.trim();
3046                if trimmed.is_empty() {
3047                    body_lines.push(String::new());
3048                } else {
3049                    body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
3050                }
3051            }
3052        }
3053
3054        if idx < all_nested_rules.len() - 1 {
3055            body_lines.push(String::new());
3056        }
3057    }
3058
3059    if !conditional_lines.is_empty() {
3060        if !body_lines.is_empty() {
3061            body_lines.push(String::new());
3062        }
3063        body_lines.extend(conditional_lines);
3064    }
3065
3066    if !absorbed_nests.is_empty() {
3067        if !body_lines.is_empty() {
3068            body_lines.push(String::new());
3069        }
3070        for (idx, nr) in absorbed_nests.iter().enumerate() {
3071            if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
3072                let item = NestedRuleItem {
3073                    comment: nested_rule_comment_prefix(nr),
3074                    sel,
3075                    body: nested_rule_inner(nr),
3076                };
3077                body_lines.push(render_item_indented(&item, &nested_indent, unit));
3078            } else {
3079                for line in nr.lines() {
3080                    let trimmed = line.trim();
3081                    if trimmed.is_empty() {
3082                        body_lines.push(String::new());
3083                    } else {
3084                        body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
3085                    }
3086                }
3087            }
3088            if idx < absorbed_nests.len() - 1 {
3089                body_lines.push(String::new());
3090            }
3091        }
3092    }
3093
3094    let body_content = body_lines.join("\n");
3095    format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
3096}
3097
3098fn plan_merge_adjacent_identical_selectors(
3099    path: &Path,
3100    source: &str,
3101    nodes: &[SourceNode],
3102    enabled: &HashSet<RuleId>,
3103    plans: &mut Vec<PlanEntry>,
3104) {
3105    if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
3106        return;
3107    }
3108    let mut i = 0;
3109    while i < nodes.len() {
3110        let first = &nodes[i];
3111        if matches!(&first.kind, NodeKind::Style) {
3112            let first_sel = first.prelude(source).trim();
3113            let mut cluster = vec![first];
3114            let mut cursor = i + 1;
3115            let mut prev_end = first.end;
3116
3117            while cursor < nodes.len() {
3118                let next = &nodes[cursor];
3119                if !is_whitespace_only(source, prev_end..next.start) {
3120                    break;
3121                }
3122                if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
3123                {
3124                    cluster.push(next);
3125                    prev_end = next.end;
3126                    cursor += 1;
3127                    continue;
3128                }
3129                break;
3130            }
3131
3132            if cluster.len() > 1 {
3133                let last = cluster.last().unwrap();
3134                let parent_indent = line_indent(source, first.start);
3135                let first_body_range = first.body_range.as_ref().unwrap();
3136                let unit = detect_indent_unit(source, first_body_range.clone())
3137                    .unwrap_or_else(|| "    ".to_string());
3138
3139                let members: Vec<GatherMember> = cluster
3140                    .iter()
3141                    .map(|n| GatherMember::Style((*n).clone()))
3142                    .collect();
3143                let proposed =
3144                    format_merged_rule(first_sel, &parent_indent, &unit, &members, source);
3145
3146                plans.push(PlanEntry {
3147                    id: String::new(),
3148                    file: path.to_path_buf(),
3149                    rules: vec![RuleId::MergeAdjacentIdenticalSelector],
3150                    safety: Safety::Safe,
3151                    source_range: SourceRange {
3152                        start: first.start,
3153                        end: last.end,
3154                    },
3155                    original: source[first.start..last.end].to_string(),
3156                    proposed,
3157                    proof: Proof::safe_local(),
3158                    warnings: Vec::new(),
3159                    reason: format!(
3160                        "Merge {} adjacent identical selector rules for '{}' into a single block.",
3161                        cluster.len(),
3162                        first_sel
3163                    ),
3164                    selected: true,
3165                });
3166                i = cursor;
3167                continue;
3168            }
3169        }
3170        i += 1;
3171    }
3172}
3173
3174fn note_gather_home<'s>(
3175    source: &'s str,
3176    node: &SourceNode,
3177    exact_homes: &mut Vec<&'s str>,
3178    home_weight: &mut HashMap<&'s str, usize>,
3179) {
3180    let sel = node.prelude(source).trim();
3181    let core = first_compound_stripped(sel).unwrap_or(sel);
3182    if is_simple_compound_selector(sel) && core == sel && !sel.contains("::") {
3183        if !exact_homes.contains(&sel) {
3184            exact_homes.push(sel);
3185        }
3186        *home_weight.entry(sel).or_insert(0) += style_body_weight(source, node);
3187    }
3188    if let Some(stripped) = first_compound_stripped(sel)
3189        && !exact_homes.contains(&stripped)
3190        && is_simple_compound_selector(stripped)
3191        && !stripped.contains("::")
3192    {
3193        exact_homes.push(stripped);
3194        home_weight.entry(stripped).or_insert(0);
3195    }
3196}
3197
3198fn collect_gather_homes<'s>(
3199    source: &'s str,
3200    nodes: &[SourceNode],
3201    exact_homes: &mut Vec<&'s str>,
3202    home_weight: &mut HashMap<&'s str, usize>,
3203) {
3204    for node in nodes {
3205        if matches!(&node.kind, NodeKind::Style) {
3206            note_gather_home(source, node, exact_homes, home_weight);
3207        } else if let NodeKind::AtBlock { name, .. } = &node.kind
3208            && GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3209            && let Some(body_range) = &node.body_range
3210        {
3211            let inner = scan_nodes(source, body_range.clone());
3212            collect_gather_homes(source, &inner, exact_homes, home_weight);
3213        }
3214    }
3215}
3216
3217fn should_group_at_block(
3218    name: &str,
3219    source: &str,
3220    style_inners: &[&SourceNode],
3221    exact_homes: &[&str],
3222    home_weight: &HashMap<&str, usize>,
3223) -> bool {
3224    if style_inners.len() < 3 {
3225        return false;
3226    }
3227    // `@starting-style` has temporal semantics in addition to selector
3228    // semantics. Do not gather a nested selector into a shorter selector
3229    // home, because that can change the subject receiving the transition
3230    // starting state.
3231    if name == "starting-style"
3232        && style_inners.iter().any(|inner| {
3233            let sel = inner.prelude(source).trim();
3234            assign_gather_home(sel, exact_homes, home_weight).is_some_and(|home| home != sel)
3235        })
3236    {
3237        return false;
3238    }
3239    let mut assigned: HashMap<&str, usize> = HashMap::new();
3240    let mut unsafe_extract = false;
3241    for inner in style_inners {
3242        let sel = inner.prelude(source).trim();
3243        match assign_gather_home(sel, exact_homes, home_weight) {
3244            Some(home) => {
3245                *assigned.entry(home).or_insert(0) += 1;
3246                if home != sel
3247                    && extract_related_nested_selector(home, sel).is_none()
3248                    && !is_absorbable_descendant(home, sel)
3249                {
3250                    unsafe_extract = true;
3251                }
3252            }
3253            None => {
3254                if contains_top_level_comma(sel)
3255                    && exact_homes
3256                        .iter()
3257                        .any(|home| extract_related_nested_selector(home, sel).is_some())
3258                {
3259                    // rewriteable comma list — do not force-group
3260                } else {
3261                    *assigned.entry("").or_insert(0) += 1;
3262                    unsafe_extract = true;
3263                }
3264            }
3265        }
3266    }
3267    let strong: Vec<(&str, usize)> = assigned
3268        .iter()
3269        .filter(|(home, _)| !home.is_empty() && is_strong_gather_home(home))
3270        .map(|(h, c)| (*h, *c))
3271        .collect();
3272    let total = style_inners.len();
3273    let dominant = strong.iter().any(|(_, c)| *c * 3 >= total * 2);
3274
3275    if name == "media" || name == "container" {
3276        return unsafe_extract || strong.len() >= 2;
3277    }
3278    // @supports / @starting-style: invert when one component owns the block.
3279    if strong.len() >= 2 && !dominant {
3280        return true;
3281    }
3282    unsafe_extract && strong.is_empty()
3283}
3284
3285fn mark_grouped_at_blocks(
3286    source: &str,
3287    nodes: &[SourceNode],
3288    exact_homes: &[&str],
3289    home_weight: &HashMap<&str, usize>,
3290    grouped: &mut HashSet<usize>,
3291) {
3292    for node in nodes {
3293        if let NodeKind::AtBlock { name, .. } = &node.kind
3294            && let Some(body_range) = &node.body_range
3295        {
3296            let inner_nodes = scan_nodes(source, body_range.clone());
3297            if GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3298                let style_inners: Vec<&SourceNode> = inner_nodes
3299                    .iter()
3300                    .filter(|n| matches!(&n.kind, NodeKind::Style))
3301                    .collect();
3302                if should_group_at_block(name, source, &style_inners, exact_homes, home_weight) {
3303                    grouped.insert(node.start);
3304                }
3305            }
3306            if GATHERABLE_CONDITIONALS.contains(&name.as_str())
3307                || GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3308            {
3309                mark_grouped_at_blocks(source, &inner_nodes, exact_homes, home_weight, grouped);
3310            }
3311        }
3312    }
3313}
3314
3315fn style_belongs_to_home(
3316    source: &str,
3317    node: &SourceNode,
3318    base: &str,
3319    exact_homes: &[&str],
3320    home_weight: &HashMap<&str, usize>,
3321) -> bool {
3322    let sel = node.prelude(source).trim();
3323    sel == base || assign_gather_home(sel, exact_homes, home_weight) == Some(base)
3324}
3325
3326fn collect_gather_cluster(
3327    source: &str,
3328    nodes: &[SourceNode],
3329    base: &str,
3330    exact_homes: &[&str],
3331    home_weight: &HashMap<&str, usize>,
3332    grouped_at_blocks: &HashSet<usize>,
3333    cluster: &mut Vec<GatherMember>,
3334) {
3335    for node in nodes {
3336        if matches!(&node.kind, NodeKind::Style) {
3337            if style_belongs_to_home(source, node, base, exact_homes, home_weight) {
3338                cluster.push(GatherMember::Style(node.clone()));
3339            }
3340        } else if let NodeKind::AtBlock { name, .. } = &node.kind {
3341            let Some(body_range) = &node.body_range else {
3342                continue;
3343            };
3344            let inner_nodes = scan_nodes(source, body_range.clone());
3345            if GATHER_SCAN_CONTAINERS.contains(&name.as_str()) {
3346                collect_gather_cluster(
3347                    source,
3348                    &inner_nodes,
3349                    base,
3350                    exact_homes,
3351                    home_weight,
3352                    grouped_at_blocks,
3353                    cluster,
3354                );
3355                continue;
3356            }
3357            if grouped_at_blocks.contains(&node.start) {
3358                continue;
3359            }
3360            if !GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3361                continue;
3362            }
3363            // A conditional block containing another at-rule is a structured
3364            // subtree. Extracting only its direct style children would make
3365            // the replacement span delete nested conditionals (for example
3366            // `@supports { details { ... } @starting-style { ... } }`).
3367            // Leave mixed conditional contents grouped for a later, dedicated
3368            // rule rather than risking data loss.
3369            if inner_nodes
3370                .iter()
3371                .any(|inner| !matches!(&inner.kind, NodeKind::Style))
3372            {
3373                continue;
3374            }
3375            let style_inners: Vec<&SourceNode> = inner_nodes
3376                .iter()
3377                .filter(|n| matches!(n.kind, NodeKind::Style))
3378                .collect();
3379            let owned = style_inners
3380                .iter()
3381                .filter(|inner| {
3382                    let sel = inner.prelude(source).trim();
3383                    (name != "starting-style"
3384                        && style_belongs_to_home(source, inner, base, exact_homes, home_weight))
3385                        || (name == "starting-style" && sel == base)
3386                })
3387                .count();
3388            let dominate = !style_inners.is_empty() && owned * 3 >= style_inners.len() * 2;
3389            let related: Vec<SourceNode> = inner_nodes
3390                .iter()
3391                .filter(|inner| {
3392                    matches!(&inner.kind, NodeKind::Style) && {
3393                        let sel = inner.prelude(source).trim();
3394                        ((name != "starting-style"
3395                            && style_belongs_to_home(
3396                                source,
3397                                inner,
3398                                base,
3399                                exact_homes,
3400                                home_weight,
3401                            ))
3402                            || (name == "starting-style" && sel == base))
3403                            || (name != "starting-style"
3404                                && dominate
3405                                && is_absorbable_descendant(base, sel))
3406                    }
3407                })
3408                .cloned()
3409                .collect();
3410            if !related.is_empty() {
3411                let delete_whole_at_block = related.len() == inner_nodes.len()
3412                    || (dominate
3413                        && style_inners.iter().all(|inner| {
3414                            let sel = inner.prelude(source).trim();
3415                            ((name != "starting-style"
3416                                && style_belongs_to_home(
3417                                    source,
3418                                    inner,
3419                                    base,
3420                                    exact_homes,
3421                                    home_weight,
3422                                ))
3423                                || (name == "starting-style" && sel == base))
3424                                || (name != "starting-style" && is_absorbable_descendant(base, sel))
3425                        }));
3426                for inner in related {
3427                    cluster.push(GatherMember::Conditional {
3428                        at_node: node.clone(),
3429                        inner,
3430                        delete_whole_at_block,
3431                    });
3432                }
3433            }
3434        }
3435    }
3436}
3437
3438fn enclosing_container_end(
3439    source: &str,
3440    nodes: &[SourceNode],
3441    first: &SourceNode,
3442) -> Option<usize> {
3443    fn find(source: &str, nodes: &[SourceNode], first: &SourceNode) -> Option<usize> {
3444        for node in nodes {
3445            if let NodeKind::AtBlock { name, .. } = &node.kind
3446                && let Some(body_range) = &node.body_range
3447                && (GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3448                    || GATHERABLE_CONDITIONALS.contains(&name.as_str()))
3449                && first.start >= body_range.start
3450                && first.end <= body_range.end
3451            {
3452                let inner = scan_nodes(source, body_range.clone());
3453                if let Some(deeper) = find(source, &inner, first) {
3454                    return Some(deeper);
3455                }
3456                return Some(body_range.end);
3457            }
3458        }
3459        None
3460    }
3461    find(source, nodes, first)
3462}
3463
3464fn named_layer_ident(prelude: &str) -> Option<String> {
3465    let rest = prelude
3466        .trim()
3467        .strip_prefix("@layer")
3468        .unwrap_or(prelude)
3469        .trim();
3470    if rest.is_empty() || contains_top_level_comma(rest) {
3471        return None;
3472    }
3473    let ident: String = rest
3474        .chars()
3475        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3476        .collect();
3477    if ident.is_empty() { None } else { Some(ident) }
3478}
3479
3480#[derive(Clone)]
3481struct LayeredExactHit {
3482    selector: String,
3483    layer_path: Vec<String>,
3484    style: SourceNode,
3485    layer: SourceNode,
3486}
3487
3488fn collect_layered_exact_hits(
3489    source: &str,
3490    nodes: &[SourceNode],
3491    path: &[String],
3492    current_layer: Option<&SourceNode>,
3493    out: &mut Vec<LayeredExactHit>,
3494) {
3495    for node in nodes {
3496        if matches!(node.kind, NodeKind::Style) {
3497            if let Some(layer) = current_layer
3498                && !path.is_empty()
3499            {
3500                let sel = node.prelude(source).trim();
3501                if !sel.is_empty() && !contains_top_level_comma(sel) {
3502                    out.push(LayeredExactHit {
3503                        selector: sel.to_string(),
3504                        layer_path: path.to_vec(),
3505                        style: node.clone(),
3506                        layer: layer.clone(),
3507                    });
3508                }
3509            }
3510            continue;
3511        }
3512        if let NodeKind::AtBlock { name, .. } = &node.kind
3513            && name == "layer"
3514            && let Some(body_range) = &node.body_range
3515            && let Some(ident) = named_layer_ident(node.prelude(source))
3516        {
3517            let mut child = path.to_vec();
3518            child.push(ident);
3519            let inner = scan_nodes(source, body_range.clone());
3520            collect_layered_exact_hits(source, &inner, &child, Some(node), out);
3521        }
3522    }
3523}
3524
3525fn unwrap_style_block_body(block: &str) -> String {
3526    let open = match block.find('{') {
3527        Some(i) => i + 1,
3528        None => return block.to_string(),
3529    };
3530    let close = match block.rfind('}') {
3531        Some(i) => i,
3532        None => return block[open..].to_string(),
3533    };
3534    if close <= open {
3535        return String::new();
3536    }
3537    block[open..close].trim().to_string()
3538}
3539
3540fn reindent_owned(text: &str, indent: &str) -> Vec<String> {
3541    let lines: Vec<&str> = text.lines().collect();
3542    let min_pad = lines
3543        .iter()
3544        .filter_map(|l| {
3545            if l.trim().is_empty() {
3546                None
3547            } else {
3548                Some(l.len() - l.trim_start().len())
3549            }
3550        })
3551        .min()
3552        .unwrap_or(0);
3553    lines
3554        .into_iter()
3555        .map(|l| {
3556            if l.trim().is_empty() {
3557                String::new()
3558            } else {
3559                let rest = if l.len() >= min_pad {
3560                    &l[min_pad..]
3561                } else {
3562                    l.trim_start()
3563                };
3564                format!("{indent}{rest}")
3565            }
3566        })
3567        .collect()
3568}
3569
3570fn layer_body_cluster_for_selector(
3571    source: &str,
3572    layer: &SourceNode,
3573    selector: &str,
3574    exact_homes: &[&str],
3575    home_weight: &HashMap<&str, usize>,
3576) -> Vec<GatherMember> {
3577    let Some(body_range) = &layer.body_range else {
3578        return Vec::new();
3579    };
3580    let inner = scan_nodes(source, body_range.clone());
3581    let mut cluster = Vec::new();
3582    for node in &inner {
3583        if matches!(node.kind, NodeKind::Style) && node.prelude(source).trim() == selector {
3584            cluster.push(GatherMember::Style(node.clone()));
3585        } else if let NodeKind::AtBlock { name, .. } = &node.kind
3586            && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3587            && let Some(at_body) = &node.body_range
3588        {
3589            let at_inners = scan_nodes(source, at_body.clone());
3590            let related: Vec<SourceNode> = at_inners
3591                .iter()
3592                .filter(|n| {
3593                    matches!(n.kind, NodeKind::Style)
3594                        && (n.prelude(source).trim() == selector
3595                            || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3596                })
3597                .cloned()
3598                .collect();
3599            if related.is_empty() {
3600                continue;
3601            }
3602            let delete_whole = related.len() == at_inners.len();
3603            for inner_style in related {
3604                cluster.push(GatherMember::Conditional {
3605                    at_node: node.clone(),
3606                    inner: inner_style,
3607                    delete_whole_at_block: delete_whole,
3608                });
3609            }
3610        }
3611    }
3612    cluster
3613}
3614
3615fn layer_contains_only_selector_cluster(
3616    source: &str,
3617    layer: &SourceNode,
3618    selector: &str,
3619    exact_homes: &[&str],
3620    home_weight: &HashMap<&str, usize>,
3621) -> bool {
3622    let Some(body_range) = &layer.body_range else {
3623        return false;
3624    };
3625    let inner = scan_nodes(source, body_range.clone());
3626    if inner.is_empty() {
3627        return false;
3628    }
3629    inner.iter().all(|node| {
3630        if matches!(node.kind, NodeKind::Style) {
3631            return node.prelude(source).trim() == selector
3632                || style_belongs_to_home(source, node, selector, exact_homes, home_weight);
3633        }
3634        if let NodeKind::AtBlock { name, .. } = &node.kind
3635            && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3636            && let Some(at_body) = &node.body_range
3637        {
3638            let at_inners = scan_nodes(source, at_body.clone());
3639            return !at_inners.is_empty()
3640                && at_inners.iter().all(|n| {
3641                    matches!(n.kind, NodeKind::Style)
3642                        && (n.prelude(source).trim() == selector
3643                            || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3644                });
3645        }
3646        false
3647    })
3648}
3649
3650fn intervening_unlayered(
3651    nodes: &[SourceNode],
3652    first_layer_start: usize,
3653    last_layer_end: usize,
3654) -> bool {
3655    nodes.iter().any(|n| {
3656        n.start > first_layer_start
3657            && n.end < last_layer_end
3658            && match &n.kind {
3659                NodeKind::Style => true,
3660                NodeKind::AtBlock { name, .. } => name != "layer",
3661                NodeKind::AtStatement { name, .. } => name != "layer",
3662            }
3663    })
3664}
3665
3666fn plan_nest_layer_by_selector(
3667    path: &Path,
3668    source: &str,
3669    nodes: &[SourceNode],
3670    enabled: &HashSet<RuleId>,
3671    plans: &mut Vec<PlanEntry>,
3672) {
3673    if !enabled.contains(&RuleId::NestLayerBySelector) {
3674        return;
3675    }
3676
3677    let mut hits = Vec::new();
3678    collect_layered_exact_hits(source, nodes, &[], None, &mut hits);
3679    if hits.is_empty() {
3680        return;
3681    }
3682
3683    let mut exact_homes: Vec<&str> = Vec::new();
3684    let mut home_weight: HashMap<&str, usize> = HashMap::new();
3685    collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3686
3687    let mut by_sel: HashMap<String, Vec<LayeredExactHit>> = HashMap::new();
3688    for hit in hits {
3689        by_sel.entry(hit.selector.clone()).or_default().push(hit);
3690    }
3691
3692    for (selector, mut group) in by_sel {
3693        group.sort_by_key(|h| h.style.start);
3694        let mut seen_paths: Vec<Vec<String>> = Vec::new();
3695        for hit in &group {
3696            if !seen_paths.iter().any(|p| p == &hit.layer_path) {
3697                seen_paths.push(hit.layer_path.clone());
3698            }
3699        }
3700        if seen_paths.len() < 2 {
3701            continue;
3702        }
3703
3704        let first_layer = &group[0].layer;
3705        let last_layer = &group.last().unwrap().layer;
3706        if intervening_unlayered(nodes, first_layer.start, last_layer.end) {
3707            continue;
3708        }
3709
3710        let parent_indent = String::new();
3711        let unit = "    ".to_string();
3712        let mut layer_blocks = Vec::new();
3713        let mut deletes: Vec<(usize, usize, usize)> = Vec::new();
3714        let mut deleted_layers = HashSet::new();
3715
3716        for path in &seen_paths {
3717            let Some(hit) = group.iter().find(|h| &h.layer_path == path) else {
3718                continue;
3719            };
3720            let cluster = layer_body_cluster_for_selector(
3721                source,
3722                &hit.layer,
3723                &selector,
3724                &exact_homes,
3725                &home_weight,
3726            );
3727            if cluster.is_empty() {
3728                continue;
3729            }
3730            let merged = format_merged_rule(&selector, &parent_indent, &unit, &cluster, source);
3731            let body = unwrap_style_block_body(&merged);
3732            let header = format!("@layer {}", path.join("."));
3733            layer_blocks.push((header, body));
3734
3735            if layer_contains_only_selector_cluster(
3736                source,
3737                &hit.layer,
3738                &selector,
3739                &exact_homes,
3740                &home_weight,
3741            ) {
3742                if deleted_layers.insert(hit.layer.start) {
3743                    deletes.push((
3744                        hit.layer.start,
3745                        extend_with_trailing_newline(source, hit.layer.end),
3746                        hit.layer.start,
3747                    ));
3748                }
3749            } else {
3750                for member in &cluster {
3751                    match member {
3752                        GatherMember::Style(n) => {
3753                            let start = leading_block_comment(source, n.start)
3754                                .map(|(s, _)| s)
3755                                .unwrap_or(n.start);
3756                            deletes.push((
3757                                start,
3758                                extend_with_trailing_newline(source, n.end),
3759                                n.start,
3760                            ));
3761                        }
3762                        GatherMember::Conditional {
3763                            at_node,
3764                            inner,
3765                            delete_whole_at_block,
3766                        } => {
3767                            if *delete_whole_at_block {
3768                                deletes.push((
3769                                    at_node.start,
3770                                    extend_with_trailing_newline(source, at_node.end),
3771                                    at_node.start,
3772                                ));
3773                            } else {
3774                                deletes.push((
3775                                    inner.start,
3776                                    extend_with_trailing_newline(source, inner.end),
3777                                    inner.start,
3778                                ));
3779                            }
3780                        }
3781                    }
3782                }
3783            }
3784        }
3785
3786        if layer_blocks.len() < 2 {
3787            continue;
3788        }
3789
3790        let first_layer_only_ours = layer_contains_only_selector_cluster(
3791            source,
3792            first_layer,
3793            &selector,
3794            &exact_homes,
3795            &home_weight,
3796        );
3797        let insert_at = first_layer.start;
3798        let replace_end = if first_layer_only_ours {
3799            extend_with_trailing_newline(source, first_layer.end)
3800        } else {
3801            insert_at
3802        };
3803        deletes.retain(|(start, end, _)| !(*start == insert_at && *end == replace_end));
3804        let nested_indent = unit.clone();
3805        let inner_indent = format!("{unit}{unit}");
3806        let mut hoisted = format!("{selector} {{\n");
3807        for (i, (header, body)) in layer_blocks.iter().enumerate() {
3808            if i > 0 {
3809                hoisted.push('\n');
3810            }
3811            hoisted.push_str(&nested_indent);
3812            hoisted.push_str(header);
3813            hoisted.push_str(" {\n");
3814            if !body.trim().is_empty() {
3815                for line in reindent_owned(body, &inner_indent) {
3816                    hoisted.push_str(&line);
3817                    hoisted.push('\n');
3818                }
3819            }
3820            hoisted.push_str(&nested_indent);
3821            hoisted.push_str("}\n");
3822        }
3823        hoisted.push('}');
3824        if first_layer_only_ours {
3825            hoisted.push('\n');
3826        } else {
3827            hoisted.push('\n');
3828            hoisted.push('\n');
3829        }
3830
3831        plans.push(PlanEntry {
3832            id: String::new(),
3833            file: path.to_path_buf(),
3834            rules: vec![RuleId::NestLayerBySelector],
3835            safety: Safety::Review,
3836            source_range: SourceRange {
3837                start: insert_at,
3838                end: replace_end,
3839            },
3840            original: if first_layer_only_ours {
3841                source[insert_at..replace_end].to_string()
3842            } else {
3843                String::new()
3844            },
3845            proposed: hoisted,
3846            proof: Proof {
3847                selector_set_equivalent: true,
3848                specificity_equivalent: true,
3849                cascade_context_equivalent: true,
3850                source_order_equivalent: false,
3851                layer_equivalent: true,
3852                scope_equivalent: true,
3853                declarations_exact: true,
3854                important_exact: true,
3855            },
3856            warnings: vec![format!(
3857                "Hoisted '{}' out of {} named layers so nested @layer blocks do not create child layers.",
3858                selector,
3859                layer_blocks.len()
3860            )],
3861            reason: format!(
3862                "Nest {} named layers under shared selector '{}'.",
3863                layer_blocks.len(),
3864                selector
3865            ),
3866            selected: true,
3867        });
3868
3869        for (start, end, line_at) in deletes {
3870            plans.push(PlanEntry {
3871                id: String::new(),
3872                file: path.to_path_buf(),
3873                rules: vec![RuleId::NestLayerBySelector],
3874                safety: Safety::Review,
3875                source_range: SourceRange { start, end },
3876                original: source[start..end].to_string(),
3877                proposed: String::new(),
3878                proof: Proof::safe_local(),
3879                warnings: Vec::new(),
3880                reason: format!(
3881                    "Remove layer-local '{}' after nesting layers at line {}.",
3882                    selector,
3883                    line_number(source, line_at)
3884                ),
3885                selected: true,
3886            });
3887        }
3888    }
3889}
3890
3891fn plan_gather_related_selector_rules(
3892    path: &Path,
3893    source: &str,
3894    nodes: &[SourceNode],
3895    enabled: &HashSet<RuleId>,
3896    plans: &mut Vec<PlanEntry>,
3897) {
3898    if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
3899        return;
3900    }
3901
3902    // This is a review-only advisory pass. Its home-assignment search is
3903    // quadratic in the number of distinct selectors, so bound it for large
3904    // flat stylesheets and keep the responsive, local transformations active.
3905    if nodes.len() > 256 {
3906        return;
3907    }
3908
3909    let mut exact_homes: Vec<&str> = Vec::new();
3910    let mut home_weight: HashMap<&str, usize> = HashMap::new();
3911    collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3912
3913    let mut grouped_at_blocks: HashSet<usize> = HashSet::new();
3914    mark_grouped_at_blocks(
3915        source,
3916        nodes,
3917        &exact_homes,
3918        &home_weight,
3919        &mut grouped_at_blocks,
3920    );
3921
3922    for base in exact_homes.clone() {
3923        // Global selector homes are too broad for structural gathering. They
3924        // can span most of a large stylesheet and win overlap resolution over
3925        // precise component plans, while also making it easy to move rules
3926        // across unrelated cascade contexts. Keep them as ordinary existing
3927        // rules; gather only under a concrete selector home.
3928        if is_weak_gather_base(base) {
3929            continue;
3930        }
3931        let mut cluster: Vec<GatherMember> = Vec::new();
3932        collect_gather_cluster(
3933            source,
3934            nodes,
3935            base,
3936            &exact_homes,
3937            &home_weight,
3938            &grouped_at_blocks,
3939            &mut cluster,
3940        );
3941
3942        if cluster.len() > 1 {
3943            let mut is_non_adjacent = false;
3944            for window in cluster.windows(2) {
3945                let prev_end = window[0].outer_end();
3946                let next_start = window[1].outer_start();
3947                if next_start < prev_end {
3948                    continue;
3949                }
3950                if !is_whitespace_only(source, prev_end..next_start) {
3951                    is_non_adjacent = true;
3952                    break;
3953                }
3954            }
3955
3956            let first_style = cluster.iter().find_map(|m| match m {
3957                GatherMember::Style(n) => Some(n),
3958                GatherMember::Conditional { .. } => None,
3959            });
3960
3961            let Some(first) = first_style else {
3962                continue;
3963            };
3964            let first_sel = first.prelude(source).trim();
3965            let has_non_style = cluster
3966                .iter()
3967                .any(|m| matches!(m, GatherMember::Conditional { .. }));
3968            let should_gather = is_non_adjacent || first_sel != base || has_non_style;
3969            if !should_gather {
3970                continue;
3971            }
3972
3973            let cluster_safe = cluster.iter().all(|member| {
3974                let sel = match member {
3975                    GatherMember::Style(n) => n.prelude(source).trim(),
3976                    GatherMember::Conditional { inner, .. } => inner.prelude(source).trim(),
3977                };
3978                can_nest_under_gather_home(base, sel)
3979            });
3980            if !cluster_safe {
3981                continue;
3982            }
3983            let parent_indent = line_indent(source, first.start);
3984            let unit = relative_indent_unit(source, first);
3985
3986            let mut merged = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
3987
3988            let style_members: Vec<&SourceNode> = cluster
3989                .iter()
3990                .filter_map(|m| match m {
3991                    GatherMember::Style(n) => Some(n),
3992                    GatherMember::Conditional { .. } => None,
3993                })
3994                .collect();
3995            let container_end = enclosing_container_end(source, nodes, first);
3996            let (local_styles, remote_styles): (Vec<&SourceNode>, Vec<&SourceNode>) =
3997                style_members.iter().copied().partition(|sec| {
3998                    if let Some(limit) = container_end {
3999                        sec.end <= limit
4000                    } else {
4001                        nodes.iter().any(|n| n.start == sec.start)
4002                    }
4003                });
4004            let last_local = local_styles.last().copied().unwrap_or(first);
4005            let first_comment = leading_block_comment(source, first.start);
4006            let span_start =
4007                line_start(source, first_comment.map(|(s, _)| s).unwrap_or(first.start));
4008            let mut span_end = extend_with_trailing_newline(source, last_local.end);
4009            for member in &cluster {
4010                let GatherMember::Conditional {
4011                    at_node,
4012                    delete_whole_at_block,
4013                    ..
4014                } = member
4015                else {
4016                    continue;
4017                };
4018                if *delete_whole_at_block
4019                    && at_node.start >= span_start
4020                    && (container_end.is_none_or(|limit| at_node.end <= limit))
4021                {
4022                    span_end = span_end.max(extend_with_trailing_newline(source, at_node.end));
4023                }
4024            }
4025            if let Some((_, cmt)) = first_comment {
4026                merged = format!("{parent_indent}{cmt}\n{merged}");
4027            }
4028
4029            let mut replacements = vec![(span_start, first.end, merged)];
4030            let mut cluster_starts = HashSet::new();
4031            for sec in &local_styles {
4032                cluster_starts.insert(sec.start);
4033            }
4034            for sec in local_styles.iter().skip(1) {
4035                let start = leading_block_comment(source, sec.start)
4036                    .map(|(s, _)| s)
4037                    .unwrap_or(sec.start);
4038                let end = extend_with_trailing_newline(source, sec.end);
4039                replacements.push((start, end, String::new()));
4040            }
4041            let mut replaced_at = HashSet::new();
4042            for member in &cluster {
4043                let GatherMember::Conditional {
4044                    at_node,
4045                    delete_whole_at_block,
4046                    ..
4047                } = member
4048                else {
4049                    continue;
4050                };
4051                if *delete_whole_at_block
4052                    && at_node.start >= span_start
4053                    && at_node.end <= span_end
4054                    && replaced_at.insert(at_node.start)
4055                {
4056                    replacements.push((
4057                        at_node.start,
4058                        extend_with_trailing_newline(source, at_node.end),
4059                        String::new(),
4060                    ));
4061                }
4062            }
4063            replacements.extend(leftover_style_replacements(
4064                source,
4065                nodes,
4066                span_start,
4067                span_end,
4068                &cluster_starts,
4069            ));
4070            let proposed = squeeze_excess_blank_lines(&apply_range_replacements(
4071                source,
4072                span_start,
4073                span_end,
4074                &replacements,
4075            ));
4076
4077            plans.push(PlanEntry {
4078                id: String::new(),
4079                file: path.to_path_buf(),
4080                rules: vec![RuleId::GatherRelatedSelectorRules],
4081                safety: Safety::Review,
4082                source_range: SourceRange {
4083                    start: span_start,
4084                    end: span_end,
4085                },
4086                original: source[span_start..span_end].to_string(),
4087                proposed,
4088                proof: Proof {
4089                    selector_set_equivalent: true,
4090                    specificity_equivalent: true,
4091                    cascade_context_equivalent: false,
4092                    source_order_equivalent: false,
4093                    layer_equivalent: remote_styles.is_empty(),
4094                    scope_equivalent: true,
4095                    declarations_exact: true,
4096                    important_exact: true,
4097                },
4098                warnings: vec![format!(
4099                    "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
4100                    cluster.len(),
4101                    base
4102                )],
4103                reason: format!(
4104                    "Gather {} related rules for '{}' into the canonical first selector block.",
4105                    cluster.len(),
4106                    base
4107                ),
4108                selected: true,
4109            });
4110
4111            let mut deleted_at_blocks = HashSet::new();
4112            for sec in &remote_styles {
4113                let start = leading_block_comment(source, sec.start)
4114                    .map(|(s, _)| s)
4115                    .unwrap_or(sec.start);
4116                let end = extend_with_trailing_newline(source, sec.end);
4117                plans.push(PlanEntry {
4118                    id: String::new(),
4119                    file: path.to_path_buf(),
4120                    rules: vec![RuleId::GatherRelatedSelectorRules],
4121                    safety: Safety::Review,
4122                    source_range: SourceRange { start, end },
4123                    original: source[start..end].to_string(),
4124                    proposed: String::new(),
4125                    proof: Proof::safe_local(),
4126                    warnings: Vec::new(),
4127                    reason: format!(
4128                        "Remove non-adjacent gathered rule for '{}' at line {}.",
4129                        base,
4130                        line_number(source, sec.start)
4131                    ),
4132                    selected: true,
4133                });
4134            }
4135            for member in &cluster {
4136                let GatherMember::Conditional {
4137                    at_node,
4138                    inner,
4139                    delete_whole_at_block,
4140                } = member
4141                else {
4142                    continue;
4143                };
4144                let (sec_start, sec_end, line_at) = if *delete_whole_at_block {
4145                    if !deleted_at_blocks.insert(at_node.start) {
4146                        continue;
4147                    }
4148                    (
4149                        at_node.start,
4150                        extend_with_trailing_newline(source, at_node.end),
4151                        at_node.start,
4152                    )
4153                } else {
4154                    (
4155                        inner.start,
4156                        extend_with_trailing_newline(source, inner.end),
4157                        inner.start,
4158                    )
4159                };
4160
4161                plans.push(PlanEntry {
4162                    id: String::new(),
4163                    file: path.to_path_buf(),
4164                    rules: vec![RuleId::GatherRelatedSelectorRules],
4165                    safety: Safety::Review,
4166                    source_range: SourceRange {
4167                        start: sec_start,
4168                        end: sec_end,
4169                    },
4170                    original: source[sec_start..sec_end].to_string(),
4171                    proposed: String::new(),
4172                    proof: Proof::safe_local(),
4173                    warnings: Vec::new(),
4174                    reason: format!(
4175                        "Remove non-adjacent gathered rule for '{}' at line {}.",
4176                        base,
4177                        line_number(source, line_at)
4178                    ),
4179                    selected: true,
4180                });
4181            }
4182        }
4183    }
4184}
4185
4186fn plan_factor_identical_states_with_is(
4187    path: &Path,
4188    source: &str,
4189    nodes: &[SourceNode],
4190    enabled: &HashSet<RuleId>,
4191    plans: &mut Vec<PlanEntry>,
4192) {
4193    if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
4194        return;
4195    }
4196    let mut i = 0;
4197    while i < nodes.len() {
4198        let first = &nodes[i];
4199        if matches!(&first.kind, NodeKind::Style) {
4200            let first_sel = first.prelude(source).trim();
4201            if let Some(colon_pos) = first_sel.find(':')
4202                && !first_sel[colon_pos..].starts_with("::")
4203            {
4204                let base = &first_sel[..colon_pos];
4205                if !base.is_empty() && !base.contains(' ') {
4206                    let first_body = first.body(source).unwrap_or("").trim();
4207                    let mut cluster = vec![first];
4208                    let mut cursor = i + 1;
4209                    let mut prev_end = first.end;
4210
4211                    while cursor < nodes.len() {
4212                        let next = &nodes[cursor];
4213                        if !is_whitespace_only(source, prev_end..next.start) {
4214                            break;
4215                        }
4216                        if matches!(&next.kind, NodeKind::Style) {
4217                            let next_sel = next.prelude(source).trim();
4218                            if next_sel.starts_with(base)
4219                                && next_sel[base.len()..].starts_with(':')
4220                                && !next_sel[base.len()..].starts_with("::")
4221                                && next.body(source).unwrap_or("").trim() == first_body
4222                            {
4223                                cluster.push(next);
4224                                prev_end = next.end;
4225                                cursor += 1;
4226                                continue;
4227                            }
4228                        }
4229                        break;
4230                    }
4231
4232                    if cluster.len() > 1 {
4233                        let last = cluster.last().unwrap();
4234                        let pseudos: Vec<&str> = cluster
4235                            .iter()
4236                            .map(|c| {
4237                                let s = c.prelude(source).trim();
4238                                &s[base.len()..]
4239                            })
4240                            .collect();
4241                        let is_inner = pseudos.join(", ");
4242                        let parent_indent = line_indent(source, first.start);
4243                        let first_body_range = first.body_range.as_ref().unwrap();
4244                        let unit = detect_indent_unit(source, first_body_range.clone())
4245                            .unwrap_or_else(|| "  ".to_string());
4246                        let nested_indent = format!("{parent_indent}{unit}");
4247                        let inner_decl_indent = format!("{nested_indent}{unit}");
4248
4249                        let mut decls = String::new();
4250                        for line in source[first_body_range.clone()].lines() {
4251                            let trimmed = line.trim();
4252                            if !trimmed.is_empty() {
4253                                decls.push_str(&inner_decl_indent);
4254                                decls.push_str(&ensure_semicolon(trimmed));
4255                                decls.push('\n');
4256                            }
4257                        }
4258
4259                        let proposed = format!(
4260                            "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
4261                        );
4262                        plans.push(PlanEntry {
4263                            id: String::new(),
4264                            file: path.to_path_buf(),
4265                            rules: vec![RuleId::FactorIdenticalStatesWithIs],
4266                            safety: Safety::Safe,
4267                            source_range: SourceRange {
4268                                start: first.start,
4269                                end: last.end,
4270                            },
4271                            original: source[first.start..last.end].to_string(),
4272                            proposed,
4273                            proof: Proof::safe_local(),
4274                            warnings: Vec::new(),
4275                            reason: format!(
4276                                "Factor {} identical state rules for '{}' into &:is({}) form.",
4277                                cluster.len(),
4278                                base,
4279                                is_inner
4280                            ),
4281                            selected: true,
4282                        });
4283                        i = cursor;
4284                        continue;
4285                    }
4286                }
4287            }
4288        }
4289        i += 1;
4290    }
4291}
4292
4293fn plan_factor_multi_selector_cluster_with_is(
4294    path: &Path,
4295    source: &str,
4296    nodes: &[SourceNode],
4297    enabled: &HashSet<RuleId>,
4298    plans: &mut Vec<PlanEntry>,
4299) {
4300    if !enabled.contains(&RuleId::ModernizeIs) {
4301        return;
4302    }
4303
4304    let mut i = 0;
4305    while i < nodes.len() {
4306        let first = &nodes[i];
4307        if matches!(&first.kind, NodeKind::Style) {
4308            let first_sel = first.prelude(source).trim();
4309            if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
4310                let mut cluster = vec![(first, first_suffix)];
4311                let mut cursor = i + 1;
4312                let mut prev_end = first.end;
4313
4314                while cursor < nodes.len() {
4315                    let next = &nodes[cursor];
4316                    if !is_whitespace_only(source, prev_end..next.start) {
4317                        break;
4318                    }
4319                    if matches!(&next.kind, NodeKind::Style) {
4320                        let next_sel = next.prelude(source).trim();
4321                        if let Some((next_prefixes, next_suffix)) =
4322                            extract_multi_branch_pattern(next_sel)
4323                            && next_prefixes == base_prefixes
4324                        {
4325                            cluster.push((next, next_suffix));
4326                            prev_end = next.end;
4327                            cursor += 1;
4328                            continue;
4329                        }
4330                    }
4331                    break;
4332                }
4333
4334                if cluster.len() > 1 {
4335                    let (last_node, _) = cluster.last().unwrap();
4336                    let parent_indent = line_indent(source, first.start);
4337                    let first_body_range = first.body_range.as_ref().unwrap();
4338                    let unit = detect_indent_unit(source, first_body_range.clone())
4339                        .unwrap_or_else(|| "  ".to_string());
4340                    let nested_indent = format!("{parent_indent}{unit}");
4341                    let inner_decl_indent = format!("{nested_indent}{unit}");
4342
4343                    let is_header = format!(":is({})", base_prefixes.join(", "));
4344                    let mut out = format!("{parent_indent}{is_header} {{\n");
4345                    let mut has_direct_decls = false;
4346
4347                    // 1. Direct declarations from base rules (suffix == None)
4348                    for &(c_node, ref suffix) in &cluster {
4349                        if suffix.is_none()
4350                            && let Some(c_body_range) = &c_node.body_range
4351                        {
4352                            for line in source[c_body_range.clone()].lines() {
4353                                let trimmed = line.trim();
4354                                if !trimmed.is_empty() {
4355                                    out.push_str(&nested_indent);
4356                                    out.push_str(&ensure_semicolon(trimmed));
4357                                    out.push('\n');
4358                                    has_direct_decls = true;
4359                                }
4360                            }
4361                        }
4362                    }
4363
4364                    // 2. Nested child rules (suffix == Some(sub_sel))
4365                    for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
4366                        if let Some(sub_sel) = suffix {
4367                            if has_direct_decls || c_idx > 0 {
4368                                out.push('\n');
4369                            }
4370                            out.push_str(&nested_indent);
4371                            out.push_str(sub_sel);
4372                            out.push_str(" {\n");
4373                            if let Some(c_body_range) = &c_node.body_range {
4374                                for line in source[c_body_range.clone()].lines() {
4375                                    let trimmed = line.trim();
4376                                    if !trimmed.is_empty() {
4377                                        out.push_str(&inner_decl_indent);
4378                                        out.push_str(&ensure_semicolon(trimmed));
4379                                        out.push('\n');
4380                                    }
4381                                }
4382                            }
4383                            out.push_str(&nested_indent);
4384                            out.push_str("}\n");
4385                        }
4386                    }
4387
4388                    out.push_str(&parent_indent);
4389                    out.push('}');
4390
4391                    let specificities: Vec<Specificity> = base_prefixes
4392                        .iter()
4393                        .map(|p| calculate_specificity(p))
4394                        .collect();
4395                    let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
4396
4397                    plans.push(PlanEntry {
4398                        id: String::new(),
4399                        file: path.to_path_buf(),
4400                        rules: vec![RuleId::ModernizeIs],
4401                        safety: if uniform { Safety::Safe } else { Safety::Review },
4402                        source_range: SourceRange {
4403                            start: first.start,
4404                            end: last_node.end,
4405                        },
4406                        original: source[first.start..last_node.end].to_string(),
4407                        proposed: out,
4408                        proof: Proof {
4409                            specificity_equivalent: uniform,
4410                            ..Proof::safe_local()
4411                        },
4412                        warnings: if uniform {
4413                            Vec::new()
4414                        } else {
4415                            vec!["Mixed selector specificity: :is() takes the specificity of its most specific argument.".into()]
4416                        },
4417                        reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
4418                        selected: true,
4419                    });
4420                    i = cursor;
4421                    continue;
4422                }
4423            }
4424        }
4425        i += 1;
4426    }
4427}
4428
4429fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
4430    let branches: Vec<&str> = split_top_level_comma(selector)
4431        .into_iter()
4432        .map(|s| s.trim())
4433        .collect();
4434    if branches.len() < 2 {
4435        return None;
4436    }
4437    if branches.iter().any(|b| b.contains("::")) {
4438        return None;
4439    }
4440
4441    let first = branches[0];
4442    if let Some(space_pos) = first.rfind(' ') {
4443        let suffix = &first[space_pos..];
4444        if branches.iter().all(|b| b.ends_with(suffix)) {
4445            let prefixes: Vec<String> = branches
4446                .iter()
4447                .map(|b| b[..b.len() - suffix.len()].trim().to_string())
4448                .collect();
4449            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4450                return Some((prefixes, Some(suffix.trim().to_string())));
4451            }
4452        }
4453    }
4454
4455    if branches
4456        .iter()
4457        .all(|b| is_valid_selector_token(b) && !b.contains(' '))
4458    {
4459        let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
4460        return Some((prefixes, None));
4461    }
4462
4463    None
4464}
4465
4466fn plan_merge_identical_rule_bodies(
4467    path: &Path,
4468    source: &str,
4469    nodes: &[SourceNode],
4470    enabled: &HashSet<RuleId>,
4471    plans: &mut Vec<PlanEntry>,
4472) {
4473    if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
4474        return;
4475    }
4476    let mut i = 0;
4477    while i < nodes.len() {
4478        let first = &nodes[i];
4479        if matches!(&first.kind, NodeKind::Style) {
4480            let first_body = first.body(source).unwrap_or("").trim();
4481            if !first_body.is_empty() {
4482                let mut cluster = vec![first];
4483                let mut cursor = i + 1;
4484                let mut prev_end = first.end;
4485
4486                while cursor < nodes.len() {
4487                    let next = &nodes[cursor];
4488                    if !is_whitespace_only(source, prev_end..next.start) {
4489                        break;
4490                    }
4491                    if matches!(&next.kind, NodeKind::Style)
4492                        && next.body(source).unwrap_or("").trim() == first_body
4493                    {
4494                        cluster.push(next);
4495                        prev_end = next.end;
4496                        cursor += 1;
4497                        continue;
4498                    }
4499                    break;
4500                }
4501
4502                if cluster.len() > 1 {
4503                    let last = cluster.last().unwrap();
4504                    let selectors: Vec<&str> =
4505                        cluster.iter().map(|c| c.prelude(source).trim()).collect();
4506                    let specificities: Vec<Specificity> =
4507                        selectors.iter().map(|s| calculate_specificity(s)).collect();
4508                    let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4509                    let parent_indent = line_indent(source, first.start);
4510                    let first_body_range = first.body_range.as_ref().unwrap();
4511                    let unit = detect_indent_unit(source, first_body_range.clone())
4512                        .unwrap_or_else(|| "  ".to_string());
4513                    let nested_indent = format!("{parent_indent}{unit}");
4514
4515                    let mut decls = String::new();
4516                    for line in source[first_body_range.clone()].lines() {
4517                        let trimmed = line.trim();
4518                        if !trimmed.is_empty() {
4519                            decls.push_str(&nested_indent);
4520                            decls.push_str(&ensure_semicolon(trimmed));
4521                            decls.push('\n');
4522                        }
4523                    }
4524
4525                    let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
4526                    let proposed =
4527                        format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
4528                    plans.push(PlanEntry {
4529                        id: String::new(),
4530                        file: path.to_path_buf(),
4531                        rules: vec![RuleId::MergeIdenticalRuleBodies],
4532                        safety: if uniform_specificity {
4533                            Safety::Safe
4534                        } else {
4535                            Safety::Review
4536                        },
4537                        source_range: SourceRange {
4538                            start: first.start,
4539                            end: last.end,
4540                        },
4541                        original: source[first.start..last.end].to_string(),
4542                        proposed,
4543                        proof: Proof {
4544                            specificity_equivalent: uniform_specificity,
4545                            ..Proof::safe_local()
4546                        },
4547                        warnings: if uniform_specificity {
4548                            Vec::new()
4549                        } else {
4550                            vec!["Mixed selector specificity: combining these selectors changes cascade weight.".into()]
4551                        },
4552                        reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
4553                        selected: true,
4554                    });
4555                    i = cursor;
4556                    continue;
4557                }
4558            }
4559        }
4560        i += 1;
4561    }
4562}
4563
4564pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
4565    let bytes = selector.as_bytes();
4566    let mut parts = Vec::new();
4567    let mut last = 0;
4568    let mut parens = 0usize;
4569    let mut brackets = 0usize;
4570    let mut quote: Option<u8> = None;
4571    let mut escaped = false;
4572    let mut i = 0usize;
4573
4574    while i < bytes.len() {
4575        let b = bytes[i];
4576        if let Some(q) = quote {
4577            if escaped {
4578                escaped = false;
4579            } else if b == b'\\' {
4580                escaped = true;
4581            } else if b == q {
4582                quote = None;
4583            }
4584            i += 1;
4585            continue;
4586        }
4587        match b {
4588            b'\'' | b'"' => quote = Some(b),
4589            b'(' => parens += 1,
4590            b')' => parens = parens.saturating_sub(1),
4591            b'[' => brackets += 1,
4592            b']' => brackets = brackets.saturating_sub(1),
4593            b',' if parens == 0 && brackets == 0 => {
4594                parts.push(&selector[last..i]);
4595                last = i + 1;
4596            }
4597            _ => {}
4598        }
4599        i += 1;
4600    }
4601    if last < selector.len() {
4602        parts.push(&selector[last..]);
4603    }
4604    parts
4605}
4606
4607pub fn factor_selector_list(
4608    selector: &str,
4609    body: &str,
4610    indent: &str,
4611    unit: &str,
4612) -> Option<String> {
4613    let branches: Vec<&str> = split_top_level_comma(selector)
4614        .into_iter()
4615        .map(|s| s.trim())
4616        .collect();
4617    if branches.len() < 2 {
4618        return None;
4619    }
4620    let base = branches[0];
4621    if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
4622        return None;
4623    }
4624
4625    let mut inner_selectors = Vec::new();
4626    for &branch in &branches {
4627        if branch == base {
4628            inner_selectors.push("&".to_string());
4629        } else {
4630            let rel = branch.strip_prefix(base)?;
4631            if rel.starts_with("::")
4632                || rel.starts_with(':')
4633                || rel.starts_with('[')
4634                || rel.starts_with('.')
4635                || rel.starts_with('#')
4636            {
4637                inner_selectors.push(format!("&{rel}"));
4638            } else {
4639                let trimmed = rel.strip_prefix(' ')?;
4640                inner_selectors.push(trimmed.trim_start().to_string());
4641            }
4642        }
4643    }
4644
4645    let nested_indent = format!("{indent}{unit}");
4646    let inner_decl_indent = format!("{nested_indent}{unit}");
4647    let mut out = String::new();
4648    out.push_str(base);
4649    out.push_str(" {\n");
4650    out.push_str(&nested_indent);
4651    out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
4652    out.push_str(" {\n");
4653    for line in preserve_body_lines(body) {
4654        out.push_str(&inner_decl_indent);
4655        out.push_str(&ensure_semicolon(&line));
4656        out.push('\n');
4657    }
4658    out.push_str(&nested_indent);
4659    out.push_str("}\n");
4660    out.push_str(indent);
4661    out.push('}');
4662    Some(out)
4663}
4664
4665/// Returns the first functional pseudo-class call that occurs at selector
4666/// level, keeping the text before, inside, and after the call separate.
4667/// Parentheses and quoted strings inside the argument list are respected.
4668fn split_outer_function_call(selector: &str) -> Option<(&str, &'static str, &str, &str)> {
4669    let functions = [
4670        (":is(", "is"),
4671        (":where(", "where"),
4672        (":has(", "has"),
4673        (":not(", "not"),
4674    ];
4675    let bytes = selector.as_bytes();
4676    let mut depth = 0usize;
4677    let mut quote = None;
4678    let mut escaped = false;
4679    let mut i = 0usize;
4680
4681    while i < bytes.len() {
4682        let b = bytes[i];
4683        if let Some(q) = quote {
4684            if escaped {
4685                escaped = false;
4686            } else if b == b'\\' {
4687                escaped = true;
4688            } else if b == q {
4689                quote = None;
4690            }
4691            i += 1;
4692            continue;
4693        }
4694        if b == b'\'' || b == b'"' {
4695            quote = Some(b);
4696            i += 1;
4697            continue;
4698        }
4699        if b == b'(' {
4700            depth += 1;
4701            i += 1;
4702            continue;
4703        }
4704        if b == b')' {
4705            depth = depth.saturating_sub(1);
4706            i += 1;
4707            continue;
4708        }
4709        if depth == 0 {
4710            for (needle, name) in functions {
4711                if selector[i..].starts_with(needle) {
4712                    let open = i + needle.len() - 1;
4713                    let close = find_matching_paren(selector, open)?;
4714                    return Some((
4715                        &selector[..i],
4716                        name,
4717                        &selector[open + 1..close],
4718                        &selector[close + 1..],
4719                    ));
4720                }
4721            }
4722        }
4723        i += 1;
4724    }
4725    None
4726}
4727
4728fn factor_outer_function_arguments(
4729    branches: &[&str],
4730    uniform_specificity: bool,
4731) -> Option<(String, bool)> {
4732    let (first_prefix, first_name, first_args, first_suffix) =
4733        split_outer_function_call(branches[0])?;
4734    // Separate :not() rules are an OR of negations; putting their arguments
4735    // into one :not() changes that to an intersection and is not equivalent.
4736    if first_name == "not" || first_args.trim().is_empty() {
4737        return None;
4738    }
4739
4740    let mut arguments = vec![first_args.trim()];
4741    for branch in &branches[1..] {
4742        let (prefix, name, args, suffix) = split_outer_function_call(branch)?;
4743        if prefix != first_prefix
4744            || name != first_name
4745            || suffix != first_suffix
4746            || args.trim().is_empty()
4747        {
4748            return None;
4749        }
4750        arguments.push(args.trim());
4751    }
4752
4753    let combined = format!(
4754        "{first_prefix}:{first_name}({}){first_suffix}",
4755        arguments.join(", ")
4756    );
4757    // :where() contributes zero specificity regardless of its arguments.
4758    let safe = first_name == "where" || uniform_specificity;
4759    Some((combined, safe))
4760}
4761
4762fn contains_pseudo_element_syntax(selector: &str) -> bool {
4763    if selector.contains("::") {
4764        return true;
4765    }
4766
4767    // CSS2 permits the legacy single-colon spelling only for these
4768    // pseudo-elements. They are still pseudo-elements, so putting them in
4769    // :is(), :where(), or :not() is invalid even though they use one colon.
4770    const LEGACY_PSEUDO_ELEMENTS: &[&str] = &[":before", ":after", ":first-line", ":first-letter"];
4771    LEGACY_PSEUDO_ELEMENTS.iter().any(|pseudo| {
4772        let mut offset = 0;
4773        while let Some(relative) = selector[offset..].find(pseudo) {
4774            let start = offset + relative;
4775            let end = start + pseudo.len();
4776            let boundary = selector[end..].chars().next();
4777            if boundary.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_') {
4778                return true;
4779            }
4780            offset = end;
4781        }
4782        false
4783    })
4784}
4785
4786pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
4787    let branches: Vec<&str> = split_top_level_comma(selector)
4788        .into_iter()
4789        .map(|s| s.trim())
4790        .filter(|s| !s.is_empty())
4791        .collect();
4792    if branches.len() < 2 {
4793        return None;
4794    }
4795
4796    if branches.iter().any(|b| contains_pseudo_element_syntax(b)) {
4797        return None;
4798    }
4799
4800    let specificities: Vec<Specificity> =
4801        branches.iter().map(|b| calculate_specificity(b)).collect();
4802    let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4803
4804    if let Some(result) = factor_outer_function_arguments(&branches, uniform_specificity) {
4805        return Some(result);
4806    }
4807
4808    // The remaining factoring patterns operate on top-level selector
4809    // boundaries. Do not let them inspect commas or combinators inside a
4810    // functional pseudo-class, where they could produce malformed output.
4811    if branches.iter().any(|branch| {
4812        [":is(", ":where(", ":has(", ":not("]
4813            .iter()
4814            .any(|function| branch.contains(function))
4815    }) {
4816        return None;
4817    }
4818
4819    let first = branches[0];
4820
4821    // Suffix alternatives (e.g. .alpha .title, #hero .title -> :is(.alpha, #hero) .title)
4822    if let Some(space_pos) = first.rfind(' ') {
4823        let suffix = &first[space_pos..];
4824        if branches.iter().all(|b| b.ends_with(suffix)) {
4825            let prefixes: Vec<&str> = branches
4826                .iter()
4827                .map(|b| b[..b.len() - suffix.len()].trim())
4828                .collect();
4829            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4830                let is_inner = prefixes.join(", ");
4831                return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
4832            }
4833        }
4834    }
4835
4836    // Descendant alternatives (e.g. .card .title, .card .subtitle -> .card :is(.title, .subtitle))
4837    if let Some(space_pos) = first.rfind(' ') {
4838        let prefix = &first[..=space_pos];
4839        if branches.iter().all(|b| b.starts_with(prefix)) {
4840            let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
4841            if suffixes.iter().all(|s| is_valid_selector_token(s)) {
4842                let is_inner = suffixes.join(", ");
4843                return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
4844            }
4845        }
4846    }
4847
4848    // Pseudo-class alternatives (e.g. .button:hover, .button:focus -> .button:is(:hover, :focus))
4849    if let Some(colon_pos) = first.find(':') {
4850        let base = &first[..colon_pos];
4851        if !base.is_empty()
4852            && !base.contains(' ')
4853            && branches.iter().all(|b| {
4854                b.starts_with(base)
4855                    && b[base.len()..].starts_with(':')
4856                    && !b[base.len()..].starts_with("::")
4857            })
4858        {
4859            let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4860            if pseudos
4861                .iter()
4862                .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
4863            {
4864                let is_inner = pseudos.join(", ");
4865                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4866            }
4867        }
4868    }
4869
4870    // Attribute alternatives
4871    if let Some(bracket_pos) = first.find('[') {
4872        let base = &first[..bracket_pos];
4873        if !base.is_empty()
4874            && !base.contains(' ')
4875            && branches
4876                .iter()
4877                .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
4878        {
4879            let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4880            if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
4881                let is_inner = attrs.join(", ");
4882                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4883            }
4884        }
4885    }
4886
4887    None
4888}
4889
4890fn is_valid_selector_token(s: &str) -> bool {
4891    if s.is_empty() {
4892        return false;
4893    }
4894    let first = s.chars().next().unwrap();
4895    first == '.'
4896        || first == '#'
4897        || first == '['
4898        || first == ':'
4899        || first.is_ascii_alphabetic()
4900        || first == '*'
4901        || first == '>'
4902        || first == '+'
4903        || first == '~'
4904}
4905
4906pub fn factor_with_where(selector: &str) -> Option<String> {
4907    let branches: Vec<&str> = split_top_level_comma(selector)
4908        .into_iter()
4909        .map(|s| s.trim())
4910        .collect();
4911    if branches.len() < 2 {
4912        return None;
4913    }
4914    if branches.iter().any(|b| b.contains("::")) {
4915        return None;
4916    }
4917    Some(format!(":where({})", branches.join(", ")))
4918}
4919
4920pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
4921    let mut result = prelude.to_string();
4922    let mut changed = false;
4923
4924    // Range: (min-width: 400px) and (max-width: 800px) -> (400px <= width <= 800px)
4925    if let (Some((min_raw, min_val)), Some((max_raw, max_val))) = (
4926        extract_media_feature_and_raw(&result, "min-width"),
4927        extract_media_feature_and_raw(&result, "max-width"),
4928    ) {
4929        let pattern = format!("{min_raw} and {max_raw}");
4930        let replacement = format!("({min_val} <= width <= {max_val})");
4931        if result.contains(&pattern) {
4932            result = result.replace(&pattern, &replacement);
4933            changed = true;
4934        }
4935    }
4936
4937    // Single: (min-width: 800px) -> (width >= 800px)
4938    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-width") {
4939        let replacement = format!("(width >= {val})");
4940        result = result.replacen(&raw, &replacement, 1);
4941        changed = true;
4942    }
4943
4944    // Single: (max-width: 800px) -> (width <= 800px)
4945    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-width") {
4946        let replacement = format!("(width <= {val})");
4947        result = result.replacen(&raw, &replacement, 1);
4948        changed = true;
4949    }
4950
4951    // Single: (min-height: 400px) -> (height >= 400px)
4952    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-height") {
4953        let replacement = format!("(height >= {val})");
4954        result = result.replacen(&raw, &replacement, 1);
4955        changed = true;
4956    }
4957
4958    // Single: (max-height: 400px) -> (height <= 400px)
4959    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-height") {
4960        let replacement = format!("(height <= {val})");
4961        result = result.replacen(&raw, &replacement, 1);
4962        changed = true;
4963    }
4964
4965    if changed { Some(result) } else { None }
4966}
4967
4968fn extract_media_feature_and_raw(source: &str, feature: &str) -> Option<(String, String)> {
4969    let feat_idx = source.find(feature)?;
4970    let open_paren = source[..feat_idx].rfind('(')?;
4971    if source[open_paren..feat_idx].contains(')') {
4972        return None;
4973    }
4974    let colon_rel = source[feat_idx + feature.len()..].find(':')?;
4975    let colon_idx = feat_idx + feature.len() + colon_rel;
4976    let close_paren_rel = source[colon_idx..].find(')')?;
4977    let close_paren = colon_idx + close_paren_rel;
4978
4979    let raw = source[open_paren..=close_paren].to_string();
4980    let val = source[colon_idx + 1..close_paren].trim().to_string();
4981    Some((raw, val))
4982}
4983
4984fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
4985    let parent = parent.trim();
4986    let child = child.trim();
4987    if parent.is_empty()
4988        || child.is_empty()
4989        || contains_top_level_comma(parent)
4990        || contains_top_level_comma(child)
4991        || parent.contains("::")
4992        || child == parent
4993        || selector_contains_nesting_amp(child)
4994    {
4995        return None;
4996    }
4997
4998    if !child.starts_with(parent) {
4999        if is_weak_gather_base(parent) {
5000            return None;
5001        }
5002        return appended_selector_relation(parent, child);
5003    }
5004
5005    let remainder = &child[parent.len()..];
5006    let trimmed = remainder.trim_start();
5007    if trimmed.is_empty() {
5008        return None;
5009    }
5010
5011    let (relation, nested_selector) = if remainder.starts_with("::") {
5012        (RelationKind::PseudoElement, format!("&{remainder}"))
5013    } else if remainder.starts_with(':') {
5014        (RelationKind::PseudoClass, format!("&{remainder}"))
5015    } else if remainder.starts_with('[') {
5016        (RelationKind::Attribute, format!("&{remainder}"))
5017    } else if remainder.starts_with('.') || remainder.starts_with('#') {
5018        (RelationKind::Compound, format!("&{remainder}"))
5019    } else if let Some(first_char) = trimmed
5020        .chars()
5021        .next()
5022        .filter(|c| *c == '>' || *c == '+' || *c == '~')
5023    {
5024        let after_comb = trimmed[first_char.len_utf8()..].trim();
5025        if after_comb == parent {
5026            (
5027                RelationKind::Combinator,
5028                explicit_relative_combinator(first_char, parent),
5029            )
5030        } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
5031            (
5032                RelationKind::Combinator,
5033                explicit_relative_combinator(first_char, &format!("{parent}{after_parent}")),
5034            )
5035        } else {
5036            (
5037                RelationKind::Combinator,
5038                explicit_relative_combinator(first_char, after_comb),
5039            )
5040        }
5041    } else if remainder
5042        .as_bytes()
5043        .first()
5044        .is_some_and(|b| b.is_ascii_whitespace())
5045    {
5046        let descendant = remainder.trim();
5047        (RelationKind::Descendant, descendant.to_string())
5048    } else {
5049        return None;
5050    };
5051
5052    Some((relation, nested_selector))
5053}
5054
5055fn conditional_child(
5056    source: &str,
5057    parent_selector: &str,
5058    node: &SourceNode,
5059    enabled: &HashSet<RuleId>,
5060) -> Option<ClusterChild> {
5061    let (name, rule) = match &node.kind {
5062        NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
5063        NodeKind::AtBlock { name, .. } if name == "supports" => {
5064            (name.as_str(), RuleId::NestSupports)
5065        }
5066        NodeKind::AtBlock { name, .. } if name == "container" => {
5067            (name.as_str(), RuleId::NestContainer)
5068        }
5069        NodeKind::AtBlock { name, .. } if name == "starting-style" => {
5070            (name.as_str(), RuleId::NestStartingStyle)
5071        }
5072        _ => return None,
5073    };
5074    if !enabled.contains(&rule) {
5075        return None;
5076    }
5077
5078    let body_range = node.body_range.clone()?;
5079    let inner_nodes = scan_nodes(source, body_range.clone());
5080    if inner_nodes.is_empty() {
5081        return None;
5082    }
5083
5084    let mut inners = Vec::new();
5085    for inner in &inner_nodes {
5086        if !matches!(&inner.kind, NodeKind::Style) {
5087            return None;
5088        }
5089        let inner_prelude = inner.prelude(source);
5090        let inner_body = inner.body_range.clone()?;
5091        if inner_prelude == parent_selector.trim() {
5092            inners.push(ConditionalInner::Direct {
5093                body_range: inner_body,
5094            });
5095        } else if name != "starting-style"
5096            && let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude)
5097        {
5098            inners.push(ConditionalInner::Nested {
5099                nested_selector: nested_sel,
5100                body_range: inner_body,
5101            });
5102        } else {
5103            return None;
5104        }
5105    }
5106
5107    debug_assert!(
5108        name == "media" || name == "supports" || name == "container" || name == "starting-style"
5109    );
5110    Some(ClusterChild::Conditional {
5111        node: node.clone(),
5112        rule,
5113        inners,
5114    })
5115}
5116
5117pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
5118    if !selector.contains(":not(") {
5119        return None;
5120    }
5121    let mut result = String::new();
5122    let mut i = 0;
5123    let bytes = selector.as_bytes();
5124    let mut changed = false;
5125    let mut uniform_specificity = true;
5126
5127    while i < bytes.len() {
5128        if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
5129            let mut args = Vec::new();
5130            let mut current_end = i;
5131
5132            while current_end + 5 <= bytes.len()
5133                && &selector[current_end..current_end + 5] == ":not("
5134            {
5135                let open = current_end + 4;
5136                if let Some(close) = find_matching_paren(selector, open) {
5137                    let arg = selector[open + 1..close].trim();
5138                    args.push(arg);
5139                    current_end = close + 1;
5140                } else {
5141                    break;
5142                }
5143            }
5144
5145            if args.len() > 1 {
5146                changed = true;
5147                let specs: Vec<Specificity> =
5148                    args.iter().map(|a| calculate_specificity(a)).collect();
5149                if specs.windows(2).any(|w| w[0] != w[1]) {
5150                    uniform_specificity = false;
5151                }
5152
5153                result.push_str(":not(");
5154                result.push_str(&args.join(", "));
5155                result.push(')');
5156                i = current_end;
5157                continue;
5158            }
5159        }
5160        let ch = selector[i..].chars().next().unwrap();
5161        result.push(ch);
5162        i += ch.len_utf8();
5163    }
5164
5165    if changed {
5166        Some((result, uniform_specificity))
5167    } else {
5168        None
5169    }
5170}
5171
5172fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
5173    let bytes = source.as_bytes();
5174    let mut depth = 1usize;
5175    let mut i = open + 1;
5176    let mut quote: Option<u8> = None;
5177    let mut escaped = false;
5178
5179    while i < bytes.len() {
5180        let b = bytes[i];
5181        if let Some(q) = quote {
5182            if escaped {
5183                escaped = false;
5184            } else if b == b'\\' {
5185                escaped = true;
5186            } else if b == q {
5187                quote = None;
5188            }
5189            i += 1;
5190            continue;
5191        }
5192
5193        match b {
5194            b'\'' | b'"' => quote = Some(b),
5195            b'(' => depth += 1,
5196            b')' => {
5197                depth -= 1;
5198                if depth == 0 {
5199                    return Some(i);
5200                }
5201            }
5202            _ => {}
5203        }
5204        i += 1;
5205    }
5206    None
5207}
5208
5209#[derive(Debug, Clone)]
5210struct HierarchicalRule {
5211    relative_selector: String,
5212    body_lines: Vec<String>,
5213    sub_rules: Vec<HierarchicalRule>,
5214    conditional_header: Option<String>,
5215}
5216
5217fn preserve_body_lines(body: &str) -> Vec<String> {
5218    let base_indent = body
5219        .lines()
5220        .find(|line| !line.trim().is_empty())
5221        .map(|line| line.len() - line.trim_start().len())
5222        .unwrap_or(0);
5223    body.lines()
5224        .filter(|line| !line.trim().is_empty())
5225        .map(|line| {
5226            line.get(base_indent..)
5227                .unwrap_or(line)
5228                .trim_end()
5229                .to_string()
5230        })
5231        .collect()
5232}
5233
5234fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
5235    let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
5236    let parent_indent = line_indent(source, parent.start);
5237    let unit = relative_indent_unit(source, parent);
5238    let nested_indent = format!("{parent_indent}{unit}");
5239
5240    let mut out = String::new();
5241    let open = parent_body_range.start - 1;
5242    out.push_str(&source[parent.start..=open]);
5243
5244    let parent_body = &source[parent_body_range.clone()];
5245    let trimmed_body = parent_body.trim();
5246    if !trimmed_body.is_empty() {
5247        out.push('\n');
5248        for line in preserve_body_lines(parent_body) {
5249            out.push_str(&nested_indent);
5250            out.push_str(&ensure_semicolon(&line));
5251            out.push('\n');
5252        }
5253    }
5254
5255    // Build hierarchical rules
5256    let mut root_rules: Vec<HierarchicalRule> = Vec::new();
5257
5258    for child in children {
5259        match child {
5260            ClusterChild::Style {
5261                node,
5262                nested_selector,
5263                ..
5264            } => {
5265                let mut body_lines = Vec::new();
5266                if let Some(body_range) = &node.body_range {
5267                    body_lines = preserve_body_lines(&source[body_range.clone()]);
5268                }
5269                insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
5270            }
5271            ClusterChild::Conditional { node, inners, .. } => {
5272                let header = node.prelude(source).trim().to_string();
5273                let mut cond_sub_rules = Vec::new();
5274                for inner in inners {
5275                    match inner {
5276                        ConditionalInner::Direct { body_range } => {
5277                            let lines = preserve_body_lines(&source[body_range.clone()]);
5278                            cond_sub_rules.push(HierarchicalRule {
5279                                relative_selector: String::new(),
5280                                body_lines: lines,
5281                                sub_rules: Vec::new(),
5282                                conditional_header: None,
5283                            });
5284                        }
5285                        ConditionalInner::Nested {
5286                            nested_selector,
5287                            body_range,
5288                        } => {
5289                            let lines = preserve_body_lines(&source[body_range.clone()]);
5290                            cond_sub_rules.push(HierarchicalRule {
5291                                relative_selector: nested_selector.trim().to_string(),
5292                                body_lines: lines,
5293                                sub_rules: Vec::new(),
5294                                conditional_header: None,
5295                            });
5296                        }
5297                    }
5298                }
5299                root_rules.push(HierarchicalRule {
5300                    relative_selector: String::new(),
5301                    body_lines: Vec::new(),
5302                    sub_rules: cond_sub_rules,
5303                    conditional_header: Some(header),
5304                });
5305            }
5306        }
5307    }
5308
5309    for rule in &root_rules {
5310        out.push('\n');
5311        render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
5312    }
5313
5314    out.push_str(&parent_indent);
5315    out.push('}');
5316    out
5317}
5318
5319fn insert_hierarchical_style(
5320    root_rules: &mut Vec<HierarchicalRule>,
5321    selector: &str,
5322    body_lines: Vec<String>,
5323) {
5324    if let Some(last_rule) = root_rules.last_mut()
5325        && last_rule.conditional_header.is_none()
5326        && !last_rule.relative_selector.is_empty()
5327    {
5328        let parent_sel = &last_rule.relative_selector;
5329        if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
5330            insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
5331            return;
5332        }
5333    }
5334
5335    root_rules.push(HierarchicalRule {
5336        relative_selector: selector.to_string(),
5337        body_lines,
5338        sub_rules: Vec::new(),
5339        conditional_header: None,
5340    });
5341}
5342
5343fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
5344    let parent = parent.trim();
5345    let child = child.trim();
5346    if child == parent || !child.starts_with(parent) {
5347        return None;
5348    }
5349    let remainder = &child[parent.len()..];
5350    let trimmed = remainder.trim_start();
5351    if trimmed.is_empty() {
5352        return None;
5353    }
5354
5355    if remainder.starts_with("::")
5356        || remainder.starts_with(':')
5357        || remainder.starts_with('[')
5358        || remainder.starts_with('.')
5359        || remainder.starts_with('#')
5360    {
5361        Some(format!("&{remainder}"))
5362    } else if let Some(first_char) = trimmed
5363        .chars()
5364        .next()
5365        .filter(|c| *c == '>' || *c == '+' || *c == '~')
5366    {
5367        let after_comb = trimmed[first_char.len_utf8()..].trim();
5368        Some(explicit_relative_combinator(first_char, after_comb))
5369    } else if remainder
5370        .as_bytes()
5371        .first()
5372        .is_some_and(|b| b.is_ascii_whitespace())
5373    {
5374        Some(trimmed.to_string())
5375    } else {
5376        None
5377    }
5378}
5379
5380fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
5381    let inner_indent = format!("{indent}{unit}");
5382
5383    if let Some(header) = &rule.conditional_header {
5384        out.push_str(indent);
5385        out.push_str(header);
5386        out.push_str(" {\n");
5387        for (idx, sub) in rule.sub_rules.iter().enumerate() {
5388            if idx > 0 {
5389                out.push('\n');
5390            }
5391            if sub.relative_selector.is_empty() {
5392                for line in &sub.body_lines {
5393                    out.push_str(&inner_indent);
5394                    out.push_str(&ensure_semicolon(line));
5395                    out.push('\n');
5396                }
5397            } else {
5398                render_hierarchical_rule(out, sub, &inner_indent, unit);
5399            }
5400        }
5401        out.push_str(indent);
5402        out.push_str("}\n");
5403    } else {
5404        out.push_str(indent);
5405        out.push_str(&rule.relative_selector);
5406        out.push_str(" {\n");
5407
5408        for line in &rule.body_lines {
5409            out.push_str(&inner_indent);
5410            out.push_str(&ensure_semicolon(line));
5411            out.push('\n');
5412        }
5413
5414        for sub in &rule.sub_rules {
5415            out.push('\n');
5416            render_hierarchical_rule(out, sub, &inner_indent, unit);
5417        }
5418
5419        out.push_str(indent);
5420        out.push_str("}\n");
5421    }
5422}
5423
5424fn line_indent(source: &str, offset: usize) -> String {
5425    let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
5426    source[line_start..offset]
5427        .chars()
5428        .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
5429        .collect()
5430}
5431
5432/// Ensure a CSS declaration line ends with `;`.
5433/// Only adds the semicolon when the line looks like a property declaration
5434/// (contains `:`, does not already end with `;`, `{`, or `}`, and is not a comment).
5435fn ensure_semicolon(line: &str) -> std::borrow::Cow<'_, str> {
5436    let trimmed = line.trim_end();
5437    if trimmed.ends_with(';')
5438        || trimmed.ends_with('{')
5439        || trimmed.ends_with('}')
5440        || trimmed.ends_with(',')
5441        || trimmed.ends_with(':')
5442        || trimmed.starts_with("//")
5443        || trimmed.starts_with("/*")
5444        || !trimmed.contains(':')
5445    {
5446        return std::borrow::Cow::Borrowed(line);
5447    }
5448    std::borrow::Cow::Owned(format!("{trimmed};"))
5449}
5450
5451fn line_number(source: &str, offset: usize) -> usize {
5452    source[..offset.min(source.len())].lines().count()
5453}
5454
5455fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
5456    for line in source[body].lines() {
5457        if line.trim().is_empty() {
5458            continue;
5459        }
5460        let indent: String = line
5461            .chars()
5462            .take_while(|c| *c == ' ' || *c == '\t')
5463            .collect();
5464        if !indent.is_empty() {
5465            return Some(indent);
5466        }
5467    }
5468    None
5469}
5470
5471fn contains_top_level_comma(selector: &str) -> bool {
5472    let bytes = selector.as_bytes();
5473    let mut parens = 0usize;
5474    let mut brackets = 0usize;
5475    let mut quote: Option<u8> = None;
5476    let mut escaped = false;
5477    let mut i = 0usize;
5478    while i < bytes.len() {
5479        let b = bytes[i];
5480        if let Some(q) = quote {
5481            if escaped {
5482                escaped = false;
5483            } else if b == b'\\' {
5484                escaped = true;
5485            } else if b == q {
5486                quote = None;
5487            }
5488            i += 1;
5489            continue;
5490        }
5491        match b {
5492            b'\'' | b'"' => quote = Some(b),
5493            b'(' => parens += 1,
5494            b')' => parens = parens.saturating_sub(1),
5495            b'[' => brackets += 1,
5496            b']' => brackets = brackets.saturating_sub(1),
5497            b',' if parens == 0 && brackets == 0 => return true,
5498            _ => {}
5499        }
5500        i += 1;
5501    }
5502    false
5503}
5504
5505pub fn apply_selected_plans(
5506    source: &str,
5507    plans: &[PlanEntry],
5508    include_review: bool,
5509) -> Result<String> {
5510    let selected: Vec<&PlanEntry> = plans
5511        .iter()
5512        .filter(|plan| {
5513            plan.selected
5514                && (plan.safety == Safety::Safe
5515                    || (include_review && plan.safety == Safety::Review))
5516        })
5517        .collect();
5518    let owned: Vec<PlanEntry> = selected.into_iter().cloned().collect();
5519    let keep = select_disjoint_plan_indices(&owned);
5520    let mut non_overlapping: Vec<&PlanEntry> = keep.iter().map(|&i| &owned[i]).collect();
5521    non_overlapping.sort_by(|a, b| {
5522        a.source_range
5523            .start
5524            .cmp(&b.source_range.start)
5525            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
5526    });
5527
5528    let mut output = source.to_string();
5529    for plan in non_overlapping.into_iter().rev() {
5530        if plan.source_range.start <= output.len()
5531            && plan.source_range.end <= output.len()
5532            && plan.source_range.start <= plan.source_range.end
5533        {
5534            output.replace_range(
5535                plan.source_range.start..plan.source_range.end,
5536                &plan.proposed,
5537            );
5538        }
5539    }
5540    Ok(output)
5541}
5542
5543/// Apply all currently available safe/review plans until the source reaches a
5544/// fixed point. Plans can overlap, so one disjoint pass may intentionally leave
5545/// a second transformation for the next analysis pass.
5546pub fn apply_until_stable(
5547    path: &Path,
5548    source: &str,
5549    enabled_rules: &[RuleId],
5550    include_review: bool,
5551) -> Result<String> {
5552    let mut current = source.to_string();
5553
5554    for _ in 0..32 {
5555        let report = analyze_content(path.to_path_buf(), &current, enabled_rules)?;
5556        let mut plans = report.plans;
5557        for plan in &mut plans {
5558            plan.selected =
5559                plan.safety == Safety::Safe || (include_review && plan.safety == Safety::Review);
5560        }
5561
5562        let next = apply_selected_plans(&current, &plans, include_review)?;
5563        if next == current {
5564            return Ok(current);
5565        }
5566        current = next;
5567    }
5568
5569    anyhow::bail!(
5570        "CSS transformations did not reach a stable result after 32 passes for {}",
5571        path.display()
5572    )
5573}
5574
5575pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
5576    TextDiff::from_lines(old, new)
5577        .unified_diff()
5578        .header(old_name, new_name)
5579        .to_string()
5580}
5581
5582#[cfg(test)]
5583mod tests {
5584    use super::*;
5585
5586    fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
5587        analyze_source(PathBuf::from("test.css"), css, rules)
5588            .unwrap()
5589            .plans
5590    }
5591
5592    #[test]
5593    fn extracts_style_blocks_from_template_sources() {
5594        let source = r#"@php($title = 'demo')
5595<div>
5596  <style scoped>
5597    .card { color: red; }
5598  </style>
5599  <style>.badge { color: blue; }</style>
5600</div>
5601"#;
5602        let blocks = extract_style_blocks(source);
5603        assert_eq!(blocks.len(), 2);
5604        assert_eq!(
5605            &source[blocks[0].clone()],
5606            "\n    .card { color: red; }\n  "
5607        );
5608        assert_eq!(&source[blocks[1].clone()], ".badge { color: blue; }");
5609    }
5610
5611    #[test]
5612    fn transforms_only_embedded_style_contents() {
5613        let source = r#"@php($title = 'demo')
5614<div class="card">
5615  <style>
5616    .button { color: red; }
5617    .button:hover { color: blue; }
5618  </style>
5619  {{ $title }}
5620</div>
5621"#;
5622        let output = apply_until_stable(
5623            Path::new("view.blade.php"),
5624            source,
5625            &[RuleId::NestPseudoClass],
5626            false,
5627        )
5628        .unwrap();
5629        assert!(output.starts_with("@php($title = 'demo')\n<div class=\"card\">\n  <style>"));
5630        assert!(
5631            output.contains("&:hover"),
5632            "embedded CSS was not transformed: {output}"
5633        );
5634        assert!(output.contains("{{ $title }}"));
5635        assert!(output.ends_with("</div>\n"));
5636    }
5637
5638    #[test]
5639    fn plans_selector_lists_inside_nested_style_rules() {
5640        let source = r#"<div>
5641  <style>
5642    .daterangepicker {
5643      .next span,
5644      .prev span {
5645        border-color: #64748b;
5646      }
5647    }
5648  </style>
5649</div>
5650"#;
5651        let report = analyze_content(
5652            PathBuf::from("view.blade.php"),
5653            source,
5654            &[RuleId::ModernizeIs],
5655        )
5656        .unwrap();
5657        assert_eq!(report.plans.len(), 1);
5658        assert_eq!(
5659            &source[report.plans[0].source_range.start..report.plans[0].source_range.end],
5660            ".next span,\n      .prev span "
5661        );
5662
5663        let output = apply_until_stable(
5664            Path::new("view.blade.php"),
5665            source,
5666            &[RuleId::ModernizeIs],
5667            false,
5668        )
5669        .unwrap();
5670        assert!(output.contains(":is(.next, .prev) span"));
5671        assert!(output.contains("<div>"));
5672        assert!(output.contains("</style>"));
5673    }
5674
5675    #[test]
5676    fn plans_rules_inside_nested_starting_style_at_rules() {
5677        let source = r#".dialog {
5678  @starting-style {
5679    .dialog-panel:hover,
5680    .dialog-panel:focus {
5681      opacity: 0;
5682    }
5683  }
5684}
5685"#;
5686        let report = analyze_content(
5687            PathBuf::from("nested-starting-style.css"),
5688            source,
5689            &[RuleId::ModernizeIs],
5690        )
5691        .unwrap();
5692        assert_eq!(report.plans.len(), 1);
5693        assert_eq!(report.plans[0].rules, vec![RuleId::ModernizeIs]);
5694
5695        let output = apply_until_stable(
5696            Path::new("nested-starting-style.css"),
5697            source,
5698            &[RuleId::ModernizeIs],
5699            false,
5700        )
5701        .unwrap();
5702        assert!(output.contains(".dialog-panel:is(:hover, :focus)"));
5703        assert!(output.contains("@starting-style"));
5704    }
5705
5706    #[test]
5707    fn preserves_nested_starting_style_when_processing_conditional_styles() {
5708        let source = r#"@supports selector(details::details-content) {
5709  .faq-list {
5710    details {
5711      &[open]::details-content {
5712        height: auto;
5713      }
5714    }
5715
5716    @starting-style {
5717      details[open]::details-content {
5718        height: 0;
5719      }
5720    }
5721  }
5722}
5723"#;
5724        let output = apply_until_stable(
5725            Path::new("nested-starting-style.css"),
5726            source,
5727            &RuleId::ALL,
5728            true,
5729        )
5730        .unwrap();
5731        assert_eq!(output.matches("@starting-style").count(), 1, "{output}");
5732        assert!(
5733            output.contains("details[open]::details-content") && output.contains("height: 0;"),
5734            "nested starting-style declaration was lost or changed:\n{output}"
5735        );
5736    }
5737
5738    #[test]
5739    fn preserves_utf8_declaration_values_during_nesting() {
5740        let source = "input[type=checkbox] { content: '✓'; }\n";
5741        let output = apply_until_stable(
5742            Path::new("utf8.css"),
5743            source,
5744            &[RuleId::NestAttribute],
5745            false,
5746        )
5747        .unwrap();
5748        assert!(
5749            output.contains("content: '✓';"),
5750            "UTF-8 value changed: {output}"
5751        );
5752        assert!(!output.contains("Ã"), "mojibake introduced: {output}");
5753    }
5754
5755    #[test]
5756    fn does_not_treat_relative_or_already_nested_selectors_as_state_bases() {
5757        assert_eq!(extract_base_target("> [class*='col-']"), None);
5758        assert_eq!(extract_base_target(":is(.prev, .next):hover"), None);
5759        assert_eq!(extract_base_target("&:hover"), None);
5760        assert_eq!(extract_base_target(".button:hover"), Some(".button"));
5761    }
5762
5763    #[test]
5764    fn does_not_reparent_existing_nesting_selectors() {
5765        assert_eq!(
5766            selector_relation(".card-body", "&.compact .card-body"),
5767            None
5768        );
5769        assert_eq!(selector_relation("select", "&:hover"), None);
5770    }
5771
5772    #[test]
5773    fn marks_mixed_specificity_identical_body_merges_for_review() {
5774        let css =
5775            ".form-select option { color: white; }\nselect.form-control option { color: white; }\n";
5776        let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
5777        assert_eq!(plans.len(), 1);
5778        assert_eq!(plans[0].safety, Safety::Review);
5779        assert!(!plans[0].proof.specificity_equivalent);
5780    }
5781
5782    #[test]
5783    fn already_nested_selector_lists_reach_a_fixed_point() {
5784        let css = ".card {
5785  &,
5786  &.active {
5787    color: red;
5788  }
5789}
5790";
5791        let output = apply_until_stable(Path::new("test.css"), css, &RuleId::ALL, false).unwrap();
5792        assert_eq!(output, css);
5793    }
5794
5795    #[test]
5796    fn nests_adjacent_pseudo_and_descendant_rules() {
5797        let css = ".card {\n  color: red;\n}\n.card:hover {\n  color: blue !important;\n}\n.card .title {\n  font-weight: 700;\n}\n";
5798        let plans = plan(css, &RuleId::ALL);
5799        assert_eq!(plans.len(), 1);
5800        let output = apply_selected_plans(css, &plans, false).unwrap();
5801        assert!(output.contains("&:hover"));
5802        assert!(output.contains(".title"));
5803        assert!(output.contains("color: blue !important;"));
5804    }
5805
5806    #[test]
5807    fn calculates_functional_pseudo_specificity_from_the_highest_argument() {
5808        assert_eq!(
5809            calculate_specificity(":is(.a, #b, div)"),
5810            Specificity {
5811                ids: 1,
5812                classes: 0,
5813                elements: 0,
5814            }
5815        );
5816        assert_eq!(
5817            calculate_specificity(":has(> .a, #b)"),
5818            Specificity {
5819                ids: 1,
5820                classes: 0,
5821                elements: 0,
5822            }
5823        );
5824        assert_eq!(
5825            calculate_specificity(":not(.a, #b)"),
5826            Specificity {
5827                ids: 1,
5828                classes: 0,
5829                elements: 0,
5830            }
5831        );
5832        assert_eq!(
5833            calculate_specificity(":where(#header, .nav, div)"),
5834            Specificity::default()
5835        );
5836        assert_eq!(
5837            calculate_specificity(".parent > :has(.a, #b)"),
5838            Specificity {
5839                ids: 1,
5840                classes: 1,
5841                elements: 0,
5842            }
5843        );
5844    }
5845
5846    #[test]
5847    fn preserves_not_matching_logic_when_consolidating_only_chained_negations() {
5848        assert_eq!(
5849            consolidate_not_in_selector(".card:not(.a):not(.b)"),
5850            Some((".card:not(.a, .b)".to_string(), true))
5851        );
5852        assert_eq!(consolidate_not_in_selector(".card:not(.a, .b)"), None);
5853        assert_eq!(
5854            consolidate_not_in_selector(".card:not(.a), .card:not(.b)"),
5855            None
5856        );
5857    }
5858
5859    #[test]
5860    fn keeps_has_arguments_as_relative_selectors_without_rewriting_the_logic() {
5861        let selector = ".parent:has(> .a, + #b, .c .d)";
5862        assert_eq!(
5863            calculate_specificity(selector),
5864            Specificity {
5865                ids: 1,
5866                classes: 1,
5867                elements: 0,
5868            }
5869        );
5870        assert_eq!(factor_with_is(selector), None);
5871        assert_eq!(factor_with_where(selector), None);
5872    }
5873
5874    #[test]
5875    fn applies_overlapping_plans_until_the_source_is_stable() {
5876        let css = r#".states {
5877  border: 1px solid transparent;
5878}
5879
5880.states > .bolt:is(:hover, :focus-visible) {
5881  outline: 2px solid currentColor;
5882}
5883
5884.states > .bolt,
5885.states > .spark {
5886  min-block-size: 2rem;
5887}
5888"#;
5889        let output = apply_until_stable(Path::new("test.css"), css, &RuleId::ALL, false).unwrap();
5890
5891        assert!(output.contains("& > .bolt:is(:hover, :focus-visible) {"));
5892        assert!(output.contains("& > :is(.bolt, .spark) {"));
5893        assert!(!output.contains(".states > .bolt,\n.states > .spark"));
5894    }
5895
5896    #[test]
5897    fn nests_exact_full_modernize_example() {
5898        let original = r#".card {
5899  color: #222;
5900  padding: 1rem;
5901}
5902.card:hover {
5903  color: #111 !important;
5904}
5905.card::before {
5906  content: "";
5907}
5908.card[data-active] {
5909  border-color: currentColor;
5910}
5911.card.featured {
5912  box-shadow: 0 0 0 1px currentColor;
5913}
5914.card .title {
5915  font-weight: 700;
5916}
5917.card > .body {
5918  min-width: 0;
5919}
5920.card + .card {
5921  margin-top: 1rem;
5922}
5923@media (width >= 48rem) {
5924  .card {
5925    padding: 1.5rem;
5926  }
5927}
5928@supports (display: grid) {
5929  .card {
5930    display: grid;
5931  }
5932}
5933"#;
5934
5935        let expected = r#".card {
5936  color: #222;
5937  padding: 1rem;
5938
5939  &:hover {
5940    color: #111 !important;
5941  }
5942
5943  &::before {
5944    content: "";
5945  }
5946
5947  &[data-active] {
5948    border-color: currentColor;
5949  }
5950
5951  &.featured {
5952    box-shadow: 0 0 0 1px currentColor;
5953  }
5954
5955  .title {
5956    font-weight: 700;
5957  }
5958
5959  & > .body {
5960    min-width: 0;
5961  }
5962
5963  & + .card {
5964    margin-top: 1rem;
5965  }
5966
5967  @media (width >= 48rem) {
5968    padding: 1.5rem;
5969  }
5970
5971  @supports (display: grid) {
5972    display: grid;
5973  }
5974}"#;
5975
5976        let plans = plan(
5977            original,
5978            &[
5979                RuleId::NestPseudoClass,
5980                RuleId::NestPseudoElement,
5981                RuleId::NestAttribute,
5982                RuleId::NestCompound,
5983                RuleId::NestDescendant,
5984                RuleId::NestCombinator,
5985                RuleId::NestMedia,
5986                RuleId::NestSupports,
5987            ],
5988        );
5989        assert_eq!(plans.len(), 1);
5990        let output = apply_selected_plans(original, &plans, false).unwrap();
5991        assert_eq!(output.trim(), expected.trim());
5992    }
5993
5994    #[test]
5995    fn factors_selector_list_sharing_base() {
5996        let css = ".marker,\n.marker::before,\n.marker::after {\n  box-sizing: border-box;\n}\n";
5997        let plans = plan(css, &[RuleId::FactorSelectorList]);
5998        assert_eq!(plans.len(), 1);
5999        let output = apply_selected_plans(css, &plans, false).unwrap();
6000        assert!(output.contains(".marker {"));
6001        assert!(output.contains("&,"));
6002        assert!(output.contains("&::before,"));
6003        assert!(output.contains("&::after {"));
6004        assert!(output.contains("box-sizing: border-box;"));
6005        assert!(output.contains("    box-sizing: border-box;"));
6006    }
6007
6008    #[test]
6009    fn modernizes_is_with_uniform_specificity() {
6010        let css = ".button:hover, .button:focus, .button:active {\n  color: blue;\n}\n";
6011        let plans = plan(css, &[RuleId::ModernizeIs]);
6012        assert_eq!(plans.len(), 1);
6013        let output = apply_selected_plans(css, &plans, false).unwrap();
6014        assert!(output.contains(".button:is(:hover, :focus, :active)"));
6015    }
6016
6017    #[test]
6018    fn never_factors_pseudo_elements_into_functional_pseudo_classes() {
6019        for selector in [
6020            ".icon:before, .icon:after",
6021            ".icon::before, .icon::after",
6022            ".icon:first-line, .icon:first-letter",
6023            ".icon:is(:before), .icon:is(:after)",
6024        ] {
6025            assert_eq!(
6026                factor_with_is(selector),
6027                None,
6028                "pseudo-element selector was incorrectly factored: {selector}"
6029            );
6030        }
6031    }
6032
6033    #[test]
6034    fn does_not_factor_inside_functional_pseudo_arguments() {
6035        let selector = ".equivalent-has:has(> .alpha), .equivalent-has:has(> .beta)";
6036        assert_eq!(
6037            factor_with_is(selector),
6038            Some((".equivalent-has:has(> .alpha, > .beta)".to_string(), true))
6039        );
6040
6041        let css = format!("{selector} {{\n  color: seagreen;\n}}\n");
6042        let plans = plan(&css, &[RuleId::ModernizeIs]);
6043        assert_eq!(plans.len(), 1);
6044        let output = apply_selected_plans(&css, &plans, false).unwrap();
6045        assert!(output.contains(".equivalent-has:has(> .alpha, > .beta)"));
6046    }
6047
6048    #[test]
6049    fn combines_uniform_is_and_where_function_arguments() {
6050        assert_eq!(
6051            factor_with_is(".card:is(.alpha), .card:is(.beta)"),
6052            Some((".card:is(.alpha, .beta)".to_string(), true))
6053        );
6054        assert_eq!(
6055            factor_with_is(".card:where(#alpha), .card:where(.beta)"),
6056            Some((".card:where(#alpha, .beta)".to_string(), true))
6057        );
6058    }
6059
6060    #[test]
6061    fn refuses_to_combine_separate_not_functions() {
6062        assert_eq!(factor_with_is(".card:not(.alpha), .card:not(.beta)"), None);
6063    }
6064
6065    #[test]
6066    fn modernizes_media_range_syntax() {
6067        let css = "@media (min-width: 800px) {\n  .card { padding: 2rem; }\n}\n";
6068        let plans = plan(css, &[RuleId::ModernizeMediaRange]);
6069        assert_eq!(plans.len(), 1);
6070        let output = apply_selected_plans(css, &plans, false).unwrap();
6071        assert!(output.contains("@media (width >= 800px)"));
6072    }
6073
6074    #[test]
6075    fn consolidates_not_selectors() {
6076        let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n  border: 1px solid gray;\n}\n";
6077        let plans = plan(css, &[RuleId::ConsolidateNot]);
6078        assert_eq!(plans.len(), 1);
6079        assert_eq!(plans[0].safety, Safety::Review);
6080        let output = apply_selected_plans(css, &plans, true).unwrap();
6081        assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
6082    }
6083
6084    #[test]
6085    fn refuses_subtoken_is_factoring_false_positive() {
6086        let css = ".same-specificity-a,\n.same-specificity-b {\n  color: black;\n}\n";
6087        let plans = plan(css, &[RuleId::ModernizeIs]);
6088        assert!(plans.is_empty());
6089    }
6090
6091    #[test]
6092    fn modernizes_descendant_is_alternatives() {
6093        let css = ".card .title, .card .subtitle, .card .description {\n  color: black;\n}\n";
6094        let plans = plan(css, &[RuleId::ModernizeIs]);
6095        assert_eq!(plans.len(), 1);
6096        let output = apply_selected_plans(css, &plans, false).unwrap();
6097        assert!(output.contains(".card :is(.title, .subtitle, .description)"));
6098    }
6099
6100    #[test]
6101    fn modernizes_suffix_is_alternatives() {
6102        let css = ".alpha .title,\n#hero .title {\n  color: rebeccapurple;\n}\n";
6103        let plans = plan(css, &[RuleId::ModernizeIs]);
6104        assert_eq!(plans.len(), 1);
6105        assert_eq!(plans[0].safety, Safety::Review);
6106        let output = apply_selected_plans(css, &plans, true).unwrap();
6107        assert!(output.contains(":is(.alpha, #hero) .title"));
6108    }
6109
6110    #[test]
6111    fn factors_multi_selector_cluster_with_is_and_nesting() {
6112        let css = r#".alpha .title,
6113#hero .title {
6114  color: rebeccapurple;
6115}
6116
6117.alpha .subtitle,
6118#hero .subtitle {
6119  color: slateblue;
6120}
6121
6122.alpha,
6123#hero {
6124  border-color: currentColor;
6125}
6126"#;
6127        let plans = plan(css, &[RuleId::ModernizeIs]);
6128        assert_eq!(plans.len(), 1);
6129        let output = apply_selected_plans(css, &plans, true).unwrap();
6130        assert!(output.contains(":is(.alpha, #hero) {"));
6131        assert!(output.contains("border-color: currentColor;"));
6132        assert!(output.contains(".title {"));
6133        assert!(output.contains("color: rebeccapurple;"));
6134        assert!(output.contains(".subtitle {"));
6135        assert!(output.contains("color: slateblue;"));
6136    }
6137
6138    #[test]
6139    fn refuses_bem_token_concatenation() {
6140        let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
6141        let plans = plan(css, &RuleId::ALL);
6142        assert!(plans.is_empty());
6143    }
6144
6145    #[test]
6146    fn merges_same_named_layer_blocks() {
6147        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";
6148        let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
6149        assert_eq!(plans.len(), 2);
6150        let output = apply_selected_plans(css, &plans, false).unwrap();
6151        assert!(output.contains("@layer overrides {"));
6152        assert!(output.contains(".layered-card {"));
6153        assert!(output.contains(".layer-important {"));
6154    }
6155
6156    #[test]
6157    fn merges_adjacent_media_queries() {
6158        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";
6159        let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
6160        assert_eq!(plans.len(), 1);
6161        let output = apply_selected_plans(css, &plans, false).unwrap();
6162        assert!(output.contains("@media (width >= 48rem) {"));
6163        assert!(output.contains(".card {"));
6164        assert!(output.contains(".panel {"));
6165    }
6166
6167    #[test]
6168    fn merges_adjacent_supports_queries() {
6169        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";
6170        let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
6171        assert_eq!(plans.len(), 1);
6172        let output = apply_selected_plans(css, &plans, false).unwrap();
6173        assert!(output.contains("@supports (display: grid) {"));
6174        assert!(output.contains(".card {"));
6175        assert!(output.contains(".panel {"));
6176    }
6177
6178    #[test]
6179    fn merges_adjacent_identical_selectors() {
6180        let css = ".card {\n  color: black;\n}\n\n.card {\n  padding: 1rem;\n}\n";
6181        let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
6182        assert_eq!(plans.len(), 1);
6183        let output = apply_selected_plans(css, &plans, false).unwrap();
6184        assert!(output.contains(".card {"));
6185        assert!(output.contains("color: black;"));
6186        assert!(output.contains("padding: 1rem;"));
6187    }
6188
6189    #[test]
6190    fn merges_identical_rule_bodies() {
6191        let css = ".card:hover {\n  color: red;\n}\n\n.panel:hover {\n  color: red;\n}\n";
6192        let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
6193        assert_eq!(plans.len(), 1);
6194        let output = apply_selected_plans(css, &plans, false).unwrap();
6195        assert!(output.contains(".card:hover,"));
6196        assert!(output.contains(".panel:hover {"));
6197        assert!(output.contains("color: red;"));
6198    }
6199
6200    #[test]
6201    fn factors_identical_states_with_is() {
6202        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";
6203        let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
6204        assert_eq!(plans.len(), 1);
6205        let output = apply_selected_plans(css, &plans, false).unwrap();
6206        assert!(output.contains(".card {"));
6207        assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
6208        assert!(output.contains("background: silver;"));
6209    }
6210
6211    #[test]
6212    fn nests_multi_level_tree_hierarchy() {
6213        let css = r#".tree {
6214  display: grid;
6215  gap: 0.5rem;
6216}
6217.tree .node {
6218  position: relative;
6219}
6220.tree .node .label {
6221  display: flex;
6222}
6223.tree .node .label:hover {
6224  color: var(--accent);
6225}
6226.tree .node > .children {
6227  margin-inline-start: 1.25rem;
6228}
6229.tree .node > .children > .node + .node {
6230  margin-block-start: 0.25rem;
6231}
6232"#;
6233        let plans = plan(
6234            css,
6235            &[
6236                RuleId::NestDescendant,
6237                RuleId::NestCombinator,
6238                RuleId::NestPseudoClass,
6239            ],
6240        );
6241        assert_eq!(plans.len(), 1);
6242        let output = apply_selected_plans(css, &plans, false).unwrap();
6243        assert!(output.contains(".node {"));
6244        assert!(output.contains(".label {"));
6245        assert!(output.contains("&:hover {"));
6246        assert!(output.contains("> .children {"));
6247        assert!(output.contains("> .node + .node {"));
6248    }
6249
6250    #[test]
6251    fn emits_explicit_nesting_selector_for_child_combinator() {
6252        let css = r#".c-command-hero {
6253  display: grid;
6254}
6255
6256.c-command-hero > .c-bolt {
6257  z-index: 4;
6258}
6259
6260.c-command-hero + .c-command-hero {
6261  margin-block-start: 1rem;
6262}
6263
6264.c-command-hero ~ .c-command-note {
6265  color: gray;
6266}
6267
6268.c-command-hero > .c-bolt > .c-bolt__icon {
6269  inline-size: 1rem;
6270}
6271"#;
6272        let plans = plan(css, &[RuleId::NestCombinator, RuleId::NestDescendant]);
6273        let output = apply_selected_plans(css, &plans, false).unwrap();
6274
6275        assert!(output.contains("& > .c-bolt {"), "{output}");
6276        assert!(output.contains("& + .c-command-hero {"), "{output}");
6277        assert!(output.contains("& ~ .c-command-note {"), "{output}");
6278        assert!(
6279            output.contains("& > .c-bolt > .c-bolt__icon {"),
6280            "multi-level combinator should preserve its full selector: {output}"
6281        );
6282        assert!(
6283            !output.contains("& {\n"),
6284            "combinator must not be split into a nested block: {output}"
6285        );
6286        assert!(!output.contains("\n    > {"), "{output}");
6287        assert!(!output.contains("\n    + {"), "{output}");
6288        assert!(!output.contains("\n    ~ {"), "{output}");
6289    }
6290
6291    #[test]
6292    fn nests_in_place_input_states() {
6293        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";
6294        let plans = plan(css, &[RuleId::NestPseudoClass]);
6295        assert_eq!(plans.len(), 1);
6296        let output = apply_selected_plans(css, &plans, false).unwrap();
6297        assert!(output.contains("input {"));
6298        assert!(output.contains("&:user-invalid {"));
6299        assert!(output.contains("&:user-valid {"));
6300        assert!(output.contains("&:placeholder-shown {"));
6301    }
6302
6303    #[test]
6304    fn gathers_consecutive_conditions_by_selector() {
6305        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";
6306        let plans = plan(css, &[RuleId::NestMedia]);
6307        assert_eq!(plans.len(), 1);
6308        let output = apply_selected_plans(css, &plans, false).unwrap();
6309        assert!(output.contains(".responsive-grid {"));
6310        assert!(output.contains("@media (width >= 30rem) {"));
6311        assert!(output.contains("@media (width >= 80rem) {"));
6312    }
6313
6314    #[test]
6315    fn factors_selector_list_with_adjacent_hover() {
6316        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";
6317        let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
6318        assert_eq!(plans.len(), 1);
6319        let output = apply_selected_plans(css, &plans, false).unwrap();
6320        assert!(output.contains(".notice {"));
6321        assert!(output.contains("&,"));
6322        assert!(output.contains("&::before,"));
6323        assert!(output.contains("&::after {"));
6324        assert!(output.contains("&:hover {"));
6325        assert!(output.contains("background: color-mix"));
6326    }
6327
6328    #[test]
6329    fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
6330        let css = r#".skip-link {
6331    position: absolute;
6332    inset-block-start: -48px;
6333    inset-inline-start: 1rem;
6334    z-index: 10000000000;
6335    background: var(--bg-color);
6336    color: var(--text-color);
6337    border: 1px solid var(--border-color);
6338    border-radius: 0.5rem;
6339    padding: 0.55rem 0.8rem;
6340    text-decoration: none;
6341    font-weight: 700;
6342    transition: inset-block-start 0.2s ease;
6343
6344    &:focus-visible {
6345        inset-block-start: 0.75rem;
6346    }
6347}
6348
6349.unrelated-rule {
6350    color: red;
6351}
6352
6353.skip-link {
6354    font: optional;
6355
6356    &::after {
6357        content: '';
6358    }
6359
6360    :not(*) & {
6361        all: unset
6362    }
6363}
6364"#;
6365        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6366        assert_eq!(plans.len(), 1);
6367        let output = apply_selected_plans(css, &plans, true).unwrap();
6368        assert!(output.contains("font: optional;"));
6369    }
6370
6371    #[test]
6372    fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
6373        let css = r#".skip-link {
6374    position: absolute;
6375    inset-block-start: -48px;
6376    inset-inline-start: 1rem;
6377    z-index: 10000000000;
6378    background: var(--bg-color);
6379    color: var(--text-color);
6380    border: 1px solid var(--border-color);
6381    border-radius: 0.5rem;
6382    padding: 0.55rem 0.8rem;
6383    text-decoration: none;
6384    font-weight: 700;
6385    transition: inset-block-start 0.2s ease;
6386
6387    &:focus-visible {
6388        inset-block-start: 0.75rem;
6389    }
6390}
6391
6392.unrelated {
6393    color: red;
6394}
6395
6396.skip-link {
6397    font: optional;
6398
6399    &::after {
6400        content: '';
6401    }
6402
6403    :not(*) & {
6404        all: unset
6405    }
6406}
6407
6408.skip-link+* {
6409    display: block;
6410}
6411
6412.skip-link::backdrop {
6413    background-color: gray;
6414}
6415
6416.skip-link:has(*) {
6417    color: #27ca3f;
6418}
6419"#;
6420        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6421        assert_eq!(plans.len(), 1);
6422        let output = apply_selected_plans(css, &plans, true).unwrap();
6423        assert!(output.contains("font: optional;"));
6424        assert!(output.contains("+ * {"));
6425        assert!(output.contains("&::backdrop {"));
6426        assert!(output.contains("&:has(*) {"));
6427    }
6428
6429    #[test]
6430    fn gather_keeps_explicit_combinators_as_complete_nested_selectors() {
6431        let css = r#".c-command-hero {
6432  display: grid;
6433}
6434
6435.c-command-hero > .c-bolt {
6436  z-index: 4;
6437}
6438
6439.unrelated {
6440  color: gray;
6441}
6442
6443.c-command-hero {
6444  border-radius: 0.75rem;
6445}
6446
6447.c-command-hero + .c-command-hero {
6448  margin-block-start: 1rem;
6449}
6450"#;
6451        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6452        let output = apply_selected_plans(css, &plans, true).unwrap();
6453
6454        assert!(output.contains("& > .c-bolt {"), "{output}");
6455        assert!(
6456            !output.contains("& {\n"),
6457            "explicit combinator must not be split: {output}"
6458        );
6459        assert!(!output.contains("\n    > .c-bolt {"), "{output}");
6460    }
6461
6462    #[test]
6463    fn test_modernizes_media_range_syntax_no_whitespace() {
6464        let input = "@media (max-width:60rem) { .content { width: 100%; } }";
6465        let plans = plan(input, &[RuleId::ModernizeMediaRange]);
6466        assert_eq!(plans.len(), 1);
6467        let output = apply_selected_plans(input, &plans, true).unwrap();
6468        assert!(output.contains("(width <= 60rem)"));
6469    }
6470
6471    #[test]
6472    fn plans_continue_when_lightningcss_rejects_picker_pseudo() {
6473        // `::picker()` is valid CSS but LightningCSS rejects it. Planning must
6474        // still run from the structural scanner so siblings can nest/modernize.
6475        let css = r#".custom-select {
6476    appearance: none;
6477}
6478
6479.custom-select:hover {
6480    border-color: teal;
6481}
6482
6483.custom-select::picker(select) {
6484    background: white;
6485}
6486
6487.custom-select::picker(select)::-webkit-scrollbar {
6488    width: 6px;
6489}
6490
6491@media (max-width: 768px) {
6492    .custom-select {
6493        inline-size: 100%;
6494    }
6495}
6496"#;
6497        let report = analyze_source(PathBuf::from("picker.css"), css, &RuleId::ALL).unwrap();
6498        eprintln!(
6499            "parse_ok={} err={:?} plans={}",
6500            report.parse_ok,
6501            report.parse_error,
6502            report.plans.len()
6503        );
6504        for p in &report.plans {
6505            eprintln!("  {:?} {:?}", p.rules, p.reason);
6506        }
6507        assert!(
6508            !report.plans.is_empty(),
6509            "must still emit plans (parse_ok={:?} err={:?}): findings={:?}",
6510            report.parse_ok,
6511            report.parse_error,
6512            report.findings
6513        );
6514        let output = apply_selected_plans(css, &report.plans, true).unwrap();
6515        assert!(
6516            output.contains("&:hover") || output.contains("&::picker"),
6517            "custom-select relatives should nest: {output}"
6518        );
6519        assert!(
6520            output.contains("(width <= 768px)") || output.contains("inline-size: 100%"),
6521            "media-range or nest should still apply: {output}"
6522        );
6523    }
6524
6525    #[test]
6526    fn nesting_adds_semicolon_to_last_declaration_without_semicolon() {
6527        // .tabpanel body ends with `display: block !important` (no `;`)
6528        // After nesting the combinator, the declaration must get a `;` inserted
6529        // so it doesn't run together with the opening `{` of the nested rule.
6530        let css = r#".tabpanel {
6531  display: block !important
6532}
6533
6534.tabpanel+.tabpanel {
6535  margin-block-start: .5rem
6536}
6537"#;
6538        let rules = &[
6539            RuleId::NestCombinator,
6540            RuleId::NestDescendant,
6541            RuleId::NestPseudoClass,
6542            RuleId::NestPseudoElement,
6543        ];
6544        let plans = plan(css, rules);
6545        assert!(!plans.is_empty(), "expected at least one nesting plan");
6546        let output = apply_selected_plans(css, &plans, true).unwrap();
6547        // Must NOT contain the malformed concatenation
6548        assert!(
6549            !output.contains("!important+"),
6550            "semicolon missing before nested rule: {output}"
6551        );
6552        // Must contain properly terminated declaration
6553        assert!(
6554            output.contains("!important;") || output.contains("!important\n"),
6555            "declaration should end with ';': {output}"
6556        );
6557        // Nested rule selector must appear on its own
6558        assert!(
6559            output.contains("+ .tabpanel {") || output.contains("+.tabpanel {"),
6560            "nested combinator rule missing: {output}"
6561        );
6562    }
6563
6564    #[test]
6565    fn preserves_multiline_custom_property_values_when_nesting() {
6566        let css = r#".card {
6567  --shadow:
6568    0 1px 2px rgb(0 0 0 / 8%),
6569    0 4px 12px rgb(0 0 0 / 5%);
6570}
6571
6572.card:hover {
6573  color: blue;
6574}
6575"#;
6576        let plans = plan(css, &[RuleId::NestPseudoClass]);
6577        assert!(!plans.is_empty(), "expected a pseudo-class nesting plan");
6578        let output = apply_selected_plans(css, &plans, true).unwrap();
6579        assert!(
6580            output.contains("--shadow:\n"),
6581            "custom property was altered: {output}"
6582        );
6583        assert!(
6584            output.contains("0 1px 2px rgb(0 0 0 / 8%),\n    0 4px 12px rgb(0 0 0 / 5%);")
6585                || output.contains("0 1px 2px rgb(0 0 0 / 8%),\n      0 4px 12px rgb(0 0 0 / 5%);"),
6586            "custom property value was reformatted: {output}"
6587        );
6588        assert!(
6589            !output.contains("--shadow:;"),
6590            "multiline value was terminated early: {output}"
6591        );
6592    }
6593
6594    #[test]
6595    fn gather_related_selector_rules_adds_semicolon_to_declarations_without_semicolon() {
6596        // Declarations without trailing `;` must get one inserted when gathered
6597        // into the canonical block so they don't corrupt nested rule opening braces.
6598        let css = r#".skip-link {
6599    position: absolute;
6600    font-weight: 700
6601}
6602
6603.unrelated { color: red }
6604
6605.skip-link:focus {
6606    outline: 2px solid currentColor
6607}
6608
6609.skip-link+* {
6610    display: block
6611}
6612"#;
6613        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6614        assert!(!plans.is_empty(), "expected gather plan");
6615        let output = apply_selected_plans(css, &plans, true).unwrap();
6616        // No declaration should run together with `{`
6617        assert!(
6618            !output.contains("700\n    &") && !output.contains("700&") && !output.contains("700{"),
6619            "semicolon missing before nested pseudo/combinator: {output}"
6620        );
6621        assert!(
6622            !output.contains("currentColor\n    + *") && !output.contains("currentColor{"),
6623            "semicolon missing before nested combinator: {output}"
6624        );
6625        // All gathered declarations must end with `;`
6626        assert!(
6627            output.contains("font-weight: 700;"),
6628            "missing ';' after font-weight: {output}"
6629        );
6630        assert!(
6631            output.contains("position: absolute;"),
6632            "missing ';' after position: {output}"
6633        );
6634    }
6635
6636    #[test]
6637    fn gathers_non_adjacent_related_rule_inside_media_query() {
6638        let css = r#".skip-link {
6639    position: absolute;
6640    font-weight: 700;
6641
6642    &:focus-visible {
6643        inset-block-start: 0.75rem;
6644    }
6645}
6646
6647.unrelated {
6648    color: red;
6649}
6650
6651@media (width <= 1024px) {
6652    .skip-link :not(*) {
6653        position: inherit
6654    }
6655}
6656"#;
6657        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6658        assert!(
6659            !plans.is_empty(),
6660            "expected gather plan for media-wrapped related rule"
6661        );
6662        let output = apply_selected_plans(css, &plans, true).unwrap();
6663        assert!(
6664            !output.contains(".skip-link :not(*)"),
6665            "flat media descendant should be gathered: {output}"
6666        );
6667        assert!(
6668            output.contains(":not(*) {"),
6669            "descendant :not(*) should nest under .skip-link: {output}"
6670        );
6671        assert!(
6672            output.contains("@media (width <= 1024px) {"),
6673            "media query should nest inside :not(*): {output}"
6674        );
6675        assert!(
6676            output.contains("position: inherit"),
6677            "declaration should be preserved: {output}"
6678        );
6679        let not_pos = output.find(":not(*) {").expect(":not(*)");
6680        let media_pos = output.find("@media (width <= 1024px) {").expect("@media");
6681        assert!(
6682            media_pos > not_pos,
6683            "media must nest inside :not(*), not the reverse: {output}"
6684        );
6685    }
6686
6687    #[test]
6688    fn gathers_exact_parent_inside_media_as_nested_at_rule() {
6689        let css = r#".skip-link {
6690    position: absolute;
6691}
6692
6693.unrelated { color: red }
6694
6695@media (width <= 600px) {
6696    .skip-link {
6697        display: none
6698    }
6699}
6700"#;
6701        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6702        let output = apply_selected_plans(css, &plans, true).unwrap();
6703        assert!(output.contains("@media (width <= 600px) {"), "{output}");
6704        assert!(output.contains("display: none;"), "{output}");
6705        assert!(
6706            !output.contains("@media (width <= 600px) {\n    .skip-link"),
6707            "should invert to .skip-link {{ @media }}: {output}"
6708        );
6709    }
6710
6711    #[test]
6712    fn gather_media_leaves_unrelated_siblings_in_place() {
6713        let css = r#".skip-link {
6714    position: absolute;
6715}
6716
6717.unrelated { color: red }
6718
6719@media (width <= 1024px) {
6720    .skip-link :not(*) {
6721        position: inherit
6722    }
6723    .other {
6724        color: blue
6725    }
6726}
6727"#;
6728        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6729        let output = apply_selected_plans(css, &plans, true).unwrap();
6730        assert!(output.contains(":not(*) {"), "{output}");
6731        assert!(
6732            output.contains(".other") && output.contains("color: blue"),
6733            "unrelated sibling must stay in the media block: {output}"
6734        );
6735    }
6736
6737    #[test]
6738    fn nests_appended_nesting_selector_descendant() {
6739        // MDN: .featured .card → .card { .featured & { ... } }
6740        let css = ".card {\n  color: red;\n}\n.featured .card {\n  border: 1px solid;\n}\n";
6741        let plans = plan(css, &[RuleId::NestDescendant]);
6742        assert_eq!(plans.len(), 1);
6743        let output = apply_selected_plans(css, &plans, false).unwrap();
6744        assert!(
6745            output.contains(".featured & {"),
6746            "expected appended &: {output}"
6747        );
6748        assert!(output.contains("border: 1px solid;"), "{output}");
6749    }
6750
6751    #[test]
6752    fn nests_appended_nesting_selector_not() {
6753        // MDN-adjacent: :not(.card) → .card { :not(&) { ... } }
6754        let css = ".card {\n  color: red;\n}\n:not(.card) {\n  display: none;\n}\n";
6755        let plans = plan(css, &[RuleId::NestPseudoClass]);
6756        assert_eq!(plans.len(), 1);
6757        let output = apply_selected_plans(css, &plans, false).unwrap();
6758        assert!(output.contains(":not(&) {"), "expected :not(&): {output}");
6759        assert!(output.contains("display: none;"), "{output}");
6760    }
6761
6762    #[test]
6763    fn nests_appended_compound_selector() {
6764        let css = ".card {\n  color: red;\n}\n.featured.card {\n  font-weight: 700;\n}\n";
6765        let plans = plan(css, &[RuleId::NestCompound]);
6766        assert_eq!(plans.len(), 1);
6767        let output = apply_selected_plans(css, &plans, false).unwrap();
6768        assert!(
6769            output.contains(".featured& {"),
6770            "expected compound appended &: {output}"
6771        );
6772    }
6773
6774    #[test]
6775    fn gathers_non_adjacent_appended_nesting_selector() {
6776        let css = r#".card {
6777    color: red;
6778}
6779
6780.unrelated { color: blue }
6781
6782.featured .card {
6783    border: 1px solid
6784}
6785
6786:not(.card) {
6787    display: none
6788}
6789"#;
6790        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6791        assert!(!plans.is_empty());
6792        let output = apply_selected_plans(css, &plans, true).unwrap();
6793        assert!(output.contains(".featured & {"), "{output}");
6794        assert!(output.contains(":not(&) {"), "{output}");
6795        assert!(!output.contains(".featured .card"), "{output}");
6796        assert!(!output.contains(":not(.card)"), "{output}");
6797    }
6798
6799    #[test]
6800    fn gather_does_not_nest_selector_list_under_one_branch() {
6801        // `A, B { shared }` must not become `B { A, & { shared } }` —
6802        // that turns A into a descendant of B.
6803        let css = r#"::view-transition-old(root),
6804::view-transition-new(root) {
6805    position: absolute;
6806    inset: 0;
6807    animation: 0.55s ease-in-out both;
6808}
6809
6810.unrelated { color: red }
6811
6812::view-transition-old(root) {
6813    animation-name: fadeOut;
6814}
6815
6816::view-transition-new(root) {
6817    animation-name: fadeIn;
6818}
6819"#;
6820        let plans = plan(
6821            css,
6822            &[
6823                RuleId::GatherRelatedSelectorRules,
6824                RuleId::FactorSelectorList,
6825            ],
6826        );
6827        let output = apply_selected_plans(css, &plans, true).unwrap();
6828        assert!(
6829            !output.contains("::view-transition-old(root),\n    &")
6830                && !output.contains("::view-transition-old(root),\n    & {")
6831                && !output.contains("::view-transition-old(root),\n        &"),
6832            "selector list must not nest under one branch: {output}"
6833        );
6834        assert!(
6835            output.contains("::view-transition-old(root)")
6836                && output.contains("::view-transition-new(root)")
6837                && output.contains("animation-name: fadeOut")
6838                && output.contains("animation-name: fadeIn"),
6839            "both branches and their unique decls must remain: {output}"
6840        );
6841        assert!(
6842            output.contains("position: absolute"),
6843            "shared declarations must be kept: {output}"
6844        );
6845    }
6846
6847    #[test]
6848    fn gather_nests_not_focus_visible_as_non_relative_amp() {
6849        // `&` anywhere (here inside `:not()`) makes the nest non-relative:
6850        // `:focus-visible { .skip-link:focus:not(&) }` ≡
6851        // `.skip-link:focus:not(:is(:focus-visible))` — no descendant combinator.
6852        assert!(selector_contains_nesting_amp(".skip-link:focus:not(&)"));
6853        assert!(!selector_contains_nesting_amp(".skip-link:focus"));
6854
6855        let css = r#":focus-visible {
6856    outline: 2px solid blue;
6857}
6858
6859.unrelated { color: red }
6860
6861/* Preserve visible focus for skip-link activation (any modality). */
6862.skip-link:focus:not(:focus-visible) {
6863    outline: 2px solid blue;
6864}
6865"#;
6866        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6867        let output = apply_selected_plans(css, &plans, true).unwrap();
6868        assert!(
6869            output.contains(".skip-link:focus:not(&)"),
6870            "should nest as non-relative :not(&): {output}"
6871        );
6872        assert!(
6873            !output.contains(":focus-visible .skip-link")
6874                && !output.contains(":focus-visible  .skip-link"),
6875            "must not insert a descendant combinator: {output}"
6876        );
6877        assert!(
6878            output.contains("Preserve visible focus for skip-link activation"),
6879            "leading comment must move with the gathered rule: {output}"
6880        );
6881        let cmt = output.find("Preserve visible focus").expect("comment");
6882        let nest = output.find(".skip-link:focus:not(&)").expect("nest");
6883        assert!(
6884            cmt < nest,
6885            "comment should precede the nested rule: {output}"
6886        );
6887    }
6888
6889    #[test]
6890    fn gather_nests_skip_link_focus_under_skip_link_not_focus_visible() {
6891        let css = r#":focus-visible {
6892    outline: 2px solid blue;
6893}
6894
6895.skip-link {
6896    position: fixed;
6897}
6898
6899.unrelated { color: red }
6900
6901.skip-link:focus:not(:focus-visible) {
6902    outline: 2px solid blue;
6903}
6904"#;
6905        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6906        let output = apply_selected_plans(css, &plans, true).unwrap();
6907        assert!(
6908            output.contains("&:focus:not(:focus-visible)"),
6909            "should nest under .skip-link: {output}"
6910        );
6911        assert!(
6912            !output.contains(".skip-link:focus:not(&)"),
6913            "must not nest under :focus-visible: {output}"
6914        );
6915    }
6916
6917    #[test]
6918    fn gather_merges_same_nested_selector_from_style_and_media() {
6919        let css = r#".demo-out-text {
6920    font-size: 1.5rem;
6921
6922    &.flash {
6923        animation: demo-out-flash .4s ease;
6924    }
6925}
6926
6927.unrelated { color: red }
6928
6929@media (prefers-reduced-motion: reduce) {
6930    .demo-out-text.flash {
6931        animation: none
6932    }
6933}
6934"#;
6935        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6936        let output = apply_selected_plans(css, &plans, true).unwrap();
6937        let flash_opens = output.matches("&.flash {").count();
6938        assert_eq!(
6939            flash_opens, 1,
6940            "duplicate &.flash nests should merge: {output}"
6941        );
6942        assert!(output.contains("animation: demo-out-flash"), "{output}");
6943        assert!(
6944            output.contains("@media (prefers-reduced-motion: reduce)"),
6945            "{output}"
6946        );
6947        assert!(output.contains("animation: none"), "{output}");
6948    }
6949
6950    #[test]
6951    fn gather_prefers_existing_specific_home_over_universal_append() {
6952        // `.skip-link + *` and `.skip-link:has(*)` can also be written as
6953        // `*` { `.skip-link+&` / `.skip-link:has(&)` }. The existing `.skip-link`
6954        // rule is the stronger home — do not duplicate into `*`.
6955        let css = r#".skip-link {
6956    position: absolute;
6957    font-weight: 700;
6958}
6959
6960* {
6961    margin: 0;
6962}
6963
6964.unrelated { color: red }
6965
6966.skip-link+* {
6967    display: block
6968}
6969
6970.skip-link:has(*) {
6971    color: #27ca3f
6972}
6973"#;
6974        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6975        let output = apply_selected_plans(css, &plans, true).unwrap();
6976        assert!(
6977            output.contains("+ * {") || output.contains("+* {"),
6978            "combinator should nest under .skip-link: {output}"
6979        );
6980        assert!(
6981            output.contains("&:has(*) {"),
6982            ":has(*) should nest under .skip-link: {output}"
6983        );
6984        assert!(
6985            !output.contains(".skip-link+&")
6986                && !output.contains(".skip-link + &")
6987                && !output.contains(".skip-link:has(&)"),
6988            "must not append the same rules into *: {output}"
6989        );
6990        let star = output.find("\n* {").or_else(|| output.find("* {"));
6991        if let Some(star_at) = star {
6992            let after_star = &output[star_at..];
6993            let star_body = after_star.split('}').next().unwrap_or(after_star);
6994            assert!(
6995                !star_body.contains("skip-link"),
6996                "* must not absorb skip-link rules: {output}"
6997            );
6998        }
6999    }
7000
7001    #[test]
7002    fn precise_conditional_home_is_not_hidden_by_universal_gather() {
7003        let css = r#"*
7004{
7005    margin: 0;
7006}
7007
7008.faq-list {
7009    display: grid;
7010}
7011
7012@supports selector(details::details-content) {
7013    .faq-list {
7014        details {
7015            &::details-content {
7016                height: 0;
7017            }
7018        }
7019
7020        @starting-style {
7021            details[open]::details-content {
7022                height: 0;
7023            }
7024        }
7025    }
7026}
7027"#;
7028        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7029        assert!(
7030            plans.iter().any(|p| p.reason.contains("'.faq-list'")),
7031            "precise conditional home was suppressed: {plans:?}"
7032        );
7033        assert!(
7034            !plans.iter().any(|p| p.reason.contains("'*'")),
7035            "universal gather should not be planned: {plans:?}"
7036        );
7037    }
7038
7039    #[test]
7040    fn gather_appends_only_when_no_prefix_home_exists() {
7041        let css = r#".card {
7042    color: red;
7043}
7044
7045.unrelated { color: blue }
7046
7047.featured .card {
7048    border: 1px solid
7049}
7050"#;
7051        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7052        let output = apply_selected_plans(css, &plans, true).unwrap();
7053        assert!(output.contains(".featured & {"), "{output}");
7054        assert!(!output.contains(".featured .card"), "{output}");
7055    }
7056
7057    #[test]
7058    fn gather_keeps_supports_block_with_comma_list_and_mixed_homes() {
7059        let css = r#".custom-select {
7060    color: navy;
7061}
7062
7063.unrelated { color: red }
7064
7065@supports (appearance: base-select) {
7066    .custom-select,
7067    .custom-select::picker(select) {
7068        appearance: base-select;
7069    }
7070    .custom-select button { display: flex }
7071    selectedcontent { display: flex }
7072    .custom-select option { padding: 1rem }
7073}
7074"#;
7075        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7076        let output = apply_selected_plans(css, &plans, true).unwrap();
7077        assert!(
7078            output.contains("appearance: base-select"),
7079            "comma-list body must not be dropped: {output}"
7080        );
7081        assert!(
7082            output.contains("selectedcontent") && output.contains(".custom-select button")
7083                || output.contains("button {"),
7084            "mixed @supports inners must remain: {output}"
7085        );
7086    }
7087
7088    #[test]
7089    fn gather_keeps_busy_mixed_media_grouped() {
7090        let css = r#".nav-links { display: flex; }
7091.hamburger-menu { display: none; }
7092.nav-controls { gap: 1rem; }
7093
7094.unrelated { color: red }
7095
7096@media (width <= 1024px) {
7097    .nav-links { display: none }
7098    .hamburger-menu { display: flex }
7099    .nav-controls { margin-inline-start: auto }
7100}
7101"#;
7102        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7103        let output = apply_selected_plans(css, &plans, true).unwrap();
7104        assert!(
7105            output.contains("@media (width <= 1024px)"),
7106            "mixed media with 3+ selectors should stay grouped: {output}"
7107        );
7108        assert!(
7109            !output.contains(".nav-links {\n    display: flex;\n\n    @media")
7110                && !output.contains(".hamburger-menu {\n    display: none;\n\n    @media"),
7111            "should not explode a busy media query into each parent: {output}"
7112        );
7113    }
7114
7115    #[test]
7116    fn appended_nesting_does_not_match_ident_suffix() {
7117        let css = ".card {\n  color: red;\n}\n.mycard {\n  color: blue;\n}\n";
7118        let plans = plan(css, &[RuleId::NestCompound, RuleId::NestDescendant]);
7119        assert!(
7120            plans.is_empty(),
7121            ".mycard must not nest under .card: {:?}",
7122            plans.iter().map(|p| &p.proposed).collect::<Vec<_>>()
7123        );
7124    }
7125
7126    #[test]
7127    fn gather_preserves_nested_selector_lists() {
7128        // `&::before,` contains `:` but is a selector-list continuation, not a declaration.
7129        let css = r#"* {
7130    margin: 0;
7131}
7132
7133.unrelated { color: red }
7134
7135@media (prefers-reduced-motion: reduce) {
7136    * {
7137        &,
7138        &::before,
7139        &::after {
7140            animation-duration: 0.001ms !important
7141        }
7142    }
7143}
7144"#;
7145        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7146        let output = apply_selected_plans(css, &plans, true).unwrap();
7147        assert!(
7148            !output.contains("&::before,\n        ;")
7149                && !output.contains("&::before,;")
7150                && !output.contains("&,\n        ;")
7151                && !output.contains("&,;"),
7152            "selector-list comma must not become a declaration terminator: {output}"
7153        );
7154        assert!(
7155            output.contains("&::before,") && output.contains("&::after {"),
7156            "compound pseudo selector list must stay intact: {output}"
7157        );
7158        let before = output.find("&::before,").expect("before");
7159        let after = output.find("&::after {").expect("after");
7160        let between = &output[before..after];
7161        assert!(
7162            !between.contains(';'),
7163            "no semicolon between selector-list items: {between:?} in {output}"
7164        );
7165    }
7166
7167    #[test]
7168    fn gather_keeps_every_custom_select_chrome_rule() {
7169        let css = r#".custom-select {
7170    color: navy;
7171}
7172
7173@supports (appearance: base-select) {
7174    .custom-select,
7175    .custom-select::picker(select) {
7176        appearance: base-select;
7177    }
7178    .custom-select button {
7179        display: flex;
7180    }
7181    .custom-select button:hover {
7182        border-color: teal;
7183    }
7184    .custom-select:focus button,
7185    .custom-select button:focus-visible {
7186        outline: none;
7187    }
7188    .custom-select .select-arrow {
7189        color: gray;
7190    }
7191    .custom-select:open .select-arrow {
7192        rotate: -180deg;
7193    }
7194    .custom-select:open button {
7195        border-color: teal;
7196    }
7197    selectedcontent {
7198        display: flex;
7199    }
7200    selectedcontent .opt-icon {
7201        display: none;
7202    }
7203    .custom-select::picker(select) {
7204        background: white;
7205    }
7206    .custom-select:not(:open)::picker(select) {
7207        opacity: 0;
7208    }
7209    .custom-select option {
7210        padding: 1rem;
7211    }
7212    .custom-select .dropdown-search-container {
7213        position: sticky;
7214    }
7215    .custom-select .select-search {
7216        inline-size: 100%;
7217    }
7218    .custom-select option .opt-icon {
7219        color: gray;
7220    }
7221    .custom-select option:hover .opt-icon,
7222    .custom-select option:checked .opt-icon {
7223        color: teal;
7224    }
7225    .custom-select::picker-icon {
7226        display: none;
7227    }
7228}
7229"#;
7230        let rules: Vec<RuleId> = RuleId::ALL
7231            .iter()
7232            .copied()
7233            .filter(|r| *r != RuleId::ModernizeWhere)
7234            .collect();
7235        let plans = plan(css, &rules);
7236        let output = apply_selected_plans(css, &plans, true).unwrap();
7237        for needle in [
7238            "appearance: base-select",
7239            "display: flex",
7240            "border-color: teal",
7241            "outline: none",
7242            "color: gray",
7243            "rotate: -180deg",
7244            "selectedcontent",
7245            "display: none",
7246            "background: white",
7247            "opacity: 0",
7248            "padding: 1rem",
7249            "position: sticky",
7250            "inline-size: 100%",
7251            "::picker-icon",
7252        ] {
7253            assert!(
7254                output.contains(needle),
7255                "lost `{needle}` after apply:\n{output}"
7256            );
7257        }
7258        assert!(
7259            output.contains("button") && output.contains("&:hover"),
7260            "button:hover should nest as button {{ &:hover }}:\n{output}"
7261        );
7262        assert!(
7263            !output.contains("&:open &") && !output.contains("&:focus &"),
7264            "must not rewrite :open/:focus descendants as appended &:\n{output}"
7265        );
7266        assert!(
7267            output.contains("&:open")
7268                && (output.contains("&:open {")
7269                    || output.contains("&:open .select-arrow")
7270                    || output.contains("&:open button")),
7271            ":open children should group under .custom-select:\n{output}"
7272        );
7273        assert!(
7274            output.contains("&:focus button") || output.contains("button:focus-visible"),
7275            "focus comma-list must stay as relative branches:\n{output}"
7276        );
7277        assert!(
7278            output.contains("selectedcontent")
7279                && (output.contains(".opt-icon") || output.contains("& .opt-icon")),
7280            "selectedcontent leftover must remain:\n{output}"
7281        );
7282        assert!(
7283            output.contains(".custom-select")
7284                && output.contains("@supports (appearance: base-select)"),
7285            "supports should invert under .custom-select:\n{output}"
7286        );
7287        let home = output.find(".custom-select {").expect("home");
7288        let supports = output
7289            .find("@supports (appearance: base-select)")
7290            .expect("supports");
7291        assert!(
7292            supports > home,
7293            "appearance supports should sit inside .custom-select:\n{output}"
7294        );
7295        assert!(
7296            !output.contains("@supports (appearance: base-select) {\n    .custom-select"),
7297            "must not leave a wrapper .custom-select inside @supports:\n{output}"
7298        );
7299    }
7300
7301    #[test]
7302    fn gather_folds_unlayered_picker_into_layered_home() {
7303        let css = r#"@layer components {
7304    .custom-select {
7305        color: navy;
7306        &:is(:hover) { color: teal; }
7307    }
7308}
7309
7310.custom-select::picker(select) {
7311    scrollbar-width: thin;
7312}
7313.custom-select::picker(select)::-webkit-scrollbar {
7314    width: 6px;
7315}
7316"#;
7317        let plans = plan(
7318            css,
7319            &[
7320                RuleId::GatherRelatedSelectorRules,
7321                RuleId::NestLayerBySelector,
7322            ],
7323        );
7324        let output = apply_selected_plans(css, &plans, true).unwrap();
7325        assert!(
7326            output.contains("@layer components") && output.contains(".custom-select"),
7327            "layered home must stay in its layer:\n{output}"
7328        );
7329        assert!(
7330            output.contains(".custom-select::picker(select)")
7331                || output.contains("&::picker(select)"),
7332            "picker chrome must remain:\n{output}"
7333        );
7334        let layer_at = output.find("@layer components").expect("layer");
7335        let picker_flat = output.find(".custom-select::picker(select)");
7336        if let Some(picker_at) = picker_flat {
7337            assert!(
7338                picker_at < layer_at || !output[layer_at..].contains("scrollbar-width"),
7339                "unlayered picker must not be dumped into @layer components:\n{output}"
7340            );
7341        }
7342    }
7343
7344    #[test]
7345    fn gather_does_not_dump_root_from_base_into_tokens() {
7346        let css = r#"@layer tokens {
7347    :root {
7348        color-scheme: light dark;
7349    }
7350}
7351
7352@layer base {
7353    :root {
7354        scroll-behavior: smooth;
7355    }
7356    body { margin: 0; }
7357}
7358"#;
7359        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7360        let output = apply_selected_plans(css, &plans, true).unwrap();
7361        assert!(
7362            output.contains("@layer tokens") && output.contains("@layer base"),
7363            "both layers must remain:\n{output}"
7364        );
7365        let tokens = output.split("@layer base").next().unwrap_or(&output);
7366        assert!(
7367            !tokens.contains("scroll-behavior"),
7368            "base :root decls must not move into tokens:\n{output}"
7369        );
7370        assert!(
7371            output.contains("scroll-behavior: smooth"),
7372            "base :root decls must survive:\n{output}"
7373        );
7374    }
7375
7376    #[test]
7377    fn nest_layer_by_selector_preserves_layer_identity() {
7378        let css = r#"@layer tokens {
7379    :root {
7380        color-scheme: light dark;
7381    }
7382}
7383
7384@layer base {
7385    :root {
7386        scroll-behavior: smooth;
7387    }
7388    body { margin: 0; }
7389}
7390"#;
7391        let plans = plan(css, &[RuleId::NestLayerBySelector]);
7392        let output = apply_selected_plans(css, &plans, true).unwrap();
7393        assert!(
7394            output.contains(":root {")
7395                && output.contains("@layer tokens {")
7396                && output.contains("@layer base {"),
7397            "should hoist :root and nest named layers:\n{output}"
7398        );
7399        assert!(
7400            output.contains("color-scheme: light dark")
7401                && output.contains("scroll-behavior: smooth")
7402                && output.contains("body"),
7403            "all declarations must survive:\n{output}"
7404        );
7405        // Must not create child layers like tokens.base
7406        assert!(
7407            !output.contains("@layer tokens {\n    :root")
7408                || output.find(":root {").unwrap() < output.find("@layer tokens {").unwrap_or(0)
7409                || output.matches("@layer tokens").count() >= 1,
7410            "{output}"
7411        );
7412        let root_at = output.find(":root {").expect("root");
7413        let tokens_inner = output[root_at..]
7414            .find("@layer tokens {")
7415            .expect("nested tokens");
7416        let base_inner = output[root_at..]
7417            .find("@layer base {")
7418            .expect("nested base");
7419        assert!(
7420            tokens_inner < base_inner,
7421            "layer order tokens then base must be preserved:\n{output}"
7422        );
7423        assert!(
7424            output.contains("body {") || output.contains("body{"),
7425            "unrelated base rules stay in @layer base:\n{output}"
7426        );
7427    }
7428
7429    #[test]
7430    fn nest_layer_blocks_are_not_factored_into_anonymous_layer() {
7431        // Second-pass gather of two :root blocks must not turn
7432        // `@layer tokens { }` + `@layer base { }` into
7433        // `@layer { tokens {} base {} }`.
7434        let css = r#":root {
7435    @layer tokens {
7436        color-scheme: light dark;
7437    }
7438
7439    @layer base {
7440        scroll-behavior: smooth;
7441    }
7442}
7443
7444:root {
7445    scrollbar-width: thin;
7446}
7447"#;
7448        let plans = plan(
7449            css,
7450            &[
7451                RuleId::GatherRelatedSelectorRules,
7452                RuleId::NestLayerBySelector,
7453            ],
7454        );
7455        let output = apply_selected_plans(css, &plans, true).unwrap();
7456        assert!(
7457            output.contains("@layer tokens") && output.contains("@layer base"),
7458            "named layers must stay named:\n{output}"
7459        );
7460        assert!(
7461            !output.contains("@layer {\n        tokens")
7462                && !output.contains("@layer {\n    tokens")
7463                && !output.contains("@layer {\n        base"),
7464            "must not wrap layer names as type selectors:\n{output}"
7465        );
7466        assert!(
7467            output.contains("color-scheme: light dark")
7468                && output.contains("scroll-behavior: smooth")
7469                && output.contains("scrollbar-width: thin"),
7470            "declarations must survive:\n{output}"
7471        );
7472    }
7473
7474    #[test]
7475    fn nest_layer_skips_when_unlayered_rule_intervenes() {
7476        let css = r#"@layer base {
7477    .container { color: red; }
7478}
7479
7480.some-rule { color: blue; }
7481
7482@layer layout {
7483    .container { color: green; }
7484}
7485"#;
7486        let plans = plan(css, &[RuleId::NestLayerBySelector]);
7487        let output = apply_selected_plans(css, &plans, true).unwrap();
7488        assert!(
7489            output.contains("@layer base")
7490                && output.contains("@layer layout")
7491                && output.contains(".some-rule"),
7492            "intervening unlayered rule blocks the hoist:\n{output}"
7493        );
7494        assert!(
7495            !output.contains(".container {\n    @layer base"),
7496            "must not move layout across .some-rule:\n{output}"
7497        );
7498    }
7499
7500    #[test]
7501    fn gather_factors_compact_descendants_under_option_card() {
7502        let css = r#".option-card.compact .option-label {
7503    padding: 1rem;
7504}
7505
7506.option-card.compact .option-icon {
7507    inline-size: 24px;
7508}
7509
7510.unrelated { color: red }
7511
7512.option-card {
7513    position: relative;
7514    cursor: pointer;
7515}
7516"#;
7517        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7518        let output = apply_selected_plans(css, &plans, true).unwrap();
7519        assert!(
7520            output.contains("&.compact")
7521                && output.contains(".option-label")
7522                && output.contains(".option-icon"),
7523            "compact descendants should nest under .option-card:\n{output}"
7524        );
7525        assert!(
7526            output.contains("&.compact {")
7527                && output.contains(".option-label {")
7528                && output.contains(".option-icon {"),
7529            "shared &.compact prefix should be factored:\n{output}"
7530        );
7531        assert!(output.contains("position: relative"), "{output}");
7532        assert!(output.contains("padding: 1rem"), "{output}");
7533        assert!(output.contains("inline-size: 24px"), "{output}");
7534    }
7535
7536    #[test]
7537    fn gather_does_not_orphan_deletes_when_shorter_nest_wins() {
7538        let css = r#".custom-select button {
7539    display: flex;
7540}
7541.custom-select button:hover {
7542    color: red;
7543}
7544
7545.unrelated { color: blue }
7546
7547.custom-select::picker(select) {
7548    background: white;
7549}
7550.custom-select::picker-icon {
7551    display: none;
7552}
7553"#;
7554        let rules = &[
7555            RuleId::NestPseudoClass,
7556            RuleId::NestDescendant,
7557            RuleId::GatherRelatedSelectorRules,
7558        ];
7559        let plans = plan(css, rules);
7560        let output = apply_selected_plans(css, &plans, true).unwrap();
7561        assert!(
7562            output.contains("background: white") && output.contains("display: none"),
7563            "picker chrome must survive mixed nest+gather:\n{output}"
7564        );
7565        assert!(
7566            output.contains("display: flex") && output.contains("color: red"),
7567            "button rules must survive:\n{output}"
7568        );
7569    }
7570}