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