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