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