Skip to main content

cssforge_core/
engine.rs

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