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