Skip to main content

cssforge_core/
engine.rs

1use crate::{
2    model::{
3        AnalysisStats, FileReport, Finding, PlanEntry, Proof, RuleId, Safety, SourceRange,
4        WorkspaceReport, WorkspaceSummary,
5    },
6    scanner::{
7        NodeKind, SourceNode, count_ascii_case_insensitive_outside_comments,
8        count_top_level_declarations, is_whitespace_only, scan_nodes,
9    },
10};
11use anyhow::{Context, Result};
12use lightningcss::stylesheet::{ParserOptions, StyleSheet};
13use similar::TextDiff;
14use std::{
15    collections::{HashMap, HashSet},
16    fs,
17    ops::Range,
18    path::{Path, PathBuf},
19};
20
21const SPEC_BASELINE: &str = "2026-08-17";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
24pub struct Specificity {
25    pub ids: usize,
26    pub classes: usize,
27    pub elements: usize,
28}
29
30pub fn calculate_specificity(selector: &str) -> Specificity {
31    let mut ids = 0;
32    let mut classes = 0;
33    let mut elements = 0;
34    let bytes = selector.as_bytes();
35    let mut i = 0;
36    let mut in_attr = false;
37
38    while i < bytes.len() {
39        let b = bytes[i];
40        if b == b'[' {
41            in_attr = true;
42            classes += 1;
43            i += 1;
44            continue;
45        }
46        if b == b']' {
47            in_attr = false;
48            i += 1;
49            continue;
50        }
51        if in_attr {
52            i += 1;
53            continue;
54        }
55
56        if b == b'#' {
57            ids += 1;
58            i += 1;
59            while i < bytes.len()
60                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
61            {
62                i += 1;
63            }
64            continue;
65        }
66
67        if b == b'.' {
68            classes += 1;
69            i += 1;
70            while i < bytes.len()
71                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
72            {
73                i += 1;
74            }
75            continue;
76        }
77
78        if b == b':' {
79            if i + 1 < bytes.len() && bytes[i + 1] == b':' {
80                elements += 1;
81                i += 2;
82                while i < bytes.len()
83                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
84                {
85                    i += 1;
86                }
87            } else {
88                let start_name = i + 1;
89                let mut end_name = start_name;
90                while end_name < bytes.len()
91                    && (bytes[end_name].is_ascii_alphanumeric() || bytes[end_name] == b'-')
92                {
93                    end_name += 1;
94                }
95                let pseudo_name = &selector[start_name..end_name];
96                if pseudo_name == "where" {
97                    if end_name < bytes.len() && bytes[end_name] == b'(' {
98                        if let Some(close_p) = find_matching_paren(selector, end_name) {
99                            i = close_p + 1;
100                            continue;
101                        }
102                    }
103                } else if pseudo_name == "is" || pseudo_name == "not" || pseudo_name == "has" {
104                    if end_name < bytes.len() && bytes[end_name] == b'(' {
105                        if let Some(close_p) = find_matching_paren(selector, end_name) {
106                            let inner = &selector[end_name + 1..close_p];
107                            let max_inner = split_top_level_comma(inner)
108                                .into_iter()
109                                .map(|s| calculate_specificity(s.trim()))
110                                .max()
111                                .unwrap_or_default();
112                            ids += max_inner.ids;
113                            classes += max_inner.classes;
114                            elements += max_inner.elements;
115                            i = close_p + 1;
116                            continue;
117                        }
118                    }
119                    classes += 1;
120                } else {
121                    classes += 1;
122                }
123                i = end_name;
124            }
125            continue;
126        }
127
128        if (b.is_ascii_alphabetic() || b == b'*')
129            && (i == 0
130                || bytes[i - 1].is_ascii_whitespace()
131                || bytes[i - 1] == b'>'
132                || bytes[i - 1] == b'+'
133                || bytes[i - 1] == b'~'
134                || bytes[i - 1] == b'|')
135        {
136            if b != b'*' {
137                elements += 1;
138            }
139            i += 1;
140            while i < bytes.len()
141                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
142            {
143                i += 1;
144            }
145            continue;
146        }
147
148        i += 1;
149    }
150
151    Specificity {
152        ids,
153        classes,
154        elements,
155    }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum RelationKind {
160    PseudoClass,
161    PseudoElement,
162    Attribute,
163    Compound,
164    Descendant,
165    Combinator,
166}
167
168impl RelationKind {
169    fn rule(self) -> RuleId {
170        match self {
171            Self::PseudoClass => RuleId::NestPseudoClass,
172            Self::PseudoElement => RuleId::NestPseudoElement,
173            Self::Attribute => RuleId::NestAttribute,
174            Self::Compound => RuleId::NestCompound,
175            Self::Descendant => RuleId::NestDescendant,
176            Self::Combinator => RuleId::NestCombinator,
177        }
178    }
179}
180
181#[derive(Debug, Clone)]
182enum ConditionalInner {
183    Direct {
184        body_range: Range<usize>,
185    },
186    Nested {
187        nested_selector: String,
188        body_range: Range<usize>,
189    },
190}
191
192#[derive(Debug, Clone)]
193enum ClusterChild {
194    Style {
195        node: SourceNode,
196        relation: RelationKind,
197        nested_selector: String,
198    },
199    Conditional {
200        node: SourceNode,
201        rule: RuleId,
202        inners: Vec<ConditionalInner>,
203    },
204}
205
206impl ClusterChild {
207    fn node(&self) -> &SourceNode {
208        match self {
209            Self::Style { node, .. } | Self::Conditional { node, .. } => node,
210        }
211    }
212
213    fn rule(&self) -> RuleId {
214        match self {
215            Self::Style { relation, .. } => relation.rule(),
216            Self::Conditional { rule, .. } => *rule,
217        }
218    }
219}
220
221pub fn analyze_file(path: &Path, enabled_rules: &[RuleId]) -> Result<FileReport> {
222    let source = fs::read_to_string(path)
223        .with_context(|| format!("failed to read CSS file {}", path.display()))?;
224    analyze_source(path.to_path_buf(), &source, enabled_rules)
225}
226
227pub fn analyze_workspace(
228    root: &Path,
229    files: &[PathBuf],
230    enabled_rules: &[RuleId],
231) -> Result<WorkspaceReport> {
232    let mut reports = Vec::with_capacity(files.len());
233    let mut next_id = 1usize;
234
235    for path in files {
236        let mut report = analyze_file(path, enabled_rules)?;
237        for plan in &mut report.plans {
238            plan.id = format!("T-{next_id:06}");
239            next_id += 1;
240        }
241        reports.push(report);
242    }
243
244    let mut summary = WorkspaceSummary {
245        files: reports.len(),
246        ..WorkspaceSummary::default()
247    };
248
249    for report in &reports {
250        if !report.parse_ok {
251            summary.parse_errors += 1;
252        }
253        summary.rules_analyzed +=
254            report.stats.top_level_style_rules + report.stats.top_level_at_rules;
255        for plan in &report.plans {
256            match plan.safety {
257                Safety::Safe => summary.safe += 1,
258                Safety::Review => summary.review += 1,
259                Safety::Unsafe => summary.unsafe_count += 1,
260                Safety::Unsupported => summary.unsupported += 1,
261                Safety::NoOp => summary.no_op += 1,
262            }
263            if plan
264                .warnings
265                .iter()
266                .any(|w| w.to_ascii_lowercase().contains("specificity"))
267            {
268                summary.specificity_sensitive += 1;
269            }
270            if plan.warnings.iter().any(|w| {
271                w.to_ascii_lowercase().contains("cascade")
272                    || w.to_ascii_lowercase().contains("source order")
273            }) {
274                summary.cascade_sensitive += 1;
275            }
276            if plan
277                .warnings
278                .iter()
279                .any(|w| w.to_ascii_lowercase().contains("layer"))
280            {
281                summary.layer_sensitive += 1;
282            }
283            if plan
284                .warnings
285                .iter()
286                .any(|w| w.to_ascii_lowercase().contains("scope"))
287            {
288                summary.scope_sensitive += 1;
289            }
290        }
291    }
292
293    Ok(WorkspaceReport {
294        tool_version: env!("CARGO_PKG_VERSION").to_string(),
295        spec_baseline: SPEC_BASELINE.to_string(),
296        root: root.to_path_buf(),
297        enabled_rules: enabled_rules.to_vec(),
298        files: reports,
299        summary,
300    })
301}
302
303fn analyze_source(path: PathBuf, source: &str, enabled_rules: &[RuleId]) -> Result<FileReport> {
304    let parse_result = StyleSheet::parse(
305        source,
306        ParserOptions {
307            filename: path.display().to_string(),
308            error_recovery: false,
309            ..ParserOptions::default()
310        },
311    );
312
313    let parse_error = parse_result.err().map(|err| format!("{err:?}"));
314    let parse_ok = parse_error.is_none();
315    let nodes = scan_nodes(source, 0..source.len());
316    let stats = collect_stats(source, &nodes, parse_ok);
317    let mut findings = collect_findings(source, &nodes);
318
319    if let Some(error) = &parse_error {
320        findings.push(Finding {
321            safety: Safety::Unsupported,
322            title: "Semantic parse failed".into(),
323            detail: error.clone(),
324        });
325    }
326
327    let plans = if parse_ok && !enabled_rules.is_empty() {
328        build_plans_recursive(&path, source, &nodes, enabled_rules)
329    } else {
330        Vec::new()
331    };
332
333    Ok(FileReport {
334        path,
335        parse_ok,
336        parse_error,
337        stats,
338        findings,
339        plans,
340    })
341}
342
343fn collect_stats(source: &str, nodes: &[SourceNode], parse_ok: bool) -> AnalysisStats {
344    let mut stats = AnalysisStats {
345        bytes: source.len(),
346        parse_errors: usize::from(!parse_ok),
347        important_declarations: count_ascii_case_insensitive_outside_comments(source, "!important"),
348        ..AnalysisStats::default()
349    };
350    let mut selector_counts: HashMap<String, usize> = HashMap::new();
351
352    for node in nodes {
353        match &node.kind {
354            NodeKind::Style => {
355                stats.top_level_style_rules += 1;
356                let selector = node.prelude(source).to_string();
357                *selector_counts.entry(selector).or_default() += 1;
358                if let Some(body) = node.body(source) {
359                    stats.declarations += count_top_level_declarations(body);
360                    stats.custom_properties += count_custom_properties(body);
361                }
362            }
363            NodeKind::AtBlock { name, .. } => {
364                stats.top_level_at_rules += 1;
365                match name.as_str() {
366                    "media" => stats.media_rules += 1,
367                    "supports" => stats.supports_rules += 1,
368                    "container" => stats.container_rules += 1,
369                    "layer" => stats.layer_rules += 1,
370                    "scope" => stats.scope_rules += 1,
371                    "starting-style" => stats.starting_style_rules += 1,
372                    _ => {}
373                }
374            }
375            NodeKind::AtStatement { .. } => stats.top_level_at_rules += 1,
376        }
377    }
378
379    stats.duplicate_selectors = selector_counts.values().filter(|&&count| count > 1).count();
380    stats
381}
382
383fn count_custom_properties(body: &str) -> usize {
384    body.lines()
385        .filter(|line| {
386            let trimmed = line.trim_start();
387            trimmed.starts_with("--") && trimmed.contains(':')
388        })
389        .count()
390}
391
392fn collect_findings(source: &str, nodes: &[SourceNode]) -> Vec<Finding> {
393    let mut findings = Vec::new();
394    let selectors: HashSet<String> = nodes
395        .iter()
396        .filter(|n| matches!(&n.kind, NodeKind::Style))
397        .map(|n| n.prelude(source).to_string())
398        .collect();
399
400    let mut selector_occurrences: HashMap<String, usize> = HashMap::new();
401
402    for node in nodes {
403        match &node.kind {
404            NodeKind::Style => {
405                let selector = node.prelude(source);
406                *selector_occurrences.entry(selector.to_string()).or_default() += 1;
407
408                if contains_top_level_comma(selector) {
409                    let branches = split_top_level_comma(selector);
410                    let specs: Vec<Specificity> = branches.iter().map(|b| calculate_specificity(b.trim())).collect();
411                    let has_mixed = specs.windows(2).any(|w| w[0] != w[1]);
412                    if has_mixed {
413                        findings.push(Finding {
414                            safety: Safety::Review,
415                            title: "Mixed-specificity selector list detected".into(),
416                            detail: format!("{selector}: contains branches with differing specificities; factoring into :is() or parent nesting would raise lower-specificity branches."),
417                        });
418                    } else {
419                        findings.push(Finding {
420                            safety: Safety::Review,
421                            title: "Selector list kept flat".into(),
422                            detail: format!("{selector}: parent selector lists require per-branch specificity proof before native nesting."),
423                        });
424                    }
425                }
426
427                if let Some(base) = bem_base_candidate(selector) {
428                    if selectors.contains(base) {
429                        findings.push(Finding {
430                            safety: Safety::Unsupported,
431                            title: "BEM token concatenation is not native nesting".into(),
432                            detail: format!("{selector} resembles {base} + a BEM suffix; CSS nesting cannot safely generate &__element or &--modifier."),
433                        });
434                    }
435                }
436
437                if let Some(body) = node.body(source) {
438                    if body.trim().is_empty() {
439                        findings.push(Finding {
440                            safety: Safety::Review,
441                            title: "Empty rule block detected".into(),
442                            detail: format!("{selector} contains no declarations or nested rules."),
443                        });
444                    }
445
446                    let mut seen_props: HashMap<String, String> = HashMap::new();
447                    for line in body.lines() {
448                        let trimmed = line.trim();
449                        if trimmed.starts_with("/*") || trimmed.starts_with('*') || !trimmed.contains(':') {
450                            continue;
451                        }
452                        if let Some((prop, val)) = trimmed.split_once(':') {
453                            let prop = prop.trim().to_ascii_lowercase();
454                            let val = val.trim().trim_end_matches(';').trim().to_string();
455                            if let Some(prev_val) = seen_props.get(&prop) {
456                                if prev_val == &val {
457                                    findings.push(Finding {
458                                        safety: Safety::Review,
459                                        title: "Exact duplicate declaration detected".into(),
460                                        detail: format!("In {selector}: property '{prop}: {val}' is declared multiple times with identical value."),
461                                    });
462                                }
463                            } else {
464                                seen_props.insert(prop, val);
465                            }
466                        }
467                    }
468
469                    if selector.contains(" .") && !selector.contains(":has(") {
470                        findings.push(Finding {
471                            safety: Safety::Review,
472                            title: "Potential :has() relational candidate".into(),
473                            detail: format!("{selector}: parent-child descendant relationship could be expressed with :has() if container-targeting is intended (advisory)."),
474                        });
475                    }
476                }
477            }
478            NodeKind::AtBlock { name, .. } => match name.as_str() {
479                "layer" => findings.push(Finding {
480                    safety: Safety::Review,
481                    title: "Cascade layer context detected".into(),
482                    detail: "@layer participates in cascade ordering and reverses layer precedence for !important; automatic layer architecture is not applied.".into(),
483                }),
484                "scope" => findings.push(Finding {
485                    safety: Safety::Review,
486                    title: "Scope context detected".into(),
487                    detail: "@scope adds scope proximity to the cascade; scope architecture remains advisory.".into(),
488                }),
489                "container" => findings.push(Finding {
490                    safety: Safety::Review,
491                    title: "Container query context detected".into(),
492                    detail: "@container depends on eligible ancestor containers; media-to-container conversion is not inferred from CSS alone.".into(),
493                }),
494                "starting-style" => findings.push(Finding {
495                    safety: Safety::Review,
496                    title: "Starting-style context detected".into(),
497                    detail: "@starting-style is temporal transition state; this build never invents it from ordinary declarations.".into(),
498                }),
499                _ => {}
500            },
501            NodeKind::AtStatement { .. } => {}
502        }
503    }
504
505    for (sel, count) in selector_occurrences {
506        if count > 1 {
507            findings.push(Finding {
508                safety: Safety::Review,
509                title: "Duplicate selector in stylesheet".into(),
510                detail: format!("'{sel}' appears {count} times in the stylesheet; non-adjacent occurrences must not be merged across intervening rules."),
511            });
512        }
513    }
514
515    findings
516}
517
518fn bem_base_candidate(selector: &str) -> Option<&str> {
519    let trimmed = selector.trim();
520    let idx = trimmed.find("__").or_else(|| trimmed.find("--"))?;
521    if idx == 0 {
522        None
523    } else {
524        Some(&trimmed[..idx])
525    }
526}
527
528const TRANSPARENT_AT_RULES: &[&str] = &["layer", "scope", "media", "supports", "container"];
529
530fn build_plans_recursive(
531    path: &Path,
532    source: &str,
533    nodes: &[SourceNode],
534    enabled_rules: &[RuleId],
535) -> Vec<PlanEntry> {
536    let mut plans = build_plans(path, source, nodes, enabled_rules);
537
538    for node in nodes {
539        if let NodeKind::AtBlock { name, .. } = &node.kind {
540            if TRANSPARENT_AT_RULES.contains(&name.as_str()) {
541                let is_covered = plans
542                    .iter()
543                    .any(|p| p.source_range.start <= node.start && node.end <= p.source_range.end);
544                if !is_covered {
545                    if let Some(body_range) = &node.body_range {
546                        let inner_nodes = scan_nodes(source, body_range.clone());
547                        if !inner_nodes.is_empty() {
548                            let inner_plans =
549                                build_plans_recursive(path, source, &inner_nodes, enabled_rules);
550                            plans.extend(inner_plans);
551                        }
552                    }
553                }
554            }
555        }
556    }
557
558    plans.sort_by(|a, b| {
559        a.source_range
560            .start
561            .cmp(&b.source_range.start)
562            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
563    });
564    let mut disjoint = Vec::with_capacity(plans.len());
565    let mut last_end = 0;
566    for p in plans {
567        if p.source_range.start >= last_end {
568            last_end = p.source_range.end;
569            disjoint.push(p);
570        }
571    }
572
573    disjoint
574}
575
576fn build_plans(
577    path: &Path,
578    source: &str,
579    nodes: &[SourceNode],
580    enabled_rules: &[RuleId],
581) -> Vec<PlanEntry> {
582    let enabled: HashSet<RuleId> = enabled_rules.iter().copied().collect();
583    let mut plans = Vec::new();
584
585    // 1. Structural At-rule refactorings across top-level nodes
586    plan_merge_same_named_layers(path, source, nodes, &enabled, &mut plans);
587    plan_merge_adjacent_at_blocks(path, source, nodes, &enabled, &mut plans);
588    plan_gather_consecutive_conditions_by_selector(path, source, nodes, &enabled, &mut plans);
589    plan_merge_adjacent_identical_selectors(path, source, nodes, &enabled, &mut plans);
590    plan_gather_related_selector_rules(path, source, nodes, &enabled, &mut plans);
591    plan_merge_identical_rule_bodies(path, source, nodes, &enabled, &mut plans);
592    plan_factor_identical_states_with_is(path, source, nodes, &enabled, &mut plans);
593    plan_factor_multi_selector_cluster_with_is(path, source, nodes, &enabled, &mut plans);
594    plan_nest_in_place_adjacent_states(path, source, nodes, &enabled, &mut plans);
595
596    let mut i = 0usize;
597
598    while i < nodes.len() {
599        let parent = &nodes[i];
600
601        // ModernizeMediaRange on at-rules
602        if enabled.contains(&RuleId::ModernizeMediaRange) {
603            if let NodeKind::AtBlock { name, .. } = &parent.kind {
604                if name == "media" || name == "container" {
605                    let prelude = parent.prelude(source);
606                    if let Some(modernized) = modernize_media_query_str(prelude) {
607                        plans.push(PlanEntry {
608                            id: String::new(),
609                            file: path.to_path_buf(),
610                            rules: vec![RuleId::ModernizeMediaRange],
611                            safety: Safety::Safe,
612                            source_range: SourceRange {
613                                start: parent.prelude_range.start,
614                                end: parent.prelude_range.end,
615                            },
616                            original: source[parent.prelude_range.clone()].to_string(),
617                            proposed: modernized,
618                            proof: Proof::safe_local(),
619                            warnings: Vec::new(),
620                            reason: "Modernize legacy media/container feature syntax to CSS Range Syntax (e.g. (width >= 800px)).".to_string(),
621                            selected: true,
622                        });
623                    }
624                }
625            }
626        }
627
628        if !matches!(&parent.kind, NodeKind::Style) {
629            i += 1;
630            continue;
631        }
632
633        let parent_selector = parent.prelude(source);
634
635        // FactorSelectorList
636        if contains_top_level_comma(parent_selector) {
637            let parent_indent = line_indent(source, parent.start);
638            let parent_body_range = parent.body_range.clone();
639            let unit = parent_body_range
640                .as_ref()
641                .and_then(|r| detect_indent_unit(source, r.clone()))
642                .unwrap_or_else(|| "  ".to_string());
643
644            if enabled.contains(&RuleId::FactorSelectorList) {
645                if let Some(body_range) = &parent.body_range {
646                    let body = &source[body_range.clone()];
647                    if let Some(mut factored) =
648                        factor_selector_list(parent_selector, body, &parent_indent, &unit)
649                    {
650                        let branches: Vec<&str> = split_top_level_comma(parent_selector)
651                            .into_iter()
652                            .map(|s| s.trim())
653                            .collect();
654                        let base = branches[0];
655
656                        // Check if subsequent adjacent style rules share base (e.g. .notice:hover)
657                        let mut cursor = i + 1;
658                        let mut prev_end = parent.end;
659                        let mut extra_children = Vec::new();
660
661                        while cursor < nodes.len() {
662                            let next = &nodes[cursor];
663                            if !is_whitespace_only(source, prev_end..next.start) {
664                                break;
665                            }
666                            if matches!(&next.kind, NodeKind::Style) {
667                                if let Some((rel, nested_sel)) =
668                                    selector_relation(base, next.prelude(source))
669                                {
670                                    if enabled.contains(&rel.rule()) {
671                                        extra_children.push(ClusterChild::Style {
672                                            node: next.clone(),
673                                            relation: rel,
674                                            nested_selector: nested_sel,
675                                        });
676                                        prev_end = next.end;
677                                        cursor += 1;
678                                        continue;
679                                    }
680                                }
681                            }
682                            break;
683                        }
684
685                        let end_offset = if extra_children.is_empty() {
686                            parent.end
687                        } else {
688                            let nested_indent = format!("{parent_indent}{unit}");
689                            let inner_decl_indent = format!("{nested_indent}{unit}");
690                            let mut extra_rendered = String::new();
691
692                            for ch in &extra_children {
693                                if let ClusterChild::Style {
694                                    node: ch_node,
695                                    nested_selector,
696                                    ..
697                                } = ch
698                                {
699                                    extra_rendered.push('\n');
700                                    extra_rendered.push_str(&nested_indent);
701                                    extra_rendered.push_str(nested_selector.trim());
702                                    extra_rendered.push_str(" {\n");
703                                    if let Some(ch_body_range) = &ch_node.body_range {
704                                        for line in source[ch_body_range.clone()].lines() {
705                                            let trimmed = line.trim();
706                                            if !trimmed.is_empty() {
707                                                extra_rendered.push_str(&inner_decl_indent);
708                                                extra_rendered.push_str(trimmed);
709                                                extra_rendered.push('\n');
710                                            }
711                                        }
712                                    }
713                                    extra_rendered.push_str(&nested_indent);
714                                    extra_rendered.push_str("}\n");
715                                }
716                            }
717
718                            if let Some(close_brace_pos) = factored.rfind('}') {
719                                factored.insert_str(close_brace_pos, &extra_rendered);
720                            }
721                            prev_end
722                        };
723
724                        plans.push(PlanEntry {
725                            id: String::new(),
726                            file: path.to_path_buf(),
727                            rules: vec![RuleId::FactorSelectorList],
728                            safety: Safety::Safe,
729                            source_range: SourceRange {
730                                start: parent.start,
731                                end: end_offset,
732                            },
733                            original: source[parent.start..end_offset].to_string(),
734                            proposed: factored,
735                            proof: Proof::safe_local(),
736                            warnings: Vec::new(),
737                            reason: "Factor comma-separated selectors sharing a common base element into nested form.".to_string(),
738                            selected: true,
739                        });
740                        i = cursor;
741                        continue;
742                    }
743                }
744            }
745
746            if enabled.contains(&RuleId::ModernizeIs) {
747                if let Some((factored_sel, uniform)) = factor_with_is(parent_selector) {
748                    plans.push(PlanEntry {
749                        id: String::new(),
750                        file: path.to_path_buf(),
751                        rules: vec![RuleId::ModernizeIs],
752                        safety: if uniform { Safety::Safe } else { Safety::Review },
753                        source_range: SourceRange {
754                            start: parent.prelude_range.start,
755                            end: parent.prelude_range.end,
756                        },
757                        original: source[parent.prelude_range.clone()].to_string(),
758                        proposed: factored_sel,
759                        proof: Proof {
760                            specificity_equivalent: uniform,
761                            ..Proof::safe_local()
762                        },
763                        warnings: if uniform { Vec::new() } else { vec!["Mixed branch specificity: :is() takes the specificity of its most specific argument.".into()] },
764                        reason: "Factor common selector prefix/suffix into :is(...) grouping.".to_string(),
765                        selected: true,
766                    });
767                    i += 1;
768                    continue;
769                }
770            }
771
772            if enabled.contains(&RuleId::ModernizeWhere) {
773                if let Some(factored_where) = factor_with_where(parent_selector) {
774                    plans.push(PlanEntry {
775                        id: String::new(),
776                        file: path.to_path_buf(),
777                        rules: vec![RuleId::ModernizeWhere],
778                        safety: Safety::Review,
779                        source_range: SourceRange {
780                            start: parent.prelude_range.start,
781                            end: parent.prelude_range.end,
782                        },
783                        original: source[parent.prelude_range.clone()].to_string(),
784                        proposed: factored_where,
785                        proof: Proof {
786                            specificity_equivalent: false,
787                            ..Proof::safe_local()
788                        },
789                        warnings: vec!["Specificity zeroed to 0-0-0 by :where()".into()],
790                        reason: "Convert selector list to :where(...) for zero-specificity defaults (review required).".to_string(),
791                        selected: true,
792                    });
793                    i += 1;
794                    continue;
795                }
796            }
797
798            i += 1;
799            continue;
800        }
801
802        if parent_selector.contains("::") {
803            i += 1;
804            continue;
805        }
806
807        let mut children = Vec::new();
808        let mut cursor = i + 1;
809        let mut previous_end = parent.end;
810
811        while cursor < nodes.len() {
812            let node = &nodes[cursor];
813            if !is_whitespace_only(source, previous_end..node.start) {
814                break;
815            }
816
817            if matches!(&node.kind, NodeKind::Style) {
818                if let Some((relation, nested_selector)) =
819                    selector_relation(parent_selector, node.prelude(source))
820                {
821                    if enabled.contains(&relation.rule()) {
822                        children.push(ClusterChild::Style {
823                            node: node.clone(),
824                            relation,
825                            nested_selector,
826                        });
827                        previous_end = node.end;
828                        cursor += 1;
829                        continue;
830                    }
831                }
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            if name == "layer" {
921                let prelude = node.prelude(source).trim();
922                if let Some(layer_name) = prelude.strip_prefix("@layer") {
923                    let layer_name = layer_name.trim();
924                    if !layer_name.is_empty() && !layer_name.contains('{') {
925                        layer_groups
926                            .entry(layer_name.to_string())
927                            .or_default()
928                            .push(node);
929                    }
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(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                {
1072                    if next_name == name && next.prelude(source).trim() == first_prelude {
1073                        cluster.push(next);
1074                        prev_end = next.end;
1075                        cursor += 1;
1076                        continue;
1077                    }
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(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            if name == "media" || name == "supports" {
1151                if let Some(target_sel) = extract_single_style_selector(source, first) {
1152                    let mut cluster = vec![first];
1153                    let mut cursor = i + 1;
1154                    let mut prev_end = first.end;
1155
1156                    while cursor < nodes.len() {
1157                        let next = &nodes[cursor];
1158                        if !is_whitespace_only(source, prev_end..next.start) {
1159                            break;
1160                        }
1161                        if let NodeKind::AtBlock {
1162                            name: next_name, ..
1163                        } = &next.kind
1164                        {
1165                            if next_name == "media" || next_name == "supports" {
1166                                if let Some(next_sel) = extract_single_style_selector(source, next)
1167                                {
1168                                    if next_sel == target_sel {
1169                                        cluster.push(next);
1170                                        prev_end = next.end;
1171                                        cursor += 1;
1172                                        continue;
1173                                    }
1174                                }
1175                            }
1176                        }
1177                        break;
1178                    }
1179
1180                    if cluster.len() > 1 {
1181                        let last = cluster.last().unwrap();
1182                        let parent_indent = line_indent(source, first.start);
1183                        let first_body_range = first.body_range.as_ref().unwrap();
1184                        let unit = detect_indent_unit(source, first_body_range.clone())
1185                            .unwrap_or_else(|| "  ".to_string());
1186                        let nested_indent = format!("{parent_indent}{unit}");
1187                        let inner_decl_indent = format!("{nested_indent}{unit}");
1188
1189                        let mut body_out = String::new();
1190                        for (idx, &c) in cluster.iter().enumerate() {
1191                            if idx > 0 {
1192                                body_out.push('\n');
1193                            }
1194                            let at_header = c.prelude(source).trim();
1195                            body_out.push_str(&nested_indent);
1196                            body_out.push_str(at_header);
1197                            body_out.push_str(" {\n");
1198
1199                            let c_body_range = c.body_range.as_ref().unwrap();
1200                            let inner_nodes = scan_nodes(source, c_body_range.clone());
1201                            for in_node in &inner_nodes {
1202                                if let Some(in_body_range) = &in_node.body_range {
1203                                    for line in source[in_body_range.clone()].lines() {
1204                                        let trimmed = line.trim();
1205                                        if !trimmed.is_empty() {
1206                                            body_out.push_str(&inner_decl_indent);
1207                                            body_out.push_str(trimmed);
1208                                            body_out.push('\n');
1209                                        }
1210                                    }
1211                                }
1212                            }
1213
1214                            body_out.push_str(&nested_indent);
1215                            body_out.push_str("}\n");
1216                        }
1217
1218                        let proposed =
1219                            format!("{parent_indent}{target_sel} {{\n{body_out}{parent_indent}}}");
1220                        plans.push(PlanEntry {
1221                            id: String::new(),
1222                            file: path.to_path_buf(),
1223                            rules: vec![RuleId::NestMedia, RuleId::NestSupports],
1224                            safety: Safety::Safe,
1225                            source_range: SourceRange {
1226                                start: first.start,
1227                                end: last.end,
1228                            },
1229                            original: source[first.start..last.end].to_string(),
1230                            proposed,
1231                            proof: Proof::safe_local(),
1232                            warnings: Vec::new(),
1233                            reason: format!(
1234                                "Gather {} consecutive condition blocks targeting '{}' into a single component rule.",
1235                                cluster.len(),
1236                                target_sel
1237                            ),
1238                            selected: true,
1239                        });
1240                        i = cursor;
1241                        continue;
1242                    }
1243                }
1244            }
1245        }
1246        i += 1;
1247    }
1248}
1249
1250fn extract_single_style_selector<'a>(source: &'a str, at_node: &SourceNode) -> Option<&'a str> {
1251    let body_range = at_node.body_range.as_ref()?;
1252    let inner_nodes = scan_nodes(source, body_range.clone());
1253    if inner_nodes.len() == 1 && matches!(&inner_nodes[0].kind, NodeKind::Style) {
1254        Some(inner_nodes[0].prelude(source).trim())
1255    } else {
1256        None
1257    }
1258}
1259
1260fn plan_nest_in_place_adjacent_states(
1261    path: &Path,
1262    source: &str,
1263    nodes: &[SourceNode],
1264    enabled: &HashSet<RuleId>,
1265    plans: &mut Vec<PlanEntry>,
1266) {
1267    if !enabled.contains(&RuleId::NestPseudoClass) {
1268        return;
1269    }
1270
1271    let mut i = 0;
1272    while i < nodes.len() {
1273        let first = &nodes[i];
1274        if matches!(&first.kind, NodeKind::Style) {
1275            let first_sel = first.prelude(source).trim();
1276            if let Some(base) = extract_base_target(first_sel) {
1277                let mut cluster = vec![first];
1278                let mut cursor = i + 1;
1279                let mut prev_end = first.end;
1280
1281                while cursor < nodes.len() {
1282                    let next = &nodes[cursor];
1283                    if !is_whitespace_only(source, prev_end..next.start) {
1284                        break;
1285                    }
1286                    if matches!(&next.kind, NodeKind::Style) {
1287                        let next_sel = next.prelude(source).trim();
1288                        if let Some(next_base) = extract_base_target(next_sel) {
1289                            if next_base == base {
1290                                cluster.push(next);
1291                                prev_end = next.end;
1292                                cursor += 1;
1293                                continue;
1294                            }
1295                        }
1296                    }
1297                    break;
1298                }
1299
1300                if cluster.len() > 1 {
1301                    let last = cluster.last().unwrap();
1302                    let parent_indent = line_indent(source, first.start);
1303                    let first_body_range = first.body_range.as_ref().unwrap();
1304                    let unit = detect_indent_unit(source, first_body_range.clone())
1305                        .unwrap_or_else(|| "  ".to_string());
1306                    let nested_indent = format!("{parent_indent}{unit}");
1307                    let inner_decl_indent = format!("{nested_indent}{unit}");
1308
1309                    let mut out = format!("{parent_indent}{base} {{\n");
1310                    for (idx, &c) in cluster.iter().enumerate() {
1311                        if idx > 0 {
1312                            out.push('\n');
1313                        }
1314                        let c_sel = c.prelude(source).trim();
1315                        let remainder = &c_sel[base.len()..];
1316                        let nested_sel = if remainder.starts_with(':')
1317                            || remainder.starts_with('[')
1318                            || remainder.starts_with('.')
1319                            || remainder.starts_with('#')
1320                        {
1321                            format!("&{remainder}")
1322                        } else {
1323                            remainder.trim().to_string()
1324                        };
1325
1326                        out.push_str(&nested_indent);
1327                        out.push_str(&nested_sel);
1328                        out.push_str(" {\n");
1329
1330                        if let Some(c_body_range) = &c.body_range {
1331                            for line in source[c_body_range.clone()].lines() {
1332                                let trimmed = line.trim();
1333                                if !trimmed.is_empty() {
1334                                    out.push_str(&inner_decl_indent);
1335                                    out.push_str(trimmed);
1336                                    out.push('\n');
1337                                }
1338                            }
1339                        }
1340
1341                        out.push_str(&nested_indent);
1342                        out.push_str("}\n");
1343                    }
1344
1345                    out.push_str(&parent_indent);
1346                    out.push('}');
1347
1348                    plans.push(PlanEntry {
1349                        id: String::new(),
1350                        file: path.to_path_buf(),
1351                        rules: vec![RuleId::NestPseudoClass, RuleId::NestAttribute],
1352                        safety: Safety::Safe,
1353                        source_range: SourceRange {
1354                            start: first.start,
1355                            end: last.end,
1356                        },
1357                        original: source[first.start..last.end].to_string(),
1358                        proposed: out,
1359                        proof: Proof::safe_local(),
1360                        warnings: Vec::new(),
1361                        reason: format!(
1362                            "Nest {} adjacent state rules for '{}' in place without moving.",
1363                            cluster.len(),
1364                            base
1365                        ),
1366                        selected: true,
1367                    });
1368                    i = cursor;
1369                    continue;
1370                }
1371            }
1372        }
1373        i += 1;
1374    }
1375}
1376
1377fn extract_base_target(selector: &str) -> Option<&str> {
1378    if contains_top_level_comma(selector) {
1379        return None;
1380    }
1381    if let Some(pos) = selector.find(':') {
1382        if pos > 0 && !selector[pos..].starts_with("::") {
1383            let base = &selector[..pos];
1384            if !base.is_empty() {
1385                return Some(base);
1386            }
1387        }
1388    }
1389    if let Some(pos) = selector.find('[') {
1390        if pos > 0 {
1391            let base = &selector[..pos];
1392            if !base.is_empty() {
1393                return Some(base);
1394            }
1395        }
1396    }
1397    None
1398}
1399
1400fn parse_rule_body_items(body_str: &str) -> (Vec<String>, Vec<String>) {
1401    let mut declarations = Vec::new();
1402    let mut nested_rules = Vec::new();
1403
1404    let mut depth = 0usize;
1405    let mut current_block = String::new();
1406    let mut current_decl = String::new();
1407    let mut in_comment = false;
1408    let bytes = body_str.as_bytes();
1409    let mut i = 0;
1410
1411    while i < bytes.len() {
1412        if in_comment {
1413            current_decl.push(bytes[i] as char);
1414            current_block.push(bytes[i] as char);
1415            if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
1416                current_decl.push('/');
1417                current_block.push('/');
1418                i += 2;
1419                in_comment = false;
1420                continue;
1421            }
1422            i += 1;
1423            continue;
1424        }
1425
1426        if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
1427            in_comment = true;
1428            current_decl.push('/');
1429            current_decl.push('*');
1430            current_block.push('/');
1431            current_block.push('*');
1432            i += 2;
1433            continue;
1434        }
1435
1436        let b = bytes[i];
1437        if b == b'{' {
1438            depth += 1;
1439            if depth == 1 {
1440                current_block = current_decl.clone();
1441                current_decl.clear();
1442            }
1443            current_block.push('{');
1444            i += 1;
1445            continue;
1446        } else if b == b'}' {
1447            if depth > 0 {
1448                depth -= 1;
1449                current_block.push('}');
1450                if depth == 0 {
1451                    let trimmed = current_block.trim().to_string();
1452                    if !trimmed.is_empty() {
1453                        nested_rules.push(trimmed);
1454                    }
1455                    current_block.clear();
1456                    current_decl.clear();
1457                }
1458            }
1459            i += 1;
1460            continue;
1461        }
1462
1463        if depth > 0 {
1464            current_block.push(b as char);
1465        } else {
1466            if b == b';' {
1467                current_decl.push(';');
1468                let trimmed = current_decl.trim().to_string();
1469                if !trimmed.is_empty() {
1470                    declarations.push(trimmed);
1471                }
1472                current_decl.clear();
1473            } else if b == b'\n' {
1474                let trimmed = current_decl.trim();
1475                if !trimmed.is_empty() && trimmed.contains(':') && !trimmed.ends_with('{') {
1476                    let rest = body_str[i + 1..].trim_start();
1477                    if !rest.starts_with('{') {
1478                        declarations.push(trimmed.to_string());
1479                        current_decl.clear();
1480                    } else {
1481                        current_decl.push('\n');
1482                    }
1483                } else {
1484                    current_decl.push('\n');
1485                }
1486            } else {
1487                current_decl.push(b as char);
1488            }
1489        }
1490        i += 1;
1491    }
1492
1493    let trailing_decl = current_decl.trim().to_string();
1494    if !trailing_decl.is_empty() && trailing_decl.contains(':') {
1495        declarations.push(trailing_decl);
1496    }
1497
1498    (declarations, nested_rules)
1499}
1500
1501fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1502    if candidate_sel == base {
1503        return None;
1504    }
1505    if !candidate_sel.starts_with(base) {
1506        return None;
1507    }
1508    let rem_raw = &candidate_sel[base.len()..];
1509    let rem = rem_raw.trim_start();
1510    if rem.is_empty() {
1511        return None;
1512    }
1513    // Only attach '&' when the suffix is directly touching the base (no whitespace).
1514    // e.g. `.foo:hover` → `&:hover` but `.foo :not(*)` → `:not(*)` (descendant, no &).
1515    let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1516    if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#') {
1517        if directly_attached {
1518            return Some(format!("&{rem}"));
1519        } else {
1520            // Whitespace-separated: descendant combinator, no &
1521            return Some(rem.to_string());
1522        }
1523    }
1524    if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1525        let first_char = &rem[..1];
1526        let rest = rem[1..].trim_start();
1527        return Some(format!("{first_char} {rest}"));
1528    }
1529    if rem_raw.starts_with(' ') {
1530        return Some(rem.to_string());
1531    }
1532    None
1533}
1534
1535fn format_merged_rule(
1536    first_sel: &str,
1537    parent_indent: &str,
1538    unit: &str,
1539    cluster: &[&SourceNode],
1540    source: &str,
1541) -> String {
1542    let nested_indent = format!("{parent_indent}{unit}");
1543    let inner_indent = format!("{nested_indent}{unit}");
1544
1545    let mut all_decls = Vec::new();
1546    let mut all_nested_rules = Vec::new();
1547
1548    for c in cluster {
1549        let cand_sel = c.prelude(source).trim();
1550        if let Some(body_range) = &c.body_range {
1551            let body_str = &source[body_range.clone()];
1552            if cand_sel == first_sel {
1553                let (decls, nested) = parse_rule_body_items(body_str);
1554                all_decls.extend(decls);
1555                all_nested_rules.extend(nested);
1556            } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
1557                let (decls, nested) = parse_rule_body_items(body_str);
1558                if nested.is_empty() {
1559                    let mut rel_body = String::new();
1560                    for d in &decls {
1561                        rel_body.push_str(&format!("{d}\n"));
1562                    }
1563                    all_nested_rules.push(format!("{rel_sel} {{\n    {rel_body}}}"));
1564                } else {
1565                    let mut rel_body_lines = Vec::new();
1566                    for d in &decls {
1567                        rel_body_lines.push(format!("    {d}"));
1568                    }
1569                    for nr in &nested {
1570                        rel_body_lines.push(nr.clone());
1571                    }
1572                    let rel_body = rel_body_lines.join("\n");
1573                    all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
1574                }
1575            }
1576        }
1577    }
1578
1579    let mut body_lines = Vec::new();
1580
1581    for d in &all_decls {
1582        body_lines.push(format!("{nested_indent}{d}"));
1583    }
1584
1585    if !all_decls.is_empty() && !all_nested_rules.is_empty() {
1586        body_lines.push(String::new());
1587    }
1588
1589    for (idx, nr) in all_nested_rules.iter().enumerate() {
1590        let lines: Vec<&str> = nr.lines().collect();
1591        if lines.is_empty() {
1592            continue;
1593        }
1594        let first_line = lines[0].trim();
1595        body_lines.push(format!("{nested_indent}{first_line}"));
1596
1597        for mid_line in &lines[1..lines.len().saturating_sub(1)] {
1598            let m_trimmed = mid_line.trim();
1599            if m_trimmed.is_empty() {
1600                body_lines.push(String::new());
1601            } else {
1602                body_lines.push(format!("{inner_indent}{m_trimmed}"));
1603            }
1604        }
1605
1606        if lines.len() > 1 {
1607            let last_line = lines.last().unwrap().trim();
1608            body_lines.push(format!("{nested_indent}{last_line}"));
1609        }
1610
1611        if idx < all_nested_rules.len() - 1 {
1612            body_lines.push(String::new());
1613        }
1614    }
1615
1616    let body_content = body_lines.join("\n");
1617    format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
1618}
1619
1620fn plan_merge_adjacent_identical_selectors(
1621    path: &Path,
1622    source: &str,
1623    nodes: &[SourceNode],
1624    enabled: &HashSet<RuleId>,
1625    plans: &mut Vec<PlanEntry>,
1626) {
1627    if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
1628        return;
1629    }
1630    let mut i = 0;
1631    while i < nodes.len() {
1632        let first = &nodes[i];
1633        if matches!(&first.kind, NodeKind::Style) {
1634            let first_sel = first.prelude(source).trim();
1635            let mut cluster = vec![first];
1636            let mut cursor = i + 1;
1637            let mut prev_end = first.end;
1638
1639            while cursor < nodes.len() {
1640                let next = &nodes[cursor];
1641                if !is_whitespace_only(source, prev_end..next.start) {
1642                    break;
1643                }
1644                if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
1645                {
1646                    cluster.push(next);
1647                    prev_end = next.end;
1648                    cursor += 1;
1649                    continue;
1650                }
1651                break;
1652            }
1653
1654            if cluster.len() > 1 {
1655                let last = cluster.last().unwrap();
1656                let parent_indent = line_indent(source, first.start);
1657                let first_body_range = first.body_range.as_ref().unwrap();
1658                let unit = detect_indent_unit(source, first_body_range.clone())
1659                    .unwrap_or_else(|| "    ".to_string());
1660
1661                let proposed = format_merged_rule(first_sel, &parent_indent, &unit, &cluster, source);
1662
1663                plans.push(PlanEntry {
1664                    id: String::new(),
1665                    file: path.to_path_buf(),
1666                    rules: vec![RuleId::MergeAdjacentIdenticalSelector],
1667                    safety: Safety::Safe,
1668                    source_range: SourceRange {
1669                        start: first.start,
1670                        end: last.end,
1671                    },
1672                    original: source[first.start..last.end].to_string(),
1673                    proposed,
1674                    proof: Proof::safe_local(),
1675                    warnings: Vec::new(),
1676                    reason: format!(
1677                        "Merge {} adjacent identical selector rules for '{}' into a single block.",
1678                        cluster.len(),
1679                        first_sel
1680                    ),
1681                    selected: true,
1682                });
1683                i = cursor;
1684                continue;
1685            }
1686        }
1687        i += 1;
1688    }
1689}
1690
1691fn plan_gather_related_selector_rules(
1692    path: &Path,
1693    source: &str,
1694    nodes: &[SourceNode],
1695    enabled: &HashSet<RuleId>,
1696    plans: &mut Vec<PlanEntry>,
1697) {
1698    if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
1699        return;
1700    }
1701
1702    let mut base_candidates = Vec::new();
1703
1704    for node in nodes {
1705        if matches!(&node.kind, NodeKind::Style) {
1706            let sel = node.prelude(source).trim();
1707            if !sel.is_empty() && !sel.starts_with('&') && !sel.starts_with('+') && !sel.starts_with('>') && !sel.starts_with('~') {
1708                let base = if let Some(colon_pos) = sel.find(':') {
1709                    sel[..colon_pos].trim()
1710                } else if let Some(bracket_pos) = sel.find('[') {
1711                    sel[..bracket_pos].trim()
1712                } else {
1713                    sel
1714                };
1715                if !base.is_empty() && !base_candidates.contains(&base) {
1716                    base_candidates.push(base);
1717                }
1718            }
1719        }
1720    }
1721
1722    for base in base_candidates {
1723        let mut cluster: Vec<&SourceNode> = Vec::new();
1724        for node in nodes {
1725            if matches!(&node.kind, NodeKind::Style) {
1726                let sel = node.prelude(source).trim();
1727                if sel == base || extract_related_nested_selector(base, sel).is_some() {
1728                    cluster.push(node);
1729                }
1730            }
1731        }
1732
1733        if cluster.len() > 1 {
1734            let mut is_non_adjacent = false;
1735            for window in cluster.windows(2) {
1736                let prev = window[0];
1737                let next = window[1];
1738                if !is_whitespace_only(source, prev.end..next.start) {
1739                    is_non_adjacent = true;
1740                    break;
1741                }
1742            }
1743
1744            if is_non_adjacent {
1745                let first = cluster[0];
1746                let parent_indent = line_indent(source, first.start);
1747                let first_body_range = first.body_range.as_ref().unwrap();
1748                let unit = detect_indent_unit(source, first_body_range.clone())
1749                    .unwrap_or_else(|| "    ".to_string());
1750
1751                let proposed = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
1752
1753                plans.push(PlanEntry {
1754                    id: String::new(),
1755                    file: path.to_path_buf(),
1756                    rules: vec![RuleId::GatherRelatedSelectorRules],
1757                    safety: Safety::Review,
1758                    source_range: SourceRange {
1759                        start: first.start,
1760                        end: first.end,
1761                    },
1762                    original: source[first.start..first.end].to_string(),
1763                    proposed,
1764                    proof: Proof {
1765                        selector_set_equivalent: true,
1766                        specificity_equivalent: true,
1767                        cascade_context_equivalent: false,
1768                        source_order_equivalent: false,
1769                        layer_equivalent: true,
1770                        scope_equivalent: true,
1771                        declarations_exact: true,
1772                        important_exact: true,
1773                    },
1774                    warnings: vec![format!(
1775                        "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
1776                        cluster.len(),
1777                        base
1778                    )],
1779                    reason: format!(
1780                        "Gather {} related rules for '{}' into the canonical first selector block.",
1781                        cluster.len(),
1782                        base
1783                    ),
1784                    selected: true,
1785                });
1786
1787                for sec in &cluster[1..] {
1788                    let mut sec_end = sec.end;
1789                    if source[sec_end..].starts_with("\r\n") {
1790                        sec_end += 2;
1791                    } else if source[sec_end..].starts_with('\n') {
1792                        sec_end += 1;
1793                    }
1794
1795                    plans.push(PlanEntry {
1796                        id: String::new(),
1797                        file: path.to_path_buf(),
1798                        rules: vec![RuleId::GatherRelatedSelectorRules],
1799                        safety: Safety::Review,
1800                        source_range: SourceRange {
1801                            start: sec.start,
1802                            end: sec_end,
1803                        },
1804                        original: source[sec.start..sec_end].to_string(),
1805                        proposed: String::new(),
1806                        proof: Proof::safe_local(),
1807                        warnings: Vec::new(),
1808                        reason: format!(
1809                            "Remove non-adjacent gathered rule for '{}' at line {}.",
1810                            base,
1811                            line_number(source, sec.start)
1812                        ),
1813                        selected: true,
1814                    });
1815                }
1816            }
1817        }
1818    }
1819}
1820
1821fn plan_factor_identical_states_with_is(
1822    path: &Path,
1823    source: &str,
1824    nodes: &[SourceNode],
1825    enabled: &HashSet<RuleId>,
1826    plans: &mut Vec<PlanEntry>,
1827) {
1828    if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
1829        return;
1830    }
1831    let mut i = 0;
1832    while i < nodes.len() {
1833        let first = &nodes[i];
1834        if matches!(&first.kind, NodeKind::Style) {
1835            let first_sel = first.prelude(source).trim();
1836            if let Some(colon_pos) = first_sel.find(':') {
1837                if !first_sel[colon_pos..].starts_with("::") {
1838                    let base = &first_sel[..colon_pos];
1839                    if !base.is_empty() && !base.contains(' ') {
1840                        let first_body = first.body(source).unwrap_or("").trim();
1841                        let mut cluster = vec![first];
1842                        let mut cursor = i + 1;
1843                        let mut prev_end = first.end;
1844
1845                        while cursor < nodes.len() {
1846                            let next = &nodes[cursor];
1847                            if !is_whitespace_only(source, prev_end..next.start) {
1848                                break;
1849                            }
1850                            if matches!(&next.kind, NodeKind::Style) {
1851                                let next_sel = next.prelude(source).trim();
1852                                if next_sel.starts_with(base)
1853                                    && next_sel[base.len()..].starts_with(':')
1854                                    && !next_sel[base.len()..].starts_with("::")
1855                                    && next.body(source).unwrap_or("").trim() == first_body
1856                                {
1857                                    cluster.push(next);
1858                                    prev_end = next.end;
1859                                    cursor += 1;
1860                                    continue;
1861                                }
1862                            }
1863                            break;
1864                        }
1865
1866                        if cluster.len() > 1 {
1867                            let last = cluster.last().unwrap();
1868                            let pseudos: Vec<&str> = cluster
1869                                .iter()
1870                                .map(|c| {
1871                                    let s = c.prelude(source).trim();
1872                                    &s[base.len()..]
1873                                })
1874                                .collect();
1875                            let is_inner = pseudos.join(", ");
1876                            let parent_indent = line_indent(source, first.start);
1877                            let first_body_range = first.body_range.as_ref().unwrap();
1878                            let unit = detect_indent_unit(source, first_body_range.clone())
1879                                .unwrap_or_else(|| "  ".to_string());
1880                            let nested_indent = format!("{parent_indent}{unit}");
1881                            let inner_decl_indent = format!("{nested_indent}{unit}");
1882
1883                            let mut decls = String::new();
1884                            for line in source[first_body_range.clone()].lines() {
1885                                let trimmed = line.trim();
1886                                if !trimmed.is_empty() {
1887                                    decls.push_str(&inner_decl_indent);
1888                                    decls.push_str(trimmed);
1889                                    decls.push('\n');
1890                                }
1891                            }
1892
1893                            let proposed = format!(
1894                                "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
1895                            );
1896                            plans.push(PlanEntry {
1897                                id: String::new(),
1898                                file: path.to_path_buf(),
1899                                rules: vec![RuleId::FactorIdenticalStatesWithIs],
1900                                safety: Safety::Safe,
1901                                source_range: SourceRange {
1902                                    start: first.start,
1903                                    end: last.end,
1904                                },
1905                                original: source[first.start..last.end].to_string(),
1906                                proposed,
1907                                proof: Proof::safe_local(),
1908                                warnings: Vec::new(),
1909                                reason: format!(
1910                                    "Factor {} identical state rules for '{}' into &:is({}) form.",
1911                                    cluster.len(),
1912                                    base,
1913                                    is_inner
1914                                ),
1915                                selected: true,
1916                            });
1917                            i = cursor;
1918                            continue;
1919                        }
1920                    }
1921                }
1922            }
1923        }
1924        i += 1;
1925    }
1926}
1927
1928fn plan_factor_multi_selector_cluster_with_is(
1929    path: &Path,
1930    source: &str,
1931    nodes: &[SourceNode],
1932    enabled: &HashSet<RuleId>,
1933    plans: &mut Vec<PlanEntry>,
1934) {
1935    if !enabled.contains(&RuleId::ModernizeIs) {
1936        return;
1937    }
1938
1939    let mut i = 0;
1940    while i < nodes.len() {
1941        let first = &nodes[i];
1942        if matches!(&first.kind, NodeKind::Style) {
1943            let first_sel = first.prelude(source).trim();
1944            if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
1945                let mut cluster = vec![(first, first_suffix)];
1946                let mut cursor = i + 1;
1947                let mut prev_end = first.end;
1948
1949                while cursor < nodes.len() {
1950                    let next = &nodes[cursor];
1951                    if !is_whitespace_only(source, prev_end..next.start) {
1952                        break;
1953                    }
1954                    if matches!(&next.kind, NodeKind::Style) {
1955                        let next_sel = next.prelude(source).trim();
1956                        if let Some((next_prefixes, next_suffix)) =
1957                            extract_multi_branch_pattern(next_sel)
1958                        {
1959                            if next_prefixes == base_prefixes {
1960                                cluster.push((next, next_suffix));
1961                                prev_end = next.end;
1962                                cursor += 1;
1963                                continue;
1964                            }
1965                        }
1966                    }
1967                    break;
1968                }
1969
1970                if cluster.len() > 1 {
1971                    let (last_node, _) = cluster.last().unwrap();
1972                    let parent_indent = line_indent(source, first.start);
1973                    let first_body_range = first.body_range.as_ref().unwrap();
1974                    let unit = detect_indent_unit(source, first_body_range.clone())
1975                        .unwrap_or_else(|| "  ".to_string());
1976                    let nested_indent = format!("{parent_indent}{unit}");
1977                    let inner_decl_indent = format!("{nested_indent}{unit}");
1978
1979                    let is_header = format!(":is({})", base_prefixes.join(", "));
1980                    let mut out = format!("{parent_indent}{is_header} {{\n");
1981                    let mut has_direct_decls = false;
1982
1983                    // 1. Direct declarations from base rules (suffix == None)
1984                    for &(c_node, ref suffix) in &cluster {
1985                        if suffix.is_none() {
1986                            if let Some(c_body_range) = &c_node.body_range {
1987                                for line in source[c_body_range.clone()].lines() {
1988                                    let trimmed = line.trim();
1989                                    if !trimmed.is_empty() {
1990                                        out.push_str(&nested_indent);
1991                                        out.push_str(trimmed);
1992                                        out.push('\n');
1993                                        has_direct_decls = true;
1994                                    }
1995                                }
1996                            }
1997                        }
1998                    }
1999
2000                    // 2. Nested child rules (suffix == Some(sub_sel))
2001                    for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
2002                        if let Some(sub_sel) = suffix {
2003                            if has_direct_decls || c_idx > 0 {
2004                                out.push('\n');
2005                            }
2006                            out.push_str(&nested_indent);
2007                            out.push_str(sub_sel);
2008                            out.push_str(" {\n");
2009                            if let Some(c_body_range) = &c_node.body_range {
2010                                for line in source[c_body_range.clone()].lines() {
2011                                    let trimmed = line.trim();
2012                                    if !trimmed.is_empty() {
2013                                        out.push_str(&inner_decl_indent);
2014                                        out.push_str(trimmed);
2015                                        out.push('\n');
2016                                    }
2017                                }
2018                            }
2019                            out.push_str(&nested_indent);
2020                            out.push_str("}\n");
2021                        }
2022                    }
2023
2024                    out.push_str(&parent_indent);
2025                    out.push('}');
2026
2027                    let specificities: Vec<Specificity> = base_prefixes
2028                        .iter()
2029                        .map(|p| calculate_specificity(p))
2030                        .collect();
2031                    let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
2032
2033                    plans.push(PlanEntry {
2034                        id: String::new(),
2035                        file: path.to_path_buf(),
2036                        rules: vec![RuleId::ModernizeIs],
2037                        safety: Safety::Safe,
2038                        source_range: SourceRange {
2039                            start: first.start,
2040                            end: last_node.end,
2041                        },
2042                        original: source[first.start..last_node.end].to_string(),
2043                        proposed: out,
2044                        proof: Proof::safe_local(),
2045                        warnings: if uniform { Vec::new() } else { vec!["Notice: :is() takes the specificity of its most specific argument.".into()] },
2046                        reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
2047                        selected: true,
2048                    });
2049                    i = cursor;
2050                    continue;
2051                }
2052            }
2053        }
2054        i += 1;
2055    }
2056}
2057
2058fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
2059    let branches: Vec<&str> = split_top_level_comma(selector)
2060        .into_iter()
2061        .map(|s| s.trim())
2062        .collect();
2063    if branches.len() < 2 {
2064        return None;
2065    }
2066    if branches.iter().any(|b| b.contains("::")) {
2067        return None;
2068    }
2069
2070    let first = branches[0];
2071    if let Some(space_pos) = first.rfind(' ') {
2072        let suffix = &first[space_pos..];
2073        if branches.iter().all(|b| b.ends_with(suffix)) {
2074            let prefixes: Vec<String> = branches
2075                .iter()
2076                .map(|b| b[..b.len() - suffix.len()].trim().to_string())
2077                .collect();
2078            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
2079                return Some((prefixes, Some(suffix.trim().to_string())));
2080            }
2081        }
2082    }
2083
2084    if branches
2085        .iter()
2086        .all(|b| is_valid_selector_token(b) && !b.contains(' '))
2087    {
2088        let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
2089        return Some((prefixes, None));
2090    }
2091
2092    None
2093}
2094
2095fn plan_merge_identical_rule_bodies(
2096    path: &Path,
2097    source: &str,
2098    nodes: &[SourceNode],
2099    enabled: &HashSet<RuleId>,
2100    plans: &mut Vec<PlanEntry>,
2101) {
2102    if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
2103        return;
2104    }
2105    let mut i = 0;
2106    while i < nodes.len() {
2107        let first = &nodes[i];
2108        if matches!(&first.kind, NodeKind::Style) {
2109            let first_body = first.body(source).unwrap_or("").trim();
2110            if !first_body.is_empty() {
2111                let mut cluster = vec![first];
2112                let mut cursor = i + 1;
2113                let mut prev_end = first.end;
2114
2115                while cursor < nodes.len() {
2116                    let next = &nodes[cursor];
2117                    if !is_whitespace_only(source, prev_end..next.start) {
2118                        break;
2119                    }
2120                    if matches!(&next.kind, NodeKind::Style)
2121                        && next.body(source).unwrap_or("").trim() == first_body
2122                    {
2123                        cluster.push(next);
2124                        prev_end = next.end;
2125                        cursor += 1;
2126                        continue;
2127                    }
2128                    break;
2129                }
2130
2131                if cluster.len() > 1 {
2132                    let last = cluster.last().unwrap();
2133                    let selectors: Vec<&str> =
2134                        cluster.iter().map(|c| c.prelude(source).trim()).collect();
2135                    let parent_indent = line_indent(source, first.start);
2136                    let first_body_range = first.body_range.as_ref().unwrap();
2137                    let unit = detect_indent_unit(source, first_body_range.clone())
2138                        .unwrap_or_else(|| "  ".to_string());
2139                    let nested_indent = format!("{parent_indent}{unit}");
2140
2141                    let mut decls = String::new();
2142                    for line in source[first_body_range.clone()].lines() {
2143                        let trimmed = line.trim();
2144                        if !trimmed.is_empty() {
2145                            decls.push_str(&nested_indent);
2146                            decls.push_str(trimmed);
2147                            decls.push('\n');
2148                        }
2149                    }
2150
2151                    let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
2152                    let proposed =
2153                        format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
2154                    plans.push(PlanEntry {
2155                        id: String::new(),
2156                        file: path.to_path_buf(),
2157                        rules: vec![RuleId::MergeIdenticalRuleBodies],
2158                        safety: Safety::Safe,
2159                        source_range: SourceRange {
2160                            start: first.start,
2161                            end: last.end,
2162                        },
2163                        original: source[first.start..last.end].to_string(),
2164                        proposed,
2165                        proof: Proof::safe_local(),
2166                        warnings: Vec::new(),
2167                        reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
2168                        selected: true,
2169                    });
2170                    i = cursor;
2171                    continue;
2172                }
2173            }
2174        }
2175        i += 1;
2176    }
2177}
2178
2179pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
2180    let bytes = selector.as_bytes();
2181    let mut parts = Vec::new();
2182    let mut last = 0;
2183    let mut parens = 0usize;
2184    let mut brackets = 0usize;
2185    let mut quote: Option<u8> = None;
2186    let mut escaped = false;
2187    let mut i = 0usize;
2188
2189    while i < bytes.len() {
2190        let b = bytes[i];
2191        if let Some(q) = quote {
2192            if escaped {
2193                escaped = false;
2194            } else if b == b'\\' {
2195                escaped = true;
2196            } else if b == q {
2197                quote = None;
2198            }
2199            i += 1;
2200            continue;
2201        }
2202        match b {
2203            b'\'' | b'"' => quote = Some(b),
2204            b'(' => parens += 1,
2205            b')' => parens = parens.saturating_sub(1),
2206            b'[' => brackets += 1,
2207            b']' => brackets = brackets.saturating_sub(1),
2208            b',' if parens == 0 && brackets == 0 => {
2209                parts.push(&selector[last..i]);
2210                last = i + 1;
2211            }
2212            _ => {}
2213        }
2214        i += 1;
2215    }
2216    if last < selector.len() {
2217        parts.push(&selector[last..]);
2218    }
2219    parts
2220}
2221
2222pub fn factor_selector_list(
2223    selector: &str,
2224    body: &str,
2225    indent: &str,
2226    unit: &str,
2227) -> Option<String> {
2228    let branches: Vec<&str> = split_top_level_comma(selector)
2229        .into_iter()
2230        .map(|s| s.trim())
2231        .collect();
2232    if branches.len() < 2 {
2233        return None;
2234    }
2235    let base = branches[0];
2236    if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
2237        return None;
2238    }
2239
2240    let mut inner_selectors = Vec::new();
2241    for &branch in &branches {
2242        if branch == base {
2243            inner_selectors.push("&".to_string());
2244        } else {
2245            let rel = branch.strip_prefix(base)?;
2246            if rel.starts_with("::")
2247                || rel.starts_with(':')
2248                || rel.starts_with('[')
2249                || rel.starts_with('.')
2250                || rel.starts_with('#')
2251            {
2252                inner_selectors.push(format!("&{rel}"));
2253            } else {
2254                let trimmed = rel.strip_prefix(' ')?;
2255                inner_selectors.push(trimmed.trim_start().to_string());
2256            }
2257        }
2258    }
2259
2260    let nested_indent = format!("{indent}{unit}");
2261    let inner_decl_indent = format!("{nested_indent}{unit}");
2262    let mut out = String::new();
2263    out.push_str(base);
2264    out.push_str(" {\n");
2265    out.push_str(&nested_indent);
2266    out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
2267    out.push_str(" {\n");
2268    for line in body.lines() {
2269        let trimmed = line.trim();
2270        if !trimmed.is_empty() {
2271            out.push_str(&inner_decl_indent);
2272            out.push_str(trimmed);
2273            out.push('\n');
2274        }
2275    }
2276    out.push_str(&nested_indent);
2277    out.push_str("}\n");
2278    out.push_str(indent);
2279    out.push('}');
2280    Some(out)
2281}
2282
2283pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
2284    let branches: Vec<&str> = split_top_level_comma(selector)
2285        .into_iter()
2286        .map(|s| s.trim())
2287        .filter(|s| !s.is_empty())
2288        .collect();
2289    if branches.len() < 2 {
2290        return None;
2291    }
2292
2293    if branches.iter().any(|b| b.contains("::")) {
2294        return None;
2295    }
2296
2297    let specificities: Vec<Specificity> =
2298        branches.iter().map(|b| calculate_specificity(b)).collect();
2299    let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
2300
2301    let first = branches[0];
2302
2303    // Suffix alternatives (e.g. .alpha .title, #hero .title -> :is(.alpha, #hero) .title)
2304    if let Some(space_pos) = first.rfind(' ') {
2305        let suffix = &first[space_pos..];
2306        if branches.iter().all(|b| b.ends_with(suffix)) {
2307            let prefixes: Vec<&str> = branches
2308                .iter()
2309                .map(|b| b[..b.len() - suffix.len()].trim())
2310                .collect();
2311            if prefixes.iter().all(|p| is_valid_selector_token(p)) {
2312                let is_inner = prefixes.join(", ");
2313                return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
2314            }
2315        }
2316    }
2317
2318    // Descendant alternatives (e.g. .card .title, .card .subtitle -> .card :is(.title, .subtitle))
2319    if let Some(space_pos) = first.rfind(' ') {
2320        let prefix = &first[..=space_pos];
2321        if branches.iter().all(|b| b.starts_with(prefix)) {
2322            let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
2323            if suffixes.iter().all(|s| is_valid_selector_token(s)) {
2324                let is_inner = suffixes.join(", ");
2325                return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
2326            }
2327        }
2328    }
2329
2330    // Pseudo-class alternatives (e.g. .button:hover, .button:focus -> .button:is(:hover, :focus))
2331    if let Some(colon_pos) = first.find(':') {
2332        let base = &first[..colon_pos];
2333        if !base.is_empty()
2334            && !base.contains(' ')
2335            && branches.iter().all(|b| {
2336                b.starts_with(base)
2337                    && b[base.len()..].starts_with(':')
2338                    && !b[base.len()..].starts_with("::")
2339            })
2340        {
2341            let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
2342            if pseudos
2343                .iter()
2344                .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
2345            {
2346                let is_inner = pseudos.join(", ");
2347                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
2348            }
2349        }
2350    }
2351
2352    // Attribute alternatives
2353    if let Some(bracket_pos) = first.find('[') {
2354        let base = &first[..bracket_pos];
2355        if !base.is_empty()
2356            && !base.contains(' ')
2357            && branches
2358                .iter()
2359                .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
2360        {
2361            let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
2362            if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
2363                let is_inner = attrs.join(", ");
2364                return Some((format!("{base}:is({is_inner})"), uniform_specificity));
2365            }
2366        }
2367    }
2368
2369    None
2370}
2371
2372fn is_valid_selector_token(s: &str) -> bool {
2373    if s.is_empty() {
2374        return false;
2375    }
2376    let first = s.chars().next().unwrap();
2377    first == '.'
2378        || first == '#'
2379        || first == '['
2380        || first == ':'
2381        || first.is_ascii_alphabetic()
2382        || first == '*'
2383        || first == '>'
2384        || first == '+'
2385        || first == '~'
2386}
2387
2388pub fn factor_with_where(selector: &str) -> Option<String> {
2389    let branches: Vec<&str> = split_top_level_comma(selector)
2390        .into_iter()
2391        .map(|s| s.trim())
2392        .collect();
2393    if branches.len() < 2 {
2394        return None;
2395    }
2396    if branches.iter().any(|b| b.contains("::")) {
2397        return None;
2398    }
2399    Some(format!(":where({})", branches.join(", ")))
2400}
2401
2402pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
2403    let mut result = prelude.to_string();
2404    let mut changed = false;
2405
2406    // Range: (min-width: 400px) and (max-width: 800px) -> (400px <= width <= 800px)
2407    if let (Some(min_idx), Some(max_idx)) = (result.find("min-width:"), result.find("max-width:")) {
2408        if min_idx < max_idx {
2409            if let (Some(min_val), Some(max_val)) = (
2410                extract_media_val(&result, "min-width:"),
2411                extract_media_val(&result, "max-width:"),
2412            ) {
2413                let pattern = format!("(min-width: {min_val}) and (max-width: {max_val})");
2414                let replacement = format!("({min_val} <= width <= {max_val})");
2415                if result.contains(&pattern) {
2416                    result = result.replace(&pattern, &replacement);
2417                    changed = true;
2418                }
2419            }
2420        }
2421    }
2422
2423    // Single: (min-width: 800px) -> (width >= 800px)
2424    while let Some(val) = extract_media_val(&result, "min-width:") {
2425        let pattern = format!("(min-width: {val})");
2426        let replacement = format!("(width >= {val})");
2427        result = result.replace(&pattern, &replacement);
2428        changed = true;
2429    }
2430
2431    // Single: (max-width: 800px) -> (width <= 800px)
2432    while let Some(val) = extract_media_val(&result, "max-width:") {
2433        let pattern = format!("(max-width: {val})");
2434        let replacement = format!("(width <= {val})");
2435        result = result.replace(&pattern, &replacement);
2436        changed = true;
2437    }
2438
2439    // Single: (min-height: 400px) -> (height >= 400px)
2440    while let Some(val) = extract_media_val(&result, "min-height:") {
2441        let pattern = format!("(min-height: {val})");
2442        let replacement = format!("(height >= {val})");
2443        result = result.replace(&pattern, &replacement);
2444        changed = true;
2445    }
2446
2447    // Single: (max-height: 400px) -> (height <= 400px)
2448    while let Some(val) = extract_media_val(&result, "max-height:") {
2449        let pattern = format!("(max-height: {val})");
2450        let replacement = format!("(height <= {val})");
2451        result = result.replace(&pattern, &replacement);
2452        changed = true;
2453    }
2454
2455    if changed { Some(result) } else { None }
2456}
2457
2458fn extract_media_val<'a>(source: &'a str, feature: &str) -> Option<&'a str> {
2459    let start = source.find(feature)? + feature.len();
2460    let end = source[start..].find(')')? + start;
2461    Some(source[start..end].trim())
2462}
2463
2464fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
2465    let parent = parent.trim();
2466    let child = child.trim();
2467    if parent.is_empty()
2468        || child.is_empty()
2469        || contains_top_level_comma(parent)
2470        || contains_top_level_comma(child)
2471        || parent.contains("::")
2472        || child == parent
2473        || !child.starts_with(parent)
2474    {
2475        return None;
2476    }
2477
2478    let remainder = &child[parent.len()..];
2479    let trimmed = remainder.trim_start();
2480    if trimmed.is_empty() {
2481        return None;
2482    }
2483
2484    let (relation, nested_selector) = if remainder.starts_with("::") {
2485        (RelationKind::PseudoElement, format!("&{remainder}"))
2486    } else if remainder.starts_with(':') {
2487        (RelationKind::PseudoClass, format!("&{remainder}"))
2488    } else if remainder.starts_with('[') {
2489        (RelationKind::Attribute, format!("&{remainder}"))
2490    } else if remainder.starts_with('.') || remainder.starts_with('#') {
2491        (RelationKind::Compound, format!("&{remainder}"))
2492    } else if let Some(first_char) = trimmed
2493        .chars()
2494        .next()
2495        .filter(|c| *c == '>' || *c == '+' || *c == '~')
2496    {
2497        let after_comb = trimmed[first_char.len_utf8()..].trim();
2498        if after_comb == parent {
2499            (RelationKind::Combinator, format!("{first_char} {parent}"))
2500        } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
2501            (
2502                RelationKind::Combinator,
2503                format!("{first_char} {parent}{after_parent}"),
2504            )
2505        } else {
2506            (
2507                RelationKind::Combinator,
2508                format!("{first_char} {after_comb}"),
2509            )
2510        }
2511    } else if remainder
2512        .as_bytes()
2513        .first()
2514        .is_some_and(|b| b.is_ascii_whitespace())
2515    {
2516        let descendant = remainder.trim();
2517        (RelationKind::Descendant, descendant.to_string())
2518    } else {
2519        return None;
2520    };
2521
2522    Some((relation, nested_selector))
2523}
2524
2525fn conditional_child(
2526    source: &str,
2527    parent_selector: &str,
2528    node: &SourceNode,
2529    enabled: &HashSet<RuleId>,
2530) -> Option<ClusterChild> {
2531    let (name, rule) = match &node.kind {
2532        NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
2533        NodeKind::AtBlock { name, .. } if name == "supports" => {
2534            (name.as_str(), RuleId::NestSupports)
2535        }
2536        NodeKind::AtBlock { name, .. } if name == "container" => {
2537            (name.as_str(), RuleId::NestContainer)
2538        }
2539        NodeKind::AtBlock { name, .. } if name == "starting-style" => {
2540            (name.as_str(), RuleId::NestStartingStyle)
2541        }
2542        _ => return None,
2543    };
2544    if !enabled.contains(&rule) {
2545        return None;
2546    }
2547
2548    let body_range = node.body_range.clone()?;
2549    let inner_nodes = scan_nodes(source, body_range.clone());
2550    if inner_nodes.is_empty() {
2551        return None;
2552    }
2553
2554    let mut inners = Vec::new();
2555    for inner in &inner_nodes {
2556        if !matches!(&inner.kind, NodeKind::Style) {
2557            return None;
2558        }
2559        let inner_prelude = inner.prelude(source);
2560        let inner_body = inner.body_range.clone()?;
2561        if inner_prelude == parent_selector.trim() {
2562            inners.push(ConditionalInner::Direct {
2563                body_range: inner_body,
2564            });
2565        } else if let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude) {
2566            inners.push(ConditionalInner::Nested {
2567                nested_selector: nested_sel,
2568                body_range: inner_body,
2569            });
2570        } else {
2571            return None;
2572        }
2573    }
2574
2575    debug_assert!(
2576        name == "media" || name == "supports" || name == "container" || name == "starting-style"
2577    );
2578    Some(ClusterChild::Conditional {
2579        node: node.clone(),
2580        rule,
2581        inners,
2582    })
2583}
2584
2585pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
2586    if !selector.contains(":not(") {
2587        return None;
2588    }
2589    let mut result = String::new();
2590    let mut i = 0;
2591    let bytes = selector.as_bytes();
2592    let mut changed = false;
2593    let mut uniform_specificity = true;
2594
2595    while i < bytes.len() {
2596        if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
2597            let mut args = Vec::new();
2598            let mut current_end = i;
2599
2600            while current_end + 5 <= bytes.len()
2601                && &selector[current_end..current_end + 5] == ":not("
2602            {
2603                let open = current_end + 4;
2604                if let Some(close) = find_matching_paren(selector, open) {
2605                    let arg = selector[open + 1..close].trim();
2606                    args.push(arg);
2607                    current_end = close + 1;
2608                } else {
2609                    break;
2610                }
2611            }
2612
2613            if args.len() > 1 {
2614                changed = true;
2615                let specs: Vec<Specificity> =
2616                    args.iter().map(|a| calculate_specificity(a)).collect();
2617                if specs.windows(2).any(|w| w[0] != w[1]) {
2618                    uniform_specificity = false;
2619                }
2620
2621                result.push_str(":not(");
2622                result.push_str(&args.join(", "));
2623                result.push(')');
2624                i = current_end;
2625                continue;
2626            }
2627        }
2628        let ch = selector[i..].chars().next().unwrap();
2629        result.push(ch);
2630        i += ch.len_utf8();
2631    }
2632
2633    if changed {
2634        Some((result, uniform_specificity))
2635    } else {
2636        None
2637    }
2638}
2639
2640fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
2641    let bytes = source.as_bytes();
2642    let mut depth = 1usize;
2643    let mut i = open + 1;
2644    let mut quote: Option<u8> = None;
2645    let mut escaped = false;
2646
2647    while i < bytes.len() {
2648        let b = bytes[i];
2649        if let Some(q) = quote {
2650            if escaped {
2651                escaped = false;
2652            } else if b == b'\\' {
2653                escaped = true;
2654            } else if b == q {
2655                quote = None;
2656            }
2657            i += 1;
2658            continue;
2659        }
2660
2661        match b {
2662            b'\'' | b'"' => quote = Some(b),
2663            b'(' => depth += 1,
2664            b')' => {
2665                depth -= 1;
2666                if depth == 0 {
2667                    return Some(i);
2668                }
2669            }
2670            _ => {}
2671        }
2672        i += 1;
2673    }
2674    None
2675}
2676
2677#[derive(Debug, Clone)]
2678struct HierarchicalRule {
2679    relative_selector: String,
2680    body_lines: Vec<String>,
2681    sub_rules: Vec<HierarchicalRule>,
2682    conditional_header: Option<String>,
2683}
2684
2685fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
2686    let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
2687    let parent_indent = line_indent(source, parent.start);
2688    let unit =
2689        detect_indent_unit(source, parent_body_range.clone()).unwrap_or_else(|| "  ".to_string());
2690    let nested_indent = format!("{parent_indent}{unit}");
2691
2692    let mut out = String::new();
2693    let open = parent_body_range.start - 1;
2694    out.push_str(&source[parent.start..=open]);
2695
2696    let parent_body = &source[parent_body_range.clone()];
2697    let trimmed_body = parent_body.trim();
2698    if !trimmed_body.is_empty() {
2699        out.push('\n');
2700        for line in parent_body.lines() {
2701            let trimmed_line = line.trim();
2702            if !trimmed_line.is_empty() {
2703                out.push_str(&nested_indent);
2704                out.push_str(trimmed_line);
2705                out.push('\n');
2706            }
2707        }
2708    }
2709
2710    // Build hierarchical rules
2711    let mut root_rules: Vec<HierarchicalRule> = Vec::new();
2712
2713    for child in children {
2714        match child {
2715            ClusterChild::Style {
2716                node,
2717                nested_selector,
2718                ..
2719            } => {
2720                let mut body_lines = Vec::new();
2721                if let Some(body_range) = &node.body_range {
2722                    for line in source[body_range.clone()].lines() {
2723                        let trimmed = line.trim();
2724                        if !trimmed.is_empty() {
2725                            body_lines.push(trimmed.to_string());
2726                        }
2727                    }
2728                }
2729                insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
2730            }
2731            ClusterChild::Conditional { node, inners, .. } => {
2732                let header = node.prelude(source).trim().to_string();
2733                let mut cond_sub_rules = Vec::new();
2734                for inner in inners {
2735                    match inner {
2736                        ConditionalInner::Direct { body_range } => {
2737                            let mut lines = Vec::new();
2738                            for line in source[body_range.clone()].lines() {
2739                                let trimmed = line.trim();
2740                                if !trimmed.is_empty() {
2741                                    lines.push(trimmed.to_string());
2742                                }
2743                            }
2744                            cond_sub_rules.push(HierarchicalRule {
2745                                relative_selector: String::new(),
2746                                body_lines: lines,
2747                                sub_rules: Vec::new(),
2748                                conditional_header: None,
2749                            });
2750                        }
2751                        ConditionalInner::Nested {
2752                            nested_selector,
2753                            body_range,
2754                        } => {
2755                            let mut lines = Vec::new();
2756                            for line in source[body_range.clone()].lines() {
2757                                let trimmed = line.trim();
2758                                if !trimmed.is_empty() {
2759                                    lines.push(trimmed.to_string());
2760                                }
2761                            }
2762                            cond_sub_rules.push(HierarchicalRule {
2763                                relative_selector: nested_selector.trim().to_string(),
2764                                body_lines: lines,
2765                                sub_rules: Vec::new(),
2766                                conditional_header: None,
2767                            });
2768                        }
2769                    }
2770                }
2771                root_rules.push(HierarchicalRule {
2772                    relative_selector: String::new(),
2773                    body_lines: Vec::new(),
2774                    sub_rules: cond_sub_rules,
2775                    conditional_header: Some(header),
2776                });
2777            }
2778        }
2779    }
2780
2781    for rule in &root_rules {
2782        out.push('\n');
2783        render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
2784    }
2785
2786    out.push_str(&parent_indent);
2787    out.push('}');
2788    out
2789}
2790
2791fn insert_hierarchical_style(
2792    root_rules: &mut Vec<HierarchicalRule>,
2793    selector: &str,
2794    body_lines: Vec<String>,
2795) {
2796    if let Some(last_rule) = root_rules.last_mut() {
2797        if last_rule.conditional_header.is_none() && !last_rule.relative_selector.is_empty() {
2798            let parent_sel = &last_rule.relative_selector;
2799            if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
2800                insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
2801                return;
2802            }
2803        }
2804    }
2805
2806    root_rules.push(HierarchicalRule {
2807        relative_selector: selector.to_string(),
2808        body_lines,
2809        sub_rules: Vec::new(),
2810        conditional_header: None,
2811    });
2812}
2813
2814fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
2815    let parent = parent.trim();
2816    let child = child.trim();
2817    if child == parent || !child.starts_with(parent) {
2818        return None;
2819    }
2820    let remainder = &child[parent.len()..];
2821    let trimmed = remainder.trim_start();
2822    if trimmed.is_empty() {
2823        return None;
2824    }
2825
2826    if remainder.starts_with("::")
2827        || remainder.starts_with(':')
2828        || remainder.starts_with('[')
2829        || remainder.starts_with('.')
2830        || remainder.starts_with('#')
2831    {
2832        Some(format!("&{remainder}"))
2833    } else if let Some(first_char) = trimmed
2834        .chars()
2835        .next()
2836        .filter(|c| *c == '>' || *c == '+' || *c == '~')
2837    {
2838        let after_comb = trimmed[first_char.len_utf8()..].trim();
2839        Some(format!("{first_char} {after_comb}"))
2840    } else if remainder
2841        .as_bytes()
2842        .first()
2843        .is_some_and(|b| b.is_ascii_whitespace())
2844    {
2845        Some(trimmed.to_string())
2846    } else {
2847        None
2848    }
2849}
2850
2851fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
2852    let inner_indent = format!("{indent}{unit}");
2853
2854    if let Some(header) = &rule.conditional_header {
2855        out.push_str(indent);
2856        out.push_str(header);
2857        out.push_str(" {\n");
2858        for (idx, sub) in rule.sub_rules.iter().enumerate() {
2859            if idx > 0 {
2860                out.push('\n');
2861            }
2862            if sub.relative_selector.is_empty() {
2863                for line in &sub.body_lines {
2864                    out.push_str(&inner_indent);
2865                    out.push_str(line);
2866                    out.push('\n');
2867                }
2868            } else {
2869                render_hierarchical_rule(out, sub, &inner_indent, unit);
2870            }
2871        }
2872        out.push_str(indent);
2873        out.push_str("}\n");
2874    } else {
2875        out.push_str(indent);
2876        out.push_str(&rule.relative_selector);
2877        out.push_str(" {\n");
2878
2879        for line in &rule.body_lines {
2880            out.push_str(&inner_indent);
2881            out.push_str(line);
2882            out.push('\n');
2883        }
2884
2885        for sub in &rule.sub_rules {
2886            out.push('\n');
2887            render_hierarchical_rule(out, sub, &inner_indent, unit);
2888        }
2889
2890        out.push_str(indent);
2891        out.push_str("}\n");
2892    }
2893}
2894
2895fn line_indent(source: &str, offset: usize) -> String {
2896    let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
2897    source[line_start..offset]
2898        .chars()
2899        .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
2900        .collect()
2901}
2902
2903fn line_number(source: &str, offset: usize) -> usize {
2904    source[..offset.min(source.len())].lines().count()
2905}
2906
2907fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
2908    for line in source[body].lines() {
2909        if line.trim().is_empty() {
2910            continue;
2911        }
2912        let indent: String = line
2913            .chars()
2914            .take_while(|c| *c == ' ' || *c == '\t')
2915            .collect();
2916        if !indent.is_empty() {
2917            return Some(indent);
2918        }
2919    }
2920    None
2921}
2922
2923fn contains_top_level_comma(selector: &str) -> bool {
2924    let bytes = selector.as_bytes();
2925    let mut parens = 0usize;
2926    let mut brackets = 0usize;
2927    let mut quote: Option<u8> = None;
2928    let mut escaped = false;
2929    let mut i = 0usize;
2930    while i < bytes.len() {
2931        let b = bytes[i];
2932        if let Some(q) = quote {
2933            if escaped {
2934                escaped = false;
2935            } else if b == b'\\' {
2936                escaped = true;
2937            } else if b == q {
2938                quote = None;
2939            }
2940            i += 1;
2941            continue;
2942        }
2943        match b {
2944            b'\'' | b'"' => quote = Some(b),
2945            b'(' => parens += 1,
2946            b')' => parens = parens.saturating_sub(1),
2947            b'[' => brackets += 1,
2948            b']' => brackets = brackets.saturating_sub(1),
2949            b',' if parens == 0 && brackets == 0 => return true,
2950            _ => {}
2951        }
2952        i += 1;
2953    }
2954    false
2955}
2956
2957pub fn apply_selected_plans(
2958    source: &str,
2959    plans: &[PlanEntry],
2960    include_review: bool,
2961) -> Result<String> {
2962    let mut selected: Vec<&PlanEntry> = plans
2963        .iter()
2964        .filter(|plan| {
2965            plan.selected
2966                && (plan.safety == Safety::Safe
2967                    || (include_review && plan.safety == Safety::Review))
2968        })
2969        .collect();
2970    selected.sort_by(|a, b| {
2971        a.source_range
2972            .start
2973            .cmp(&b.source_range.start)
2974            .then_with(|| b.source_range.end.cmp(&a.source_range.end))
2975    });
2976
2977    let mut non_overlapping: Vec<&PlanEntry> = Vec::with_capacity(selected.len());
2978    let mut last_end = 0;
2979    for plan in selected {
2980        if plan.source_range.start >= last_end {
2981            last_end = plan.source_range.end;
2982            non_overlapping.push(plan);
2983        }
2984    }
2985
2986    let mut output = source.to_string();
2987    for plan in non_overlapping.into_iter().rev() {
2988        if plan.source_range.start <= output.len()
2989            && plan.source_range.end <= output.len()
2990            && plan.source_range.start <= plan.source_range.end
2991        {
2992            output.replace_range(
2993                plan.source_range.start..plan.source_range.end,
2994                &plan.proposed,
2995            );
2996        }
2997    }
2998    Ok(output)
2999}
3000
3001pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
3002    TextDiff::from_lines(old, new)
3003        .unified_diff()
3004        .header(old_name, new_name)
3005        .to_string()
3006}
3007
3008#[cfg(test)]
3009mod tests {
3010    use super::*;
3011
3012    fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
3013        analyze_source(PathBuf::from("test.css"), css, rules)
3014            .unwrap()
3015            .plans
3016    }
3017
3018    #[test]
3019    fn nests_adjacent_pseudo_and_descendant_rules() {
3020        let css = ".card {\n  color: red;\n}\n.card:hover {\n  color: blue !important;\n}\n.card .title {\n  font-weight: 700;\n}\n";
3021        let plans = plan(css, &RuleId::ALL);
3022        assert_eq!(plans.len(), 1);
3023        let output = apply_selected_plans(css, &plans, false).unwrap();
3024        assert!(output.contains("&:hover"));
3025        assert!(output.contains(".title"));
3026        assert!(output.contains("color: blue !important;"));
3027    }
3028
3029    #[test]
3030    fn nests_exact_full_modernize_example() {
3031        let original = r#".card {
3032  color: #222;
3033  padding: 1rem;
3034}
3035.card:hover {
3036  color: #111 !important;
3037}
3038.card::before {
3039  content: "";
3040}
3041.card[data-active] {
3042  border-color: currentColor;
3043}
3044.card.featured {
3045  box-shadow: 0 0 0 1px currentColor;
3046}
3047.card .title {
3048  font-weight: 700;
3049}
3050.card > .body {
3051  min-width: 0;
3052}
3053.card + .card {
3054  margin-top: 1rem;
3055}
3056@media (width >= 48rem) {
3057  .card {
3058    padding: 1.5rem;
3059  }
3060}
3061@supports (display: grid) {
3062  .card {
3063    display: grid;
3064  }
3065}
3066"#;
3067
3068        let expected = r#".card {
3069  color: #222;
3070  padding: 1rem;
3071
3072  &:hover {
3073    color: #111 !important;
3074  }
3075
3076  &::before {
3077    content: "";
3078  }
3079
3080  &[data-active] {
3081    border-color: currentColor;
3082  }
3083
3084  &.featured {
3085    box-shadow: 0 0 0 1px currentColor;
3086  }
3087
3088  .title {
3089    font-weight: 700;
3090  }
3091
3092  > .body {
3093    min-width: 0;
3094  }
3095
3096  + .card {
3097    margin-top: 1rem;
3098  }
3099
3100  @media (width >= 48rem) {
3101    padding: 1.5rem;
3102  }
3103
3104  @supports (display: grid) {
3105    display: grid;
3106  }
3107}"#;
3108
3109        let plans = plan(
3110            original,
3111            &[
3112                RuleId::NestPseudoClass,
3113                RuleId::NestPseudoElement,
3114                RuleId::NestAttribute,
3115                RuleId::NestCompound,
3116                RuleId::NestDescendant,
3117                RuleId::NestCombinator,
3118                RuleId::NestMedia,
3119                RuleId::NestSupports,
3120            ],
3121        );
3122        assert_eq!(plans.len(), 1);
3123        let output = apply_selected_plans(original, &plans, false).unwrap();
3124        assert_eq!(output.trim(), expected.trim());
3125    }
3126
3127    #[test]
3128    fn factors_selector_list_sharing_base() {
3129        let css = ".marker,\n.marker::before,\n.marker::after {\n  box-sizing: border-box;\n}\n";
3130        let plans = plan(css, &[RuleId::FactorSelectorList]);
3131        assert_eq!(plans.len(), 1);
3132        let output = apply_selected_plans(css, &plans, false).unwrap();
3133        assert!(output.contains(".marker {"));
3134        assert!(output.contains("&,"));
3135        assert!(output.contains("&::before,"));
3136        assert!(output.contains("&::after {"));
3137        assert!(output.contains("box-sizing: border-box;"));
3138    }
3139
3140    #[test]
3141    fn modernizes_is_with_uniform_specificity() {
3142        let css = ".button:hover, .button:focus, .button:active {\n  color: blue;\n}\n";
3143        let plans = plan(css, &[RuleId::ModernizeIs]);
3144        assert_eq!(plans.len(), 1);
3145        let output = apply_selected_plans(css, &plans, false).unwrap();
3146        assert!(output.contains(".button:is(:hover, :focus, :active)"));
3147    }
3148
3149    #[test]
3150    fn modernizes_media_range_syntax() {
3151        let css = "@media (min-width: 800px) {\n  .card { padding: 2rem; }\n}\n";
3152        let plans = plan(css, &[RuleId::ModernizeMediaRange]);
3153        assert_eq!(plans.len(), 1);
3154        let output = apply_selected_plans(css, &plans, false).unwrap();
3155        assert!(output.contains("@media (width >= 800px)"));
3156    }
3157
3158    #[test]
3159    fn consolidates_not_selectors() {
3160        let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n  border: 1px solid gray;\n}\n";
3161        let plans = plan(css, &[RuleId::ConsolidateNot]);
3162        assert_eq!(plans.len(), 1);
3163        assert_eq!(plans[0].safety, Safety::Review);
3164        let output = apply_selected_plans(css, &plans, true).unwrap();
3165        assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
3166    }
3167
3168    #[test]
3169    fn refuses_subtoken_is_factoring_false_positive() {
3170        let css = ".same-specificity-a,\n.same-specificity-b {\n  color: black;\n}\n";
3171        let plans = plan(css, &[RuleId::ModernizeIs]);
3172        assert!(plans.is_empty());
3173    }
3174
3175    #[test]
3176    fn modernizes_descendant_is_alternatives() {
3177        let css = ".card .title, .card .subtitle, .card .description {\n  color: black;\n}\n";
3178        let plans = plan(css, &[RuleId::ModernizeIs]);
3179        assert_eq!(plans.len(), 1);
3180        let output = apply_selected_plans(css, &plans, false).unwrap();
3181        assert!(output.contains(".card :is(.title, .subtitle, .description)"));
3182    }
3183
3184    #[test]
3185    fn modernizes_suffix_is_alternatives() {
3186        let css = ".alpha .title,\n#hero .title {\n  color: rebeccapurple;\n}\n";
3187        let plans = plan(css, &[RuleId::ModernizeIs]);
3188        assert_eq!(plans.len(), 1);
3189        assert_eq!(plans[0].safety, Safety::Review);
3190        let output = apply_selected_plans(css, &plans, true).unwrap();
3191        assert!(output.contains(":is(.alpha, #hero) .title"));
3192    }
3193
3194    #[test]
3195    fn factors_multi_selector_cluster_with_is_and_nesting() {
3196        let css = r#".alpha .title,
3197#hero .title {
3198  color: rebeccapurple;
3199}
3200
3201.alpha .subtitle,
3202#hero .subtitle {
3203  color: slateblue;
3204}
3205
3206.alpha,
3207#hero {
3208  border-color: currentColor;
3209}
3210"#;
3211        let plans = plan(css, &[RuleId::ModernizeIs]);
3212        assert_eq!(plans.len(), 1);
3213        let output = apply_selected_plans(css, &plans, true).unwrap();
3214        assert!(output.contains(":is(.alpha, #hero) {"));
3215        assert!(output.contains("border-color: currentColor;"));
3216        assert!(output.contains(".title {"));
3217        assert!(output.contains("color: rebeccapurple;"));
3218        assert!(output.contains(".subtitle {"));
3219        assert!(output.contains("color: slateblue;"));
3220    }
3221
3222    #[test]
3223    fn refuses_bem_token_concatenation() {
3224        let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
3225        let plans = plan(css, &RuleId::ALL);
3226        assert!(plans.is_empty());
3227    }
3228
3229    #[test]
3230    fn merges_same_named_layer_blocks() {
3231        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";
3232        let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
3233        assert_eq!(plans.len(), 2);
3234        let output = apply_selected_plans(css, &plans, false).unwrap();
3235        assert!(output.contains("@layer overrides {"));
3236        assert!(output.contains(".layered-card {"));
3237        assert!(output.contains(".layer-important {"));
3238    }
3239
3240    #[test]
3241    fn merges_adjacent_media_queries() {
3242        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";
3243        let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
3244        assert_eq!(plans.len(), 1);
3245        let output = apply_selected_plans(css, &plans, false).unwrap();
3246        assert!(output.contains("@media (width >= 48rem) {"));
3247        assert!(output.contains(".card {"));
3248        assert!(output.contains(".panel {"));
3249    }
3250
3251    #[test]
3252    fn merges_adjacent_supports_queries() {
3253        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";
3254        let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
3255        assert_eq!(plans.len(), 1);
3256        let output = apply_selected_plans(css, &plans, false).unwrap();
3257        assert!(output.contains("@supports (display: grid) {"));
3258        assert!(output.contains(".card {"));
3259        assert!(output.contains(".panel {"));
3260    }
3261
3262    #[test]
3263    fn merges_adjacent_identical_selectors() {
3264        let css = ".card {\n  color: black;\n}\n\n.card {\n  padding: 1rem;\n}\n";
3265        let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
3266        assert_eq!(plans.len(), 1);
3267        let output = apply_selected_plans(css, &plans, false).unwrap();
3268        assert!(output.contains(".card {"));
3269        assert!(output.contains("color: black;"));
3270        assert!(output.contains("padding: 1rem;"));
3271    }
3272
3273    #[test]
3274    fn merges_identical_rule_bodies() {
3275        let css = ".card:hover {\n  color: red;\n}\n\n.panel:hover {\n  color: red;\n}\n";
3276        let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
3277        assert_eq!(plans.len(), 1);
3278        let output = apply_selected_plans(css, &plans, false).unwrap();
3279        assert!(output.contains(".card:hover,"));
3280        assert!(output.contains(".panel:hover {"));
3281        assert!(output.contains("color: red;"));
3282    }
3283
3284    #[test]
3285    fn factors_identical_states_with_is() {
3286        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";
3287        let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
3288        assert_eq!(plans.len(), 1);
3289        let output = apply_selected_plans(css, &plans, false).unwrap();
3290        assert!(output.contains(".card {"));
3291        assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
3292        assert!(output.contains("background: silver;"));
3293    }
3294
3295    #[test]
3296    fn nests_multi_level_tree_hierarchy() {
3297        let css = r#".tree {
3298  display: grid;
3299  gap: 0.5rem;
3300}
3301.tree .node {
3302  position: relative;
3303}
3304.tree .node .label {
3305  display: flex;
3306}
3307.tree .node .label:hover {
3308  color: var(--accent);
3309}
3310.tree .node > .children {
3311  margin-inline-start: 1.25rem;
3312}
3313.tree .node > .children > .node + .node {
3314  margin-block-start: 0.25rem;
3315}
3316"#;
3317        let plans = plan(
3318            css,
3319            &[
3320                RuleId::NestDescendant,
3321                RuleId::NestCombinator,
3322                RuleId::NestPseudoClass,
3323            ],
3324        );
3325        assert_eq!(plans.len(), 1);
3326        let output = apply_selected_plans(css, &plans, false).unwrap();
3327        assert!(output.contains(".node {"));
3328        assert!(output.contains(".label {"));
3329        assert!(output.contains("&:hover {"));
3330        assert!(output.contains("> .children {"));
3331        assert!(output.contains("> .node + .node {"));
3332    }
3333
3334    #[test]
3335    fn nests_in_place_input_states() {
3336        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";
3337        let plans = plan(css, &[RuleId::NestPseudoClass]);
3338        assert_eq!(plans.len(), 1);
3339        let output = apply_selected_plans(css, &plans, false).unwrap();
3340        assert!(output.contains("input {"));
3341        assert!(output.contains("&:user-invalid {"));
3342        assert!(output.contains("&:user-valid {"));
3343        assert!(output.contains("&:placeholder-shown {"));
3344    }
3345
3346    #[test]
3347    fn gathers_consecutive_conditions_by_selector() {
3348        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";
3349        let plans = plan(css, &[RuleId::NestMedia]);
3350        assert_eq!(plans.len(), 1);
3351        let output = apply_selected_plans(css, &plans, false).unwrap();
3352        assert!(output.contains(".responsive-grid {"));
3353        assert!(output.contains("@media (width >= 30rem) {"));
3354        assert!(output.contains("@media (width >= 80rem) {"));
3355    }
3356
3357    #[test]
3358    fn factors_selector_list_with_adjacent_hover() {
3359        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";
3360        let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
3361        assert_eq!(plans.len(), 1);
3362        let output = apply_selected_plans(css, &plans, false).unwrap();
3363        assert!(output.contains(".notice {"));
3364        assert!(output.contains("&,"));
3365        assert!(output.contains("&::before,"));
3366        assert!(output.contains("&::after {"));
3367        assert!(output.contains("&:hover {"));
3368        assert!(output.contains("background: color-mix"));
3369    }
3370
3371    #[test]
3372    fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
3373        let css = r#".skip-link {
3374    position: absolute;
3375    inset-block-start: -48px;
3376    inset-inline-start: 1rem;
3377    z-index: 10000000000;
3378    background: var(--bg-color);
3379    color: var(--text-color);
3380    border: 1px solid var(--border-color);
3381    border-radius: 0.5rem;
3382    padding: 0.55rem 0.8rem;
3383    text-decoration: none;
3384    font-weight: 700;
3385    transition: inset-block-start 0.2s ease;
3386
3387    &:focus-visible {
3388        inset-block-start: 0.75rem;
3389    }
3390}
3391
3392.unrelated-rule {
3393    color: red;
3394}
3395
3396.skip-link {
3397    font: optional;
3398
3399    &::after {
3400        content: '';
3401    }
3402
3403    :not(*) & {
3404        all: unset
3405    }
3406}
3407"#;
3408        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
3409        assert_eq!(plans.len(), 2);
3410        let output = apply_selected_plans(css, &plans, true).unwrap();
3411        assert!(output.contains("font: optional;"));
3412    }
3413
3414    #[test]
3415    fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
3416        let css = r#".skip-link {
3417    position: absolute;
3418    inset-block-start: -48px;
3419    inset-inline-start: 1rem;
3420    z-index: 10000000000;
3421    background: var(--bg-color);
3422    color: var(--text-color);
3423    border: 1px solid var(--border-color);
3424    border-radius: 0.5rem;
3425    padding: 0.55rem 0.8rem;
3426    text-decoration: none;
3427    font-weight: 700;
3428    transition: inset-block-start 0.2s ease;
3429
3430    &:focus-visible {
3431        inset-block-start: 0.75rem;
3432    }
3433}
3434
3435.unrelated {
3436    color: red;
3437}
3438
3439.skip-link {
3440    font: optional;
3441
3442    &::after {
3443        content: '';
3444    }
3445
3446    :not(*) & {
3447        all: unset
3448    }
3449}
3450
3451.skip-link+* {
3452    display: block;
3453}
3454
3455.skip-link::backdrop {
3456    background-color: gray;
3457}
3458
3459.skip-link:has(*) {
3460    color: #27ca3f;
3461}
3462"#;
3463        let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
3464        assert_eq!(plans.len(), 5);
3465        let output = apply_selected_plans(css, &plans, true).unwrap();
3466        assert!(output.contains("font: optional;"));
3467        assert!(output.contains("+ * {"));
3468        assert!(output.contains("&::backdrop {"));
3469        assert!(output.contains("&:has(*) {"));
3470    }
3471}