Skip to main content

cssforge_core/
engine.rs

1use crate::{
2    model::{
3        AnalysisStats, FileReport, Finding, PlanEntry, Proof, RuleId, Safety, SourceRange,
4        WorkspaceReport, WorkspaceSummary,
5    },
6    scanner::{
7        NodeKind, SourceNode, count_ascii_case_insensitive_outside_comments,
8        count_top_level_declarations, is_whitespace_only, scan_nodes,
9    },
10};
11use anyhow::{Context, Result};
12use lightningcss::stylesheet::{ParserOptions, StyleSheet};
13use similar::TextDiff;
14use std::{
15    collections::{HashMap, HashSet},
16    fs,
17    ops::Range,
18    path::{Path, PathBuf},
19};
20
21const SPEC_BASELINE: &str = "2026-08-17";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
24pub struct Specificity {
25    pub ids: usize,
26    pub classes: usize,
27    pub elements: usize,
28}
29
30pub fn calculate_specificity(selector: &str) -> Specificity {
31    let mut ids = 0;
32    let mut classes = 0;
33    let mut elements = 0;
34    let bytes = selector.as_bytes();
35    let mut i = 0;
36    let mut in_attr = false;
37
38    while i < bytes.len() {
39        let b = bytes[i];
40        if b == b'[' {
41            in_attr = true;
42            classes += 1;
43            i += 1;
44            continue;
45        }
46        if b == b']' {
47            in_attr = false;
48            i += 1;
49            continue;
50        }
51        if in_attr {
52            i += 1;
53            continue;
54        }
55
56        if b == b'#' {
57            ids += 1;
58            i += 1;
59            while i < bytes.len()
60                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
61            {
62                i += 1;
63            }
64            continue;
65        }
66
67        if b == b'.' {
68            classes += 1;
69            i += 1;
70            while i < bytes.len()
71                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
72            {
73                i += 1;
74            }
75            continue;
76        }
77
78        if b == b':' {
79            if i + 1 < bytes.len() && bytes[i + 1] == b':' {
80                elements += 1;
81                i += 2;
82                while i < bytes.len()
83                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
84                {
85                    i += 1;
86                }
87            } else {
88                let start_name = i + 1;
89                let mut end_name = start_name;
90                while end_name < bytes.len()
91                    && (bytes[end_name].is_ascii_alphanumeric() || bytes[end_name] == b'-')
92                {
93                    end_name += 1;
94                }
95                let pseudo_name = &selector[start_name..end_name];
96                if pseudo_name == "where" {
97                    if end_name < bytes.len() && bytes[end_name] == b'(' {
98                        if let Some(close_p) = find_matching_paren(selector, end_name) {
99                            i = close_p + 1;
100                            continue;
101                        }
102                    }
103                } else if pseudo_name == "is" || pseudo_name == "not" || pseudo_name == "has" {
104                    if end_name < bytes.len() && bytes[end_name] == b'(' {
105                        if let Some(close_p) = find_matching_paren(selector, end_name) {
106                            let inner = &selector[end_name + 1..close_p];
107                            let max_inner = split_top_level_comma(inner)
108                                .into_iter()
109                                .map(|s| calculate_specificity(s.trim()))
110                                .max()
111                                .unwrap_or_default();
112                            ids += max_inner.ids;
113                            classes += max_inner.classes;
114                            elements += max_inner.elements;
115                            i = close_p + 1;
116                            continue;
117                        }
118                    }
119                    classes += 1;
120                } else {
121                    classes += 1;
122                }
123                i = end_name;
124            }
125            continue;
126        }
127
128        if (b.is_ascii_alphabetic() || b == b'*')
129            && (i == 0
130                || bytes[i - 1].is_ascii_whitespace()
131                || bytes[i - 1] == b'>'
132                || bytes[i - 1] == b'+'
133                || bytes[i - 1] == b'~'
134                || bytes[i - 1] == b'|')
135        {
136            if b != b'*' {
137                elements += 1;
138            }
139            i += 1;
140            while i < bytes.len()
141                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
142            {
143                i += 1;
144            }
145            continue;
146        }
147
148        i += 1;
149    }
150
151    Specificity {
152        ids,
153        classes,
154        elements,
155    }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum RelationKind {
160    PseudoClass,
161    PseudoElement,
162    Attribute,
163    Compound,
164    Descendant,
165    Combinator,
166}
167
168impl RelationKind {
169    fn rule(self) -> RuleId {
170        match self {
171            Self::PseudoClass => RuleId::NestPseudoClass,
172            Self::PseudoElement => RuleId::NestPseudoElement,
173            Self::Attribute => RuleId::NestAttribute,
174            Self::Compound => RuleId::NestCompound,
175            Self::Descendant => RuleId::NestDescendant,
176            Self::Combinator => RuleId::NestCombinator,
177        }
178    }
179}
180
181#[derive(Debug, Clone)]
182enum ConditionalInner {
183    Direct {
184        body_range: Range<usize>,
185    },
186    Nested {
187        nested_selector: String,
188        body_range: Range<usize>,
189    },
190}
191
192#[derive(Debug, Clone)]
193enum ClusterChild {
194    Style {
195        node: SourceNode,
196        relation: RelationKind,
197        nested_selector: String,
198    },
199    Conditional {
200        node: SourceNode,
201        rule: RuleId,
202        inners: Vec<ConditionalInner>,
203    },
204}
205
206impl ClusterChild {
207    fn node(&self) -> &SourceNode {
208        match self {
209            Self::Style { node, .. } | Self::Conditional { node, .. } => node,
210        }
211    }
212
213    fn rule(&self) -> RuleId {
214        match self {
215            Self::Style { relation, .. } => relation.rule(),
216            Self::Conditional { rule, .. } => *rule,
217        }
218    }
219}
220
221pub fn analyze_file(path: &Path, enabled_rules: &[RuleId]) -> Result<FileReport> {
222    let source = fs::read_to_string(path)
223        .with_context(|| format!("failed to read CSS file {}", path.display()))?;
224    analyze_source(path.to_path_buf(), &source, enabled_rules)
225}
226
227pub fn analyze_workspace(
228    root: &Path,
229    files: &[PathBuf],
230    enabled_rules: &[RuleId],
231) -> Result<WorkspaceReport> {
232    let mut reports = Vec::with_capacity(files.len());
233    let mut next_id = 1usize;
234
235    for path in files {
236        let mut report = analyze_file(path, enabled_rules)?;
237        for plan in &mut report.plans {
238            plan.id = format!("T-{next_id:06}");
239            next_id += 1;
240        }
241        reports.push(report);
242    }
243
244    let mut summary = WorkspaceSummary {
245        files: reports.len(),
246        ..WorkspaceSummary::default()
247    };
248
249    for report in &reports {
250        if !report.parse_ok {
251            summary.parse_errors += 1;
252        }
253        summary.rules_analyzed +=
254            report.stats.top_level_style_rules + report.stats.top_level_at_rules;
255        for plan in &report.plans {
256            match plan.safety {
257                Safety::Safe => summary.safe += 1,
258                Safety::Review => summary.review += 1,
259                Safety::Unsafe => summary.unsafe_count += 1,
260                Safety::Unsupported => summary.unsupported += 1,
261                Safety::NoOp => summary.no_op += 1,
262            }
263            if plan
264                .warnings
265                .iter()
266                .any(|w| w.to_ascii_lowercase().contains("specificity"))
267            {
268                summary.specificity_sensitive += 1;
269            }
270            if plan.warnings.iter().any(|w| {
271                w.to_ascii_lowercase().contains("cascade")
272                    || w.to_ascii_lowercase().contains("source order")
273            }) {
274                summary.cascade_sensitive += 1;
275            }
276            if plan
277                .warnings
278                .iter()
279                .any(|w| w.to_ascii_lowercase().contains("layer"))
280            {
281                summary.layer_sensitive += 1;
282            }
283            if plan
284                .warnings
285                .iter()
286                .any(|w| w.to_ascii_lowercase().contains("scope"))
287            {
288                summary.scope_sensitive += 1;
289            }
290        }
291    }
292
293    Ok(WorkspaceReport {
294        tool_version: env!("CARGO_PKG_VERSION").to_string(),
295        spec_baseline: SPEC_BASELINE.to_string(),
296        root: root.to_path_buf(),
297        enabled_rules: enabled_rules.to_vec(),
298        files: reports,
299        summary,
300    })
301}
302
303fn analyze_source(path: PathBuf, source: &str, enabled_rules: &[RuleId]) -> Result<FileReport> {
304    let parse_result = StyleSheet::parse(
305        source,
306        ParserOptions {
307            filename: path.display().to_string(),
308            error_recovery: false,
309            ..ParserOptions::default()
310        },
311    );
312
313    let parse_error = parse_result.err().map(|err| format!("{err:?}"));
314    let parse_ok = parse_error.is_none();
315    let nodes = scan_nodes(source, 0..source.len());
316    let stats = collect_stats(source, &nodes, parse_ok);
317    let mut findings = collect_findings(source, &nodes);
318
319    if let Some(error) = &parse_error {
320        findings.push(Finding {
321            safety: Safety::Unsupported,
322            title: "Semantic parse failed".into(),
323            detail: error.clone(),
324        });
325    }
326
327    let plans = if parse_ok && !enabled_rules.is_empty() {
328        build_plans_recursive(&path, source, &nodes, enabled_rules)
329    } else {
330        Vec::new()
331    };
332
333    Ok(FileReport {
334        path,
335        parse_ok,
336        parse_error,
337        stats,
338        findings,
339        plans,
340    })
341}
342
343fn collect_stats(source: &str, nodes: &[SourceNode], parse_ok: bool) -> AnalysisStats {
344    let mut stats = AnalysisStats {
345        bytes: source.len(),
346        parse_errors: usize::from(!parse_ok),
347        important_declarations: count_ascii_case_insensitive_outside_comments(source, "!important"),
348        ..AnalysisStats::default()
349    };
350    let mut selector_counts: HashMap<String, usize> = HashMap::new();
351
352    for node in nodes {
353        match &node.kind {
354            NodeKind::Style => {
355                stats.top_level_style_rules += 1;
356                let selector = node.prelude(source).to_string();
357                *selector_counts.entry(selector).or_default() += 1;
358                if let Some(body) = node.body(source) {
359                    stats.declarations += count_top_level_declarations(body);
360                    stats.custom_properties += count_custom_properties(body);
361                }
362            }
363            NodeKind::AtBlock { name, .. } => {
364                stats.top_level_at_rules += 1;
365                match name.as_str() {
366                    "media" => stats.media_rules += 1,
367                    "supports" => stats.supports_rules += 1,
368                    "container" => stats.container_rules += 1,
369                    "layer" => stats.layer_rules += 1,
370                    "scope" => stats.scope_rules += 1,
371                    "starting-style" => stats.starting_style_rules += 1,
372                    _ => {}
373                }
374            }
375            NodeKind::AtStatement { .. } => stats.top_level_at_rules += 1,
376        }
377    }
378
379    stats.duplicate_selectors = selector_counts.values().filter(|&&count| count > 1).count();
380    stats
381}
382
383fn count_custom_properties(body: &str) -> usize {
384    body.lines()
385        .filter(|line| {
386            let trimmed = line.trim_start();
387            trimmed.starts_with("--") && trimmed.contains(':')
388        })
389        .count()
390}
391
392fn collect_findings(source: &str, nodes: &[SourceNode]) -> Vec<Finding> {
393    let mut findings = Vec::new();
394    let selectors: HashSet<String> = nodes
395        .iter()
396        .filter(|n| matches!(&n.kind, NodeKind::Style))
397        .map(|n| n.prelude(source).to_string())
398        .collect();
399
400    let mut selector_occurrences: HashMap<String, usize> = HashMap::new();
401
402    for node in nodes {
403        match &node.kind {
404            NodeKind::Style => {
405                let selector = node.prelude(source);
406                *selector_occurrences.entry(selector.to_string()).or_default() += 1;
407
408                if contains_top_level_comma(selector) {
409                    let branches = split_top_level_comma(selector);
410                    let specs: Vec<Specificity> = branches.iter().map(|b| calculate_specificity(b.trim())).collect();
411                    let has_mixed = specs.windows(2).any(|w| w[0] != w[1]);
412                    if has_mixed {
413                        findings.push(Finding {
414                            safety: Safety::Review,
415                            title: "Mixed-specificity selector list detected".into(),
416                            detail: format!("{selector}: contains branches with differing specificities; factoring into :is() or parent nesting would raise lower-specificity branches."),
417                        });
418                    } else {
419                        findings.push(Finding {
420                            safety: Safety::Review,
421                            title: "Selector list kept flat".into(),
422                            detail: format!("{selector}: parent selector lists require per-branch specificity proof before native nesting."),
423                        });
424                    }
425                }
426
427                if let Some(base) = bem_base_candidate(selector) {
428                    if selectors.contains(base) {
429                        findings.push(Finding {
430                            safety: Safety::Unsupported,
431                            title: "BEM token concatenation is not native nesting".into(),
432                            detail: format!("{selector} resembles {base} + a BEM suffix; CSS nesting cannot safely generate &__element or &--modifier."),
433                        });
434                    }
435                }
436
437                if let Some(body) = node.body(source) {
438                    if body.trim().is_empty() {
439                        findings.push(Finding {
440                            safety: Safety::Review,
441                            title: "Empty rule block detected".into(),
442                            detail: format!("{selector} contains no declarations or nested rules."),
443                        });
444                    }
445
446                    let mut seen_props: HashMap<String, String> = HashMap::new();
447                    for line in body.lines() {
448                        let trimmed = line.trim();
449                        if trimmed.starts_with("/*") || trimmed.starts_with('*') || !trimmed.contains(':') {
450                            continue;
451                        }
452                        if let Some((prop, val)) = trimmed.split_once(':') {
453                            let prop = prop.trim().to_ascii_lowercase();
454                            let val = val.trim().trim_end_matches(';').trim().to_string();
455                            if let Some(prev_val) = seen_props.get(&prop) {
456                                if prev_val == &val {
457                                    findings.push(Finding {
458                                        safety: Safety::Review,
459                                        title: "Exact duplicate declaration detected".into(),
460                                        detail: format!("In {selector}: property '{prop}: {val}' is declared multiple times with identical value."),
461                                    });
462                                }
463                            } else {
464                                seen_props.insert(prop, val);
465                            }
466                        }
467                    }
468
469                    if selector.contains(" .") && !selector.contains(":has(") {
470                        findings.push(Finding {
471                            safety: Safety::Review,
472                            title: "Potential :has() relational candidate".into(),
473                            detail: format!("{selector}: parent-child descendant relationship could be expressed with :has() if container-targeting is intended (advisory)."),
474                        });
475                    }
476                }
477            }
478            NodeKind::AtBlock { name, .. } => match name.as_str() {
479                "layer" => findings.push(Finding {
480                    safety: Safety::Review,
481                    title: "Cascade layer context detected".into(),
482                    detail: "@layer participates in cascade ordering and reverses layer precedence for !important; automatic layer architecture is not applied.".into(),
483                }),
484                "scope" => findings.push(Finding {
485                    safety: Safety::Review,
486                    title: "Scope boundary detected".into(),
487                    detail: "@scope boundaries enforce doughnut scoping; scoping parameters require manual architect review.".into(),
488                }),
489                "container" => findings.push(Finding {
490                    safety: Safety::Review,
491                    title: "Container query context detected".into(),
492                    detail: "@container depends on eligible ancestor containers; media-to-container conversion is not inferred from CSS alone.".into(),
493                }),
494                "starting-style" => findings.push(Finding {
495                    safety: Safety::Review,
496                    title: "Starting-style context detected".into(),
497                    detail: "@starting-style is temporal transition state; this build never invents it from ordinary declarations.".into(),
498                }),
499                _ => {}
500            },
501            NodeKind::AtStatement { .. } => {}
502        }
503    }
504
505    for (selector, count) in selector_occurrences {
506        if count > 1 {
507            findings.push(Finding {
508                safety: Safety::Review,
509                title: "Duplicate selector in stylesheet".into(),
510                detail: format!("'{selector}' appears {count} times in the stylesheet; non-adjacent occurrences must not be merged across intervening rules."),
511            });
512        }
513    }
514
515    findings
516}
517
518fn bem_base_candidate(selector: &str) -> Option<&str> {
519    if let Some(pos) = selector.find("__") {
520        let base = &selector[..pos];
521        if !base.is_empty() && !base.contains(' ') {
522            return Some(base);
523        }
524    }
525    if let Some(pos) = selector.find("--") {
526        let base = &selector[..pos];
527        if !base.is_empty() && !base.contains(' ') {
528            return Some(base);
529        }
530    }
531    None
532}
533
534const TRANSPARENT_AT_RULES: &[&str] = &["layer", "scope", "media", "supports", "container"];
535
536fn build_plans_recursive(
537    path: &Path,
538    source: &str,
539    nodes: &[SourceNode],
540    enabled_rules: &[RuleId],
541) -> Vec<PlanEntry> {
542    let mut plans = build_plans(path, source, nodes, enabled_rules);
543
544    for node in nodes {
545        if let NodeKind::AtBlock { name, .. } = &node.kind {
546            if TRANSPARENT_AT_RULES.contains(&name.as_str()) {
547                let is_covered = plans
548                    .iter()
549                    .any(|p| p.source_range.start <= node.start && node.end <= p.source_range.end);
550                if !is_covered {
551                    if let Some(body_range) = &node.body_range {
552                        let inner_nodes = scan_nodes(source, body_range.clone());
553                        if !inner_nodes.is_empty() {
554                            let inner_plans =
555                                build_plans_recursive(path, source, &inner_nodes, enabled_rules);
556                            plans.extend(inner_plans);
557                        }
558                    }
559                }
560            }
561        }
562    }
563
564    plans.sort_by(|a, b| {
565        a.source_range
566            .start
567            .cmp(&b.source_range.start)
568            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
569    });
570    let mut disjoint = Vec::with_capacity(plans.len());
571    let mut last_end = 0;
572    for p in plans {
573        if p.source_range.start >= last_end {
574            last_end = p.source_range.end;
575            disjoint.push(p);
576        }
577    }
578
579    disjoint
580}
581
582fn build_plans(
583    path: &Path,
584    source: &str,
585    nodes: &[SourceNode],
586    enabled_rules: &[RuleId],
587) -> Vec<PlanEntry> {
588    let enabled: HashSet<RuleId> = enabled_rules.iter().copied().collect();
589    let mut plans = Vec::new();
590
591    // 1. Structural At-rule refactorings across top-level nodes
592    plan_merge_same_named_layers(path, source, nodes, &enabled, &mut plans);
593    plan_merge_adjacent_at_blocks(path, source, nodes, &enabled, &mut plans);
594    plan_gather_consecutive_conditions_by_selector(path, source, nodes, &enabled, &mut plans);
595    plan_merge_adjacent_identical_selectors(path, source, nodes, &enabled, &mut plans);
596    plan_gather_related_selector_rules(path, source, nodes, &enabled, &mut plans);
597    plan_merge_identical_rule_bodies(path, source, nodes, &enabled, &mut plans);
598    plan_factor_identical_states_with_is(path, source, nodes, &enabled, &mut plans);
599    plan_factor_multi_selector_cluster_with_is(path, source, nodes, &enabled, &mut plans);
600    plan_nest_in_place_adjacent_states(path, source, nodes, &enabled, &mut plans);
601
602    let mut i = 0usize;
603
604    while i < nodes.len() {
605        let parent = &nodes[i];
606
607        // ModernizeMediaRange on at-rules
608        if enabled.contains(&RuleId::ModernizeMediaRange) {
609            if let NodeKind::AtBlock { name, .. } = &parent.kind {
610                if name == "media" || name == "container" {
611                    let prelude = parent.prelude(source);
612                    if let Some(modernized) = modernize_media_query_str(prelude) {
613                        plans.push(PlanEntry {
614                            id: String::new(),
615                            file: path.to_path_buf(),
616                            rules: vec![RuleId::ModernizeMediaRange],
617                            safety: Safety::Safe,
618                            source_range: SourceRange {
619                                start: parent.prelude_range.start,
620                                end: parent.prelude_range.end,
621                            },
622                            original: source[parent.prelude_range.clone()].to_string(),
623                            proposed: modernized,
624                            proof: Proof::safe_local(),
625                            warnings: Vec::new(),
626                            reason: "Modernize legacy media/container feature syntax to CSS Range Syntax (e.g. (width >= 800px)).".to_string(),
627                            selected: true,
628                        });
629                    }
630                }
631            }
632        }
633
634        if !matches!(&parent.kind, NodeKind::Style) {
635            i += 1;
636            continue;
637        }
638
639        let parent_selector = parent.prelude(source);
640
641        // FactorSelectorList
642        if contains_top_level_comma(parent_selector) {
643            let parent_indent = line_indent(source, parent.start);
644            let parent_body_range = parent.body_range.clone();
645            let unit = parent_body_range
646                .as_ref()
647                .and_then(|r| detect_indent_unit(source, r.clone()))
648                .unwrap_or_else(|| "  ".to_string());
649
650            if enabled.contains(&RuleId::FactorSelectorList) {
651                if let Some(body_range) = &parent.body_range {
652                    let body = &source[body_range.clone()];
653                    if let Some(mut factored) =
654                        factor_selector_list(parent_selector, body, &parent_indent, &unit)
655                    {
656                        let branches: Vec<&str> = split_top_level_comma(parent_selector)
657                            .into_iter()
658                            .map(|s| s.trim())
659                            .collect();
660                        let base = branches[0];
661
662                        // Check if subsequent adjacent style rules share base (e.g. .notice:hover)
663                        let mut cursor = i + 1;
664                        let mut prev_end = parent.end;
665                        let mut extra_children = Vec::new();
666
667                        while cursor < nodes.len() {
668                            let next = &nodes[cursor];
669                            if !is_whitespace_only(source, prev_end..next.start) {
670                                break;
671                            }
672                            if matches!(&next.kind, NodeKind::Style) {
673                                if let Some((rel, nested_sel)) =
674                                    selector_relation(base, next.prelude(source))
675                                {
676                                    if enabled.contains(&rel.rule()) {
677                                        extra_children.push(ClusterChild::Style {
678                                            node: next.clone(),
679                                            relation: rel,
680                                            nested_selector: nested_sel,
681                                        });
682                                        prev_end = next.end;
683                                        cursor += 1;
684                                        continue;
685                                    }
686                                }
687                            }
688                            break;
689                        }
690
691                        let end_offset = if extra_children.is_empty() {
692                            parent.end
693                        } else {
694                            let nested_indent = format!("{parent_indent}{unit}");
695                            let inner_decl_indent = format!("{nested_indent}{unit}");
696                            let mut extra_rendered = String::new();
697
698                            for ch in &extra_children {
699                                if let ClusterChild::Style {
700                                    node: ch_node,
701                                    nested_selector,
702                                    ..
703                                } = ch
704                                {
705                                    extra_rendered.push('\n');
706                                    extra_rendered.push_str(&nested_indent);
707                                    extra_rendered.push_str(nested_selector.trim());
708                                    extra_rendered.push_str(" {\n");
709                                    if let Some(ch_body_range) = &ch_node.body_range {
710                                        for line in source[ch_body_range.clone()].lines() {
711                                            let trimmed = line.trim();
712                                            if !trimmed.is_empty() {
713                                                extra_rendered.push_str(&inner_decl_indent);
714                                                extra_rendered.push_str(&ensure_semicolon(trimmed));
715                                                extra_rendered.push('\n');
716                                            }
717                                        }
718                                    }
719                                    extra_rendered.push_str(&nested_indent);
720                                    extra_rendered.push_str("}\n");
721                                }
722                            }
723
724                            if let Some(close_brace_pos) = factored.rfind('}') {
725                                factored.insert_str(close_brace_pos, &extra_rendered);
726                            }
727                            prev_end
728                        };
729
730                        plans.push(PlanEntry {
731                            id: String::new(),
732                            file: path.to_path_buf(),
733                            rules: vec![RuleId::FactorSelectorList],
734                            safety: Safety::Safe,
735                            source_range: SourceRange {
736                                start: parent.start,
737                                end: end_offset,
738                            },
739                            original: source[parent.start..end_offset].to_string(),
740                            proposed: factored,
741                            proof: Proof::safe_local(),
742                            warnings: Vec::new(),
743                            reason: "Factor comma-separated selectors sharing a common base element into nested form.".to_string(),
744                            selected: true,
745                        });
746                        i = cursor;
747                        continue;
748                    }
749                }
750            }
751
752            if enabled.contains(&RuleId::ModernizeIs) {
753                if let Some((factored_sel, uniform)) = factor_with_is(parent_selector) {
754                    plans.push(PlanEntry {
755                        id: String::new(),
756                        file: path.to_path_buf(),
757                        rules: vec![RuleId::ModernizeIs],
758                        safety: if uniform { Safety::Safe } else { Safety::Review },
759                        source_range: SourceRange {
760                            start: parent.prelude_range.start,
761                            end: parent.prelude_range.end,
762                        },
763                        original: source[parent.prelude_range.clone()].to_string(),
764                        proposed: factored_sel,
765                        proof: Proof {
766                            specificity_equivalent: uniform,
767                            ..Proof::safe_local()
768                        },
769                        warnings: if uniform { Vec::new() } else { vec!["Mixed branch specificity: :is() takes the specificity of its most specific argument.".into()] },
770                        reason: "Factor common selector prefix/suffix into :is(...) grouping.".to_string(),
771                        selected: true,
772                    });
773                    i += 1;
774                    continue;
775                }
776            }
777
778            if enabled.contains(&RuleId::ModernizeWhere) {
779                if let Some(factored_where) = factor_with_where(parent_selector) {
780                    plans.push(PlanEntry {
781                        id: String::new(),
782                        file: path.to_path_buf(),
783                        rules: vec![RuleId::ModernizeWhere],
784                        safety: Safety::Review,
785                        source_range: SourceRange {
786                            start: parent.prelude_range.start,
787                            end: parent.prelude_range.end,
788                        },
789                        original: source[parent.prelude_range.clone()].to_string(),
790                        proposed: factored_where,
791                        proof: Proof {
792                            specificity_equivalent: false,
793                            ..Proof::safe_local()
794                        },
795                        warnings: vec!["Specificity zeroed to 0-0-0 by :where()".into()],
796                        reason: "Convert selector list to :where(...) for zero-specificity defaults (review required).".to_string(),
797                        selected: true,
798                    });
799                    i += 1;
800                    continue;
801                }
802            }
803
804            i += 1;
805            continue;
806        }
807
808        if parent_selector.contains("::") {
809            i += 1;
810            continue;
811        }
812
813        let mut children = Vec::new();
814        let mut cursor = i + 1;
815        let mut previous_end = parent.end;
816
817        while cursor < nodes.len() {
818            let node = &nodes[cursor];
819            if !is_whitespace_only(source, previous_end..node.start) {
820                break;
821            }
822
823            if matches!(&node.kind, NodeKind::Style) {
824                if let Some((relation, nested_selector)) =
825                    selector_relation(parent_selector, node.prelude(source))
826                {
827                    if enabled.contains(&relation.rule()) {
828                        children.push(ClusterChild::Style {
829                            node: node.clone(),
830                            relation,
831                            nested_selector,
832                        });
833                        previous_end = node.end;
834                        cursor += 1;
835                        continue;
836                    }
837                }
838            }
839
840            if let Some(child) = conditional_child(source, parent_selector, node, &enabled) {
841                previous_end = node.end;
842                children.push(child);
843                cursor += 1;
844                continue;
845            }
846
847            break;
848        }
849
850        if !children.is_empty() {
851            let last_end = children.last().expect("non-empty cluster").node().end;
852            let proposed = render_cluster(source, parent, &children);
853            let mut rules = Vec::new();
854            for child in &children {
855                let rule = child.rule();
856                if !rules.contains(&rule) {
857                    rules.push(rule);
858                }
859            }
860            plans.push(PlanEntry {
861                id: String::new(),
862                file: path.to_path_buf(),
863                rules,
864                safety: Safety::Safe,
865                source_range: SourceRange {
866                    start: parent.start,
867                    end: last_end,
868                },
869                original: source[parent.start..last_end].to_string(),
870                proposed,
871                proof: Proof::safe_local(),
872                warnings: Vec::new(),
873                reason: format!(
874                    "{} immediately adjacent rule(s) share the exact parent selector and can be nested without crossing comments or unrelated rules.",
875                    children.len()
876                ),
877                selected: true,
878            });
879            i = cursor;
880        } else {
881            if enabled.contains(&RuleId::ConsolidateNot) && matches!(&parent.kind, NodeKind::Style)
882            {
883                let prelude = parent.prelude(source);
884                if let Some((consolidated, _uniform)) = consolidate_not_in_selector(prelude) {
885                    plans.push(PlanEntry {
886                        id: String::new(),
887                        file: path.to_path_buf(),
888                        rules: vec![RuleId::ConsolidateNot],
889                        safety: Safety::Review,
890                        source_range: SourceRange {
891                            start: parent.prelude_range.start,
892                            end: parent.prelude_range.end,
893                        },
894                        original: source[parent.prelude_range.clone()].to_string(),
895                        proposed: consolidated,
896                        proof: Proof {
897                            specificity_equivalent: false,
898                            ..Proof::safe_local()
899                        },
900                        warnings: vec!["Specificity reduced: chained :not() has additive specificity; comma-separated :not() takes only the maximum argument specificity.".into()],
901                        reason: "Consolidate chained :not() selectors into a single comma-separated :not() list (review required for specificity drop).".to_string(),
902                        selected: true,
903                    });
904                }
905            }
906            i += 1;
907        }
908    }
909
910    plans
911}
912
913fn plan_merge_same_named_layers(
914    path: &Path,
915    source: &str,
916    nodes: &[SourceNode],
917    enabled: &HashSet<RuleId>,
918    plans: &mut Vec<PlanEntry>,
919) {
920    if !enabled.contains(&RuleId::MergeSameNamedLayer) {
921        return;
922    }
923    let mut layer_groups: HashMap<String, Vec<&SourceNode>> = HashMap::new();
924    for node in nodes {
925        if let NodeKind::AtBlock { name, .. } = &node.kind {
926            if name == "layer" {
927                let prelude = node.prelude(source).trim();
928                if let Some(layer_name) = prelude.strip_prefix("@layer") {
929                    let layer_name = layer_name.trim();
930                    if !layer_name.is_empty() && !layer_name.contains('{') {
931                        layer_groups
932                            .entry(layer_name.to_string())
933                            .or_default()
934                            .push(node);
935                    }
936                }
937            }
938        }
939    }
940
941    let enabled_rules_vec: Vec<RuleId> = enabled.iter().copied().collect();
942
943    for (layer_name, blocks) in layer_groups {
944        if blocks.len() > 1 {
945            let first = blocks[0];
946            let parent_indent = line_indent(source, first.start);
947            let first_body_range = first.body_range.as_ref().unwrap();
948            let unit = detect_indent_unit(source, first_body_range.clone())
949                .unwrap_or_else(|| "  ".to_string());
950            let nested_indent = format!("{parent_indent}{unit}");
951
952            let mut merged_body = String::new();
953            for b in &blocks {
954                if let Some(body_range) = &b.body_range {
955                    let inner_nodes = scan_nodes(source, body_range.clone());
956                    let inner_plans =
957                        build_plans_recursive(path, source, &inner_nodes, &enabled_rules_vec);
958                    let body_text = &source[body_range.clone()];
959                    let modernized_body = if inner_plans.is_empty() {
960                        body_text.to_string()
961                    } else {
962                        let mut local_plans = Vec::new();
963                        for p in inner_plans {
964                            if p.source_range.start >= body_range.start
965                                && p.source_range.end <= body_range.end
966                            {
967                                let mut local_p = p.clone();
968                                local_p.source_range.start -= body_range.start;
969                                local_p.source_range.end -= body_range.start;
970                                local_plans.push(local_p);
971                            }
972                        }
973                        apply_selected_plans(body_text, &local_plans, true)
974                            .unwrap_or_else(|_| body_text.to_string())
975                    };
976
977                    for line in modernized_body.lines() {
978                        let trimmed = line.trim();
979                        if !trimmed.is_empty() {
980                            merged_body.push_str(&nested_indent);
981                            merged_body.push_str(&ensure_semicolon(trimmed));
982                            merged_body.push('\n');
983                        }
984                    }
985                }
986            }
987
988            let proposed_first =
989                format!("{parent_indent}@layer {layer_name} {{\n{merged_body}{parent_indent}}}");
990            plans.push(PlanEntry {
991                id: String::new(),
992                file: path.to_path_buf(),
993                rules: vec![RuleId::MergeSameNamedLayer],
994                safety: Safety::Safe,
995                source_range: SourceRange {
996                    start: first.start,
997                    end: first.end,
998                },
999                original: source[first.start..first.end].to_string(),
1000                proposed: proposed_first,
1001                proof: Proof::safe_local(),
1002                warnings: Vec::new(),
1003                reason: format!(
1004                    "Consolidate {} separated blocks of @layer {} into first occurrence.",
1005                    blocks.len(),
1006                    layer_name
1007                ),
1008                selected: true,
1009            });
1010
1011            for subsequent in &blocks[1..] {
1012                plans.push(PlanEntry {
1013                    id: String::new(),
1014                    file: path.to_path_buf(),
1015                    rules: vec![RuleId::MergeSameNamedLayer],
1016                    safety: Safety::Safe,
1017                    source_range: SourceRange {
1018                        start: subsequent.start,
1019                        end: subsequent.end,
1020                    },
1021                    original: source[subsequent.start..subsequent.end].to_string(),
1022                    proposed: String::new(),
1023                    proof: Proof::safe_local(),
1024                    warnings: Vec::new(),
1025                    reason: format!(
1026                        "Remove consolidated subsequent block of @layer {}.",
1027                        layer_name
1028                    ),
1029                    selected: true,
1030                });
1031            }
1032        }
1033    }
1034}
1035
1036fn plan_merge_adjacent_at_blocks(
1037    path: &Path,
1038    source: &str,
1039    nodes: &[SourceNode],
1040    enabled: &HashSet<RuleId>,
1041    plans: &mut Vec<PlanEntry>,
1042) {
1043    let mut i = 0;
1044    while i < nodes.len() {
1045        let first = &nodes[i];
1046        if let NodeKind::AtBlock { name, .. } = &first.kind {
1047            let rule = match name.as_str() {
1048                "media" => RuleId::MergeAdjacentMedia,
1049                "supports" => RuleId::MergeAdjacentSupports,
1050                "container" => RuleId::MergeAdjacentContainer,
1051                "scope" => RuleId::MergeIdenticalScope,
1052                "starting-style" => RuleId::MergeIdenticalStartingStyle,
1053                _ => {
1054                    i += 1;
1055                    continue;
1056                }
1057            };
1058
1059            if !enabled.contains(&rule) {
1060                i += 1;
1061                continue;
1062            }
1063
1064            let first_prelude = first.prelude(source).trim();
1065            let mut cluster = vec![first];
1066            let mut cursor = i + 1;
1067            let mut prev_end = first.end;
1068
1069            while cursor < nodes.len() {
1070                let next = &nodes[cursor];
1071                if !is_whitespace_only(source, prev_end..next.start) {
1072                    break;
1073                }
1074                if let NodeKind::AtBlock {
1075                    name: next_name, ..
1076                } = &next.kind
1077                {
1078                    if next_name == name && next.prelude(source).trim() == first_prelude {
1079                        cluster.push(next);
1080                        prev_end = next.end;
1081                        cursor += 1;
1082                        continue;
1083                    }
1084                }
1085                break;
1086            }
1087
1088            if cluster.len() > 1 {
1089                let last = cluster.last().unwrap();
1090                let parent_indent = line_indent(source, first.start);
1091                let first_body_range = first.body_range.as_ref().unwrap();
1092                let unit = detect_indent_unit(source, first_body_range.clone())
1093                    .unwrap_or_else(|| "  ".to_string());
1094                let nested_indent = format!("{parent_indent}{unit}");
1095
1096                let mut merged_body = String::new();
1097                for c in &cluster {
1098                    if let Some(body_range) = &c.body_range {
1099                        let body_text = &source[body_range.clone()];
1100                        for line in body_text.lines() {
1101                            let trimmed = line.trim();
1102                            if !trimmed.is_empty() {
1103                                merged_body.push_str(&nested_indent);
1104                                merged_body.push_str(&ensure_semicolon(trimmed));
1105                                merged_body.push('\n');
1106                            }
1107                        }
1108                    }
1109                }
1110
1111                let proposed =
1112                    format!("{parent_indent}{first_prelude} {{\n{merged_body}{parent_indent}}}");
1113                plans.push(PlanEntry {
1114                    id: String::new(),
1115                    file: path.to_path_buf(),
1116                    rules: vec![rule],
1117                    safety: Safety::Safe,
1118                    source_range: SourceRange {
1119                        start: first.start,
1120                        end: last.end,
1121                    },
1122                    original: source[first.start..last.end].to_string(),
1123                    proposed,
1124                    proof: Proof::safe_local(),
1125                    warnings: Vec::new(),
1126                    reason: format!(
1127                        "Merge {} adjacent identical {} blocks into a single block.",
1128                        cluster.len(),
1129                        first_prelude
1130                    ),
1131                    selected: true,
1132                });
1133                i = cursor;
1134                continue;
1135            }
1136        }
1137        i += 1;
1138    }
1139}
1140
1141fn plan_gather_consecutive_conditions_by_selector(
1142    path: &Path,
1143    source: &str,
1144    nodes: &[SourceNode],
1145    enabled: &HashSet<RuleId>,
1146    plans: &mut Vec<PlanEntry>,
1147) {
1148    if !enabled.contains(&RuleId::NestMedia) && !enabled.contains(&RuleId::NestSupports) {
1149        return;
1150    }
1151
1152    let mut i = 0;
1153    while i < nodes.len() {
1154        let first = &nodes[i];
1155        if let NodeKind::AtBlock { name, .. } = &first.kind {
1156            if name == "media" || name == "supports" {
1157                if let Some(target_sel) = extract_single_style_selector(source, first) {
1158                    let mut cluster = vec![first];
1159                    let mut cursor = i + 1;
1160                    let mut prev_end = first.end;
1161
1162                    while cursor < nodes.len() {
1163                        let next = &nodes[cursor];
1164                        if !is_whitespace_only(source, prev_end..next.start) {
1165                            break;
1166                        }
1167                        if let NodeKind::AtBlock {
1168                            name: next_name, ..
1169                        } = &next.kind
1170                        {
1171                            if next_name == "media" || next_name == "supports" {
1172                                if let Some(next_sel) = extract_single_style_selector(source, next)
1173                                {
1174                                    if next_sel == target_sel {
1175                                        cluster.push(next);
1176                                        prev_end = next.end;
1177                                        cursor += 1;
1178                                        continue;
1179                                    }
1180                                }
1181                            }
1182                        }
1183                        break;
1184                    }
1185
1186                    if cluster.len() > 1 {
1187                        let last = cluster.last().unwrap();
1188                        let parent_indent = line_indent(source, first.start);
1189                        let first_body_range = first.body_range.as_ref().unwrap();
1190                        let unit = detect_indent_unit(source, first_body_range.clone())
1191                            .unwrap_or_else(|| "  ".to_string());
1192                        let nested_indent = format!("{parent_indent}{unit}");
1193                        let inner_decl_indent = format!("{nested_indent}{unit}");
1194
1195                        let mut body_out = String::new();
1196                        for (idx, &c) in cluster.iter().enumerate() {
1197                            if idx > 0 {
1198                                body_out.push('\n');
1199                            }
1200                            let at_header = c.prelude(source).trim();
1201                            body_out.push_str(&nested_indent);
1202                            body_out.push_str(at_header);
1203                            body_out.push_str(" {\n");
1204
1205                            let c_body_range = c.body_range.as_ref().unwrap();
1206                            let inner_nodes = scan_nodes(source, c_body_range.clone());
1207                            for in_node in &inner_nodes {
1208                                if let Some(in_body_range) = &in_node.body_range {
1209                                    for line in source[in_body_range.clone()].lines() {
1210                                        let trimmed = line.trim();
1211                                        if !trimmed.is_empty() {
1212                                            body_out.push_str(&inner_decl_indent);
1213                                            body_out.push_str(&ensure_semicolon(trimmed));
1214                                            body_out.push('\n');
1215                                        }
1216                                    }
1217                                }
1218                            }
1219
1220                            body_out.push_str(&nested_indent);
1221                            body_out.push_str("}\n");
1222                        }
1223
1224                        let proposed =
1225                            format!("{parent_indent}{target_sel} {{\n{body_out}{parent_indent}}}");
1226                        plans.push(PlanEntry {
1227                            id: String::new(),
1228                            file: path.to_path_buf(),
1229                            rules: vec![RuleId::NestMedia, RuleId::NestSupports],
1230                            safety: Safety::Safe,
1231                            source_range: SourceRange {
1232                                start: first.start,
1233                                end: last.end,
1234                            },
1235                            original: source[first.start..last.end].to_string(),
1236                            proposed,
1237                            proof: Proof::safe_local(),
1238                            warnings: Vec::new(),
1239                            reason: format!(
1240                                "Gather {} consecutive condition blocks targeting '{}' into a single component rule.",
1241                                cluster.len(),
1242                                target_sel
1243                            ),
1244                            selected: true,
1245                        });
1246                        i = cursor;
1247                        continue;
1248                    }
1249                }
1250            }
1251        }
1252        i += 1;
1253    }
1254}
1255
1256fn extract_single_style_selector<'a>(source: &'a str, at_node: &SourceNode) -> Option<&'a str> {
1257    let body_range = at_node.body_range.as_ref()?;
1258    let inner_nodes = scan_nodes(source, body_range.clone());
1259    if inner_nodes.len() == 1 && matches!(&inner_nodes[0].kind, NodeKind::Style) {
1260        Some(inner_nodes[0].prelude(source).trim())
1261    } else {
1262        None
1263    }
1264}
1265
1266fn plan_nest_in_place_adjacent_states(
1267    path: &Path,
1268    source: &str,
1269    nodes: &[SourceNode],
1270    enabled: &HashSet<RuleId>,
1271    plans: &mut Vec<PlanEntry>,
1272) {
1273    if !enabled.contains(&RuleId::NestPseudoClass) {
1274        return;
1275    }
1276
1277    let mut i = 0;
1278    while i < nodes.len() {
1279        let first = &nodes[i];
1280        if matches!(&first.kind, NodeKind::Style) {
1281            let first_sel = first.prelude(source).trim();
1282            if let Some(base) = extract_base_target(first_sel) {
1283                let mut cluster = vec![first];
1284                let mut cursor = i + 1;
1285                let mut prev_end = first.end;
1286
1287                while cursor < nodes.len() {
1288                    let next = &nodes[cursor];
1289                    if !is_whitespace_only(source, prev_end..next.start) {
1290                        break;
1291                    }
1292                    if matches!(&next.kind, NodeKind::Style) {
1293                        let next_sel = next.prelude(source).trim();
1294                        if let Some(next_base) = extract_base_target(next_sel) {
1295                            if next_base == base {
1296                                cluster.push(next);
1297                                prev_end = next.end;
1298                                cursor += 1;
1299                                continue;
1300                            }
1301                        }
1302                    }
1303                    break;
1304                }
1305
1306                if cluster.len() > 1 {
1307                    let last = cluster.last().unwrap();
1308                    let parent_indent = line_indent(source, first.start);
1309                    let first_body_range = first.body_range.as_ref().unwrap();
1310                    let unit = detect_indent_unit(source, first_body_range.clone())
1311                        .unwrap_or_else(|| "  ".to_string());
1312                    let nested_indent = format!("{parent_indent}{unit}");
1313                    let inner_decl_indent = format!("{nested_indent}{unit}");
1314
1315                    let mut out = format!("{parent_indent}{base} {{\n");
1316                    for (idx, &c) in cluster.iter().enumerate() {
1317                        if idx > 0 {
1318                            out.push('\n');
1319                        }
1320                        let c_sel = c.prelude(source).trim();
1321                        let remainder = &c_sel[base.len()..];
1322                        let nested_sel = if remainder.starts_with(':')
1323                            || remainder.starts_with('[')
1324                            || remainder.starts_with('.')
1325                            || remainder.starts_with('#')
1326                        {
1327                            format!("&{remainder}")
1328                        } else {
1329                            remainder.trim().to_string()
1330                        };
1331
1332                        out.push_str(&nested_indent);
1333                        out.push_str(&nested_sel);
1334                        out.push_str(" {\n");
1335
1336                        if let Some(c_body_range) = &c.body_range {
1337                            for line in source[c_body_range.clone()].lines() {
1338                                let trimmed = line.trim();
1339                                if !trimmed.is_empty() {
1340                                    out.push_str(&inner_decl_indent);
1341                                    out.push_str(&ensure_semicolon(trimmed));
1342                                    out.push('\n');
1343                                }
1344                            }
1345                        }
1346
1347                        out.push_str(&nested_indent);
1348                        out.push_str("}\n");
1349                    }
1350
1351                    out.push_str(&parent_indent);
1352                    out.push('}');
1353
1354                    plans.push(PlanEntry {
1355                        id: String::new(),
1356                        file: path.to_path_buf(),
1357                        rules: vec![RuleId::NestPseudoClass, RuleId::NestAttribute],
1358                        safety: Safety::Safe,
1359                        source_range: SourceRange {
1360                            start: first.start,
1361                            end: last.end,
1362                        },
1363                        original: source[first.start..last.end].to_string(),
1364                        proposed: out,
1365                        proof: Proof::safe_local(),
1366                        warnings: Vec::new(),
1367                        reason: format!(
1368                            "Nest {} adjacent state rules for '{}' in place without moving.",
1369                            cluster.len(),
1370                            base
1371                        ),
1372                        selected: true,
1373                    });
1374                    i = cursor;
1375                    continue;
1376                }
1377            }
1378        }
1379        i += 1;
1380    }
1381}
1382
1383fn extract_base_target(selector: &str) -> Option<&str> {
1384    if contains_top_level_comma(selector) {
1385        return None;
1386    }
1387    if let Some(pos) = selector.find(':') {
1388        if pos > 0 && !selector[pos..].starts_with("::") {
1389            let base = &selector[..pos];
1390            if !base.is_empty() {
1391                return Some(base);
1392            }
1393        }
1394    }
1395    if let Some(pos) = selector.find('[') {
1396        if pos > 0 {
1397            let base = &selector[..pos];
1398            if !base.is_empty() {
1399                return Some(base);
1400            }
1401        }
1402    }
1403    None
1404}
1405
1406fn parse_rule_body_items(body_str: &str) -> (Vec<String>, Vec<String>) {
1407    let mut declarations = Vec::new();
1408    let mut nested_rules = Vec::new();
1409
1410    let mut depth = 0usize;
1411    let mut current_block = String::new();
1412    let mut current_decl = String::new();
1413    let mut in_comment = false;
1414    let bytes = body_str.as_bytes();
1415    let mut i = 0;
1416
1417    while i < bytes.len() {
1418        if in_comment {
1419            current_decl.push(bytes[i] as char);
1420            current_block.push(bytes[i] as char);
1421            if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
1422                current_decl.push('/');
1423                current_block.push('/');
1424                i += 2;
1425                in_comment = false;
1426                continue;
1427            }
1428            i += 1;
1429            continue;
1430        }
1431
1432        if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
1433            in_comment = true;
1434            current_decl.push('/');
1435            current_decl.push('*');
1436            current_block.push('/');
1437            current_block.push('*');
1438            i += 2;
1439            continue;
1440        }
1441
1442        let b = bytes[i];
1443        if b == b'{' {
1444            depth += 1;
1445            if depth == 1 {
1446                current_block = current_decl.clone();
1447                current_decl.clear();
1448            }
1449            current_block.push('{');
1450            i += 1;
1451            continue;
1452        } else if b == b'}' {
1453            if depth > 0 {
1454                depth -= 1;
1455                current_block.push('}');
1456                if depth == 0 {
1457                    let trimmed = current_block.trim().to_string();
1458                    if !trimmed.is_empty() {
1459                        nested_rules.push(trimmed);
1460                    }
1461                    current_block.clear();
1462                    current_decl.clear();
1463                }
1464            }
1465            i += 1;
1466            continue;
1467        }
1468
1469        if depth > 0 {
1470            current_block.push(b as char);
1471        } else {
1472            if b == b';' {
1473                current_decl.push(';');
1474                let trimmed = current_decl.trim().to_string();
1475                if !trimmed.is_empty() {
1476                    declarations.push(trimmed);
1477                }
1478                current_decl.clear();
1479            } else if b == b'\n' {
1480                let trimmed = current_decl.trim();
1481                // Selector-list continuations (`&:hover,` / `&::before,`) contain `:`
1482                // but are not declarations — they must stay attached to the `{` that follows.
1483                if !trimmed.is_empty()
1484                    && trimmed.contains(':')
1485                    && !trimmed.ends_with('{')
1486                    && !trimmed.ends_with(',')
1487                {
1488                    let rest = body_str[i + 1..].trim_start();
1489                    if !rest.starts_with('{') {
1490                        declarations.push(trimmed.to_string());
1491                        current_decl.clear();
1492                    } else {
1493                        current_decl.push('\n');
1494                    }
1495                } else {
1496                    current_decl.push('\n');
1497                }
1498            } else {
1499                current_decl.push(b as char);
1500            }
1501        }
1502        i += 1;
1503    }
1504
1505    let trailing_decl = current_decl.trim().to_string();
1506    if !trailing_decl.is_empty() && trailing_decl.contains(':') {
1507        declarations.push(ensure_semicolon(&trailing_decl).into_owned());
1508    }
1509
1510    (declarations, nested_rules)
1511}
1512
1513fn is_ident_continue(c: char) -> bool {
1514    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '\\'
1515}
1516
1517/// MDN "Appending the `&` nesting selector": reverse the context so the
1518/// parent appears as a suffix (`&`) of the nested selector.
1519///
1520/// `.featured .card` → `.featured &`
1521/// `.featured.card`  → `.featured&`
1522/// `:not(.card)`     → `:not(&)`
1523/// `.foo > .card`    → `.foo > &`
1524fn appended_nesting_selector(base: &str, candidate_sel: &str) -> Option<String> {
1525    let base = base.trim();
1526    let candidate_sel = candidate_sel.trim();
1527    if base.is_empty() || candidate_sel == base || contains_top_level_comma(candidate_sel) {
1528        return None;
1529    }
1530
1531    const FNS: &[&str] = &[":not(", ":is(", ":where(", ":has("];
1532    for fn_name in FNS {
1533        let wrapped = format!("{fn_name}{base})");
1534        if candidate_sel == wrapped {
1535            return Some(format!("{fn_name}&)"));
1536        }
1537        if let Some(prefix) = candidate_sel.strip_suffix(wrapped.as_str()) {
1538            if !prefix.is_empty() {
1539                let prev = prefix.chars().last()?;
1540                if is_ident_continue(prev)
1541                    || prev == '.'
1542                    || prev == '#'
1543                    || prev == ']'
1544                    || prev == ')'
1545                    || prev == '*'
1546                {
1547                    return Some(format!("{prefix}{fn_name}&)"));
1548                }
1549            }
1550        }
1551    }
1552
1553    if !candidate_sel.ends_with(base) {
1554        return None;
1555    }
1556    let prefix = &candidate_sel[..candidate_sel.len() - base.len()];
1557    if prefix.is_empty() {
1558        return None;
1559    }
1560    let prev = prefix.chars().last()?;
1561    let first_of_base = base.chars().next()?;
1562
1563    if prev.is_whitespace() || matches!(prev, '>' | '+' | '~') {
1564        return Some(format!("{prefix}&"));
1565    }
1566
1567    // Compound join: `.featured.card`, `div.card` — the `.`/`#`/`[`/`:` of
1568    // `base` starts a new simple selector, not a mid-ident substring.
1569    if matches!(first_of_base, '.' | '#' | '[' | ':')
1570        && (is_ident_continue(prev) || prev == ']' || prev == ')' || prev == '*')
1571    {
1572        return Some(format!("{prefix}&"));
1573    }
1574
1575    None
1576}
1577
1578fn appended_selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
1579    let nested = appended_nesting_selector(parent, child)?;
1580    let before_amp = nested.strip_suffix('&').unwrap_or(nested.as_str());
1581    let kind = if nested.contains(":not(")
1582        || nested.contains(":is(")
1583        || nested.contains(":where(")
1584        || nested.contains(":has(")
1585    {
1586        RelationKind::PseudoClass
1587    } else if before_amp.contains('>') || before_amp.contains('+') || before_amp.contains('~') {
1588        RelationKind::Combinator
1589    } else if nested.ends_with('&')
1590        && before_amp
1591            .chars()
1592            .last()
1593            .is_some_and(|c| !c.is_whitespace())
1594    {
1595        RelationKind::Compound
1596    } else {
1597        RelationKind::Descendant
1598    };
1599    Some((kind, nested))
1600}
1601
1602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1603enum RelatedKind {
1604    Prefix,
1605    Appended,
1606}
1607
1608fn prefix_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1609    if candidate_sel == base || !candidate_sel.starts_with(base) {
1610        return None;
1611    }
1612    let rem_raw = &candidate_sel[base.len()..];
1613    let rem = rem_raw.trim_start();
1614    if rem.is_empty() {
1615        return None;
1616    }
1617    // Only attach '&' when the suffix is directly touching the base (no whitespace).
1618    // e.g. `.foo:hover` → `&:hover` but `.foo :not(*)` → `:not(*)` (descendant, no &).
1619    let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1620    if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#') {
1621        if directly_attached {
1622            return Some(format!("&{rem}"));
1623        } else {
1624            return Some(rem.to_string());
1625        }
1626    }
1627    if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1628        let first_char = &rem[..1];
1629        let rest = rem[1..].trim_start();
1630        return Some(format!("{first_char} {rest}"));
1631    }
1632    if rem_raw.starts_with(' ') {
1633        return Some(rem.to_string());
1634    }
1635    None
1636}
1637
1638fn classify_related_nested(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1639    if candidate_sel == base || contains_top_level_comma(candidate_sel) {
1640        return None;
1641    }
1642    if let Some(rel) = prefix_related_nested_selector(base, candidate_sel) {
1643        return Some((RelatedKind::Prefix, rel));
1644    }
1645    appended_nesting_selector(base, candidate_sel).map(|rel| (RelatedKind::Appended, rel))
1646}
1647
1648fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1649    classify_related_nested(base, candidate_sel).map(|(_, rel)| rel)
1650}
1651
1652fn is_weak_gather_base(base: &str) -> bool {
1653    matches!(base.trim(), "*" | "html" | "body" | ":root" | ":host")
1654}
1655
1656fn is_simple_compound_selector(sel: &str) -> bool {
1657    let sel = sel.trim();
1658    if sel.is_empty()
1659        || sel.starts_with('&')
1660        || sel.starts_with('+')
1661        || sel.starts_with('>')
1662        || sel.starts_with('~')
1663        || contains_top_level_comma(sel)
1664    {
1665        return false;
1666    }
1667    let mut paren = 0usize;
1668    let mut brack = 0usize;
1669    for c in sel.chars() {
1670        match c {
1671            '(' => paren += 1,
1672            ')' => paren = paren.saturating_sub(1),
1673            '[' => brack += 1,
1674            ']' => brack = brack.saturating_sub(1),
1675            _ if paren == 0 && brack == 0 => {
1676                if c.is_whitespace() || matches!(c, '+' | '>' | '~') {
1677                    return false;
1678                }
1679            }
1680            _ => {}
1681        }
1682    }
1683    true
1684}
1685
1686fn first_compound_stripped(sel: &str) -> Option<&str> {
1687    let sel = sel.trim();
1688    if sel.is_empty() || sel.starts_with('&') {
1689        return None;
1690    }
1691    let mut paren = 0usize;
1692    let mut brack = 0usize;
1693    let mut end = sel.len();
1694    for (i, c) in sel.char_indices() {
1695        match c {
1696            '(' => paren += 1,
1697            ')' => paren = paren.saturating_sub(1),
1698            '[' => brack += 1,
1699            ']' => brack = brack.saturating_sub(1),
1700            _ if paren == 0 && brack == 0 && (c.is_whitespace() || matches!(c, '+' | '>' | '~' | ',')) => {
1701                end = i;
1702                break;
1703            }
1704            _ => {}
1705        }
1706    }
1707    let head = sel[..end].trim();
1708    if head.is_empty() || head.starts_with(':') || head.starts_with('[') {
1709        return None;
1710    }
1711    paren = 0;
1712    brack = 0;
1713    for (i, c) in head.char_indices() {
1714        match c {
1715            '(' => paren += 1,
1716            ')' => paren = paren.saturating_sub(1),
1717            '[' if paren == 0 && i > 0 => return Some(&head[..i]),
1718            ':' if paren == 0 && brack == 0 && i > 0 => return Some(&head[..i]),
1719            _ => {}
1720        }
1721    }
1722    Some(head)
1723}
1724
1725fn style_body_weight(source: &str, node: &SourceNode) -> usize {
1726    node.body(source)
1727        .map(|b| b.lines().filter(|l| !l.trim().is_empty()).count())
1728        .unwrap_or(0)
1729}
1730
1731/// Pick a single gather home for `sel`.
1732/// Exact existing rules win by specificity; prefix beats appended on a tie;
1733/// virtual stripped compounds (`.skip-link` from `.skip-link:hover`) are last resort.
1734fn assign_gather_home<'a>(
1735    sel: &str,
1736    exact_homes: &[&'a str],
1737    home_weight: &HashMap<&'a str, usize>,
1738) -> Option<&'a str> {
1739    #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1740    struct Rank {
1741        spec: Specificity,
1742        is_prefix: bool,
1743        weight: usize,
1744        len: usize,
1745    }
1746
1747    let mut best: Option<(Rank, &'a str)> = None;
1748    let consider = |best: &mut Option<(Rank, &'a str)>, home: &'a str, kind: Option<RelatedKind>| {
1749        let is_prefix = matches!(kind, None | Some(RelatedKind::Prefix));
1750        if matches!(kind, Some(RelatedKind::Appended)) && is_weak_gather_base(home) {
1751            return;
1752        }
1753        let rank = Rank {
1754            spec: calculate_specificity(home),
1755            is_prefix,
1756            weight: home_weight.get(home).copied().unwrap_or(0),
1757            len: home.len(),
1758        };
1759        if best.as_ref().is_none_or(|(cur, _)| rank > *cur) {
1760            *best = Some((rank, home));
1761        }
1762    };
1763
1764    for &home in exact_homes {
1765        if sel == home || home_weight.get(home).copied().unwrap_or(0) == 0 {
1766            continue;
1767        }
1768        if let Some((kind, _)) = classify_related_nested(home, sel) {
1769            consider(&mut best, home, Some(kind));
1770        }
1771    }
1772    if best.is_some() {
1773        return best.map(|(_, home)| home);
1774    }
1775    // No existing rule claimed this selector — fall back to a virtual
1776    // stripped compound so `.skip-link:hover` + `.skip-link:focus` can
1777    // still wrap under a synthesized `.skip-link`.
1778    for &home in exact_homes {
1779        if sel == home || home_weight.get(home).copied().unwrap_or(0) != 0 {
1780            continue;
1781        }
1782        if let Some((kind, _)) = classify_related_nested(home, sel) {
1783            consider(&mut best, home, Some(kind));
1784        }
1785    }
1786    best.map(|(_, home)| home)
1787}
1788
1789const GATHERABLE_CONDITIONALS: &[&str] = &["media", "supports", "container", "starting-style"];
1790
1791enum GatherMember<'a> {
1792    Style(&'a SourceNode),
1793    Conditional {
1794        at_node: &'a SourceNode,
1795        inner: SourceNode,
1796        delete_whole_at_block: bool,
1797    },
1798}
1799
1800impl GatherMember<'_> {
1801    fn outer_start(&self) -> usize {
1802        match self {
1803            Self::Style(n) => n.start,
1804            Self::Conditional { at_node, .. } => at_node.start,
1805        }
1806    }
1807
1808    fn outer_end(&self) -> usize {
1809        match self {
1810            Self::Style(n) => n.end,
1811            Self::Conditional { at_node, .. } => at_node.end,
1812        }
1813    }
1814}
1815
1816fn extend_with_trailing_newline(source: &str, end: usize) -> usize {
1817    if source[end..].starts_with("\r\n") {
1818        end + 2
1819    } else if source[end..].starts_with('\n') {
1820        end + 1
1821    } else {
1822        end
1823    }
1824}
1825
1826fn push_style_member_into_merge(
1827    first_sel: &str,
1828    cand_sel: &str,
1829    body_str: &str,
1830    all_decls: &mut Vec<String>,
1831    all_nested_rules: &mut Vec<String>,
1832) {
1833    if cand_sel == first_sel {
1834        let (decls, nested) = parse_rule_body_items(body_str);
1835        all_decls.extend(decls);
1836        all_nested_rules.extend(nested);
1837    } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
1838        let (decls, nested) = parse_rule_body_items(body_str);
1839        if nested.is_empty() {
1840            let mut rel_body = String::new();
1841            for d in &decls {
1842                rel_body.push_str(&format!("{}\n", ensure_semicolon(d)));
1843            }
1844            all_nested_rules.push(format!("{rel_sel} {{\n    {rel_body}}}"));
1845        } else {
1846            let mut rel_body_lines = Vec::new();
1847            for d in &decls {
1848                rel_body_lines.push(format!("    {}", ensure_semicolon(d)));
1849            }
1850            for nr in &nested {
1851                rel_body_lines.push(nr.clone());
1852            }
1853            let rel_body = rel_body_lines.join("\n");
1854            all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
1855        }
1856    }
1857}
1858
1859fn format_conditional_into_lines(
1860    first_sel: &str,
1861    at_header: &str,
1862    inner_sel: &str,
1863    inner_body: &str,
1864    nested_indent: &str,
1865    unit: &str,
1866) -> Vec<String> {
1867    let (decls, nested) = parse_rule_body_items(inner_body);
1868    let level2 = format!("{nested_indent}{unit}");
1869    let level3 = format!("{level2}{unit}");
1870    let mut lines = Vec::new();
1871
1872    let push_at_block = |lines: &mut Vec<String>, header_indent: &str, body_indent: &str| {
1873        lines.push(format!("{header_indent}{at_header} {{"));
1874        for d in &decls {
1875            lines.push(format!("{body_indent}{}", ensure_semicolon(d)));
1876        }
1877        for nr in &nested {
1878            for line in nr.lines() {
1879                let trimmed = line.trim();
1880                if trimmed.is_empty() {
1881                    lines.push(String::new());
1882                } else {
1883                    lines.push(format!("{body_indent}{}", ensure_semicolon(trimmed)));
1884                }
1885            }
1886        }
1887        lines.push(format!("{header_indent}}}"));
1888    };
1889
1890    if inner_sel == first_sel {
1891        push_at_block(&mut lines, nested_indent, &level2);
1892    } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, inner_sel) {
1893        lines.push(format!("{nested_indent}{rel_sel} {{"));
1894        push_at_block(&mut lines, &level2, &level3);
1895        lines.push(format!("{nested_indent}}}"));
1896    }
1897    lines
1898}
1899
1900fn format_merged_rule(
1901    first_sel: &str,
1902    parent_indent: &str,
1903    unit: &str,
1904    cluster: &[GatherMember<'_>],
1905    source: &str,
1906) -> String {
1907    let nested_indent = format!("{parent_indent}{unit}");
1908    let inner_indent = format!("{nested_indent}{unit}");
1909
1910    let mut all_decls = Vec::new();
1911    let mut all_nested_rules = Vec::new();
1912    let mut conditional_lines = Vec::new();
1913
1914    for member in cluster {
1915        match member {
1916            GatherMember::Style(c) => {
1917                if let Some(body_range) = &c.body_range {
1918                    let cand_sel = c.prelude(source).trim();
1919                    let body_str = &source[body_range.clone()];
1920                    push_style_member_into_merge(
1921                        first_sel,
1922                        cand_sel,
1923                        body_str,
1924                        &mut all_decls,
1925                        &mut all_nested_rules,
1926                    );
1927                }
1928            }
1929            GatherMember::Conditional { at_node, inner, .. } => {
1930                if let Some(body_range) = &inner.body_range {
1931                    let extra = format_conditional_into_lines(
1932                        first_sel,
1933                        at_node.prelude(source).trim(),
1934                        inner.prelude(source).trim(),
1935                        &source[body_range.clone()],
1936                        &nested_indent,
1937                        unit,
1938                    );
1939                    if !conditional_lines.is_empty() && !extra.is_empty() {
1940                        conditional_lines.push(String::new());
1941                    }
1942                    conditional_lines.extend(extra);
1943                }
1944            }
1945        }
1946    }
1947
1948    let mut body_lines = Vec::new();
1949
1950    for d in &all_decls {
1951        body_lines.push(format!("{nested_indent}{}", ensure_semicolon(d)));
1952    }
1953
1954    if !all_decls.is_empty() && !all_nested_rules.is_empty() {
1955        body_lines.push(String::new());
1956    }
1957
1958    for (idx, nr) in all_nested_rules.iter().enumerate() {
1959        let lines: Vec<&str> = nr.lines().collect();
1960        if lines.is_empty() {
1961            continue;
1962        }
1963        let first_line = lines[0].trim();
1964        body_lines.push(format!("{nested_indent}{first_line}"));
1965
1966        for mid_line in &lines[1..lines.len().saturating_sub(1)] {
1967            let m_trimmed = mid_line.trim();
1968            if m_trimmed.is_empty() {
1969                body_lines.push(String::new());
1970            } else {
1971                body_lines.push(format!("{inner_indent}{}", ensure_semicolon(m_trimmed)));
1972            }
1973        }
1974
1975        if lines.len() > 1 {
1976            let last_line = lines.last().unwrap().trim();
1977            body_lines.push(format!("{nested_indent}{last_line}"));
1978        }
1979
1980        if idx < all_nested_rules.len() - 1 {
1981            body_lines.push(String::new());
1982        }
1983    }
1984
1985    if !conditional_lines.is_empty() {
1986        if !body_lines.is_empty() {
1987            body_lines.push(String::new());
1988        }
1989        body_lines.extend(conditional_lines);
1990    }
1991
1992    let body_content = body_lines.join("\n");
1993    format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
1994}
1995
1996fn plan_merge_adjacent_identical_selectors(
1997    path: &Path,
1998    source: &str,
1999    nodes: &[SourceNode],
2000    enabled: &HashSet<RuleId>,
2001    plans: &mut Vec<PlanEntry>,
2002) {
2003    if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
2004        return;
2005    }
2006    let mut i = 0;
2007    while i < nodes.len() {
2008        let first = &nodes[i];
2009        if matches!(&first.kind, NodeKind::Style) {
2010            let first_sel = first.prelude(source).trim();
2011            let mut cluster = vec![first];
2012            let mut cursor = i + 1;
2013            let mut prev_end = first.end;
2014
2015            while cursor < nodes.len() {
2016                let next = &nodes[cursor];
2017                if !is_whitespace_only(source, prev_end..next.start) {
2018                    break;
2019                }
2020                if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
2021                {
2022                    cluster.push(next);
2023                    prev_end = next.end;
2024                    cursor += 1;
2025                    continue;
2026                }
2027                break;
2028            }
2029
2030            if cluster.len() > 1 {
2031                let last = cluster.last().unwrap();
2032                let parent_indent = line_indent(source, first.start);
2033                let first_body_range = first.body_range.as_ref().unwrap();
2034                let unit = detect_indent_unit(source, first_body_range.clone())
2035                    .unwrap_or_else(|| "    ".to_string());
2036
2037                let members: Vec<GatherMember<'_>> =
2038                    cluster.iter().copied().map(GatherMember::Style).collect();
2039                let proposed =
2040                    format_merged_rule(first_sel, &parent_indent, &unit, &members, source);
2041
2042                plans.push(PlanEntry {
2043                    id: String::new(),
2044                    file: path.to_path_buf(),
2045                    rules: vec![RuleId::MergeAdjacentIdenticalSelector],
2046                    safety: Safety::Safe,
2047                    source_range: SourceRange {
2048                        start: first.start,
2049                        end: last.end,
2050                    },
2051                    original: source[first.start..last.end].to_string(),
2052                    proposed,
2053                    proof: Proof::safe_local(),
2054                    warnings: Vec::new(),
2055                    reason: format!(
2056                        "Merge {} adjacent identical selector rules for '{}' into a single block.",
2057                        cluster.len(),
2058                        first_sel
2059                    ),
2060                    selected: true,
2061                });
2062                i = cursor;
2063                continue;
2064            }
2065        }
2066        i += 1;
2067    }
2068}
2069
2070fn plan_gather_related_selector_rules(
2071    path: &Path,
2072    source: &str,
2073    nodes: &[SourceNode],
2074    enabled: &HashSet<RuleId>,
2075    plans: &mut Vec<PlanEntry>,
2076) {
2077    if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
2078        return;
2079    }
2080
2081    let mut exact_homes: Vec<&str> = Vec::new();
2082    let mut home_weight: HashMap<&str, usize> = HashMap::new();
2083    for node in nodes {
2084        if matches!(&node.kind, NodeKind::Style) {
2085            let sel = node.prelude(source).trim();
2086            // Core homes only: `.skip-link`, `*`, `div` — not `.skip-link:hover`.
2087            let core = first_compound_stripped(sel).unwrap_or(sel);
2088            // `&` cannot represent a pseudo-element (CSS Nesting). Do not use
2089            // `::foo` rules as gather homes for other selectors.
2090            if is_simple_compound_selector(sel) && core == sel && !sel.contains("::") {
2091                if !exact_homes.contains(&sel) {
2092                    exact_homes.push(sel);
2093                }
2094                *home_weight.entry(sel).or_insert(0) += style_body_weight(source, node);
2095            }
2096            if let Some(stripped) = first_compound_stripped(sel) {
2097                if !exact_homes.contains(&stripped)
2098                    && is_simple_compound_selector(stripped)
2099                    && !stripped.contains("::")
2100                {
2101                    // Virtual home (no exact rule yet). Weight 0 so a real
2102                    // existing selector of equal specificity still wins.
2103                    exact_homes.push(stripped);
2104                    home_weight.entry(stripped).or_insert(0);
2105                }
2106            }
2107        }
2108    }
2109
2110    let mut grouped_at_blocks: HashSet<usize> = HashSet::new();
2111    for node in nodes {
2112        if let NodeKind::AtBlock { name, .. } = &node.kind {
2113            if !GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
2114                continue;
2115            }
2116            let Some(body_range) = &node.body_range else {
2117                continue;
2118            };
2119            let inner_nodes = scan_nodes(source, body_range.clone());
2120            let style_inners: Vec<&SourceNode> = inner_nodes
2121                .iter()
2122                .filter(|n| matches!(&n.kind, NodeKind::Style))
2123                .collect();
2124            if style_inners.len() < 3 {
2125                continue;
2126            }
2127            let mut assigned_homes: HashSet<&str> = HashSet::new();
2128            for inner in &style_inners {
2129                let sel = inner.prelude(source).trim();
2130                match assign_gather_home(sel, &exact_homes, &home_weight) {
2131                    Some(home) => {
2132                        assigned_homes.insert(home);
2133                    }
2134                    None => {
2135                        assigned_homes.insert("");
2136                    }
2137                }
2138            }
2139            if assigned_homes.len() >= 2 {
2140                grouped_at_blocks.insert(node.start);
2141            }
2142        }
2143    }
2144
2145    for base in exact_homes.clone() {
2146        let mut cluster: Vec<GatherMember<'_>> = Vec::new();
2147        for node in nodes {
2148            if matches!(&node.kind, NodeKind::Style) {
2149                let sel = node.prelude(source).trim();
2150                let belongs = sel == base
2151                    || assign_gather_home(sel, &exact_homes, &home_weight) == Some(base);
2152                if belongs {
2153                    cluster.push(GatherMember::Style(node));
2154                }
2155            } else if let NodeKind::AtBlock { name, .. } = &node.kind {
2156                if grouped_at_blocks.contains(&node.start) {
2157                    continue;
2158                }
2159                if GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
2160                    if let Some(body_range) = &node.body_range {
2161                        let inner_nodes = scan_nodes(source, body_range.clone());
2162                        let related: Vec<SourceNode> = inner_nodes
2163                            .iter()
2164                            .filter(|inner| {
2165                                matches!(&inner.kind, NodeKind::Style) && {
2166                                    let sel = inner.prelude(source).trim();
2167                                    sel == base
2168                                        || assign_gather_home(sel, &exact_homes, &home_weight)
2169                                            == Some(base)
2170                                }
2171                            })
2172                            .cloned()
2173                            .collect();
2174                        if !related.is_empty() {
2175                            let delete_whole_at_block = related.len() == inner_nodes.len();
2176                            for inner in related {
2177                                cluster.push(GatherMember::Conditional {
2178                                    at_node: node,
2179                                    inner,
2180                                    delete_whole_at_block,
2181                                });
2182                            }
2183                        }
2184                    }
2185                }
2186            }
2187        }
2188
2189        if cluster.len() > 1 {
2190            let mut is_non_adjacent = false;
2191            for window in cluster.windows(2) {
2192                let prev_end = window[0].outer_end();
2193                let next_start = window[1].outer_start();
2194                if !is_whitespace_only(source, prev_end..next_start) {
2195                    is_non_adjacent = true;
2196                    break;
2197                }
2198            }
2199
2200            let first_style = cluster.iter().find_map(|m| match m {
2201                GatherMember::Style(n) => Some(*n),
2202                GatherMember::Conditional { .. } => None,
2203            });
2204
2205            if is_non_adjacent {
2206                let Some(first) = first_style else {
2207                    continue;
2208                };
2209                let parent_indent = line_indent(source, first.start);
2210                let first_body_range = first.body_range.as_ref().unwrap();
2211                let unit = detect_indent_unit(source, first_body_range.clone())
2212                    .unwrap_or_else(|| "    ".to_string());
2213
2214                let proposed = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
2215
2216                plans.push(PlanEntry {
2217                    id: String::new(),
2218                    file: path.to_path_buf(),
2219                    rules: vec![RuleId::GatherRelatedSelectorRules],
2220                    safety: Safety::Review,
2221                    source_range: SourceRange {
2222                        start: first.start,
2223                        end: first.end,
2224                    },
2225                    original: source[first.start..first.end].to_string(),
2226                    proposed,
2227                    proof: Proof {
2228                        selector_set_equivalent: true,
2229                        specificity_equivalent: true,
2230                        cascade_context_equivalent: false,
2231                        source_order_equivalent: false,
2232                        layer_equivalent: true,
2233                        scope_equivalent: true,
2234                        declarations_exact: true,
2235                        important_exact: true,
2236                    },
2237                    warnings: vec![format!(
2238                        "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
2239                        cluster.len(),
2240                        base
2241                    )],
2242                    reason: format!(
2243                        "Gather {} related rules for '{}' into the canonical first selector block.",
2244                        cluster.len(),
2245                        base
2246                    ),
2247                    selected: true,
2248                });
2249
2250                let mut deleted_at_blocks = HashSet::new();
2251                for member in &cluster {
2252                    let (sec_start, sec_end, line_at) = match member {
2253                        GatherMember::Style(sec) if sec.start == first.start => continue,
2254                        GatherMember::Style(sec) => (
2255                            sec.start,
2256                            extend_with_trailing_newline(source, sec.end),
2257                            sec.start,
2258                        ),
2259                        GatherMember::Conditional {
2260                            at_node,
2261                            inner,
2262                            delete_whole_at_block,
2263                        } => {
2264                            if *delete_whole_at_block {
2265                                if !deleted_at_blocks.insert(at_node.start) {
2266                                    continue;
2267                                }
2268                                (
2269                                    at_node.start,
2270                                    extend_with_trailing_newline(source, at_node.end),
2271                                    at_node.start,
2272                                )
2273                            } else {
2274                                (
2275                                    inner.start,
2276                                    extend_with_trailing_newline(source, inner.end),
2277                                    inner.start,
2278                                )
2279                            }
2280                        }
2281                    };
2282
2283                    plans.push(PlanEntry {
2284                        id: String::new(),
2285                        file: path.to_path_buf(),
2286                        rules: vec![RuleId::GatherRelatedSelectorRules],
2287                        safety: Safety::Review,
2288                        source_range: SourceRange {
2289                            start: sec_start,
2290                            end: sec_end,
2291                        },
2292                        original: source[sec_start..sec_end].to_string(),
2293                        proposed: String::new(),
2294                        proof: Proof::safe_local(),
2295                        warnings: Vec::new(),
2296                        reason: format!(
2297                            "Remove non-adjacent gathered rule for '{}' at line {}.",
2298                            base,
2299                            line_number(source, line_at)
2300                        ),
2301                        selected: true,
2302                    });
2303                }
2304            }
2305        }
2306    }
2307}
2308
2309fn plan_factor_identical_states_with_is(
2310    path: &Path,
2311    source: &str,
2312    nodes: &[SourceNode],
2313    enabled: &HashSet<RuleId>,
2314    plans: &mut Vec<PlanEntry>,
2315) {
2316    if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
2317        return;
2318    }
2319    let mut i = 0;
2320    while i < nodes.len() {
2321        let first = &nodes[i];
2322        if matches!(&first.kind, NodeKind::Style) {
2323            let first_sel = first.prelude(source).trim();
2324            if let Some(colon_pos) = first_sel.find(':') {
2325                if !first_sel[colon_pos..].starts_with("::") {
2326                    let base = &first_sel[..colon_pos];
2327                    if !base.is_empty() && !base.contains(' ') {
2328                        let first_body = first.body(source).unwrap_or("").trim();
2329                        let mut cluster = vec![first];
2330                        let mut cursor = i + 1;
2331                        let mut prev_end = first.end;
2332
2333                        while cursor < nodes.len() {
2334                            let next = &nodes[cursor];
2335                            if !is_whitespace_only(source, prev_end..next.start) {
2336                                break;
2337                            }
2338                            if matches!(&next.kind, NodeKind::Style) {
2339                                let next_sel = next.prelude(source).trim();
2340                                if next_sel.starts_with(base)
2341                                    && next_sel[base.len()..].starts_with(':')
2342                                    && !next_sel[base.len()..].starts_with("::")
2343                                    && next.body(source).unwrap_or("").trim() == first_body
2344                                {
2345                                    cluster.push(next);
2346                                    prev_end = next.end;
2347                                    cursor += 1;
2348                                    continue;
2349                                }
2350                            }
2351                            break;
2352                        }
2353
2354                        if cluster.len() > 1 {
2355                            let last = cluster.last().unwrap();
2356                            let pseudos: Vec<&str> = cluster
2357                                .iter()
2358                                .map(|c| {
2359                                    let s = c.prelude(source).trim();
2360                                    &s[base.len()..]
2361                                })
2362                                .collect();
2363                            let is_inner = pseudos.join(", ");
2364                            let parent_indent = line_indent(source, first.start);
2365                            let first_body_range = first.body_range.as_ref().unwrap();
2366                            let unit = detect_indent_unit(source, first_body_range.clone())
2367                                .unwrap_or_else(|| "  ".to_string());
2368                            let nested_indent = format!("{parent_indent}{unit}");
2369                            let inner_decl_indent = format!("{nested_indent}{unit}");
2370
2371                            let mut decls = String::new();
2372                            for line in source[first_body_range.clone()].lines() {
2373                                let trimmed = line.trim();
2374                                if !trimmed.is_empty() {
2375                                    decls.push_str(&inner_decl_indent);
2376                                    decls.push_str(&ensure_semicolon(trimmed));
2377                                    decls.push('\n');
2378                                }
2379                            }
2380
2381                            let proposed = format!(
2382                                "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
2383                            );
2384                            plans.push(PlanEntry {
2385                                id: String::new(),
2386                                file: path.to_path_buf(),
2387                                rules: vec![RuleId::FactorIdenticalStatesWithIs],
2388                                safety: Safety::Safe,
2389                                source_range: SourceRange {
2390                                    start: first.start,
2391                                    end: last.end,
2392                                },
2393                                original: source[first.start..last.end].to_string(),
2394                                proposed,
2395                                proof: Proof::safe_local(),
2396                                warnings: Vec::new(),
2397                                reason: format!(
2398                                    "Factor {} identical state rules for '{}' into &:is({}) form.",
2399                                    cluster.len(),
2400                                    base,
2401                                    is_inner
2402                                ),
2403                                selected: true,
2404                            });
2405                            i = cursor;
2406                            continue;
2407                        }
2408                    }
2409                }
2410            }
2411        }
2412        i += 1;
2413    }
2414}
2415
2416fn plan_factor_multi_selector_cluster_with_is(
2417    path: &Path,
2418    source: &str,
2419    nodes: &[SourceNode],
2420    enabled: &HashSet<RuleId>,
2421    plans: &mut Vec<PlanEntry>,
2422) {
2423    if !enabled.contains(&RuleId::ModernizeIs) {
2424        return;
2425    }
2426
2427    let mut i = 0;
2428    while i < nodes.len() {
2429        let first = &nodes[i];
2430        if matches!(&first.kind, NodeKind::Style) {
2431            let first_sel = first.prelude(source).trim();
2432            if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
2433                let mut cluster = vec![(first, first_suffix)];
2434                let mut cursor = i + 1;
2435                let mut prev_end = first.end;
2436
2437                while cursor < nodes.len() {
2438                    let next = &nodes[cursor];
2439                    if !is_whitespace_only(source, prev_end..next.start) {
2440                        break;
2441                    }
2442                    if matches!(&next.kind, NodeKind::Style) {
2443                        let next_sel = next.prelude(source).trim();
2444                        if let Some((next_prefixes, next_suffix)) =
2445                            extract_multi_branch_pattern(next_sel)
2446                        {
2447                            if next_prefixes == base_prefixes {
2448                                cluster.push((next, next_suffix));
2449                                prev_end = next.end;
2450                                cursor += 1;
2451                                continue;
2452                            }
2453                        }
2454                    }
2455                    break;
2456                }
2457
2458                if cluster.len() > 1 {
2459                    let (last_node, _) = cluster.last().unwrap();
2460                    let parent_indent = line_indent(source, first.start);
2461                    let first_body_range = first.body_range.as_ref().unwrap();
2462                    let unit = detect_indent_unit(source, first_body_range.clone())
2463                        .unwrap_or_else(|| "  ".to_string());
2464                    let nested_indent = format!("{parent_indent}{unit}");
2465                    let inner_decl_indent = format!("{nested_indent}{unit}");
2466
2467                    let is_header = format!(":is({})", base_prefixes.join(", "));
2468                    let mut out = format!("{parent_indent}{is_header} {{\n");
2469                    let mut has_direct_decls = false;
2470
2471                    // 1. Direct declarations from base rules (suffix == None)
2472                    for &(c_node, ref suffix) in &cluster {
2473                        if suffix.is_none() {
2474                            if let Some(c_body_range) = &c_node.body_range {
2475                                for line in source[c_body_range.clone()].lines() {
2476                                    let trimmed = line.trim();
2477                                    if !trimmed.is_empty() {
2478                                        out.push_str(&nested_indent);
2479                                        out.push_str(&ensure_semicolon(trimmed));
2480                                        out.push('\n');
2481                                        has_direct_decls = true;
2482                                    }
2483                                }
2484                            }
2485                        }
2486                    }
2487
2488                    // 2. Nested child rules (suffix == Some(sub_sel))
2489                    for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
2490                        if let Some(sub_sel) = suffix {
2491                            if has_direct_decls || c_idx > 0 {
2492                                out.push('\n');
2493                            }
2494                            out.push_str(&nested_indent);
2495                            out.push_str(sub_sel);
2496                            out.push_str(" {\n");
2497                            if let Some(c_body_range) = &c_node.body_range {
2498                                for line in source[c_body_range.clone()].lines() {
2499                                    let trimmed = line.trim();
2500                                    if !trimmed.is_empty() {
2501                                        out.push_str(&inner_decl_indent);
2502                                        out.push_str(&ensure_semicolon(trimmed));
2503                                        out.push('\n');
2504                                    }
2505                                }
2506                            }
2507                            out.push_str(&nested_indent);
2508                            out.push_str("}\n");
2509                        }
2510                    }
2511
2512                    out.push_str(&parent_indent);
2513                    out.push('}');
2514
2515                    let specificities: Vec<Specificity> = base_prefixes
2516                        .iter()
2517                        .map(|p| calculate_specificity(p))
2518                        .collect();
2519                    let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
2520
2521                    plans.push(PlanEntry {
2522                        id: String::new(),
2523                        file: path.to_path_buf(),
2524                        rules: vec![RuleId::ModernizeIs],
2525                        safety: Safety::Safe,
2526                        source_range: SourceRange {
2527                            start: first.start,
2528                            end: last_node.end,
2529                        },
2530                        original: source[first.start..last_node.end].to_string(),
2531                        proposed: out,
2532                        proof: Proof::safe_local(),
2533                        warnings: if uniform { Vec::new() } else { vec!["Notice: :is() takes the specificity of its most specific argument.".into()] },
2534                        reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
2535                        selected: true,
2536                    });
2537                    i = cursor;
2538                    continue;
2539                }
2540            }
2541        }
2542        i += 1;
2543    }
2544}
2545
2546fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
2547    let branches: Vec<&str> = split_top_level_comma(selector)
2548        .into_iter()
2549        .map(|s| s.trim())
2550        .collect();
2551    if branches.len() < 2 {
2552        return None;
2553    }
2554    if branches.iter().any(|b| b.contains("::")) {
2555        return None;
2556    }
2557
2558    let first = branches[0];
2559    if let Some(space_pos) = first.rfind(' ') {
2560        let suffix = &first[space_pos..];
2561        if branches.iter().all(|b| b.ends_with(suffix)) {
2562            let prefixes: Vec<String> = branches
2563                .iter()
2564                .map(|b| b[..b.len() - suffix.len()].trim().to_string())
2565                .collect();
2566            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
2567                return Some((prefixes, Some(suffix.trim().to_string())));
2568            }
2569        }
2570    }
2571
2572    if branches
2573        .iter()
2574        .all(|b| is_valid_selector_token(b) && !b.contains(' '))
2575    {
2576        let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
2577        return Some((prefixes, None));
2578    }
2579
2580    None
2581}
2582
2583fn plan_merge_identical_rule_bodies(
2584    path: &Path,
2585    source: &str,
2586    nodes: &[SourceNode],
2587    enabled: &HashSet<RuleId>,
2588    plans: &mut Vec<PlanEntry>,
2589) {
2590    if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
2591        return;
2592    }
2593    let mut i = 0;
2594    while i < nodes.len() {
2595        let first = &nodes[i];
2596        if matches!(&first.kind, NodeKind::Style) {
2597            let first_body = first.body(source).unwrap_or("").trim();
2598            if !first_body.is_empty() {
2599                let mut cluster = vec![first];
2600                let mut cursor = i + 1;
2601                let mut prev_end = first.end;
2602
2603                while cursor < nodes.len() {
2604                    let next = &nodes[cursor];
2605                    if !is_whitespace_only(source, prev_end..next.start) {
2606                        break;
2607                    }
2608                    if matches!(&next.kind, NodeKind::Style)
2609                        && next.body(source).unwrap_or("").trim() == first_body
2610                    {
2611                        cluster.push(next);
2612                        prev_end = next.end;
2613                        cursor += 1;
2614                        continue;
2615                    }
2616                    break;
2617                }
2618
2619                if cluster.len() > 1 {
2620                    let last = cluster.last().unwrap();
2621                    let selectors: Vec<&str> =
2622                        cluster.iter().map(|c| c.prelude(source).trim()).collect();
2623                    let parent_indent = line_indent(source, first.start);
2624                    let first_body_range = first.body_range.as_ref().unwrap();
2625                    let unit = detect_indent_unit(source, first_body_range.clone())
2626                        .unwrap_or_else(|| "  ".to_string());
2627                    let nested_indent = format!("{parent_indent}{unit}");
2628
2629                    let mut decls = String::new();
2630                    for line in source[first_body_range.clone()].lines() {
2631                        let trimmed = line.trim();
2632                        if !trimmed.is_empty() {
2633                            decls.push_str(&nested_indent);
2634                            decls.push_str(&ensure_semicolon(trimmed));
2635                            decls.push('\n');
2636                        }
2637                    }
2638
2639                    let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
2640                    let proposed =
2641                        format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
2642                    plans.push(PlanEntry {
2643                        id: String::new(),
2644                        file: path.to_path_buf(),
2645                        rules: vec![RuleId::MergeIdenticalRuleBodies],
2646                        safety: Safety::Safe,
2647                        source_range: SourceRange {
2648                            start: first.start,
2649                            end: last.end,
2650                        },
2651                        original: source[first.start..last.end].to_string(),
2652                        proposed,
2653                        proof: Proof::safe_local(),
2654                        warnings: Vec::new(),
2655                        reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
2656                        selected: true,
2657                    });
2658                    i = cursor;
2659                    continue;
2660                }
2661            }
2662        }
2663        i += 1;
2664    }
2665}
2666
2667pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
2668    let bytes = selector.as_bytes();
2669    let mut parts = Vec::new();
2670    let mut last = 0;
2671    let mut parens = 0usize;
2672    let mut brackets = 0usize;
2673    let mut quote: Option<u8> = None;
2674    let mut escaped = false;
2675    let mut i = 0usize;
2676
2677    while i < bytes.len() {
2678        let b = bytes[i];
2679        if let Some(q) = quote {
2680            if escaped {
2681                escaped = false;
2682            } else if b == b'\\' {
2683                escaped = true;
2684            } else if b == q {
2685                quote = None;
2686            }
2687            i += 1;
2688            continue;
2689        }
2690        match b {
2691            b'\'' | b'"' => quote = Some(b),
2692            b'(' => parens += 1,
2693            b')' => parens = parens.saturating_sub(1),
2694            b'[' => brackets += 1,
2695            b']' => brackets = brackets.saturating_sub(1),
2696            b',' if parens == 0 && brackets == 0 => {
2697                parts.push(&selector[last..i]);
2698                last = i + 1;
2699            }
2700            _ => {}
2701        }
2702        i += 1;
2703    }
2704    if last < selector.len() {
2705        parts.push(&selector[last..]);
2706    }
2707    parts
2708}
2709
2710pub fn factor_selector_list(
2711    selector: &str,
2712    body: &str,
2713    indent: &str,
2714    unit: &str,
2715) -> Option<String> {
2716    let branches: Vec<&str> = split_top_level_comma(selector)
2717        .into_iter()
2718        .map(|s| s.trim())
2719        .collect();
2720    if branches.len() < 2 {
2721        return None;
2722    }
2723    let base = branches[0];
2724    if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
2725        return None;
2726    }
2727
2728    let mut inner_selectors = Vec::new();
2729    for &branch in &branches {
2730        if branch == base {
2731            inner_selectors.push("&".to_string());
2732        } else {
2733            let rel = branch.strip_prefix(base)?;
2734            if rel.starts_with("::")
2735                || rel.starts_with(':')
2736                || rel.starts_with('[')
2737                || rel.starts_with('.')
2738                || rel.starts_with('#')
2739            {
2740                inner_selectors.push(format!("&{rel}"));
2741            } else {
2742                let trimmed = rel.strip_prefix(' ')?;
2743                inner_selectors.push(trimmed.trim_start().to_string());
2744            }
2745        }
2746    }
2747
2748    let nested_indent = format!("{indent}{unit}");
2749    let inner_decl_indent = format!("{nested_indent}{unit}");
2750    let mut out = String::new();
2751    out.push_str(base);
2752    out.push_str(" {\n");
2753    out.push_str(&nested_indent);
2754    out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
2755    out.push_str(" {\n");
2756    for line in body.lines() {
2757        let trimmed = line.trim();
2758        if !trimmed.is_empty() {
2759            out.push_str(&inner_decl_indent);
2760            out.push_str(&ensure_semicolon(trimmed));
2761            out.push('\n');
2762        }
2763    }
2764    out.push_str(&nested_indent);
2765    out.push_str("}\n");
2766    out.push_str(indent);
2767    out.push('}');
2768    Some(out)
2769}
2770
2771pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
2772    let branches: Vec<&str> = split_top_level_comma(selector)
2773        .into_iter()
2774        .map(|s| s.trim())
2775        .filter(|s| !s.is_empty())
2776        .collect();
2777    if branches.len() < 2 {
2778        return None;
2779    }
2780
2781    if branches.iter().any(|b| b.contains("::")) {
2782        return None;
2783    }
2784
2785    let specificities: Vec<Specificity> =
2786        branches.iter().map(|b| calculate_specificity(b)).collect();
2787    let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
2788
2789    let first = branches[0];
2790
2791    // Suffix alternatives (e.g. .alpha .title, #hero .title -> :is(.alpha, #hero) .title)
2792    if let Some(space_pos) = first.rfind(' ') {
2793        let suffix = &first[space_pos..];
2794        if branches.iter().all(|b| b.ends_with(suffix)) {
2795            let prefixes: Vec<&str> = branches
2796                .iter()
2797                .map(|b| b[..b.len() - suffix.len()].trim())
2798                .collect();
2799            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
2800                let is_inner = prefixes.join(", ");
2801                return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
2802            }
2803        }
2804    }
2805
2806    // Descendant alternatives (e.g. .card .title, .card .subtitle -> .card :is(.title, .subtitle))
2807    if let Some(space_pos) = first.rfind(' ') {
2808        let prefix = &first[..=space_pos];
2809        if branches.iter().all(|b| b.starts_with(prefix)) {
2810            let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
2811            if suffixes.iter().all(|s| is_valid_selector_token(s)) {
2812                let is_inner = suffixes.join(", ");
2813                return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
2814            }
2815        }
2816    }
2817
2818    // Pseudo-class alternatives (e.g. .button:hover, .button:focus -> .button:is(:hover, :focus))
2819    if let Some(colon_pos) = first.find(':') {
2820        let base = &first[..colon_pos];
2821        if !base.is_empty()
2822            && !base.contains(' ')
2823            && branches.iter().all(|b| {
2824                b.starts_with(base)
2825                    && b[base.len()..].starts_with(':')
2826                    && !b[base.len()..].starts_with("::")
2827            })
2828        {
2829            let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
2830            if pseudos
2831                .iter()
2832                .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
2833            {
2834                let is_inner = pseudos.join(", ");
2835                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
2836            }
2837        }
2838    }
2839
2840    // Attribute alternatives
2841    if let Some(bracket_pos) = first.find('[') {
2842        let base = &first[..bracket_pos];
2843        if !base.is_empty()
2844            && !base.contains(' ')
2845            && branches
2846                .iter()
2847                .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
2848        {
2849            let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
2850            if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
2851                let is_inner = attrs.join(", ");
2852                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
2853            }
2854        }
2855    }
2856
2857    None
2858}
2859
2860fn is_valid_selector_token(s: &str) -> bool {
2861    if s.is_empty() {
2862        return false;
2863    }
2864    let first = s.chars().next().unwrap();
2865    first == '.'
2866        || first == '#'
2867        || first == '['
2868        || first == ':'
2869        || first.is_ascii_alphabetic()
2870        || first == '*'
2871        || first == '>'
2872        || first == '+'
2873        || first == '~'
2874}
2875
2876pub fn factor_with_where(selector: &str) -> Option<String> {
2877    let branches: Vec<&str> = split_top_level_comma(selector)
2878        .into_iter()
2879        .map(|s| s.trim())
2880        .collect();
2881    if branches.len() < 2 {
2882        return None;
2883    }
2884    if branches.iter().any(|b| b.contains("::")) {
2885        return None;
2886    }
2887    Some(format!(":where({})", branches.join(", ")))
2888}
2889
2890pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
2891    let mut result = prelude.to_string();
2892    let mut changed = false;
2893
2894    // Range: (min-width: 400px) and (max-width: 800px) -> (400px <= width <= 800px)
2895    if let (Some((min_raw, min_val)), Some((max_raw, max_val))) = (
2896        extract_media_feature_and_raw(&result, "min-width"),
2897        extract_media_feature_and_raw(&result, "max-width"),
2898    ) {
2899        let pattern = format!("{min_raw} and {max_raw}");
2900        let replacement = format!("({min_val} <= width <= {max_val})");
2901        if result.contains(&pattern) {
2902            result = result.replace(&pattern, &replacement);
2903            changed = true;
2904        }
2905    }
2906
2907    // Single: (min-width: 800px) -> (width >= 800px)
2908    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-width") {
2909        let replacement = format!("(width >= {val})");
2910        result = result.replacen(&raw, &replacement, 1);
2911        changed = true;
2912    }
2913
2914    // Single: (max-width: 800px) -> (width <= 800px)
2915    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-width") {
2916        let replacement = format!("(width <= {val})");
2917        result = result.replacen(&raw, &replacement, 1);
2918        changed = true;
2919    }
2920
2921    // Single: (min-height: 400px) -> (height >= 400px)
2922    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-height") {
2923        let replacement = format!("(height >= {val})");
2924        result = result.replacen(&raw, &replacement, 1);
2925        changed = true;
2926    }
2927
2928    // Single: (max-height: 400px) -> (height <= 400px)
2929    while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-height") {
2930        let replacement = format!("(height <= {val})");
2931        result = result.replacen(&raw, &replacement, 1);
2932        changed = true;
2933    }
2934
2935    if changed {
2936        Some(result)
2937    } else {
2938        None
2939    }
2940}
2941
2942fn extract_media_feature_and_raw(source: &str, feature: &str) -> Option<(String, String)> {
2943    let feat_idx = source.find(feature)?;
2944    let open_paren = source[..feat_idx].rfind('(')?;
2945    if source[open_paren..feat_idx].contains(')') {
2946        return None;
2947    }
2948    let colon_rel = source[feat_idx + feature.len()..].find(':')?;
2949    let colon_idx = feat_idx + feature.len() + colon_rel;
2950    let close_paren_rel = source[colon_idx..].find(')')?;
2951    let close_paren = colon_idx + close_paren_rel;
2952
2953    let raw = source[open_paren..=close_paren].to_string();
2954    let val = source[colon_idx + 1..close_paren].trim().to_string();
2955    Some((raw, val))
2956}
2957
2958fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
2959    let parent = parent.trim();
2960    let child = child.trim();
2961    if parent.is_empty()
2962        || child.is_empty()
2963        || contains_top_level_comma(parent)
2964        || contains_top_level_comma(child)
2965        || parent.contains("::")
2966        || child == parent
2967    {
2968        return None;
2969    }
2970
2971    if !child.starts_with(parent) {
2972        if is_weak_gather_base(parent) {
2973            return None;
2974        }
2975        return appended_selector_relation(parent, child);
2976    }
2977
2978    let remainder = &child[parent.len()..];
2979    let trimmed = remainder.trim_start();
2980    if trimmed.is_empty() {
2981        return None;
2982    }
2983
2984    let (relation, nested_selector) = if remainder.starts_with("::") {
2985        (RelationKind::PseudoElement, format!("&{remainder}"))
2986    } else if remainder.starts_with(':') {
2987        (RelationKind::PseudoClass, format!("&{remainder}"))
2988    } else if remainder.starts_with('[') {
2989        (RelationKind::Attribute, format!("&{remainder}"))
2990    } else if remainder.starts_with('.') || remainder.starts_with('#') {
2991        (RelationKind::Compound, format!("&{remainder}"))
2992    } else if let Some(first_char) = trimmed
2993        .chars()
2994        .next()
2995        .filter(|c| *c == '>' || *c == '+' || *c == '~')
2996    {
2997        let after_comb = trimmed[first_char.len_utf8()..].trim();
2998        if after_comb == parent {
2999            (RelationKind::Combinator, format!("{first_char} {parent}"))
3000        } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
3001            (
3002                RelationKind::Combinator,
3003                format!("{first_char} {parent}{after_parent}"),
3004            )
3005        } else {
3006            (
3007                RelationKind::Combinator,
3008                format!("{first_char} {after_comb}"),
3009            )
3010        }
3011    } else if remainder
3012        .as_bytes()
3013        .first()
3014        .is_some_and(|b| b.is_ascii_whitespace())
3015    {
3016        let descendant = remainder.trim();
3017        (RelationKind::Descendant, descendant.to_string())
3018    } else {
3019        return None;
3020    };
3021
3022    Some((relation, nested_selector))
3023}
3024
3025fn conditional_child(
3026    source: &str,
3027    parent_selector: &str,
3028    node: &SourceNode,
3029    enabled: &HashSet<RuleId>,
3030) -> Option<ClusterChild> {
3031    let (name, rule) = match &node.kind {
3032        NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
3033        NodeKind::AtBlock { name, .. } if name == "supports" => {
3034            (name.as_str(), RuleId::NestSupports)
3035        }
3036        NodeKind::AtBlock { name, .. } if name == "container" => {
3037            (name.as_str(), RuleId::NestContainer)
3038        }
3039        NodeKind::AtBlock { name, .. } if name == "starting-style" => {
3040            (name.as_str(), RuleId::NestStartingStyle)
3041        }
3042        _ => return None,
3043    };
3044    if !enabled.contains(&rule) {
3045        return None;
3046    }
3047
3048    let body_range = node.body_range.clone()?;
3049    let inner_nodes = scan_nodes(source, body_range.clone());
3050    if inner_nodes.is_empty() {
3051        return None;
3052    }
3053
3054    let mut inners = Vec::new();
3055    for inner in &inner_nodes {
3056        if !matches!(&inner.kind, NodeKind::Style) {
3057            return None;
3058        }
3059        let inner_prelude = inner.prelude(source);
3060        let inner_body = inner.body_range.clone()?;
3061        if inner_prelude == parent_selector.trim() {
3062            inners.push(ConditionalInner::Direct {
3063                body_range: inner_body,
3064            });
3065        } else if let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude) {
3066            inners.push(ConditionalInner::Nested {
3067                nested_selector: nested_sel,
3068                body_range: inner_body,
3069            });
3070        } else {
3071            return None;
3072        }
3073    }
3074
3075    debug_assert!(
3076        name == "media" || name == "supports" || name == "container" || name == "starting-style"
3077    );
3078    Some(ClusterChild::Conditional {
3079        node: node.clone(),
3080        rule,
3081        inners,
3082    })
3083}
3084
3085pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
3086    if !selector.contains(":not(") {
3087        return None;
3088    }
3089    let mut result = String::new();
3090    let mut i = 0;
3091    let bytes = selector.as_bytes();
3092    let mut changed = false;
3093    let mut uniform_specificity = true;
3094
3095    while i < bytes.len() {
3096        if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
3097            let mut args = Vec::new();
3098            let mut current_end = i;
3099
3100            while current_end + 5 <= bytes.len()
3101                && &selector[current_end..current_end + 5] == ":not("
3102            {
3103                let open = current_end + 4;
3104                if let Some(close) = find_matching_paren(selector, open) {
3105                    let arg = selector[open + 1..close].trim();
3106                    args.push(arg);
3107                    current_end = close + 1;
3108                } else {
3109                    break;
3110                }
3111            }
3112
3113            if args.len() > 1 {
3114                changed = true;
3115                let specs: Vec<Specificity> =
3116                    args.iter().map(|a| calculate_specificity(a)).collect();
3117                if specs.windows(2).any(|w| w[0] != w[1]) {
3118                    uniform_specificity = false;
3119                }
3120
3121                result.push_str(":not(");
3122                result.push_str(&args.join(", "));
3123                result.push(')');
3124                i = current_end;
3125                continue;
3126            }
3127        }
3128        let ch = selector[i..].chars().next().unwrap();
3129        result.push(ch);
3130        i += ch.len_utf8();
3131    }
3132
3133    if changed {
3134        Some((result, uniform_specificity))
3135    } else {
3136        None
3137    }
3138}
3139
3140fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
3141    let bytes = source.as_bytes();
3142    let mut depth = 1usize;
3143    let mut i = open + 1;
3144    let mut quote: Option<u8> = None;
3145    let mut escaped = false;
3146
3147    while i < bytes.len() {
3148        let b = bytes[i];
3149        if let Some(q) = quote {
3150            if escaped {
3151                escaped = false;
3152            } else if b == b'\\' {
3153                escaped = true;
3154            } else if b == q {
3155                quote = None;
3156            }
3157            i += 1;
3158            continue;
3159        }
3160
3161        match b {
3162            b'\'' | b'"' => quote = Some(b),
3163            b'(' => depth += 1,
3164            b')' => {
3165                depth -= 1;
3166                if depth == 0 {
3167                    return Some(i);
3168                }
3169            }
3170            _ => {}
3171        }
3172        i += 1;
3173    }
3174    None
3175}
3176
3177#[derive(Debug, Clone)]
3178struct HierarchicalRule {
3179    relative_selector: String,
3180    body_lines: Vec<String>,
3181    sub_rules: Vec<HierarchicalRule>,
3182    conditional_header: Option<String>,
3183}
3184
3185fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
3186    let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
3187    let parent_indent = line_indent(source, parent.start);
3188    let unit =
3189        detect_indent_unit(source, parent_body_range.clone()).unwrap_or_else(|| "  ".to_string());
3190    let nested_indent = format!("{parent_indent}{unit}");
3191
3192    let mut out = String::new();
3193    let open = parent_body_range.start - 1;
3194    out.push_str(&source[parent.start..=open]);
3195
3196    let parent_body = &source[parent_body_range.clone()];
3197    let trimmed_body = parent_body.trim();
3198    if !trimmed_body.is_empty() {
3199        out.push('\n');
3200        for line in parent_body.lines() {
3201            let trimmed_line = line.trim();
3202            if !trimmed_line.is_empty() {
3203                out.push_str(&nested_indent);
3204                out.push_str(&ensure_semicolon(trimmed_line));
3205                out.push('\n');
3206            }
3207        }
3208    }
3209
3210    // Build hierarchical rules
3211    let mut root_rules: Vec<HierarchicalRule> = Vec::new();
3212
3213    for child in children {
3214        match child {
3215            ClusterChild::Style {
3216                node,
3217                nested_selector,
3218                ..
3219            } => {
3220                let mut body_lines = Vec::new();
3221                if let Some(body_range) = &node.body_range {
3222                    for line in source[body_range.clone()].lines() {
3223                        let trimmed = line.trim();
3224                        if !trimmed.is_empty() {
3225                            body_lines.push(trimmed.to_string());
3226                        }
3227                    }
3228                }
3229                insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
3230            }
3231            ClusterChild::Conditional { node, inners, .. } => {
3232                let header = node.prelude(source).trim().to_string();
3233                let mut cond_sub_rules = Vec::new();
3234                for inner in inners {
3235                    match inner {
3236                        ConditionalInner::Direct { body_range } => {
3237                            let mut lines = Vec::new();
3238                            for line in source[body_range.clone()].lines() {
3239                                let trimmed = line.trim();
3240                                if !trimmed.is_empty() {
3241                                    lines.push(trimmed.to_string());
3242                                }
3243                            }
3244                            cond_sub_rules.push(HierarchicalRule {
3245                                relative_selector: String::new(),
3246                                body_lines: lines,
3247                                sub_rules: Vec::new(),
3248                                conditional_header: None,
3249                            });
3250                        }
3251                        ConditionalInner::Nested {
3252                            nested_selector,
3253                            body_range,
3254                        } => {
3255                            let mut lines = Vec::new();
3256                            for line in source[body_range.clone()].lines() {
3257                                let trimmed = line.trim();
3258                                if !trimmed.is_empty() {
3259                                    lines.push(trimmed.to_string());
3260                                }
3261                            }
3262                            cond_sub_rules.push(HierarchicalRule {
3263                                relative_selector: nested_selector.trim().to_string(),
3264                                body_lines: lines,
3265                                sub_rules: Vec::new(),
3266                                conditional_header: None,
3267                            });
3268                        }
3269                    }
3270                }
3271                root_rules.push(HierarchicalRule {
3272                    relative_selector: String::new(),
3273                    body_lines: Vec::new(),
3274                    sub_rules: cond_sub_rules,
3275                    conditional_header: Some(header),
3276                });
3277            }
3278        }
3279    }
3280
3281    for rule in &root_rules {
3282        out.push('\n');
3283        render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
3284    }
3285
3286    out.push_str(&parent_indent);
3287    out.push('}');
3288    out
3289}
3290
3291fn insert_hierarchical_style(
3292    root_rules: &mut Vec<HierarchicalRule>,
3293    selector: &str,
3294    body_lines: Vec<String>,
3295) {
3296    if let Some(last_rule) = root_rules.last_mut() {
3297        if last_rule.conditional_header.is_none() && !last_rule.relative_selector.is_empty() {
3298            let parent_sel = &last_rule.relative_selector;
3299            if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
3300                insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
3301                return;
3302            }
3303        }
3304    }
3305
3306    root_rules.push(HierarchicalRule {
3307        relative_selector: selector.to_string(),
3308        body_lines,
3309        sub_rules: Vec::new(),
3310        conditional_header: None,
3311    });
3312}
3313
3314fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
3315    let parent = parent.trim();
3316    let child = child.trim();
3317    if child == parent || !child.starts_with(parent) {
3318        return None;
3319    }
3320    let remainder = &child[parent.len()..];
3321    let trimmed = remainder.trim_start();
3322    if trimmed.is_empty() {
3323        return None;
3324    }
3325
3326    if remainder.starts_with("::")
3327        || remainder.starts_with(':')
3328        || remainder.starts_with('[')
3329        || remainder.starts_with('.')
3330        || remainder.starts_with('#')
3331    {
3332        Some(format!("&{remainder}"))
3333    } else if let Some(first_char) = trimmed
3334        .chars()
3335        .next()
3336        .filter(|c| *c == '>' || *c == '+' || *c == '~')
3337    {
3338        let after_comb = trimmed[first_char.len_utf8()..].trim();
3339        Some(format!("{first_char} {after_comb}"))
3340    } else if remainder
3341        .as_bytes()
3342        .first()
3343        .is_some_and(|b| b.is_ascii_whitespace())
3344    {
3345        Some(trimmed.to_string())
3346    } else {
3347        None
3348    }
3349}
3350
3351fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
3352    let inner_indent = format!("{indent}{unit}");
3353
3354    if let Some(header) = &rule.conditional_header {
3355        out.push_str(indent);
3356        out.push_str(header);
3357        out.push_str(" {\n");
3358        for (idx, sub) in rule.sub_rules.iter().enumerate() {
3359            if idx > 0 {
3360                out.push('\n');
3361            }
3362            if sub.relative_selector.is_empty() {
3363                for line in &sub.body_lines {
3364                    out.push_str(&inner_indent);
3365                    out.push_str(&ensure_semicolon(line));
3366                    out.push('\n');
3367                }
3368            } else {
3369                render_hierarchical_rule(out, sub, &inner_indent, unit);
3370            }
3371        }
3372        out.push_str(indent);
3373        out.push_str("}\n");
3374    } else {
3375        out.push_str(indent);
3376        out.push_str(&rule.relative_selector);
3377        out.push_str(" {\n");
3378
3379        for line in &rule.body_lines {
3380            out.push_str(&inner_indent);
3381            out.push_str(&ensure_semicolon(line));
3382            out.push('\n');
3383        }
3384
3385        for sub in &rule.sub_rules {
3386            out.push('\n');
3387            render_hierarchical_rule(out, sub, &inner_indent, unit);
3388        }
3389
3390        out.push_str(indent);
3391        out.push_str("}\n");
3392    }
3393}
3394
3395fn line_indent(source: &str, offset: usize) -> String {
3396    let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
3397    source[line_start..offset]
3398        .chars()
3399        .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
3400        .collect()
3401}
3402
3403/// Ensure a CSS declaration line ends with `;`.
3404/// Only adds the semicolon when the line looks like a property declaration
3405/// (contains `:`, does not already end with `;`, `{`, or `}`, and is not a comment).
3406fn ensure_semicolon(line: &str) -> std::borrow::Cow<'_, str> {
3407    let trimmed = line.trim_end();
3408    if trimmed.ends_with(';')
3409        || trimmed.ends_with('{')
3410        || trimmed.ends_with('}')
3411        || trimmed.ends_with(',')
3412        || trimmed.starts_with("//")
3413        || trimmed.starts_with("/*")
3414        || !trimmed.contains(':')
3415    {
3416        return std::borrow::Cow::Borrowed(line);
3417    }
3418    std::borrow::Cow::Owned(format!("{trimmed};"))
3419}
3420
3421fn line_number(source: &str, offset: usize) -> usize {
3422    source[..offset.min(source.len())].lines().count()
3423}
3424
3425fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
3426    for line in source[body].lines() {
3427        if line.trim().is_empty() {
3428            continue;
3429        }
3430        let indent: String = line
3431            .chars()
3432            .take_while(|c| *c == ' ' || *c == '\t')
3433            .collect();
3434        if !indent.is_empty() {
3435            return Some(indent);
3436        }
3437    }
3438    None
3439}
3440
3441fn contains_top_level_comma(selector: &str) -> bool {
3442    let bytes = selector.as_bytes();
3443    let mut parens = 0usize;
3444    let mut brackets = 0usize;
3445    let mut quote: Option<u8> = None;
3446    let mut escaped = false;
3447    let mut i = 0usize;
3448    while i < bytes.len() {
3449        let b = bytes[i];
3450        if let Some(q) = quote {
3451            if escaped {
3452                escaped = false;
3453            } else if b == b'\\' {
3454                escaped = true;
3455            } else if b == q {
3456                quote = None;
3457            }
3458            i += 1;
3459            continue;
3460        }
3461        match b {
3462            b'\'' | b'"' => quote = Some(b),
3463            b'(' => parens += 1,
3464            b')' => parens = parens.saturating_sub(1),
3465            b'[' => brackets += 1,
3466            b']' => brackets = brackets.saturating_sub(1),
3467            b',' if parens == 0 && brackets == 0 => return true,
3468            _ => {}
3469        }
3470        i += 1;
3471    }
3472    false
3473}
3474
3475pub fn apply_selected_plans(
3476    source: &str,
3477    plans: &[PlanEntry],
3478    include_review: bool,
3479) -> Result<String> {
3480    let mut selected: Vec<&PlanEntry> = plans
3481        .iter()
3482        .filter(|plan| {
3483            plan.selected
3484                && (plan.safety == Safety::Safe
3485                    || (include_review && plan.safety == Safety::Review))
3486        })
3487        .collect();
3488    selected.sort_by(|a, b| {
3489        a.source_range
3490            .start
3491            .cmp(&b.source_range.start)
3492            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
3493    });
3494
3495    let mut non_overlapping: Vec<&PlanEntry> = Vec::with_capacity(selected.len());
3496    let mut last_end = 0;
3497    for plan in selected {
3498        if plan.source_range.start >= last_end {
3499            last_end = plan.source_range.end;
3500            non_overlapping.push(plan);
3501        }
3502    }
3503
3504    let mut output = source.to_string();
3505    for plan in non_overlapping.into_iter().rev() {
3506        if plan.source_range.start <= output.len()
3507            && plan.source_range.end <= output.len()
3508            && plan.source_range.start <= plan.source_range.end
3509        {
3510            output.replace_range(
3511                plan.source_range.start..plan.source_range.end,
3512                &plan.proposed,
3513            );
3514        }
3515    }
3516    Ok(output)
3517}
3518
3519pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
3520    TextDiff::from_lines(old, new)
3521        .unified_diff()
3522        .header(old_name, new_name)
3523        .to_string()
3524}
3525
3526#[cfg(test)]
3527mod tests {
3528    use super::*;
3529
3530    fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
3531        analyze_source(PathBuf::from("test.css"), css, rules)
3532            .unwrap()
3533            .plans
3534    }
3535
3536    #[test]
3537    fn nests_adjacent_pseudo_and_descendant_rules() {
3538        let css = ".card {\n  color: red;\n}\n.card:hover {\n  color: blue !important;\n}\n.card .title {\n  font-weight: 700;\n}\n";
3539        let plans = plan(css, &RuleId::ALL);
3540        assert_eq!(plans.len(), 1);
3541        let output = apply_selected_plans(css, &plans, false).unwrap();
3542        assert!(output.contains("&:hover"));
3543        assert!(output.contains(".title"));
3544        assert!(output.contains("color: blue !important;"));
3545    }
3546
3547    #[test]
3548    fn nests_exact_full_modernize_example() {
3549        let original = r#".card {
3550  color: #222;
3551  padding: 1rem;
3552}
3553.card:hover {
3554  color: #111 !important;
3555}
3556.card::before {
3557  content: "";
3558}
3559.card[data-active] {
3560  border-color: currentColor;
3561}
3562.card.featured {
3563  box-shadow: 0 0 0 1px currentColor;
3564}
3565.card .title {
3566  font-weight: 700;
3567}
3568.card > .body {
3569  min-width: 0;
3570}
3571.card + .card {
3572  margin-top: 1rem;
3573}
3574@media (width >= 48rem) {
3575  .card {
3576    padding: 1.5rem;
3577  }
3578}
3579@supports (display: grid) {
3580  .card {
3581    display: grid;
3582  }
3583}
3584"#;
3585
3586        let expected = r#".card {
3587  color: #222;
3588  padding: 1rem;
3589
3590  &:hover {
3591    color: #111 !important;
3592  }
3593
3594  &::before {
3595    content: "";
3596  }
3597
3598  &[data-active] {
3599    border-color: currentColor;
3600  }
3601
3602  &.featured {
3603    box-shadow: 0 0 0 1px currentColor;
3604  }
3605
3606  .title {
3607    font-weight: 700;
3608  }
3609
3610  > .body {
3611    min-width: 0;
3612  }
3613
3614  + .card {
3615    margin-top: 1rem;
3616  }
3617
3618  @media (width >= 48rem) {
3619    padding: 1.5rem;
3620  }
3621
3622  @supports (display: grid) {
3623    display: grid;
3624  }
3625}"#;
3626
3627        let plans = plan(
3628            original,
3629            &[
3630                RuleId::NestPseudoClass,
3631                RuleId::NestPseudoElement,
3632                RuleId::NestAttribute,
3633                RuleId::NestCompound,
3634                RuleId::NestDescendant,
3635                RuleId::NestCombinator,
3636                RuleId::NestMedia,
3637                RuleId::NestSupports,
3638            ],
3639        );
3640        assert_eq!(plans.len(), 1);
3641        let output = apply_selected_plans(original, &plans, false).unwrap();
3642        assert_eq!(output.trim(), expected.trim());
3643    }
3644
3645    #[test]
3646    fn factors_selector_list_sharing_base() {
3647        let css = ".marker,\n.marker::before,\n.marker::after {\n  box-sizing: border-box;\n}\n";
3648        let plans = plan(css, &[RuleId::FactorSelectorList]);
3649        assert_eq!(plans.len(), 1);
3650        let output = apply_selected_plans(css, &plans, false).unwrap();
3651        assert!(output.contains(".marker {"));
3652        assert!(output.contains("&,"));
3653        assert!(output.contains("&::before,"));
3654        assert!(output.contains("&::after {"));
3655        assert!(output.contains("box-sizing: border-box;"));
3656    }
3657
3658    #[test]
3659    fn modernizes_is_with_uniform_specificity() {
3660        let css = ".button:hover, .button:focus, .button:active {\n  color: blue;\n}\n";
3661        let plans = plan(css, &[RuleId::ModernizeIs]);
3662        assert_eq!(plans.len(), 1);
3663        let output = apply_selected_plans(css, &plans, false).unwrap();
3664        assert!(output.contains(".button:is(:hover, :focus, :active)"));
3665    }
3666
3667    #[test]
3668    fn modernizes_media_range_syntax() {
3669        let css = "@media (min-width: 800px) {\n  .card { padding: 2rem; }\n}\n";
3670        let plans = plan(css, &[RuleId::ModernizeMediaRange]);
3671        assert_eq!(plans.len(), 1);
3672        let output = apply_selected_plans(css, &plans, false).unwrap();
3673        assert!(output.contains("@media (width >= 800px)"));
3674    }
3675
3676    #[test]
3677    fn consolidates_not_selectors() {
3678        let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n  border: 1px solid gray;\n}\n";
3679        let plans = plan(css, &[RuleId::ConsolidateNot]);
3680        assert_eq!(plans.len(), 1);
3681        assert_eq!(plans[0].safety, Safety::Review);
3682        let output = apply_selected_plans(css, &plans, true).unwrap();
3683        assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
3684    }
3685
3686    #[test]
3687    fn refuses_subtoken_is_factoring_false_positive() {
3688        let css = ".same-specificity-a,\n.same-specificity-b {\n  color: black;\n}\n";
3689        let plans = plan(css, &[RuleId::ModernizeIs]);
3690        assert!(plans.is_empty());
3691    }
3692
3693    #[test]
3694    fn modernizes_descendant_is_alternatives() {
3695        let css = ".card .title, .card .subtitle, .card .description {\n  color: black;\n}\n";
3696        let plans = plan(css, &[RuleId::ModernizeIs]);
3697        assert_eq!(plans.len(), 1);
3698        let output = apply_selected_plans(css, &plans, false).unwrap();
3699        assert!(output.contains(".card :is(.title, .subtitle, .description)"));
3700    }
3701
3702    #[test]
3703    fn modernizes_suffix_is_alternatives() {
3704        let css = ".alpha .title,\n#hero .title {\n  color: rebeccapurple;\n}\n";
3705        let plans = plan(css, &[RuleId::ModernizeIs]);
3706        assert_eq!(plans.len(), 1);
3707        assert_eq!(plans[0].safety, Safety::Review);
3708        let output = apply_selected_plans(css, &plans, true).unwrap();
3709        assert!(output.contains(":is(.alpha, #hero) .title"));
3710    }
3711
3712    #[test]
3713    fn factors_multi_selector_cluster_with_is_and_nesting() {
3714        let css = r#".alpha .title,
3715#hero .title {
3716  color: rebeccapurple;
3717}
3718
3719.alpha .subtitle,
3720#hero .subtitle {
3721  color: slateblue;
3722}
3723
3724.alpha,
3725#hero {
3726  border-color: currentColor;
3727}
3728"#;
3729        let plans = plan(css, &[RuleId::ModernizeIs]);
3730        assert_eq!(plans.len(), 1);
3731        let output = apply_selected_plans(css, &plans, true).unwrap();
3732        assert!(output.contains(":is(.alpha, #hero) {"));
3733        assert!(output.contains("border-color: currentColor;"));
3734        assert!(output.contains(".title {"));
3735        assert!(output.contains("color: rebeccapurple;"));
3736        assert!(output.contains(".subtitle {"));
3737        assert!(output.contains("color: slateblue;"));
3738    }
3739
3740    #[test]
3741    fn refuses_bem_token_concatenation() {
3742        let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
3743        let plans = plan(css, &RuleId::ALL);
3744        assert!(plans.is_empty());
3745    }
3746
3747    #[test]
3748    fn merges_same_named_layer_blocks() {
3749        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";
3750        let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
3751        assert_eq!(plans.len(), 2);
3752        let output = apply_selected_plans(css, &plans, false).unwrap();
3753        assert!(output.contains("@layer overrides {"));
3754        assert!(output.contains(".layered-card {"));
3755        assert!(output.contains(".layer-important {"));
3756    }
3757
3758    #[test]
3759    fn merges_adjacent_media_queries() {
3760        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";
3761        let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
3762        assert_eq!(plans.len(), 1);
3763        let output = apply_selected_plans(css, &plans, false).unwrap();
3764        assert!(output.contains("@media (width >= 48rem) {"));
3765        assert!(output.contains(".card {"));
3766        assert!(output.contains(".panel {"));
3767    }
3768
3769    #[test]
3770    fn merges_adjacent_supports_queries() {
3771        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";
3772        let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
3773        assert_eq!(plans.len(), 1);
3774        let output = apply_selected_plans(css, &plans, false).unwrap();
3775        assert!(output.contains("@supports (display: grid) {"));
3776        assert!(output.contains(".card {"));
3777        assert!(output.contains(".panel {"));
3778    }
3779
3780    #[test]
3781    fn merges_adjacent_identical_selectors() {
3782        let css = ".card {\n  color: black;\n}\n\n.card {\n  padding: 1rem;\n}\n";
3783        let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
3784        assert_eq!(plans.len(), 1);
3785        let output = apply_selected_plans(css, &plans, false).unwrap();
3786        assert!(output.contains(".card {"));
3787        assert!(output.contains("color: black;"));
3788        assert!(output.contains("padding: 1rem;"));
3789    }
3790
3791    #[test]
3792    fn merges_identical_rule_bodies() {
3793        let css = ".card:hover {\n  color: red;\n}\n\n.panel:hover {\n  color: red;\n}\n";
3794        let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
3795        assert_eq!(plans.len(), 1);
3796        let output = apply_selected_plans(css, &plans, false).unwrap();
3797        assert!(output.contains(".card:hover,"));
3798        assert!(output.contains(".panel:hover {"));
3799        assert!(output.contains("color: red;"));
3800    }
3801
3802    #[test]
3803    fn factors_identical_states_with_is() {
3804        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";
3805        let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
3806        assert_eq!(plans.len(), 1);
3807        let output = apply_selected_plans(css, &plans, false).unwrap();
3808        assert!(output.contains(".card {"));
3809        assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
3810        assert!(output.contains("background: silver;"));
3811    }
3812
3813    #[test]
3814    fn nests_multi_level_tree_hierarchy() {
3815        let css = r#".tree {
3816  display: grid;
3817  gap: 0.5rem;
3818}
3819.tree .node {
3820  position: relative;
3821}
3822.tree .node .label {
3823  display: flex;
3824}
3825.tree .node .label:hover {
3826  color: var(--accent);
3827}
3828.tree .node > .children {
3829  margin-inline-start: 1.25rem;
3830}
3831.tree .node > .children > .node + .node {
3832  margin-block-start: 0.25rem;
3833}
3834"#;
3835        let plans = plan(
3836            css,
3837            &[
3838                RuleId::NestDescendant,
3839                RuleId::NestCombinator,
3840                RuleId::NestPseudoClass,
3841            ],
3842        );
3843        assert_eq!(plans.len(), 1);
3844        let output = apply_selected_plans(css, &plans, false).unwrap();
3845        assert!(output.contains(".node {"));
3846        assert!(output.contains(".label {"));
3847        assert!(output.contains("&:hover {"));
3848        assert!(output.contains("> .children {"));
3849        assert!(output.contains("> .node + .node {"));
3850    }
3851
3852    #[test]
3853    fn nests_in_place_input_states() {
3854        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";
3855        let plans = plan(css, &[RuleId::NestPseudoClass]);
3856        assert_eq!(plans.len(), 1);
3857        let output = apply_selected_plans(css, &plans, false).unwrap();
3858        assert!(output.contains("input {"));
3859        assert!(output.contains("&:user-invalid {"));
3860        assert!(output.contains("&:user-valid {"));
3861        assert!(output.contains("&:placeholder-shown {"));
3862    }
3863
3864    #[test]
3865    fn gathers_consecutive_conditions_by_selector() {
3866        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";
3867        let plans = plan(css, &[RuleId::NestMedia]);
3868        assert_eq!(plans.len(), 1);
3869        let output = apply_selected_plans(css, &plans, false).unwrap();
3870        assert!(output.contains(".responsive-grid {"));
3871        assert!(output.contains("@media (width >= 30rem) {"));
3872        assert!(output.contains("@media (width >= 80rem) {"));
3873    }
3874
3875    #[test]
3876    fn factors_selector_list_with_adjacent_hover() {
3877        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";
3878        let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
3879        assert_eq!(plans.len(), 1);
3880        let output = apply_selected_plans(css, &plans, false).unwrap();
3881        assert!(output.contains(".notice {"));
3882        assert!(output.contains("&,"));
3883        assert!(output.contains("&::before,"));
3884        assert!(output.contains("&::after {"));
3885        assert!(output.contains("&:hover {"));
3886        assert!(output.contains("background: color-mix"));
3887    }
3888
3889    #[test]
3890    fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
3891        let css = r#".skip-link {
3892    position: absolute;
3893    inset-block-start: -48px;
3894    inset-inline-start: 1rem;
3895    z-index: 10000000000;
3896    background: var(--bg-color);
3897    color: var(--text-color);
3898    border: 1px solid var(--border-color);
3899    border-radius: 0.5rem;
3900    padding: 0.55rem 0.8rem;
3901    text-decoration: none;
3902    font-weight: 700;
3903    transition: inset-block-start 0.2s ease;
3904
3905    &:focus-visible {
3906        inset-block-start: 0.75rem;
3907    }
3908}
3909
3910.unrelated-rule {
3911    color: red;
3912}
3913
3914.skip-link {
3915    font: optional;
3916
3917    &::after {
3918        content: '';
3919    }
3920
3921    :not(*) & {
3922        all: unset
3923    }
3924}
3925"#;
3926        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
3927        assert_eq!(plans.len(), 2);
3928        let output = apply_selected_plans(css, &plans, true).unwrap();
3929        assert!(output.contains("font: optional;"));
3930    }
3931
3932    #[test]
3933    fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
3934        let css = r#".skip-link {
3935    position: absolute;
3936    inset-block-start: -48px;
3937    inset-inline-start: 1rem;
3938    z-index: 10000000000;
3939    background: var(--bg-color);
3940    color: var(--text-color);
3941    border: 1px solid var(--border-color);
3942    border-radius: 0.5rem;
3943    padding: 0.55rem 0.8rem;
3944    text-decoration: none;
3945    font-weight: 700;
3946    transition: inset-block-start 0.2s ease;
3947
3948    &:focus-visible {
3949        inset-block-start: 0.75rem;
3950    }
3951}
3952
3953.unrelated {
3954    color: red;
3955}
3956
3957.skip-link {
3958    font: optional;
3959
3960    &::after {
3961        content: '';
3962    }
3963
3964    :not(*) & {
3965        all: unset
3966    }
3967}
3968
3969.skip-link+* {
3970    display: block;
3971}
3972
3973.skip-link::backdrop {
3974    background-color: gray;
3975}
3976
3977.skip-link:has(*) {
3978    color: #27ca3f;
3979}
3980"#;
3981        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
3982        assert_eq!(plans.len(), 5);
3983        let output = apply_selected_plans(css, &plans, true).unwrap();
3984        assert!(output.contains("font: optional;"));
3985        assert!(output.contains("+ * {"));
3986        assert!(output.contains("&::backdrop {"));
3987        assert!(output.contains("&:has(*) {"));
3988    }
3989
3990    #[test]
3991    fn test_modernizes_media_range_syntax_no_whitespace() {
3992        let input = "@media (max-width:60rem) { .content { width: 100%; } }";
3993        let plans = plan(input, &[RuleId::ModernizeMediaRange]);
3994        assert_eq!(plans.len(), 1);
3995        let output = apply_selected_plans(input, &plans, true).unwrap();
3996        assert!(output.contains("(width <= 60rem)"));
3997    }
3998
3999    #[test]
4000    fn nesting_adds_semicolon_to_last_declaration_without_semicolon() {
4001        // .tabpanel body ends with `display: block !important` (no `;`)
4002        // After nesting the combinator, the declaration must get a `;` inserted
4003        // so it doesn't run together with the opening `{` of the nested rule.
4004        let css = r#".tabpanel {
4005  display: block !important
4006}
4007
4008.tabpanel+.tabpanel {
4009  margin-block-start: .5rem
4010}
4011"#;
4012        let rules = &[
4013            RuleId::NestCombinator,
4014            RuleId::NestDescendant,
4015            RuleId::NestPseudoClass,
4016            RuleId::NestPseudoElement,
4017        ];
4018        let plans = plan(css, rules);
4019        assert!(!plans.is_empty(), "expected at least one nesting plan");
4020        let output = apply_selected_plans(css, &plans, true).unwrap();
4021        // Must NOT contain the malformed concatenation
4022        assert!(
4023            !output.contains("!important+"),
4024            "semicolon missing before nested rule: {output}"
4025        );
4026        // Must contain properly terminated declaration
4027        assert!(
4028            output.contains("!important;") || output.contains("!important\n"),
4029            "declaration should end with ';': {output}"
4030        );
4031        // Nested rule selector must appear on its own
4032        assert!(
4033            output.contains("+ .tabpanel {") || output.contains("+.tabpanel {"),
4034            "nested combinator rule missing: {output}"
4035        );
4036    }
4037
4038    #[test]
4039    fn gather_related_selector_rules_adds_semicolon_to_declarations_without_semicolon() {
4040        // Declarations without trailing `;` must get one inserted when gathered
4041        // into the canonical block so they don't corrupt nested rule opening braces.
4042        let css = r#".skip-link {
4043    position: absolute;
4044    font-weight: 700
4045}
4046
4047.unrelated { color: red }
4048
4049.skip-link:focus {
4050    outline: 2px solid currentColor
4051}
4052
4053.skip-link+* {
4054    display: block
4055}
4056"#;
4057        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4058        assert!(!plans.is_empty(), "expected gather plan");
4059        let output = apply_selected_plans(css, &plans, true).unwrap();
4060        // No declaration should run together with `{`
4061        assert!(
4062            !output.contains("700\n    &") && !output.contains("700&") && !output.contains("700{"),
4063            "semicolon missing before nested pseudo/combinator: {output}"
4064        );
4065        assert!(
4066            !output.contains("currentColor\n    + *") && !output.contains("currentColor{"),
4067            "semicolon missing before nested combinator: {output}"
4068        );
4069        // All gathered declarations must end with `;`
4070        assert!(output.contains("font-weight: 700;"), "missing ';' after font-weight: {output}");
4071        assert!(output.contains("position: absolute;"), "missing ';' after position: {output}");
4072    }
4073
4074    #[test]
4075    fn gathers_non_adjacent_related_rule_inside_media_query() {
4076        let css = r#".skip-link {
4077    position: absolute;
4078    font-weight: 700;
4079
4080    &:focus-visible {
4081        inset-block-start: 0.75rem;
4082    }
4083}
4084
4085.unrelated {
4086    color: red;
4087}
4088
4089@media (width <= 1024px) {
4090    .skip-link :not(*) {
4091        position: inherit
4092    }
4093}
4094"#;
4095        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4096        assert!(!plans.is_empty(), "expected gather plan for media-wrapped related rule");
4097        let output = apply_selected_plans(css, &plans, true).unwrap();
4098        assert!(
4099            !output.contains(".skip-link :not(*)"),
4100            "flat media descendant should be gathered: {output}"
4101        );
4102        assert!(
4103            output.contains(":not(*) {"),
4104            "descendant :not(*) should nest under .skip-link: {output}"
4105        );
4106        assert!(
4107            output.contains("@media (width <= 1024px) {"),
4108            "media query should nest inside :not(*): {output}"
4109        );
4110        assert!(
4111            output.contains("position: inherit"),
4112            "declaration should be preserved: {output}"
4113        );
4114        let not_pos = output.find(":not(*) {").expect(":not(*)");
4115        let media_pos = output.find("@media (width <= 1024px) {").expect("@media");
4116        assert!(
4117            media_pos > not_pos,
4118            "media must nest inside :not(*), not the reverse: {output}"
4119        );
4120    }
4121
4122    #[test]
4123    fn gathers_exact_parent_inside_media_as_nested_at_rule() {
4124        let css = r#".skip-link {
4125    position: absolute;
4126}
4127
4128.unrelated { color: red }
4129
4130@media (width <= 600px) {
4131    .skip-link {
4132        display: none
4133    }
4134}
4135"#;
4136        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4137        let output = apply_selected_plans(css, &plans, true).unwrap();
4138        assert!(output.contains("@media (width <= 600px) {"), "{output}");
4139        assert!(output.contains("display: none;"), "{output}");
4140        assert!(
4141            !output.contains("@media (width <= 600px) {\n    .skip-link"),
4142            "should invert to .skip-link {{ @media }}: {output}"
4143        );
4144    }
4145
4146    #[test]
4147    fn gather_media_leaves_unrelated_siblings_in_place() {
4148        let css = r#".skip-link {
4149    position: absolute;
4150}
4151
4152.unrelated { color: red }
4153
4154@media (width <= 1024px) {
4155    .skip-link :not(*) {
4156        position: inherit
4157    }
4158    .other {
4159        color: blue
4160    }
4161}
4162"#;
4163        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4164        let output = apply_selected_plans(css, &plans, true).unwrap();
4165        assert!(output.contains(":not(*) {"), "{output}");
4166        assert!(
4167            output.contains(".other") && output.contains("color: blue"),
4168            "unrelated sibling must stay in the media block: {output}"
4169        );
4170    }
4171
4172    #[test]
4173    fn nests_appended_nesting_selector_descendant() {
4174        // MDN: .featured .card → .card { .featured & { ... } }
4175        let css = ".card {\n  color: red;\n}\n.featured .card {\n  border: 1px solid;\n}\n";
4176        let plans = plan(css, &[RuleId::NestDescendant]);
4177        assert_eq!(plans.len(), 1);
4178        let output = apply_selected_plans(css, &plans, false).unwrap();
4179        assert!(
4180            output.contains(".featured & {"),
4181            "expected appended &: {output}"
4182        );
4183        assert!(output.contains("border: 1px solid;"), "{output}");
4184    }
4185
4186    #[test]
4187    fn nests_appended_nesting_selector_not() {
4188        // MDN-adjacent: :not(.card) → .card { :not(&) { ... } }
4189        let css = ".card {\n  color: red;\n}\n:not(.card) {\n  display: none;\n}\n";
4190        let plans = plan(css, &[RuleId::NestPseudoClass]);
4191        assert_eq!(plans.len(), 1);
4192        let output = apply_selected_plans(css, &plans, false).unwrap();
4193        assert!(output.contains(":not(&) {"), "expected :not(&): {output}");
4194        assert!(output.contains("display: none;"), "{output}");
4195    }
4196
4197    #[test]
4198    fn nests_appended_compound_selector() {
4199        let css = ".card {\n  color: red;\n}\n.featured.card {\n  font-weight: 700;\n}\n";
4200        let plans = plan(css, &[RuleId::NestCompound]);
4201        assert_eq!(plans.len(), 1);
4202        let output = apply_selected_plans(css, &plans, false).unwrap();
4203        assert!(
4204            output.contains(".featured& {"),
4205            "expected compound appended &: {output}"
4206        );
4207    }
4208
4209    #[test]
4210    fn gathers_non_adjacent_appended_nesting_selector() {
4211        let css = r#".card {
4212    color: red;
4213}
4214
4215.unrelated { color: blue }
4216
4217.featured .card {
4218    border: 1px solid
4219}
4220
4221:not(.card) {
4222    display: none
4223}
4224"#;
4225        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4226        assert!(!plans.is_empty());
4227        let output = apply_selected_plans(css, &plans, true).unwrap();
4228        assert!(output.contains(".featured & {"), "{output}");
4229        assert!(output.contains(":not(&) {"), "{output}");
4230        assert!(!output.contains(".featured .card"), "{output}");
4231        assert!(!output.contains(":not(.card)"), "{output}");
4232    }
4233
4234    #[test]
4235    fn gather_does_not_nest_selector_list_under_one_branch() {
4236        // `A, B { shared }` must not become `B { A, & { shared } }` —
4237        // that turns A into a descendant of B.
4238        let css = r#"::view-transition-old(root),
4239::view-transition-new(root) {
4240    position: absolute;
4241    inset: 0;
4242    animation: 0.55s ease-in-out both;
4243}
4244
4245.unrelated { color: red }
4246
4247::view-transition-old(root) {
4248    animation-name: fadeOut;
4249}
4250
4251::view-transition-new(root) {
4252    animation-name: fadeIn;
4253}
4254"#;
4255        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules, RuleId::FactorSelectorList]);
4256        let output = apply_selected_plans(css, &plans, true).unwrap();
4257        assert!(
4258            !output.contains("::view-transition-old(root),\n    &")
4259                && !output.contains("::view-transition-old(root),\n    & {")
4260                && !output.contains("::view-transition-old(root),\n        &"),
4261            "selector list must not nest under one branch: {output}"
4262        );
4263        assert!(
4264            output.contains("::view-transition-old(root)")
4265                && output.contains("::view-transition-new(root)")
4266                && output.contains("animation-name: fadeOut")
4267                && output.contains("animation-name: fadeIn"),
4268            "both branches and their unique decls must remain: {output}"
4269        );
4270        assert!(
4271            output.contains("position: absolute"),
4272            "shared declarations must be kept: {output}"
4273        );
4274    }
4275
4276    #[test]
4277    fn gather_prefers_existing_specific_home_over_universal_append() {
4278        // `.skip-link + *` and `.skip-link:has(*)` can also be written as
4279        // `*` { `.skip-link+&` / `.skip-link:has(&)` }. The existing `.skip-link`
4280        // rule is the stronger home — do not duplicate into `*`.
4281        let css = r#".skip-link {
4282    position: absolute;
4283    font-weight: 700;
4284}
4285
4286* {
4287    margin: 0;
4288}
4289
4290.unrelated { color: red }
4291
4292.skip-link+* {
4293    display: block
4294}
4295
4296.skip-link:has(*) {
4297    color: #27ca3f
4298}
4299"#;
4300        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4301        let output = apply_selected_plans(css, &plans, true).unwrap();
4302        assert!(
4303            output.contains("+ * {") || output.contains("+* {"),
4304            "combinator should nest under .skip-link: {output}"
4305        );
4306        assert!(
4307            output.contains("&:has(*) {"),
4308            ":has(*) should nest under .skip-link: {output}"
4309        );
4310        assert!(
4311            !output.contains(".skip-link+&")
4312                && !output.contains(".skip-link + &")
4313                && !output.contains(".skip-link:has(&)"),
4314            "must not append the same rules into *: {output}"
4315        );
4316        let star = output.find("\n* {").or_else(|| output.find("* {"));
4317        if let Some(star_at) = star {
4318            let after_star = &output[star_at..];
4319            let star_body = after_star.split('}').next().unwrap_or(after_star);
4320            assert!(
4321                !star_body.contains("skip-link"),
4322                "* must not absorb skip-link rules: {output}"
4323            );
4324        }
4325    }
4326
4327    #[test]
4328    fn gather_appends_only_when_no_prefix_home_exists() {
4329        let css = r#".card {
4330    color: red;
4331}
4332
4333.unrelated { color: blue }
4334
4335.featured .card {
4336    border: 1px solid
4337}
4338"#;
4339        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4340        let output = apply_selected_plans(css, &plans, true).unwrap();
4341        assert!(output.contains(".featured & {"), "{output}");
4342        assert!(!output.contains(".featured .card"), "{output}");
4343    }
4344
4345    #[test]
4346    fn gather_keeps_busy_mixed_media_grouped() {
4347        let css = r#".nav-links { display: flex; }
4348.hamburger-menu { display: none; }
4349.nav-controls { gap: 1rem; }
4350
4351.unrelated { color: red }
4352
4353@media (width <= 1024px) {
4354    .nav-links { display: none }
4355    .hamburger-menu { display: flex }
4356    .nav-controls { margin-inline-start: auto }
4357}
4358"#;
4359        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4360        let output = apply_selected_plans(css, &plans, true).unwrap();
4361        assert!(
4362            output.contains("@media (width <= 1024px)"),
4363            "mixed media with 3+ selectors should stay grouped: {output}"
4364        );
4365        assert!(
4366            !output.contains(".nav-links {\n    display: flex;\n\n    @media")
4367                && !output.contains(".hamburger-menu {\n    display: none;\n\n    @media"),
4368            "should not explode a busy media query into each parent: {output}"
4369        );
4370    }
4371
4372    #[test]
4373    fn appended_nesting_does_not_match_ident_suffix() {
4374        let css = ".card {\n  color: red;\n}\n.mycard {\n  color: blue;\n}\n";
4375        let plans = plan(css, &[RuleId::NestCompound, RuleId::NestDescendant]);
4376        assert!(
4377            plans.is_empty(),
4378            ".mycard must not nest under .card: {:?}",
4379            plans.iter().map(|p| &p.proposed).collect::<Vec<_>>()
4380        );
4381    }
4382
4383    #[test]
4384    fn gather_preserves_nested_selector_lists() {
4385        // `&::before,` contains `:` but is a selector-list continuation, not a declaration.
4386        let css = r#"* {
4387    margin: 0;
4388}
4389
4390.unrelated { color: red }
4391
4392@media (prefers-reduced-motion: reduce) {
4393    * {
4394        &,
4395        &::before,
4396        &::after {
4397            animation-duration: 0.001ms !important
4398        }
4399    }
4400}
4401"#;
4402        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
4403        let output = apply_selected_plans(css, &plans, true).unwrap();
4404        assert!(
4405            !output.contains("&::before,\n        ;")
4406                && !output.contains("&::before,;")
4407                && !output.contains("&,\n        ;")
4408                && !output.contains("&,;"),
4409            "selector-list comma must not become a declaration terminator: {output}"
4410        );
4411        assert!(
4412            output.contains("&::before,") && output.contains("&::after {"),
4413            "compound pseudo selector list must stay intact: {output}"
4414        );
4415        let before = output.find("&::before,").expect("before");
4416        let after = output.find("&::after {").expect("after");
4417        let between = &output[before..after];
4418        assert!(
4419            !between.contains(';'),
4420            "no semicolon between selector-list items: {between:?} in {output}"
4421        );
4422    }
4423}