1use crate::{
2 model::{
3 AnalysisStats, FileReport, Finding, PlanEntry, Proof, RuleId, Safety, SourceRange,
4 WorkspaceReport, WorkspaceSummary,
5 },
6 scanner::{
7 NodeKind, SourceNode, count_ascii_case_insensitive_outside_comments,
8 count_top_level_declarations, is_whitespace_only, scan_nodes,
9 },
10};
11use anyhow::{Context, Result};
12use lightningcss::stylesheet::{ParserOptions, StyleSheet};
13use similar::TextDiff;
14use std::{
15 collections::{HashMap, HashSet},
16 fs,
17 ops::Range,
18 path::{Path, PathBuf},
19};
20
21const SPEC_BASELINE: &str = "2026-08-17";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
24pub struct Specificity {
25 pub ids: usize,
26 pub classes: usize,
27 pub elements: usize,
28}
29
30pub fn calculate_specificity(selector: &str) -> Specificity {
31 let mut ids = 0;
32 let mut classes = 0;
33 let mut elements = 0;
34 let bytes = selector.as_bytes();
35 let mut i = 0;
36 let mut in_attr = false;
37
38 while i < bytes.len() {
39 let b = bytes[i];
40 if b == b'[' {
41 in_attr = true;
42 classes += 1;
43 i += 1;
44 continue;
45 }
46 if b == b']' {
47 in_attr = false;
48 i += 1;
49 continue;
50 }
51 if in_attr {
52 i += 1;
53 continue;
54 }
55
56 if b == b'#' {
57 ids += 1;
58 i += 1;
59 while i < bytes.len()
60 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
61 {
62 i += 1;
63 }
64 continue;
65 }
66
67 if b == b'.' {
68 classes += 1;
69 i += 1;
70 while i < bytes.len()
71 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
72 {
73 i += 1;
74 }
75 continue;
76 }
77
78 if b == b':' {
79 if i + 1 < bytes.len() && bytes[i + 1] == b':' {
80 elements += 1;
81 i += 2;
82 while i < bytes.len()
83 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
84 {
85 i += 1;
86 }
87 } else {
88 let start_name = i + 1;
89 let mut end_name = start_name;
90 while end_name < bytes.len()
91 && (bytes[end_name].is_ascii_alphanumeric() || bytes[end_name] == b'-')
92 {
93 end_name += 1;
94 }
95 let pseudo_name = &selector[start_name..end_name];
96 if pseudo_name == "where" {
97 if end_name < bytes.len()
98 && bytes[end_name] == b'('
99 && let Some(close_p) = find_matching_paren(selector, end_name)
100 {
101 i = close_p + 1;
102 continue;
103 }
104 } else if pseudo_name == "is" || pseudo_name == "not" || pseudo_name == "has" {
105 if end_name < bytes.len()
106 && bytes[end_name] == b'('
107 && let Some(close_p) = find_matching_paren(selector, end_name)
108 {
109 let inner = &selector[end_name + 1..close_p];
110 let max_inner = split_top_level_comma(inner)
111 .into_iter()
112 .map(|s| calculate_specificity(s.trim()))
113 .max()
114 .unwrap_or_default();
115 ids += max_inner.ids;
116 classes += max_inner.classes;
117 elements += max_inner.elements;
118 i = close_p + 1;
119 continue;
120 }
121 classes += 1;
122 } else {
123 classes += 1;
124 }
125 i = end_name;
126 }
127 continue;
128 }
129
130 if (b.is_ascii_alphabetic() || b == b'*')
131 && (i == 0
132 || bytes[i - 1].is_ascii_whitespace()
133 || bytes[i - 1] == b'>'
134 || bytes[i - 1] == b'+'
135 || bytes[i - 1] == b'~'
136 || bytes[i - 1] == b'|')
137 {
138 if b != b'*' {
139 elements += 1;
140 }
141 i += 1;
142 while i < bytes.len()
143 && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
144 {
145 i += 1;
146 }
147 continue;
148 }
149
150 i += 1;
151 }
152
153 Specificity {
154 ids,
155 classes,
156 elements,
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum RelationKind {
162 PseudoClass,
163 PseudoElement,
164 Attribute,
165 Compound,
166 Descendant,
167 Combinator,
168}
169
170impl RelationKind {
171 fn rule(self) -> RuleId {
172 match self {
173 Self::PseudoClass => RuleId::NestPseudoClass,
174 Self::PseudoElement => RuleId::NestPseudoElement,
175 Self::Attribute => RuleId::NestAttribute,
176 Self::Compound => RuleId::NestCompound,
177 Self::Descendant => RuleId::NestDescendant,
178 Self::Combinator => RuleId::NestCombinator,
179 }
180 }
181}
182
183#[derive(Debug, Clone)]
184enum ConditionalInner {
185 Direct {
186 body_range: Range<usize>,
187 },
188 Nested {
189 nested_selector: String,
190 body_range: Range<usize>,
191 },
192}
193
194#[derive(Debug, Clone)]
195enum ClusterChild {
196 Style {
197 node: SourceNode,
198 relation: RelationKind,
199 nested_selector: String,
200 },
201 Conditional {
202 node: SourceNode,
203 rule: RuleId,
204 inners: Vec<ConditionalInner>,
205 },
206}
207
208impl ClusterChild {
209 fn node(&self) -> &SourceNode {
210 match self {
211 Self::Style { node, .. } | Self::Conditional { node, .. } => node,
212 }
213 }
214
215 fn rule(&self) -> RuleId {
216 match self {
217 Self::Style { relation, .. } => relation.rule(),
218 Self::Conditional { rule, .. } => *rule,
219 }
220 }
221}
222
223pub fn analyze_file(path: &Path, enabled_rules: &[RuleId]) -> Result<FileReport> {
224 let source = fs::read_to_string(path)
225 .with_context(|| format!("failed to read CSS file {}", path.display()))?;
226 analyze_source(path.to_path_buf(), &source, enabled_rules)
227}
228
229pub fn analyze_workspace(
230 root: &Path,
231 files: &[PathBuf],
232 enabled_rules: &[RuleId],
233) -> Result<WorkspaceReport> {
234 let mut reports = Vec::with_capacity(files.len());
235 let mut next_id = 1usize;
236
237 for path in files {
238 let mut report = analyze_file(path, enabled_rules)?;
239 for plan in &mut report.plans {
240 plan.id = format!("T-{next_id:06}");
241 next_id += 1;
242 }
243 reports.push(report);
244 }
245
246 let mut summary = WorkspaceSummary {
247 files: reports.len(),
248 ..WorkspaceSummary::default()
249 };
250
251 for report in &reports {
252 if !report.parse_ok {
253 summary.parse_errors += 1;
254 }
255 summary.rules_analyzed +=
256 report.stats.top_level_style_rules + report.stats.top_level_at_rules;
257 for plan in &report.plans {
258 match plan.safety {
259 Safety::Safe => summary.safe += 1,
260 Safety::Review => summary.review += 1,
261 Safety::Unsafe => summary.unsafe_count += 1,
262 Safety::Unsupported => summary.unsupported += 1,
263 Safety::NoOp => summary.no_op += 1,
264 }
265 if plan
266 .warnings
267 .iter()
268 .any(|w| w.to_ascii_lowercase().contains("specificity"))
269 {
270 summary.specificity_sensitive += 1;
271 }
272 if plan.warnings.iter().any(|w| {
273 w.to_ascii_lowercase().contains("cascade")
274 || w.to_ascii_lowercase().contains("source order")
275 }) {
276 summary.cascade_sensitive += 1;
277 }
278 if plan
279 .warnings
280 .iter()
281 .any(|w| w.to_ascii_lowercase().contains("layer"))
282 {
283 summary.layer_sensitive += 1;
284 }
285 if plan
286 .warnings
287 .iter()
288 .any(|w| w.to_ascii_lowercase().contains("scope"))
289 {
290 summary.scope_sensitive += 1;
291 }
292 }
293 }
294
295 Ok(WorkspaceReport {
296 tool_version: env!("CARGO_PKG_VERSION").to_string(),
297 spec_baseline: SPEC_BASELINE.to_string(),
298 root: root.to_path_buf(),
299 enabled_rules: enabled_rules.to_vec(),
300 files: reports,
301 summary,
302 })
303}
304
305fn analyze_source(path: PathBuf, source: &str, enabled_rules: &[RuleId]) -> Result<FileReport> {
306 let parse_result = StyleSheet::parse(
310 source,
311 ParserOptions {
312 filename: path.display().to_string(),
313 error_recovery: true,
314 ..ParserOptions::default()
315 },
316 );
317
318 let parse_error = parse_result.err().map(|err| format!("{err:?}"));
319 let parse_ok = parse_error.is_none();
320 let nodes = scan_nodes(source, 0..source.len());
321 let stats = collect_stats(source, &nodes, parse_ok);
322 let mut findings = collect_findings(source, &nodes);
323
324 if let Some(error) = &parse_error {
325 findings.push(Finding {
326 safety: Safety::Unsupported,
327 title: "Semantic parse failed".into(),
328 detail: format!(
329 "{error} Planning continues from the structural scanner; review all plans."
330 ),
331 });
332 }
333
334 let mut plans = if !enabled_rules.is_empty() {
335 build_plans_recursive(&path, source, &nodes, enabled_rules)
336 } else {
337 Vec::new()
338 };
339 if !parse_ok {
340 for plan in &mut plans {
341 if plan.safety == Safety::Safe {
342 plan.safety = Safety::Review;
343 }
344 plan.warnings.push(
345 "Planned despite a LightningCSS parse error on this file; review required.".into(),
346 );
347 }
348 }
349
350 Ok(FileReport {
351 path,
352 parse_ok,
353 parse_error,
354 stats,
355 findings,
356 plans,
357 })
358}
359
360fn collect_stats(source: &str, nodes: &[SourceNode], parse_ok: bool) -> AnalysisStats {
361 let mut stats = AnalysisStats {
362 bytes: source.len(),
363 parse_errors: usize::from(!parse_ok),
364 important_declarations: count_ascii_case_insensitive_outside_comments(source, "!important"),
365 ..AnalysisStats::default()
366 };
367 let mut selector_counts: HashMap<String, usize> = HashMap::new();
368
369 for node in nodes {
370 match &node.kind {
371 NodeKind::Style => {
372 stats.top_level_style_rules += 1;
373 let selector = node.prelude(source).to_string();
374 *selector_counts.entry(selector).or_default() += 1;
375 if let Some(body) = node.body(source) {
376 stats.declarations += count_top_level_declarations(body);
377 stats.custom_properties += count_custom_properties(body);
378 }
379 }
380 NodeKind::AtBlock { name, .. } => {
381 stats.top_level_at_rules += 1;
382 match name.as_str() {
383 "media" => stats.media_rules += 1,
384 "supports" => stats.supports_rules += 1,
385 "container" => stats.container_rules += 1,
386 "layer" => stats.layer_rules += 1,
387 "scope" => stats.scope_rules += 1,
388 "starting-style" => stats.starting_style_rules += 1,
389 _ => {}
390 }
391 }
392 NodeKind::AtStatement { .. } => stats.top_level_at_rules += 1,
393 }
394 }
395
396 stats.duplicate_selectors = selector_counts.values().filter(|&&count| count > 1).count();
397 stats
398}
399
400fn count_custom_properties(body: &str) -> usize {
401 body.lines()
402 .filter(|line| {
403 let trimmed = line.trim_start();
404 trimmed.starts_with("--") && trimmed.contains(':')
405 })
406 .count()
407}
408
409fn collect_findings(source: &str, nodes: &[SourceNode]) -> Vec<Finding> {
410 let mut findings = Vec::new();
411 let selectors: HashSet<String> = nodes
412 .iter()
413 .filter(|n| matches!(&n.kind, NodeKind::Style))
414 .map(|n| n.prelude(source).to_string())
415 .collect();
416
417 let mut selector_occurrences: HashMap<String, usize> = HashMap::new();
418
419 for node in nodes {
420 match &node.kind {
421 NodeKind::Style => {
422 let selector = node.prelude(source);
423 *selector_occurrences.entry(selector.to_string()).or_default() += 1;
424
425 if contains_top_level_comma(selector) {
426 let branches = split_top_level_comma(selector);
427 let specs: Vec<Specificity> = branches.iter().map(|b| calculate_specificity(b.trim())).collect();
428 let has_mixed = specs.windows(2).any(|w| w[0] != w[1]);
429 if has_mixed {
430 findings.push(Finding {
431 safety: Safety::Review,
432 title: "Mixed-specificity selector list detected".into(),
433 detail: format!("{selector}: contains branches with differing specificities; factoring into :is() or parent nesting would raise lower-specificity branches."),
434 });
435 } else {
436 findings.push(Finding {
437 safety: Safety::Review,
438 title: "Selector list kept flat".into(),
439 detail: format!("{selector}: parent selector lists require per-branch specificity proof before native nesting."),
440 });
441 }
442 }
443
444 if let Some(base) = bem_base_candidate(selector)
445 && selectors.contains(base) {
446 findings.push(Finding {
447 safety: Safety::Unsupported,
448 title: "BEM token concatenation is not native nesting".into(),
449 detail: format!("{selector} resembles {base} + a BEM suffix; CSS nesting cannot safely generate &__element or &--modifier."),
450 });
451 }
452
453 if let Some(body) = node.body(source) {
454 if body.trim().is_empty() {
455 findings.push(Finding {
456 safety: Safety::Review,
457 title: "Empty rule block detected".into(),
458 detail: format!("{selector} contains no declarations or nested rules."),
459 });
460 }
461
462 let mut seen_props: HashMap<String, String> = HashMap::new();
463 for line in body.lines() {
464 let trimmed = line.trim();
465 if trimmed.starts_with("/*") || trimmed.starts_with('*') || !trimmed.contains(':') {
466 continue;
467 }
468 if let Some((prop, val)) = trimmed.split_once(':') {
469 let prop = prop.trim().to_ascii_lowercase();
470 let val = val.trim().trim_end_matches(';').trim().to_string();
471 if let Some(prev_val) = seen_props.get(&prop) {
472 if prev_val == &val {
473 findings.push(Finding {
474 safety: Safety::Review,
475 title: "Exact duplicate declaration detected".into(),
476 detail: format!("In {selector}: property '{prop}: {val}' is declared multiple times with identical value."),
477 });
478 }
479 } else {
480 seen_props.insert(prop, val);
481 }
482 }
483 }
484
485 if selector.contains(" .") && !selector.contains(":has(") {
486 findings.push(Finding {
487 safety: Safety::Review,
488 title: "Potential :has() relational candidate".into(),
489 detail: format!("{selector}: parent-child descendant relationship could be expressed with :has() if container-targeting is intended (advisory)."),
490 });
491 }
492 }
493 }
494 NodeKind::AtBlock { name, .. } => match name.as_str() {
495 "layer" => findings.push(Finding {
496 safety: Safety::Review,
497 title: "Cascade layer context detected".into(),
498 detail: "@layer participates in cascade ordering and reverses layer precedence for !important; automatic layer architecture is not applied.".into(),
499 }),
500 "scope" => findings.push(Finding {
501 safety: Safety::Review,
502 title: "Scope boundary detected".into(),
503 detail: "@scope boundaries enforce doughnut scoping; scoping parameters require manual architect review.".into(),
504 }),
505 "container" => findings.push(Finding {
506 safety: Safety::Review,
507 title: "Container query context detected".into(),
508 detail: "@container depends on eligible ancestor containers; media-to-container conversion is not inferred from CSS alone.".into(),
509 }),
510 "starting-style" => findings.push(Finding {
511 safety: Safety::Review,
512 title: "Starting-style context detected".into(),
513 detail: "@starting-style is temporal transition state; this build never invents it from ordinary declarations.".into(),
514 }),
515 _ => {}
516 },
517 NodeKind::AtStatement { .. } => {}
518 }
519 }
520
521 for (selector, count) in selector_occurrences {
522 if count > 1 {
523 findings.push(Finding {
524 safety: Safety::Review,
525 title: "Duplicate selector in stylesheet".into(),
526 detail: format!("'{selector}' appears {count} times in the stylesheet; non-adjacent occurrences must not be merged across intervening rules."),
527 });
528 }
529 }
530
531 findings
532}
533
534fn bem_base_candidate(selector: &str) -> Option<&str> {
535 if let Some(pos) = selector.find("__") {
536 let base = &selector[..pos];
537 if !base.is_empty() && !base.contains(' ') {
538 return Some(base);
539 }
540 }
541 if let Some(pos) = selector.find("--") {
542 let base = &selector[..pos];
543 if !base.is_empty() && !base.contains(' ') {
544 return Some(base);
545 }
546 }
547 None
548}
549
550const TRANSPARENT_AT_RULES: &[&str] = &["layer", "scope", "media", "supports", "container"];
551
552fn build_plans_recursive(
553 path: &Path,
554 source: &str,
555 nodes: &[SourceNode],
556 enabled_rules: &[RuleId],
557) -> Vec<PlanEntry> {
558 let mut plans = build_plans(path, source, nodes, enabled_rules);
559
560 for node in nodes {
561 if let NodeKind::AtBlock { name, .. } = &node.kind
562 && TRANSPARENT_AT_RULES.contains(&name.as_str())
563 {
564 let is_covered = plans
565 .iter()
566 .any(|p| p.source_range.start <= node.start && node.end <= p.source_range.end);
567 if !is_covered && let Some(body_range) = &node.body_range {
568 let inner_nodes = scan_nodes(source, body_range.clone());
569 if !inner_nodes.is_empty() {
570 let inner_plans =
571 build_plans_recursive(path, source, &inner_nodes, enabled_rules);
572 plans.extend(inner_plans);
573 }
574 }
575 }
576 }
577
578 let keep = select_disjoint_plan_indices(&plans);
579 keep.into_iter().map(|i| plans[i].clone()).collect()
580}
581
582fn build_plans(
583 path: &Path,
584 source: &str,
585 nodes: &[SourceNode],
586 enabled_rules: &[RuleId],
587) -> Vec<PlanEntry> {
588 let enabled: HashSet<RuleId> = enabled_rules.iter().copied().collect();
589 let mut plans = Vec::new();
590
591 plan_merge_same_named_layers(path, source, nodes, &enabled, &mut plans);
593 plan_merge_adjacent_at_blocks(path, source, nodes, &enabled, &mut plans);
594 plan_gather_consecutive_conditions_by_selector(path, source, nodes, &enabled, &mut plans);
595 plan_merge_adjacent_identical_selectors(path, source, nodes, &enabled, &mut plans);
596 plan_gather_related_selector_rules(path, source, nodes, &enabled, &mut plans);
597 plan_nest_layer_by_selector(path, source, nodes, &enabled, &mut plans);
598 plan_merge_identical_rule_bodies(path, source, nodes, &enabled, &mut plans);
599 plan_factor_identical_states_with_is(path, source, nodes, &enabled, &mut plans);
600 plan_factor_multi_selector_cluster_with_is(path, source, nodes, &enabled, &mut plans);
601 plan_nest_in_place_adjacent_states(path, source, nodes, &enabled, &mut plans);
602
603 let mut i = 0usize;
604
605 while i < nodes.len() {
606 let parent = &nodes[i];
607
608 if enabled.contains(&RuleId::ModernizeMediaRange)
610 && let NodeKind::AtBlock { name, .. } = &parent.kind
611 && (name == "media" || name == "container")
612 {
613 let prelude = parent.prelude(source);
614 if let Some(modernized) = modernize_media_query_str(prelude) {
615 plans.push(PlanEntry {
616 id: String::new(),
617 file: path.to_path_buf(),
618 rules: vec![RuleId::ModernizeMediaRange],
619 safety: Safety::Safe,
620 source_range: SourceRange {
621 start: parent.prelude_range.start,
622 end: parent.prelude_range.end,
623 },
624 original: source[parent.prelude_range.clone()].to_string(),
625 proposed: modernized,
626 proof: Proof::safe_local(),
627 warnings: Vec::new(),
628 reason: "Modernize legacy media/container feature syntax to CSS Range Syntax (e.g. (width >= 800px)).".to_string(),
629 selected: true,
630 });
631 }
632 }
633
634 if !matches!(&parent.kind, NodeKind::Style) {
635 i += 1;
636 continue;
637 }
638
639 let parent_selector = parent.prelude(source);
640
641 if contains_top_level_comma(parent_selector) {
643 let parent_indent = line_indent(source, parent.start);
644 let unit = relative_indent_unit(source, parent);
645
646 if enabled.contains(&RuleId::FactorSelectorList)
647 && let Some(body_range) = &parent.body_range
648 {
649 let body = &source[body_range.clone()];
650 if let Some(mut factored) =
651 factor_selector_list(parent_selector, body, &parent_indent, &unit)
652 {
653 let branches: Vec<&str> = split_top_level_comma(parent_selector)
654 .into_iter()
655 .map(|s| s.trim())
656 .collect();
657 let base = branches[0];
658
659 let mut cursor = i + 1;
661 let mut prev_end = parent.end;
662 let mut extra_children = Vec::new();
663
664 while cursor < nodes.len() {
665 let next = &nodes[cursor];
666 if !is_whitespace_only(source, prev_end..next.start) {
667 break;
668 }
669 if matches!(&next.kind, NodeKind::Style)
670 && let Some((rel, nested_sel)) =
671 selector_relation(base, next.prelude(source))
672 && enabled.contains(&rel.rule())
673 {
674 extra_children.push(ClusterChild::Style {
675 node: next.clone(),
676 relation: rel,
677 nested_selector: nested_sel,
678 });
679 prev_end = next.end;
680 cursor += 1;
681 continue;
682 }
683 break;
684 }
685
686 let end_offset = if extra_children.is_empty() {
687 parent.end
688 } else {
689 let nested_indent = format!("{parent_indent}{unit}");
690 let inner_decl_indent = format!("{nested_indent}{unit}");
691 let mut extra_rendered = String::new();
692
693 for ch in &extra_children {
694 if let ClusterChild::Style {
695 node: ch_node,
696 nested_selector,
697 ..
698 } = ch
699 {
700 extra_rendered.push('\n');
701 extra_rendered.push_str(&nested_indent);
702 extra_rendered.push_str(nested_selector.trim());
703 extra_rendered.push_str(" {\n");
704 if let Some(ch_body_range) = &ch_node.body_range {
705 for line in source[ch_body_range.clone()].lines() {
706 let trimmed = line.trim();
707 if !trimmed.is_empty() {
708 extra_rendered.push_str(&inner_decl_indent);
709 extra_rendered.push_str(&ensure_semicolon(trimmed));
710 extra_rendered.push('\n');
711 }
712 }
713 }
714 extra_rendered.push_str(&nested_indent);
715 extra_rendered.push_str("}\n");
716 }
717 }
718
719 if let Some(close_brace_pos) = factored.rfind('}') {
720 factored.insert_str(close_brace_pos, &extra_rendered);
721 }
722 prev_end
723 };
724
725 plans.push(PlanEntry {
726 id: String::new(),
727 file: path.to_path_buf(),
728 rules: vec![RuleId::FactorSelectorList],
729 safety: Safety::Safe,
730 source_range: SourceRange {
731 start: parent.start,
732 end: end_offset,
733 },
734 original: source[parent.start..end_offset].to_string(),
735 proposed: factored,
736 proof: Proof::safe_local(),
737 warnings: Vec::new(),
738 reason: "Factor comma-separated selectors sharing a common base element into nested form.".to_string(),
739 selected: true,
740 });
741 i = cursor;
742 continue;
743 }
744 }
745
746 if enabled.contains(&RuleId::ModernizeIs)
747 && let Some((factored_sel, uniform)) = factor_with_is(parent_selector)
748 {
749 plans.push(PlanEntry {
750 id: String::new(),
751 file: path.to_path_buf(),
752 rules: vec![RuleId::ModernizeIs],
753 safety: if uniform { Safety::Safe } else { Safety::Review },
754 source_range: SourceRange {
755 start: parent.prelude_range.start,
756 end: parent.prelude_range.end,
757 },
758 original: source[parent.prelude_range.clone()].to_string(),
759 proposed: factored_sel,
760 proof: Proof {
761 specificity_equivalent: uniform,
762 ..Proof::safe_local()
763 },
764 warnings: if uniform { Vec::new() } else { vec!["Mixed branch specificity: :is() takes the specificity of its most specific argument.".into()] },
765 reason: "Factor common selector prefix/suffix into :is(...) grouping.".to_string(),
766 selected: true,
767 });
768 i += 1;
769 continue;
770 }
771
772 if enabled.contains(&RuleId::ModernizeWhere)
773 && let Some(factored_where) = factor_with_where(parent_selector)
774 {
775 plans.push(PlanEntry {
776 id: String::new(),
777 file: path.to_path_buf(),
778 rules: vec![RuleId::ModernizeWhere],
779 safety: Safety::Review,
780 source_range: SourceRange {
781 start: parent.prelude_range.start,
782 end: parent.prelude_range.end,
783 },
784 original: source[parent.prelude_range.clone()].to_string(),
785 proposed: factored_where,
786 proof: Proof {
787 specificity_equivalent: false,
788 ..Proof::safe_local()
789 },
790 warnings: vec!["Specificity zeroed to 0-0-0 by :where()".into()],
791 reason: "Convert selector list to :where(...) for zero-specificity defaults (review required).".to_string(),
792 selected: true,
793 });
794 i += 1;
795 continue;
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 && let Some((relation, nested_selector)) =
819 selector_relation(parent_selector, node.prelude(source))
820 && enabled.contains(&relation.rule())
821 {
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 if let Some(child) = conditional_child(source, parent_selector, node, &enabled) {
833 previous_end = node.end;
834 children.push(child);
835 cursor += 1;
836 continue;
837 }
838
839 break;
840 }
841
842 if !children.is_empty() {
843 let last_end = children.last().expect("non-empty cluster").node().end;
844 let proposed = render_cluster(source, parent, &children);
845 let mut rules = Vec::new();
846 for child in &children {
847 let rule = child.rule();
848 if !rules.contains(&rule) {
849 rules.push(rule);
850 }
851 }
852 plans.push(PlanEntry {
853 id: String::new(),
854 file: path.to_path_buf(),
855 rules,
856 safety: Safety::Safe,
857 source_range: SourceRange {
858 start: parent.start,
859 end: last_end,
860 },
861 original: source[parent.start..last_end].to_string(),
862 proposed,
863 proof: Proof::safe_local(),
864 warnings: Vec::new(),
865 reason: format!(
866 "{} immediately adjacent rule(s) share the exact parent selector and can be nested without crossing comments or unrelated rules.",
867 children.len()
868 ),
869 selected: true,
870 });
871 i = cursor;
872 } else {
873 if enabled.contains(&RuleId::ConsolidateNot) && matches!(&parent.kind, NodeKind::Style)
874 {
875 let prelude = parent.prelude(source);
876 if let Some((consolidated, _uniform)) = consolidate_not_in_selector(prelude) {
877 plans.push(PlanEntry {
878 id: String::new(),
879 file: path.to_path_buf(),
880 rules: vec![RuleId::ConsolidateNot],
881 safety: Safety::Review,
882 source_range: SourceRange {
883 start: parent.prelude_range.start,
884 end: parent.prelude_range.end,
885 },
886 original: source[parent.prelude_range.clone()].to_string(),
887 proposed: consolidated,
888 proof: Proof {
889 specificity_equivalent: false,
890 ..Proof::safe_local()
891 },
892 warnings: vec!["Specificity reduced: chained :not() has additive specificity; comma-separated :not() takes only the maximum argument specificity.".into()],
893 reason: "Consolidate chained :not() selectors into a single comma-separated :not() list (review required for specificity drop).".to_string(),
894 selected: true,
895 });
896 }
897 }
898 i += 1;
899 }
900 }
901
902 plans
903}
904
905fn plan_merge_same_named_layers(
906 path: &Path,
907 source: &str,
908 nodes: &[SourceNode],
909 enabled: &HashSet<RuleId>,
910 plans: &mut Vec<PlanEntry>,
911) {
912 if !enabled.contains(&RuleId::MergeSameNamedLayer) {
913 return;
914 }
915 let mut layer_groups: HashMap<String, Vec<&SourceNode>> = HashMap::new();
916 for node in nodes {
917 if let NodeKind::AtBlock { name, .. } = &node.kind
918 && name == "layer"
919 {
920 let prelude = node.prelude(source).trim();
921 if let Some(layer_name) = prelude.strip_prefix("@layer") {
922 let layer_name = layer_name.trim();
923 if !layer_name.is_empty() && !layer_name.contains('{') {
924 layer_groups
925 .entry(layer_name.to_string())
926 .or_default()
927 .push(node);
928 }
929 }
930 }
931 }
932
933 let enabled_rules_vec: Vec<RuleId> = enabled.iter().copied().collect();
934
935 for (layer_name, blocks) in layer_groups {
936 if blocks.len() > 1 {
937 let first = blocks[0];
938 let parent_indent = line_indent(source, first.start);
939 let first_body_range = first.body_range.as_ref().unwrap();
940 let unit = detect_indent_unit(source, first_body_range.clone())
941 .unwrap_or_else(|| " ".to_string());
942 let nested_indent = format!("{parent_indent}{unit}");
943
944 let mut merged_body = String::new();
945 for b in &blocks {
946 if let Some(body_range) = &b.body_range {
947 let inner_nodes = scan_nodes(source, body_range.clone());
948 let inner_plans =
949 build_plans_recursive(path, source, &inner_nodes, &enabled_rules_vec);
950 let body_text = &source[body_range.clone()];
951 let modernized_body = if inner_plans.is_empty() {
952 body_text.to_string()
953 } else {
954 let mut local_plans = Vec::new();
955 for p in inner_plans {
956 if p.source_range.start >= body_range.start
957 && p.source_range.end <= body_range.end
958 {
959 let mut local_p = p.clone();
960 local_p.source_range.start -= body_range.start;
961 local_p.source_range.end -= body_range.start;
962 local_plans.push(local_p);
963 }
964 }
965 apply_selected_plans(body_text, &local_plans, true)
966 .unwrap_or_else(|_| body_text.to_string())
967 };
968
969 for line in modernized_body.lines() {
970 let trimmed = line.trim();
971 if !trimmed.is_empty() {
972 merged_body.push_str(&nested_indent);
973 merged_body.push_str(&ensure_semicolon(trimmed));
974 merged_body.push('\n');
975 }
976 }
977 }
978 }
979
980 let proposed_first =
981 format!("{parent_indent}@layer {layer_name} {{\n{merged_body}{parent_indent}}}");
982 plans.push(PlanEntry {
983 id: String::new(),
984 file: path.to_path_buf(),
985 rules: vec![RuleId::MergeSameNamedLayer],
986 safety: Safety::Safe,
987 source_range: SourceRange {
988 start: first.start,
989 end: first.end,
990 },
991 original: source[first.start..first.end].to_string(),
992 proposed: proposed_first,
993 proof: Proof::safe_local(),
994 warnings: Vec::new(),
995 reason: format!(
996 "Consolidate {} separated blocks of @layer {} into first occurrence.",
997 blocks.len(),
998 layer_name
999 ),
1000 selected: true,
1001 });
1002
1003 for subsequent in &blocks[1..] {
1004 plans.push(PlanEntry {
1005 id: String::new(),
1006 file: path.to_path_buf(),
1007 rules: vec![RuleId::MergeSameNamedLayer],
1008 safety: Safety::Safe,
1009 source_range: SourceRange {
1010 start: subsequent.start,
1011 end: subsequent.end,
1012 },
1013 original: source[subsequent.start..subsequent.end].to_string(),
1014 proposed: String::new(),
1015 proof: Proof::safe_local(),
1016 warnings: Vec::new(),
1017 reason: format!(
1018 "Remove consolidated subsequent block of @layer {}.",
1019 layer_name
1020 ),
1021 selected: true,
1022 });
1023 }
1024 }
1025 }
1026}
1027
1028fn plan_merge_adjacent_at_blocks(
1029 path: &Path,
1030 source: &str,
1031 nodes: &[SourceNode],
1032 enabled: &HashSet<RuleId>,
1033 plans: &mut Vec<PlanEntry>,
1034) {
1035 let mut i = 0;
1036 while i < nodes.len() {
1037 let first = &nodes[i];
1038 if let NodeKind::AtBlock { name, .. } = &first.kind {
1039 let rule = match name.as_str() {
1040 "media" => RuleId::MergeAdjacentMedia,
1041 "supports" => RuleId::MergeAdjacentSupports,
1042 "container" => RuleId::MergeAdjacentContainer,
1043 "scope" => RuleId::MergeIdenticalScope,
1044 "starting-style" => RuleId::MergeIdenticalStartingStyle,
1045 _ => {
1046 i += 1;
1047 continue;
1048 }
1049 };
1050
1051 if !enabled.contains(&rule) {
1052 i += 1;
1053 continue;
1054 }
1055
1056 let first_prelude = first.prelude(source).trim();
1057 let mut cluster = vec![first];
1058 let mut cursor = i + 1;
1059 let mut prev_end = first.end;
1060
1061 while cursor < nodes.len() {
1062 let next = &nodes[cursor];
1063 if !is_whitespace_only(source, prev_end..next.start) {
1064 break;
1065 }
1066 if let NodeKind::AtBlock {
1067 name: next_name, ..
1068 } = &next.kind
1069 && next_name == name
1070 && next.prelude(source).trim() == first_prelude
1071 {
1072 cluster.push(next);
1073 prev_end = next.end;
1074 cursor += 1;
1075 continue;
1076 }
1077 break;
1078 }
1079
1080 if cluster.len() > 1 {
1081 let last = cluster.last().unwrap();
1082 let parent_indent = line_indent(source, first.start);
1083 let first_body_range = first.body_range.as_ref().unwrap();
1084 let unit = detect_indent_unit(source, first_body_range.clone())
1085 .unwrap_or_else(|| " ".to_string());
1086 let nested_indent = format!("{parent_indent}{unit}");
1087
1088 let mut merged_body = String::new();
1089 for c in &cluster {
1090 if let Some(body_range) = &c.body_range {
1091 let body_text = &source[body_range.clone()];
1092 for line in body_text.lines() {
1093 let trimmed = line.trim();
1094 if !trimmed.is_empty() {
1095 merged_body.push_str(&nested_indent);
1096 merged_body.push_str(&ensure_semicolon(trimmed));
1097 merged_body.push('\n');
1098 }
1099 }
1100 }
1101 }
1102
1103 let proposed =
1104 format!("{parent_indent}{first_prelude} {{\n{merged_body}{parent_indent}}}");
1105 plans.push(PlanEntry {
1106 id: String::new(),
1107 file: path.to_path_buf(),
1108 rules: vec![rule],
1109 safety: Safety::Safe,
1110 source_range: SourceRange {
1111 start: first.start,
1112 end: last.end,
1113 },
1114 original: source[first.start..last.end].to_string(),
1115 proposed,
1116 proof: Proof::safe_local(),
1117 warnings: Vec::new(),
1118 reason: format!(
1119 "Merge {} adjacent identical {} blocks into a single block.",
1120 cluster.len(),
1121 first_prelude
1122 ),
1123 selected: true,
1124 });
1125 i = cursor;
1126 continue;
1127 }
1128 }
1129 i += 1;
1130 }
1131}
1132
1133fn plan_gather_consecutive_conditions_by_selector(
1134 path: &Path,
1135 source: &str,
1136 nodes: &[SourceNode],
1137 enabled: &HashSet<RuleId>,
1138 plans: &mut Vec<PlanEntry>,
1139) {
1140 if !enabled.contains(&RuleId::NestMedia) && !enabled.contains(&RuleId::NestSupports) {
1141 return;
1142 }
1143
1144 let mut i = 0;
1145 while i < nodes.len() {
1146 let first = &nodes[i];
1147 if let NodeKind::AtBlock { name, .. } = &first.kind
1148 && (name == "media" || name == "supports")
1149 && let Some(target_sel) = extract_single_style_selector(source, first)
1150 {
1151 let mut cluster = vec![first];
1152 let mut cursor = i + 1;
1153 let mut prev_end = first.end;
1154
1155 while cursor < nodes.len() {
1156 let next = &nodes[cursor];
1157 if !is_whitespace_only(source, prev_end..next.start) {
1158 break;
1159 }
1160 if let NodeKind::AtBlock {
1161 name: next_name, ..
1162 } = &next.kind
1163 && (next_name == "media" || next_name == "supports")
1164 && let Some(next_sel) = extract_single_style_selector(source, next)
1165 && next_sel == target_sel
1166 {
1167 cluster.push(next);
1168 prev_end = next.end;
1169 cursor += 1;
1170 continue;
1171 }
1172 break;
1173 }
1174
1175 if cluster.len() > 1 {
1176 let last = cluster.last().unwrap();
1177 let parent_indent = line_indent(source, first.start);
1178 let first_body_range = first.body_range.as_ref().unwrap();
1179 let unit = detect_indent_unit(source, first_body_range.clone())
1180 .unwrap_or_else(|| " ".to_string());
1181 let nested_indent = format!("{parent_indent}{unit}");
1182 let inner_decl_indent = format!("{nested_indent}{unit}");
1183
1184 let mut body_out = String::new();
1185 for (idx, &c) in cluster.iter().enumerate() {
1186 if idx > 0 {
1187 body_out.push('\n');
1188 }
1189 let at_header = c.prelude(source).trim();
1190 body_out.push_str(&nested_indent);
1191 body_out.push_str(at_header);
1192 body_out.push_str(" {\n");
1193
1194 let c_body_range = c.body_range.as_ref().unwrap();
1195 let inner_nodes = scan_nodes(source, c_body_range.clone());
1196 for in_node in &inner_nodes {
1197 if let Some(in_body_range) = &in_node.body_range {
1198 for line in source[in_body_range.clone()].lines() {
1199 let trimmed = line.trim();
1200 if !trimmed.is_empty() {
1201 body_out.push_str(&inner_decl_indent);
1202 body_out.push_str(&ensure_semicolon(trimmed));
1203 body_out.push('\n');
1204 }
1205 }
1206 }
1207 }
1208
1209 body_out.push_str(&nested_indent);
1210 body_out.push_str("}\n");
1211 }
1212
1213 let proposed =
1214 format!("{parent_indent}{target_sel} {{\n{body_out}{parent_indent}}}");
1215 plans.push(PlanEntry {
1216 id: String::new(),
1217 file: path.to_path_buf(),
1218 rules: vec![RuleId::NestMedia, RuleId::NestSupports],
1219 safety: Safety::Safe,
1220 source_range: SourceRange {
1221 start: first.start,
1222 end: last.end,
1223 },
1224 original: source[first.start..last.end].to_string(),
1225 proposed,
1226 proof: Proof::safe_local(),
1227 warnings: Vec::new(),
1228 reason: format!(
1229 "Gather {} consecutive condition blocks targeting '{}' into a single component rule.",
1230 cluster.len(),
1231 target_sel
1232 ),
1233 selected: true,
1234 });
1235 i = cursor;
1236 continue;
1237 }
1238 }
1239 i += 1;
1240 }
1241}
1242
1243fn extract_single_style_selector<'a>(source: &'a str, at_node: &SourceNode) -> Option<&'a str> {
1244 let body_range = at_node.body_range.as_ref()?;
1245 let inner_nodes = scan_nodes(source, body_range.clone());
1246 if inner_nodes.len() == 1 && matches!(&inner_nodes[0].kind, NodeKind::Style) {
1247 Some(inner_nodes[0].prelude(source).trim())
1248 } else {
1249 None
1250 }
1251}
1252
1253fn plan_nest_in_place_adjacent_states(
1254 path: &Path,
1255 source: &str,
1256 nodes: &[SourceNode],
1257 enabled: &HashSet<RuleId>,
1258 plans: &mut Vec<PlanEntry>,
1259) {
1260 if !enabled.contains(&RuleId::NestPseudoClass) {
1261 return;
1262 }
1263
1264 let mut i = 0;
1265 while i < nodes.len() {
1266 let first = &nodes[i];
1267 if matches!(&first.kind, NodeKind::Style) {
1268 let first_sel = first.prelude(source).trim();
1269 if let Some(base) = extract_base_target(first_sel) {
1270 let mut cluster = vec![first];
1271 let mut cursor = i + 1;
1272 let mut prev_end = first.end;
1273
1274 while cursor < nodes.len() {
1275 let next = &nodes[cursor];
1276 if !is_whitespace_only(source, prev_end..next.start) {
1277 break;
1278 }
1279 if matches!(&next.kind, NodeKind::Style) {
1280 let next_sel = next.prelude(source).trim();
1281 if let Some(next_base) = extract_base_target(next_sel)
1282 && next_base == base
1283 {
1284 cluster.push(next);
1285 prev_end = next.end;
1286 cursor += 1;
1287 continue;
1288 }
1289 }
1290 break;
1291 }
1292
1293 if cluster.len() > 1 {
1294 let last = cluster.last().unwrap();
1295 let parent_indent = line_indent(source, first.start);
1296 let first_body_range = first.body_range.as_ref().unwrap();
1297 let unit = detect_indent_unit(source, first_body_range.clone())
1298 .unwrap_or_else(|| " ".to_string());
1299 let nested_indent = format!("{parent_indent}{unit}");
1300 let inner_decl_indent = format!("{nested_indent}{unit}");
1301
1302 let mut out = format!("{parent_indent}{base} {{\n");
1303 for (idx, &c) in cluster.iter().enumerate() {
1304 if idx > 0 {
1305 out.push('\n');
1306 }
1307 let c_sel = c.prelude(source).trim();
1308 let remainder = &c_sel[base.len()..];
1309 let nested_sel = if remainder.starts_with(':')
1310 || remainder.starts_with('[')
1311 || remainder.starts_with('.')
1312 || remainder.starts_with('#')
1313 {
1314 format!("&{remainder}")
1315 } else {
1316 remainder.trim().to_string()
1317 };
1318
1319 out.push_str(&nested_indent);
1320 out.push_str(&nested_sel);
1321 out.push_str(" {\n");
1322
1323 if let Some(c_body_range) = &c.body_range {
1324 for line in source[c_body_range.clone()].lines() {
1325 let trimmed = line.trim();
1326 if !trimmed.is_empty() {
1327 out.push_str(&inner_decl_indent);
1328 out.push_str(&ensure_semicolon(trimmed));
1329 out.push('\n');
1330 }
1331 }
1332 }
1333
1334 out.push_str(&nested_indent);
1335 out.push_str("}\n");
1336 }
1337
1338 out.push_str(&parent_indent);
1339 out.push('}');
1340
1341 plans.push(PlanEntry {
1342 id: String::new(),
1343 file: path.to_path_buf(),
1344 rules: vec![RuleId::NestPseudoClass, RuleId::NestAttribute],
1345 safety: Safety::Safe,
1346 source_range: SourceRange {
1347 start: first.start,
1348 end: last.end,
1349 },
1350 original: source[first.start..last.end].to_string(),
1351 proposed: out,
1352 proof: Proof::safe_local(),
1353 warnings: Vec::new(),
1354 reason: format!(
1355 "Nest {} adjacent state rules for '{}' in place without moving.",
1356 cluster.len(),
1357 base
1358 ),
1359 selected: true,
1360 });
1361 i = cursor;
1362 continue;
1363 }
1364 }
1365 }
1366 i += 1;
1367 }
1368}
1369
1370fn extract_base_target(selector: &str) -> Option<&str> {
1371 if contains_top_level_comma(selector) {
1372 return None;
1373 }
1374 if let Some(pos) = selector.find(':')
1375 && pos > 0
1376 && !selector[pos..].starts_with("::")
1377 {
1378 let base = &selector[..pos];
1379 if !base.is_empty() {
1380 return Some(base);
1381 }
1382 }
1383 if let Some(pos) = selector.find('[')
1384 && pos > 0
1385 {
1386 let base = &selector[..pos];
1387 if !base.is_empty() {
1388 return Some(base);
1389 }
1390 }
1391 None
1392}
1393
1394fn parse_rule_body_items(body_str: &str) -> (Vec<String>, Vec<String>) {
1395 let mut declarations = Vec::new();
1396 let mut nested_rules = Vec::new();
1397
1398 let mut depth = 0usize;
1399 let mut current_block = String::new();
1400 let mut current_decl = String::new();
1401 let mut in_comment = false;
1402 let bytes = body_str.as_bytes();
1403 let mut i = 0;
1404
1405 while i < bytes.len() {
1406 if in_comment {
1407 current_decl.push(bytes[i] as char);
1408 current_block.push(bytes[i] as char);
1409 if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
1410 current_decl.push('/');
1411 current_block.push('/');
1412 i += 2;
1413 in_comment = false;
1414 continue;
1415 }
1416 i += 1;
1417 continue;
1418 }
1419
1420 if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
1421 in_comment = true;
1422 current_decl.push('/');
1423 current_decl.push('*');
1424 current_block.push('/');
1425 current_block.push('*');
1426 i += 2;
1427 continue;
1428 }
1429
1430 let b = bytes[i];
1431 if b == b'{' {
1432 depth += 1;
1433 if depth == 1 {
1434 current_block = current_decl.clone();
1435 current_decl.clear();
1436 }
1437 current_block.push('{');
1438 i += 1;
1439 continue;
1440 } else if b == b'}' {
1441 if depth > 0 {
1442 depth -= 1;
1443 current_block.push('}');
1444 if depth == 0 {
1445 let trimmed = current_block.trim().to_string();
1446 if !trimmed.is_empty() {
1447 nested_rules.push(trimmed);
1448 }
1449 current_block.clear();
1450 current_decl.clear();
1451 }
1452 }
1453 i += 1;
1454 continue;
1455 }
1456
1457 if depth > 0 {
1458 current_block.push(b as char);
1459 } else {
1460 if b == b';' {
1461 current_decl.push(';');
1462 let trimmed = current_decl.trim().to_string();
1463 if !trimmed.is_empty() {
1464 declarations.push(trimmed);
1465 }
1466 current_decl.clear();
1467 } else if b == b'\n' {
1468 let trimmed = current_decl.trim();
1469 if !trimmed.is_empty()
1472 && trimmed.contains(':')
1473 && !trimmed.ends_with('{')
1474 && !trimmed.ends_with(',')
1475 {
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(ensure_semicolon(&trailing_decl).into_owned());
1496 }
1497
1498 (declarations, nested_rules)
1499}
1500
1501fn is_ident_continue(c: char) -> bool {
1502 c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '\\'
1503}
1504
1505fn appended_nesting_selector_raw(base: &str, candidate_sel: &str) -> Option<String> {
1513 let base = base.trim();
1514 let candidate_sel = candidate_sel.trim();
1515 if base.is_empty() || candidate_sel == base || contains_top_level_comma(candidate_sel) {
1516 return None;
1517 }
1518
1519 const FNS: &[&str] = &[":not(", ":is(", ":where(", ":has("];
1520 for fn_name in FNS {
1521 let wrapped = format!("{fn_name}{base})");
1522 if candidate_sel == wrapped {
1523 return Some(format!("{fn_name}&)"));
1524 }
1525 if let Some(prefix) = candidate_sel.strip_suffix(wrapped.as_str())
1526 && !prefix.is_empty()
1527 {
1528 let prev = prefix.chars().last()?;
1529 if is_ident_continue(prev)
1530 || prev == '.'
1531 || prev == '#'
1532 || prev == ']'
1533 || prev == ')'
1534 || prev == '*'
1535 {
1536 return Some(format!("{prefix}{fn_name}&)"));
1537 }
1538 }
1539 }
1540
1541 if !candidate_sel.ends_with(base) {
1542 return None;
1543 }
1544 let prefix = &candidate_sel[..candidate_sel.len() - base.len()];
1545 if prefix.is_empty() {
1546 return None;
1547 }
1548 let prev = prefix.chars().last()?;
1549 let first_of_base = base.chars().next()?;
1550
1551 if prev.is_whitespace() || matches!(prev, '>' | '+' | '~') {
1552 return Some(format!("{prefix}&"));
1553 }
1554
1555 if matches!(first_of_base, '.' | '#' | '[' | ':')
1558 && (is_ident_continue(prev) || prev == ']' || prev == ')' || prev == '*')
1559 {
1560 return Some(format!("{prefix}&"));
1561 }
1562
1563 None
1564}
1565
1566fn appended_nesting_selector(base: &str, candidate_sel: &str) -> Option<String> {
1567 let nested = appended_nesting_selector_raw(base, candidate_sel)?;
1568 selector_contains_nesting_amp(&nested).then_some(nested)
1571}
1572
1573fn appended_selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
1574 let nested = appended_nesting_selector(parent, child)?;
1575 let before_amp = nested.strip_suffix('&').unwrap_or(nested.as_str());
1576 let kind = if nested.contains(":not(")
1577 || nested.contains(":is(")
1578 || nested.contains(":where(")
1579 || nested.contains(":has(")
1580 {
1581 RelationKind::PseudoClass
1582 } else if before_amp.contains('>') || before_amp.contains('+') || before_amp.contains('~') {
1583 RelationKind::Combinator
1584 } else if nested.ends_with('&')
1585 && before_amp
1586 .chars()
1587 .last()
1588 .is_some_and(|c| !c.is_whitespace())
1589 {
1590 RelationKind::Compound
1591 } else {
1592 RelationKind::Descendant
1593 };
1594 Some((kind, nested))
1595}
1596
1597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1598enum RelatedKind {
1599 Prefix,
1600 Appended,
1601}
1602
1603fn prefix_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1604 if candidate_sel == base || !candidate_sel.starts_with(base) {
1605 return None;
1606 }
1607 let rem_raw = &candidate_sel[base.len()..];
1608 let rem = rem_raw.trim_start();
1609 if rem.is_empty() {
1610 return None;
1611 }
1612 let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1615 if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#')
1616 {
1617 if directly_attached {
1618 return Some(format!("&{rem}"));
1619 } else {
1620 return Some(rem.to_string());
1621 }
1622 }
1623 if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1624 let first_char = &rem[..1];
1625 let rest = rem[1..].trim_start();
1626 return Some(format!("{first_char} {rest}"));
1627 }
1628 if rem_raw.starts_with(' ') {
1629 return Some(rem.to_string());
1630 }
1631 None
1632}
1633
1634fn classify_related_nested_one(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1635 if candidate_sel == base || contains_top_level_comma(candidate_sel) {
1636 return None;
1637 }
1638 if let Some(rel) = prefix_related_nested_selector(base, candidate_sel) {
1639 return Some((RelatedKind::Prefix, rel));
1640 }
1641 appended_nesting_selector(base, candidate_sel).map(|rel| (RelatedKind::Appended, rel))
1642}
1643
1644fn classify_related_comma_list(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1645 let parts: Vec<&str> = split_top_level_comma(candidate_sel)
1646 .into_iter()
1647 .map(str::trim)
1648 .filter(|s| !s.is_empty())
1649 .collect();
1650 if parts.len() < 2 {
1651 return None;
1652 }
1653 let mut rels = Vec::with_capacity(parts.len());
1654 let mut all_prefix = true;
1655 for part in parts {
1656 if part == base {
1657 rels.push("&".to_string());
1658 continue;
1659 }
1660 let (kind, rel) = classify_related_nested_one(base, part)?;
1661 if kind != RelatedKind::Prefix {
1662 all_prefix = false;
1663 }
1664 rels.push(rel);
1665 }
1666 let kind = if all_prefix {
1667 RelatedKind::Prefix
1668 } else {
1669 RelatedKind::Appended
1670 };
1671 Some((kind, rels.join(", ")))
1672}
1673
1674fn classify_related_nested(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1675 if candidate_sel == base {
1676 return None;
1677 }
1678 if contains_top_level_comma(candidate_sel) {
1679 return classify_related_comma_list(base, candidate_sel);
1680 }
1681 classify_related_nested_one(base, candidate_sel)
1682}
1683
1684fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1685 classify_related_nested(base, candidate_sel).map(|(_, rel)| rel)
1686}
1687
1688fn is_weak_gather_base(base: &str) -> bool {
1689 matches!(base.trim(), "*" | "html" | "body" | ":root" | ":host")
1690}
1691
1692fn selector_contains_nesting_amp(selector: &str) -> bool {
1698 let bytes = selector.as_bytes();
1699 let mut i = 0;
1700 let mut quote: Option<u8> = None;
1701 let mut escaped = false;
1702 while i < bytes.len() {
1703 let b = bytes[i];
1704 if let Some(q) = quote {
1705 if escaped {
1706 escaped = false;
1707 } else if b == b'\\' {
1708 escaped = true;
1709 } else if b == q {
1710 quote = None;
1711 }
1712 i += 1;
1713 continue;
1714 }
1715 match b {
1716 b'\'' | b'"' => quote = Some(b),
1717 b'&' => return true,
1718 _ => {}
1719 }
1720 i += 1;
1721 }
1722 false
1723}
1724
1725fn leading_block_comment(source: &str, node_start: usize) -> Option<(usize, &str)> {
1727 let before = source.get(..node_start)?;
1728 let start = before.rfind("/*")?;
1729 let close_rel = source.get(start + 2..node_start)?.find("*/")?;
1730 let end = start + 2 + close_rel + 2;
1731 if !source[end..node_start]
1732 .bytes()
1733 .all(|b| b.is_ascii_whitespace())
1734 {
1735 return None;
1736 }
1737 Some((start, source[start..end].trim_end()))
1738}
1739
1740fn is_simple_compound_selector(sel: &str) -> bool {
1741 let sel = sel.trim();
1742 if sel.is_empty()
1743 || sel.starts_with('&')
1744 || sel.starts_with('+')
1745 || sel.starts_with('>')
1746 || sel.starts_with('~')
1747 || contains_top_level_comma(sel)
1748 {
1749 return false;
1750 }
1751 let mut paren = 0usize;
1752 let mut brack = 0usize;
1753 for c in sel.chars() {
1754 match c {
1755 '(' => paren += 1,
1756 ')' => paren = paren.saturating_sub(1),
1757 '[' => brack += 1,
1758 ']' => brack = brack.saturating_sub(1),
1759 _ if paren == 0
1760 && brack == 0
1761 && (c.is_whitespace() || matches!(c, '+' | '>' | '~')) =>
1762 {
1763 return false;
1764 }
1765 _ => {}
1766 }
1767 }
1768 true
1769}
1770
1771fn first_compound_stripped(sel: &str) -> Option<&str> {
1772 let sel = sel.trim();
1773 if sel.is_empty() || sel.starts_with('&') {
1774 return None;
1775 }
1776 let mut paren = 0usize;
1777 let mut brack = 0usize;
1778 let mut end = sel.len();
1779 for (i, c) in sel.char_indices() {
1780 match c {
1781 '(' => paren += 1,
1782 ')' => paren = paren.saturating_sub(1),
1783 '[' => brack += 1,
1784 ']' => brack = brack.saturating_sub(1),
1785 _ if paren == 0
1786 && brack == 0
1787 && (c.is_whitespace() || matches!(c, '+' | '>' | '~' | ',')) =>
1788 {
1789 end = i;
1790 break;
1791 }
1792 _ => {}
1793 }
1794 }
1795 let head = sel[..end].trim();
1796 if head.is_empty() || head.starts_with(':') || head.starts_with('[') {
1797 return None;
1798 }
1799 paren = 0;
1800 brack = 0;
1801 for (i, c) in head.char_indices() {
1802 match c {
1803 '(' => paren += 1,
1804 ')' => paren = paren.saturating_sub(1),
1805 '[' if paren == 0 && i > 0 => return Some(&head[..i]),
1806 ':' if paren == 0 && brack == 0 && i > 0 => return Some(&head[..i]),
1807 _ => {}
1808 }
1809 }
1810 Some(head)
1811}
1812
1813fn style_body_weight(source: &str, node: &SourceNode) -> usize {
1814 node.body(source)
1815 .map(|b| b.lines().filter(|l| !l.trim().is_empty()).count())
1816 .unwrap_or(0)
1817}
1818
1819fn assign_gather_home<'a>(
1823 sel: &str,
1824 exact_homes: &[&'a str],
1825 home_weight: &HashMap<&'a str, usize>,
1826) -> Option<&'a str> {
1827 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1828 struct Rank {
1829 spec: Specificity,
1830 is_prefix: bool,
1831 weight: usize,
1832 len: usize,
1833 }
1834
1835 let mut best: Option<(Rank, &'a str)> = None;
1836 let consider =
1837 |best: &mut Option<(Rank, &'a str)>, home: &'a str, kind: Option<RelatedKind>| {
1838 let is_prefix = matches!(kind, None | Some(RelatedKind::Prefix));
1839 if matches!(kind, Some(RelatedKind::Appended)) && is_weak_gather_base(home) {
1840 return;
1841 }
1842 let rank = Rank {
1843 spec: calculate_specificity(home),
1844 is_prefix,
1845 weight: home_weight.get(home).copied().unwrap_or(0),
1846 len: home.len(),
1847 };
1848 if best.as_ref().is_none_or(|(cur, _)| rank > *cur) {
1849 *best = Some((rank, home));
1850 }
1851 };
1852
1853 for &home in exact_homes {
1854 if sel == home || home_weight.get(home).copied().unwrap_or(0) == 0 {
1855 continue;
1856 }
1857 if let Some((kind, _)) = classify_related_nested(home, sel) {
1858 consider(&mut best, home, Some(kind));
1859 }
1860 }
1861 if best.is_some() {
1862 return best.map(|(_, home)| home);
1863 }
1864 for &home in exact_homes {
1868 if sel == home || home_weight.get(home).copied().unwrap_or(0) != 0 {
1869 continue;
1870 }
1871 if let Some((kind, _)) = classify_related_nested(home, sel) {
1872 consider(&mut best, home, Some(kind));
1873 }
1874 }
1875 best.map(|(_, home)| home)
1876}
1877
1878const GATHERABLE_CONDITIONALS: &[&str] = &["media", "supports", "container", "starting-style"];
1879const GATHER_SCAN_CONTAINERS: &[&str] = &[];
1883
1884fn is_strong_gather_home(sel: &str) -> bool {
1885 let sel = sel.trim();
1886 sel.starts_with('.')
1887 || sel.starts_with('#')
1888 || sel.starts_with('[')
1889 || (sel.starts_with(':') && !sel.starts_with("::"))
1890}
1891
1892fn is_absorbable_descendant(base: &str, sel: &str) -> bool {
1893 let sel = sel.trim();
1894 if sel.is_empty() || sel == base {
1895 return false;
1896 }
1897 if contains_top_level_comma(sel) {
1898 return split_top_level_comma(sel)
1899 .into_iter()
1900 .map(str::trim)
1901 .filter(|s| !s.is_empty())
1902 .all(|part| is_absorbable_descendant(base, part));
1903 }
1904 if extract_related_nested_selector(base, sel).is_some() {
1905 return true;
1906 }
1907 let head = first_compound_stripped(sel).unwrap_or(sel);
1908 if is_weak_gather_base(head) {
1909 return false;
1910 }
1911 if is_strong_gather_home(head) && head != first_compound_stripped(base).unwrap_or(base) {
1912 return false;
1913 }
1914 is_simple_compound_selector(sel) || first_compound_stripped(sel).is_some()
1915}
1916
1917fn can_nest_under_gather_home(base: &str, sel: &str) -> bool {
1918 sel == base
1919 || extract_related_nested_selector(base, sel).is_some()
1920 || is_absorbable_descendant(base, sel)
1921}
1922
1923enum GatherMember {
1924 Style(SourceNode),
1925 Conditional {
1926 at_node: SourceNode,
1927 inner: SourceNode,
1928 delete_whole_at_block: bool,
1929 },
1930}
1931
1932impl GatherMember {
1933 fn outer_start(&self) -> usize {
1934 match self {
1935 Self::Style(n) => n.start,
1936 Self::Conditional { at_node, .. } => at_node.start,
1937 }
1938 }
1939
1940 fn outer_end(&self) -> usize {
1941 match self {
1942 Self::Style(n) => n.end,
1943 Self::Conditional { at_node, .. } => at_node.end,
1944 }
1945 }
1946}
1947
1948fn extend_with_trailing_newline(source: &str, end: usize) -> usize {
1949 if source[end..].starts_with("\r\n") {
1950 end + 2
1951 } else if source[end..].starts_with('\n') {
1952 end + 1
1953 } else {
1954 end
1955 }
1956}
1957
1958fn push_relative_body_as_nest(rel_sel: &str, body_str: &str, all_nested_rules: &mut Vec<String>) {
1959 let (decls, nested) = parse_rule_body_items(body_str);
1960 if nested.is_empty() {
1961 let mut rel_body = String::new();
1962 for d in &decls {
1963 rel_body.push_str(&format!("{}\n", ensure_semicolon(d)));
1964 }
1965 all_nested_rules.push(format!("{rel_sel} {{\n {rel_body}}}"));
1966 } else {
1967 let mut rel_body_lines = Vec::new();
1968 for d in &decls {
1969 rel_body_lines.push(format!(" {}", ensure_semicolon(d)));
1970 }
1971 for nr in &nested {
1972 rel_body_lines.push(nr.clone());
1973 }
1974 let rel_body = rel_body_lines.join("\n");
1975 all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
1976 }
1977}
1978
1979fn push_style_member_into_merge(
1980 first_sel: &str,
1981 cand_sel: &str,
1982 body_str: &str,
1983 all_decls: &mut Vec<String>,
1984 all_nested_rules: &mut Vec<String>,
1985) {
1986 if cand_sel == first_sel {
1987 let (decls, nested) = parse_rule_body_items(body_str);
1988 all_decls.extend(decls);
1989 all_nested_rules.extend(nested);
1990 } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
1991 push_relative_body_as_nest(&rel_sel, body_str, all_nested_rules);
1992 } else if is_absorbable_descendant(first_sel, cand_sel) {
1993 push_relative_body_as_nest(cand_sel, body_str, all_nested_rules);
1994 }
1995}
1996
1997fn format_conditional_group_into_lines(
1998 first_sel: &str,
1999 at_header: &str,
2000 inners: &[&SourceNode],
2001 source: &str,
2002 nested_indent: &str,
2003 unit: &str,
2004) -> Vec<String> {
2005 let level2 = format!("{nested_indent}{unit}");
2006 let mut direct_decls = Vec::new();
2007 let mut nested = Vec::new();
2008
2009 for inner in inners {
2010 let Some(body_range) = &inner.body_range else {
2011 continue;
2012 };
2013 let inner_sel = inner.prelude(source).trim();
2014 let inner_body = &source[body_range.clone()];
2015 if inner_sel == first_sel {
2016 let (decls, nrs) = parse_rule_body_items(inner_body);
2017 direct_decls.extend(decls);
2018 nested.extend(nrs);
2019 } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, inner_sel) {
2020 push_relative_body_as_nest(&rel_sel, inner_body, &mut nested);
2021 }
2022 }
2023
2024 let nested = factor_related_nested_rules(merge_same_prelude_nests(nested));
2025 let mut lines = Vec::new();
2026 lines.push(format!("{nested_indent}{at_header} {{"));
2027 for d in &direct_decls {
2028 lines.push(format!("{level2}{}", ensure_semicolon(d)));
2029 }
2030 for nr in &nested {
2031 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2032 let item = NestedRuleItem {
2033 comment: nested_rule_comment_prefix(nr),
2034 sel,
2035 body: nested_rule_inner(nr),
2036 };
2037 for line in render_item_indented(&item, &level2, unit).lines() {
2038 lines.push(line.to_string());
2039 }
2040 } else {
2041 for line in nr.lines() {
2042 let trimmed = line.trim();
2043 if !trimmed.is_empty() {
2044 lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2045 }
2046 }
2047 }
2048 }
2049 lines.push(format!("{nested_indent}}}"));
2050 lines
2051}
2052
2053fn format_conditional_into_lines(
2054 first_sel: &str,
2055 at_header: &str,
2056 inner_sel: &str,
2057 inner_body: &str,
2058 nested_indent: &str,
2059 unit: &str,
2060) -> Vec<String> {
2061 if inner_sel != first_sel {
2062 return Vec::new();
2063 }
2064 let (decls, nested) = parse_rule_body_items(inner_body);
2065 let level2 = format!("{nested_indent}{unit}");
2066 let mut lines = Vec::new();
2067 lines.push(format!("{nested_indent}{at_header} {{"));
2068 for d in &decls {
2069 lines.push(format!("{level2}{}", ensure_semicolon(d)));
2070 }
2071 for nr in &nested {
2072 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2073 let item = NestedRuleItem {
2074 comment: nested_rule_comment_prefix(nr),
2075 sel,
2076 body: nested_rule_inner(nr),
2077 };
2078 for line in render_item_indented(&item, &level2, unit).lines() {
2079 lines.push(line.to_string());
2080 }
2081 } else {
2082 for line in nr.lines() {
2083 let trimmed = line.trim();
2084 if trimmed.is_empty() {
2085 lines.push(String::new());
2086 } else {
2087 lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2088 }
2089 }
2090 }
2091 }
2092 lines.push(format!("{nested_indent}}}"));
2093 lines
2094}
2095
2096fn nested_rule_prelude(nr: &str) -> Option<&str> {
2097 let mut i = 0usize;
2098 while i < nr.len() {
2099 while i < nr.len() && nr.as_bytes()[i].is_ascii_whitespace() {
2100 i += 1;
2101 }
2102 if nr[i..].starts_with("/*") {
2103 i += nr[i..].find("*/")? + 2;
2104 continue;
2105 }
2106 if nr[i..].starts_with("//") {
2107 i += nr[i..].find('\n').map(|n| n + 1).unwrap_or(nr.len() - i);
2108 continue;
2109 }
2110 break;
2111 }
2112 let rest = nr.get(i..)?;
2113 let brace = rest.find('{')?;
2114 let head = rest[..brace].trim();
2115 if head.is_empty() { None } else { Some(head) }
2116}
2117
2118fn nested_rule_inner(nr: &str) -> String {
2119 let lines: Vec<&str> = nr.lines().collect();
2120 let open = lines.iter().position(|line| {
2121 let t = line.trim();
2122 t.ends_with('{') && !t.starts_with("/*") && !t.starts_with("//")
2123 });
2124 let Some(open) = open else {
2125 return String::new();
2126 };
2127 if lines.len() <= open + 2 {
2128 return String::new();
2129 }
2130 lines[open + 1..lines.len() - 1]
2131 .iter()
2132 .map(|l| l.trim_end())
2133 .collect::<Vec<_>>()
2134 .join("\n")
2135}
2136
2137fn nested_rule_comment_prefix(nr: &str) -> String {
2138 let mut out = String::new();
2139 for line in nr.lines() {
2140 let t = line.trim();
2141 if t.is_empty() || t.starts_with("/*") || t.starts_with("//") {
2142 if !out.is_empty() {
2143 out.push('\n');
2144 }
2145 out.push_str(t);
2146 } else {
2147 break;
2148 }
2149 }
2150 out
2151}
2152
2153fn merge_same_prelude_nests(rules: Vec<String>) -> Vec<String> {
2155 let mut order: Vec<String> = Vec::new();
2156 let mut merged: HashMap<String, String> = HashMap::new();
2157 let mut comments: HashMap<String, String> = HashMap::new();
2158 let mut leftovers = Vec::new();
2159
2160 for nr in rules {
2161 let Some(prelude) = nested_rule_prelude(&nr).map(str::to_string) else {
2162 leftovers.push(nr);
2163 continue;
2164 };
2165 let inner = nested_rule_inner(&nr);
2166 let prefix = nested_rule_comment_prefix(&nr);
2167 if let Some(existing) = merged.get_mut(&prelude) {
2168 if !existing.is_empty() && !inner.is_empty() {
2169 existing.push('\n');
2170 }
2171 existing.push_str(&inner);
2172 } else {
2173 order.push(prelude.clone());
2174 merged.insert(prelude.clone(), inner);
2175 if !prefix.is_empty() {
2176 comments.insert(prelude, prefix);
2177 }
2178 }
2179 }
2180
2181 let mut out: Vec<String> = order
2182 .into_iter()
2183 .map(|prelude| {
2184 let inner = merged.remove(&prelude).unwrap_or_default();
2185 let comment = comments.remove(&prelude).unwrap_or_default();
2186 let head = if comment.is_empty() {
2187 String::new()
2188 } else {
2189 format!("{comment}\n")
2190 };
2191 if inner.is_empty() {
2192 format!("{head}{prelude} {{}}")
2193 } else {
2194 format!("{head}{prelude} {{\n{inner}\n}}")
2195 }
2196 })
2197 .collect();
2198 out.extend(leftovers);
2199 out
2200}
2201
2202fn conditional_as_nested_rule(
2203 first_sel: &str,
2204 at_header: &str,
2205 inner_sel: &str,
2206 inner_body: &str,
2207) -> Option<String> {
2208 if inner_sel == first_sel {
2209 return None;
2210 }
2211 let rel_sel = extract_related_nested_selector(first_sel, inner_sel)?;
2212 let (decls, nested) = parse_rule_body_items(inner_body);
2213 let mut at_inner = String::new();
2214 for d in &decls {
2215 at_inner.push_str(" ");
2216 at_inner.push_str(&ensure_semicolon(d));
2217 at_inner.push('\n');
2218 }
2219 for nr in &nested {
2220 for line in nr.lines() {
2221 let trimmed = line.trim();
2222 if !trimmed.is_empty() {
2223 at_inner.push_str(" ");
2224 at_inner.push_str(&ensure_semicolon(trimmed));
2225 at_inner.push('\n');
2226 }
2227 }
2228 }
2229 Some(format!(
2230 "{rel_sel} {{\n {at_header} {{\n{at_inner} }}\n}}"
2231 ))
2232}
2233
2234#[derive(Debug, Clone)]
2235struct NestedRuleItem {
2236 comment: String,
2237 sel: String,
2238 body: String,
2239}
2240
2241fn render_nested_rule_item(item: &NestedRuleItem) -> String {
2242 let head = if item.comment.is_empty() {
2243 String::new()
2244 } else {
2245 format!("{}\n", item.comment)
2246 };
2247 if item.body.trim().is_empty() {
2248 format!("{head}{} {{}}", item.sel)
2249 } else {
2250 format!("{head}{} {{\n{}\n}}", item.sel, item.body)
2251 }
2252}
2253
2254fn append_child_rule(parent: &mut NestedRuleItem, rel_sel: &str, child_body: &str) {
2255 let child = NestedRuleItem {
2256 comment: String::new(),
2257 sel: rel_sel.to_string(),
2258 body: child_body.to_string(),
2259 };
2260 if !parent.body.is_empty() {
2261 parent.body.push('\n');
2262 }
2263 parent.body.push_str(&render_nested_rule_item(&child));
2264}
2265
2266fn split_relative_combinator(sel: &str) -> Option<(String, String)> {
2267 let mut paren = 0usize;
2268 let mut brack = 0usize;
2269 for (i, c) in sel.char_indices() {
2270 match c {
2271 '(' => paren += 1,
2272 ')' => paren = paren.saturating_sub(1),
2273 '[' => brack += 1,
2274 ']' => brack = brack.saturating_sub(1),
2275 _ if paren == 0 && brack == 0 => {
2276 if c.is_whitespace() {
2277 let right = sel[i..].trim_start();
2278 let left = sel[..i].trim();
2279 if left.is_empty() || right.is_empty() {
2280 return None;
2281 }
2282 return Some((left.to_string(), right.to_string()));
2283 }
2284 if matches!(c, '+' | '>' | '~') && i > 0 {
2285 let right = sel[i + c.len_utf8()..].trim_start();
2286 let left = sel[..i].trim();
2287 if left.is_empty() || right.is_empty() {
2288 return None;
2289 }
2290 return Some((left.to_string(), format!("{c} {right}")));
2291 }
2292 }
2293 _ => {}
2294 }
2295 }
2296 None
2297}
2298
2299fn is_at_rule_prelude(sel: &str) -> bool {
2300 sel.trim_start().starts_with('@')
2301}
2302
2303fn synthesizable_parent(sel: &str) -> Option<(String, String)> {
2304 let sel = sel.trim();
2305 if sel.is_empty() || sel == "&" || is_at_rule_prelude(sel) {
2306 return None;
2307 }
2308 if contains_top_level_comma(sel) {
2309 let parts: Vec<&str> = split_top_level_comma(sel)
2310 .into_iter()
2311 .map(str::trim)
2312 .filter(|s| !s.is_empty())
2313 .collect();
2314 if parts.len() < 2 {
2315 return None;
2316 }
2317 let mut parent: Option<String> = None;
2318 let mut children = Vec::new();
2319 for part in parts {
2320 let (p, c) = synthesizable_parent(part)?;
2321 match &parent {
2322 Some(existing) if existing != &p => return None,
2323 None => parent = Some(p),
2324 _ => {}
2325 }
2326 children.push(c);
2327 }
2328 return Some((parent?, children.join(", ")));
2329 }
2330 split_relative_combinator(sel)
2331}
2332
2333fn nest_items_under_existing(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2334 let n = items.len();
2335 if n < 2 {
2336 return items;
2337 }
2338
2339 let mut parent_of: Vec<Option<usize>> = vec![None; n];
2340 for i in 0..n {
2341 let mut best: Option<(usize, usize)> = None;
2342 for j in 0..n {
2343 if i == j {
2344 continue;
2345 }
2346 if matches!(
2350 classify_related_nested(&items[j].sel, &items[i].sel),
2351 Some((RelatedKind::Prefix, _))
2352 ) {
2353 let len = items[j].sel.len();
2354 if best.is_none_or(|(_, l)| len >= l) {
2355 best = Some((j, len));
2356 }
2357 }
2358 }
2359 if let Some((j, _)) = best {
2360 parent_of[i] = Some(j);
2361 }
2362 }
2363
2364 for i in 0..n {
2366 if let Some(j) = parent_of[i]
2367 && parent_of[j].is_some()
2368 {
2369 parent_of[i] = None;
2370 }
2371 }
2372
2373 let mut out = Vec::new();
2374 let mut out_idx = vec![None; n];
2375 for i in 0..n {
2376 if parent_of[i].is_none() {
2377 out_idx[i] = Some(out.len());
2378 out.push(items[i].clone());
2379 }
2380 }
2381 for i in 0..n {
2382 if let Some(j) = parent_of[i]
2383 && let Some(out_j) = out_idx[j]
2384 && let Some((RelatedKind::Prefix, rel)) =
2385 classify_related_nested(&out[out_j].sel, &items[i].sel)
2386 {
2387 append_child_rule(&mut out[out_j], &rel, &items[i].body);
2388 }
2389 }
2390 out
2391}
2392
2393fn wrap_shared_virtual_parents(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2394 #[derive(Clone)]
2395 enum Bucket {
2396 Atomic(NestedRuleItem),
2397 Shared {
2398 parent: String,
2399 originals: Vec<NestedRuleItem>,
2400 children: Vec<NestedRuleItem>,
2401 },
2402 }
2403
2404 let mut buckets: Vec<Bucket> = Vec::new();
2405 let mut shared_at: HashMap<String, usize> = HashMap::new();
2406
2407 for item in items {
2408 match synthesizable_parent(&item.sel) {
2409 Some((parent, child)) => {
2410 if let Some(&idx) = shared_at.get(&parent) {
2411 if let Bucket::Shared {
2412 originals,
2413 children,
2414 ..
2415 } = &mut buckets[idx]
2416 {
2417 originals.push(item.clone());
2418 children.push(NestedRuleItem {
2419 comment: item.comment.clone(),
2420 sel: child,
2421 body: item.body,
2422 });
2423 }
2424 } else {
2425 shared_at.insert(parent.clone(), buckets.len());
2426 buckets.push(Bucket::Shared {
2427 parent,
2428 originals: vec![item.clone()],
2429 children: vec![NestedRuleItem {
2430 comment: item.comment.clone(),
2431 sel: child,
2432 body: item.body,
2433 }],
2434 });
2435 }
2436 }
2437 None => buckets.push(Bucket::Atomic(item)),
2438 }
2439 }
2440
2441 let mut out = Vec::new();
2442 for bucket in buckets {
2443 match bucket {
2444 Bucket::Atomic(item) => out.push(item),
2445 Bucket::Shared {
2446 parent,
2447 originals,
2448 children,
2449 } => {
2450 if children.len() >= 2 {
2451 let body = children
2452 .iter()
2453 .map(render_nested_rule_item)
2454 .collect::<Vec<_>>()
2455 .join("\n");
2456 out.push(NestedRuleItem {
2457 comment: String::new(),
2458 sel: parent,
2459 body,
2460 });
2461 } else {
2462 out.extend(originals);
2463 }
2464 }
2465 }
2466 }
2467 out
2468}
2469
2470fn factor_related_nested_rules(rules: Vec<String>) -> Vec<String> {
2471 let mut items = Vec::new();
2472 let mut leftovers = Vec::new();
2473 for nr in rules {
2474 if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2475 items.push(NestedRuleItem {
2476 comment: nested_rule_comment_prefix(&nr),
2477 sel,
2478 body: nested_rule_inner(&nr),
2479 });
2480 } else {
2481 leftovers.push(nr);
2482 }
2483 }
2484
2485 for _ in 0..8 {
2486 let before = items.len();
2487 items = nest_items_under_existing(items);
2488 items = wrap_shared_virtual_parents(items);
2489 if items.len() == before {
2490 break;
2491 }
2492 }
2493
2494 let mut out: Vec<String> = items.iter().map(render_nested_rule_item).collect();
2495 out.extend(leftovers);
2496 out
2497}
2498
2499fn line_start(source: &str, pos: usize) -> usize {
2500 source[..pos.min(source.len())]
2501 .rfind('\n')
2502 .map(|i| i + 1)
2503 .unwrap_or(0)
2504}
2505
2506fn relative_indent_unit(source: &str, node: &SourceNode) -> String {
2507 let parent = line_indent(source, node.start);
2508 let Some(body) = node.body_range.clone() else {
2509 return " ".to_string();
2510 };
2511 match detect_indent_unit(source, body) {
2512 Some(raw) => raw
2513 .strip_prefix(parent.as_str())
2514 .filter(|rest| !rest.is_empty())
2515 .unwrap_or(" ")
2516 .to_string(),
2517 None => " ".to_string(),
2518 }
2519}
2520
2521fn squeeze_excess_blank_lines(s: &str) -> String {
2522 let ends_nl = s.ends_with('\n');
2523 let mut out = String::new();
2524 let mut blank_run = 0usize;
2525 for line in s.lines() {
2526 if line.trim().is_empty() {
2527 blank_run += 1;
2528 if blank_run > 1 {
2529 continue;
2530 }
2531 out.push('\n');
2532 } else {
2533 blank_run = 0;
2534 out.push_str(line);
2535 out.push('\n');
2536 }
2537 }
2538 if !ends_nl && out.ends_with('\n') {
2539 out.pop();
2540 }
2541 out
2542}
2543
2544fn render_item_indented(item: &NestedRuleItem, indent: &str, unit: &str) -> String {
2545 let mut out = String::new();
2546 if !item.comment.is_empty() {
2547 for line in item.comment.lines() {
2548 out.push_str(indent);
2549 out.push_str(line.trim());
2550 out.push('\n');
2551 }
2552 }
2553 out.push_str(indent);
2554 out.push_str(&item.sel);
2555 out.push_str(" {\n");
2556 let inner = format!("{indent}{unit}");
2557 let (decls, nested) = parse_rule_body_items(&item.body);
2558 for d in decls {
2559 out.push_str(&inner);
2560 out.push_str(&ensure_semicolon(&d));
2561 out.push('\n');
2562 }
2563 for nr in nested {
2564 if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2565 let child = NestedRuleItem {
2566 comment: nested_rule_comment_prefix(&nr),
2567 sel,
2568 body: nested_rule_inner(&nr),
2569 };
2570 out.push_str(&render_item_indented(&child, &inner, unit));
2571 out.push('\n');
2572 }
2573 }
2574 out.push_str(indent);
2575 out.push('}');
2576 out
2577}
2578
2579fn leftover_style_replacements(
2580 source: &str,
2581 nodes: &[SourceNode],
2582 span_start: usize,
2583 span_end: usize,
2584 cluster_starts: &HashSet<usize>,
2585) -> Vec<(usize, usize, String)> {
2586 let leftovers: Vec<&SourceNode> = nodes
2587 .iter()
2588 .filter(|n| {
2589 matches!(n.kind, NodeKind::Style)
2590 && n.start >= span_start
2591 && n.end <= span_end
2592 && !cluster_starts.contains(&n.start)
2593 })
2594 .collect();
2595
2596 let mut reps = Vec::new();
2597 let mut i = 0;
2598 while i < leftovers.len() {
2599 let parent = leftovers[i];
2600 let parent_sel = parent.prelude(source);
2601 if contains_top_level_comma(parent_sel) || parent_sel.contains("::") {
2602 i += 1;
2603 continue;
2604 }
2605 let mut children = Vec::new();
2606 let mut j = i + 1;
2607 let mut prev_end = parent.end;
2608 while j < leftovers.len() {
2609 let next = leftovers[j];
2610 if !is_whitespace_only(source, prev_end..next.start) {
2611 break;
2612 }
2613 if let Some((relation, nested_selector)) =
2614 selector_relation(parent_sel, next.prelude(source))
2615 {
2616 children.push(ClusterChild::Style {
2617 node: (*next).clone(),
2618 relation,
2619 nested_selector,
2620 });
2621 prev_end = next.end;
2622 j += 1;
2623 continue;
2624 }
2625 break;
2626 }
2627 if !children.is_empty() {
2628 let last_end = children.last().expect("non-empty").node().end;
2629 reps.push((
2630 parent.start,
2631 last_end,
2632 render_cluster(source, parent, &children),
2633 ));
2634 i = j;
2635 } else {
2636 i += 1;
2637 }
2638 }
2639 reps
2640}
2641
2642fn apply_range_replacements(
2643 source: &str,
2644 start: usize,
2645 end: usize,
2646 replacements: &[(usize, usize, String)],
2647) -> String {
2648 let mut reps: Vec<(usize, usize, &str)> = replacements
2649 .iter()
2650 .map(|(s, e, t)| (*s, *e, t.as_str()))
2651 .filter(|(s, e, _)| *s >= start && *e <= end && *s <= *e)
2652 .collect();
2653 reps.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
2654 let mut out = String::new();
2655 let mut cur = start;
2656 for (s, e, text) in reps {
2657 if s < cur {
2658 continue;
2659 }
2660 if s > cur {
2661 out.push_str(&source[cur..s]);
2662 }
2663 out.push_str(text);
2664 cur = e;
2665 }
2666 if cur < end {
2667 out.push_str(&source[cur..end]);
2668 }
2669 out
2670}
2671
2672fn is_gather_delete(plan: &PlanEntry) -> bool {
2673 plan.proposed.is_empty() && plan.rules.contains(&RuleId::GatherRelatedSelectorRules)
2674}
2675
2676fn gather_target_key(plan: &PlanEntry) -> Option<String> {
2677 if !plan.rules.contains(&RuleId::GatherRelatedSelectorRules) {
2678 return None;
2679 }
2680 let start = plan.reason.find("for '")? + 5;
2681 let rest = plan.reason.get(start..)?;
2682 let end = rest.find('\'')?;
2683 Some(rest[..end].to_string())
2684}
2685
2686fn ranges_overlap(a: &SourceRange, b: &SourceRange) -> bool {
2687 a.start < b.end && b.start < a.end
2688}
2689
2690fn select_disjoint_plan_indices(plans: &[PlanEntry]) -> Vec<usize> {
2691 let mut primary: Vec<usize> = (0..plans.len())
2692 .filter(|&i| !is_gather_delete(&plans[i]))
2693 .collect();
2694 primary.sort_by(|&i, &j| {
2695 plans[i]
2696 .source_range
2697 .start
2698 .cmp(&plans[j].source_range.start)
2699 .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2700 });
2701
2702 let mut kept = Vec::new();
2703 let mut last_end = 0usize;
2704 for i in primary {
2705 if plans[i].source_range.start >= last_end {
2706 last_end = plans[i].source_range.end;
2707 kept.push(i);
2708 }
2709 }
2710
2711 let mut kept_gather_keys = HashSet::new();
2712 for &i in &kept {
2713 if !plans[i].proposed.is_empty()
2714 && let Some(key) = gather_target_key(&plans[i])
2715 {
2716 kept_gather_keys.insert((plans[i].file.clone(), key));
2717 }
2718 }
2719
2720 let mut deletes: Vec<usize> = (0..plans.len())
2721 .filter(|&i| is_gather_delete(&plans[i]))
2722 .collect();
2723 deletes.sort_by(|&i, &j| {
2724 plans[i]
2725 .source_range
2726 .start
2727 .cmp(&plans[j].source_range.start)
2728 });
2729
2730 for i in deletes {
2731 let Some(key) = gather_target_key(&plans[i]) else {
2732 continue;
2733 };
2734 if !kept_gather_keys.contains(&(plans[i].file.clone(), key)) {
2735 continue;
2736 }
2737 if kept
2738 .iter()
2739 .any(|&k| ranges_overlap(&plans[k].source_range, &plans[i].source_range))
2740 {
2741 continue;
2742 }
2743 kept.push(i);
2744 }
2745
2746 kept.sort_by(|&i, &j| {
2747 plans[i]
2748 .source_range
2749 .start
2750 .cmp(&plans[j].source_range.start)
2751 .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2752 });
2753 kept
2754}
2755
2756fn format_merged_rule(
2757 first_sel: &str,
2758 parent_indent: &str,
2759 unit: &str,
2760 cluster: &[GatherMember],
2761 source: &str,
2762) -> String {
2763 let nested_indent = format!("{parent_indent}{unit}");
2764
2765 let mut all_decls = Vec::new();
2766 let mut all_nested_rules = Vec::new();
2767 let mut absorbed_nests = Vec::new();
2768 let mut conditional_lines = Vec::new();
2769
2770 for member in cluster {
2771 let GatherMember::Style(c) = member else {
2772 continue;
2773 };
2774 if let Some(body_range) = &c.body_range {
2775 let cand_sel = c.prelude(source).trim();
2776 let body_str = &source[body_range.clone()];
2777 let before_len = all_nested_rules.len();
2778 push_style_member_into_merge(
2779 first_sel,
2780 cand_sel,
2781 body_str,
2782 &mut all_decls,
2783 &mut all_nested_rules,
2784 );
2785 if all_nested_rules.len() > before_len
2786 && let Some((_, cmt)) = leading_block_comment(source, c.start)
2787 && let Some(last) = all_nested_rules.last_mut()
2788 {
2789 *last = format!("{cmt}\n{last}");
2790 }
2791 }
2792 }
2793
2794 let mut cond_order: Vec<usize> = Vec::new();
2795 let mut cond_groups: HashMap<usize, (&SourceNode, Vec<&SourceNode>)> = HashMap::new();
2796 for member in cluster {
2797 let GatherMember::Conditional { at_node, inner, .. } = member else {
2798 continue;
2799 };
2800 cond_groups
2801 .entry(at_node.start)
2802 .and_modify(|(_, inners)| inners.push(inner))
2803 .or_insert_with(|| {
2804 cond_order.push(at_node.start);
2805 (at_node, vec![inner])
2806 });
2807 }
2808
2809 for key in cond_order {
2810 let Some((at_node, inners)) = cond_groups.remove(&key) else {
2811 continue;
2812 };
2813 let at_header = at_node.prelude(source).trim();
2814 let mut related = Vec::new();
2815 let mut absorbed = Vec::new();
2816 for inner in inners {
2817 let sel = inner.prelude(source).trim();
2818 if sel == first_sel || extract_related_nested_selector(first_sel, sel).is_some() {
2819 related.push(inner);
2820 } else {
2821 absorbed.push(inner);
2822 }
2823 }
2824
2825 if !related.is_empty() {
2826 let invert_group = related.len() >= 2
2827 || related
2828 .iter()
2829 .any(|inner| inner.prelude(source).trim() == first_sel);
2830 if invert_group {
2831 let extra = format_conditional_group_into_lines(
2832 first_sel,
2833 at_header,
2834 &related,
2835 source,
2836 &nested_indent,
2837 unit,
2838 );
2839 if !conditional_lines.is_empty() && !extra.is_empty() {
2840 conditional_lines.push(String::new());
2841 }
2842 conditional_lines.extend(extra);
2843 } else {
2844 for inner in &related {
2845 if let Some(body_range) = &inner.body_range {
2846 let inner_sel = inner.prelude(source).trim();
2847 let inner_body = &source[body_range.clone()];
2848 if let Some(nr) =
2849 conditional_as_nested_rule(first_sel, at_header, inner_sel, inner_body)
2850 {
2851 all_nested_rules.push(nr);
2852 } else {
2853 let extra = format_conditional_into_lines(
2854 first_sel,
2855 at_header,
2856 inner_sel,
2857 inner_body,
2858 &nested_indent,
2859 unit,
2860 );
2861 if !conditional_lines.is_empty() && !extra.is_empty() {
2862 conditional_lines.push(String::new());
2863 }
2864 conditional_lines.extend(extra);
2865 }
2866 }
2867 }
2868 }
2869 }
2870
2871 for inner in absorbed {
2872 if let Some(body_range) = &inner.body_range {
2873 push_style_member_into_merge(
2874 first_sel,
2875 inner.prelude(source).trim(),
2876 &source[body_range.clone()],
2877 &mut all_decls,
2878 &mut absorbed_nests,
2879 );
2880 }
2881 }
2882 }
2883
2884 let all_nested_rules = factor_related_nested_rules(merge_same_prelude_nests(all_nested_rules));
2885 let absorbed_nests = factor_related_nested_rules(merge_same_prelude_nests(absorbed_nests));
2886
2887 let mut body_lines = Vec::new();
2888
2889 for d in &all_decls {
2890 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(d)));
2891 }
2892
2893 if !all_decls.is_empty() && !all_nested_rules.is_empty() {
2894 body_lines.push(String::new());
2895 }
2896
2897 for (idx, nr) in all_nested_rules.iter().enumerate() {
2898 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2899 let item = NestedRuleItem {
2900 comment: nested_rule_comment_prefix(nr),
2901 sel,
2902 body: nested_rule_inner(nr),
2903 };
2904 body_lines.push(render_item_indented(&item, &nested_indent, unit));
2905 } else {
2906 for line in nr.lines() {
2907 let trimmed = line.trim();
2908 if trimmed.is_empty() {
2909 body_lines.push(String::new());
2910 } else {
2911 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
2912 }
2913 }
2914 }
2915
2916 if idx < all_nested_rules.len() - 1 {
2917 body_lines.push(String::new());
2918 }
2919 }
2920
2921 if !conditional_lines.is_empty() {
2922 if !body_lines.is_empty() {
2923 body_lines.push(String::new());
2924 }
2925 body_lines.extend(conditional_lines);
2926 }
2927
2928 if !absorbed_nests.is_empty() {
2929 if !body_lines.is_empty() {
2930 body_lines.push(String::new());
2931 }
2932 for (idx, nr) in absorbed_nests.iter().enumerate() {
2933 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2934 let item = NestedRuleItem {
2935 comment: nested_rule_comment_prefix(nr),
2936 sel,
2937 body: nested_rule_inner(nr),
2938 };
2939 body_lines.push(render_item_indented(&item, &nested_indent, unit));
2940 } else {
2941 for line in nr.lines() {
2942 let trimmed = line.trim();
2943 if trimmed.is_empty() {
2944 body_lines.push(String::new());
2945 } else {
2946 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
2947 }
2948 }
2949 }
2950 if idx < absorbed_nests.len() - 1 {
2951 body_lines.push(String::new());
2952 }
2953 }
2954 }
2955
2956 let body_content = body_lines.join("\n");
2957 format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
2958}
2959
2960fn plan_merge_adjacent_identical_selectors(
2961 path: &Path,
2962 source: &str,
2963 nodes: &[SourceNode],
2964 enabled: &HashSet<RuleId>,
2965 plans: &mut Vec<PlanEntry>,
2966) {
2967 if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
2968 return;
2969 }
2970 let mut i = 0;
2971 while i < nodes.len() {
2972 let first = &nodes[i];
2973 if matches!(&first.kind, NodeKind::Style) {
2974 let first_sel = first.prelude(source).trim();
2975 let mut cluster = vec![first];
2976 let mut cursor = i + 1;
2977 let mut prev_end = first.end;
2978
2979 while cursor < nodes.len() {
2980 let next = &nodes[cursor];
2981 if !is_whitespace_only(source, prev_end..next.start) {
2982 break;
2983 }
2984 if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
2985 {
2986 cluster.push(next);
2987 prev_end = next.end;
2988 cursor += 1;
2989 continue;
2990 }
2991 break;
2992 }
2993
2994 if cluster.len() > 1 {
2995 let last = cluster.last().unwrap();
2996 let parent_indent = line_indent(source, first.start);
2997 let first_body_range = first.body_range.as_ref().unwrap();
2998 let unit = detect_indent_unit(source, first_body_range.clone())
2999 .unwrap_or_else(|| " ".to_string());
3000
3001 let members: Vec<GatherMember> = cluster
3002 .iter()
3003 .map(|n| GatherMember::Style((*n).clone()))
3004 .collect();
3005 let proposed =
3006 format_merged_rule(first_sel, &parent_indent, &unit, &members, source);
3007
3008 plans.push(PlanEntry {
3009 id: String::new(),
3010 file: path.to_path_buf(),
3011 rules: vec![RuleId::MergeAdjacentIdenticalSelector],
3012 safety: Safety::Safe,
3013 source_range: SourceRange {
3014 start: first.start,
3015 end: last.end,
3016 },
3017 original: source[first.start..last.end].to_string(),
3018 proposed,
3019 proof: Proof::safe_local(),
3020 warnings: Vec::new(),
3021 reason: format!(
3022 "Merge {} adjacent identical selector rules for '{}' into a single block.",
3023 cluster.len(),
3024 first_sel
3025 ),
3026 selected: true,
3027 });
3028 i = cursor;
3029 continue;
3030 }
3031 }
3032 i += 1;
3033 }
3034}
3035
3036fn note_gather_home<'s>(
3037 source: &'s str,
3038 node: &SourceNode,
3039 exact_homes: &mut Vec<&'s str>,
3040 home_weight: &mut HashMap<&'s str, usize>,
3041) {
3042 let sel = node.prelude(source).trim();
3043 let core = first_compound_stripped(sel).unwrap_or(sel);
3044 if is_simple_compound_selector(sel) && core == sel && !sel.contains("::") {
3045 if !exact_homes.contains(&sel) {
3046 exact_homes.push(sel);
3047 }
3048 *home_weight.entry(sel).or_insert(0) += style_body_weight(source, node);
3049 }
3050 if let Some(stripped) = first_compound_stripped(sel)
3051 && !exact_homes.contains(&stripped)
3052 && is_simple_compound_selector(stripped)
3053 && !stripped.contains("::")
3054 {
3055 exact_homes.push(stripped);
3056 home_weight.entry(stripped).or_insert(0);
3057 }
3058}
3059
3060fn collect_gather_homes<'s>(
3061 source: &'s str,
3062 nodes: &[SourceNode],
3063 exact_homes: &mut Vec<&'s str>,
3064 home_weight: &mut HashMap<&'s str, usize>,
3065) {
3066 for node in nodes {
3067 if matches!(&node.kind, NodeKind::Style) {
3068 note_gather_home(source, node, exact_homes, home_weight);
3069 } else if let NodeKind::AtBlock { name, .. } = &node.kind
3070 && GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3071 && let Some(body_range) = &node.body_range
3072 {
3073 let inner = scan_nodes(source, body_range.clone());
3074 collect_gather_homes(source, &inner, exact_homes, home_weight);
3075 }
3076 }
3077}
3078
3079fn should_group_at_block(
3080 name: &str,
3081 source: &str,
3082 style_inners: &[&SourceNode],
3083 exact_homes: &[&str],
3084 home_weight: &HashMap<&str, usize>,
3085) -> bool {
3086 if style_inners.len() < 3 {
3087 return false;
3088 }
3089 let mut assigned: HashMap<&str, usize> = HashMap::new();
3090 let mut unsafe_extract = false;
3091 for inner in style_inners {
3092 let sel = inner.prelude(source).trim();
3093 match assign_gather_home(sel, exact_homes, home_weight) {
3094 Some(home) => {
3095 *assigned.entry(home).or_insert(0) += 1;
3096 if home != sel
3097 && extract_related_nested_selector(home, sel).is_none()
3098 && !is_absorbable_descendant(home, sel)
3099 {
3100 unsafe_extract = true;
3101 }
3102 }
3103 None => {
3104 if contains_top_level_comma(sel)
3105 && exact_homes
3106 .iter()
3107 .any(|home| extract_related_nested_selector(home, sel).is_some())
3108 {
3109 } else {
3111 *assigned.entry("").or_insert(0) += 1;
3112 unsafe_extract = true;
3113 }
3114 }
3115 }
3116 }
3117 let strong: Vec<(&str, usize)> = assigned
3118 .iter()
3119 .filter(|(home, _)| !home.is_empty() && is_strong_gather_home(home))
3120 .map(|(h, c)| (*h, *c))
3121 .collect();
3122 let total = style_inners.len();
3123 let dominant = strong.iter().any(|(_, c)| *c * 3 >= total * 2);
3124
3125 if name == "media" || name == "container" {
3126 return unsafe_extract || strong.len() >= 2;
3127 }
3128 if strong.len() >= 2 && !dominant {
3130 return true;
3131 }
3132 unsafe_extract && strong.is_empty()
3133}
3134
3135fn mark_grouped_at_blocks(
3136 source: &str,
3137 nodes: &[SourceNode],
3138 exact_homes: &[&str],
3139 home_weight: &HashMap<&str, usize>,
3140 grouped: &mut HashSet<usize>,
3141) {
3142 for node in nodes {
3143 if let NodeKind::AtBlock { name, .. } = &node.kind
3144 && let Some(body_range) = &node.body_range
3145 {
3146 let inner_nodes = scan_nodes(source, body_range.clone());
3147 if GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3148 let style_inners: Vec<&SourceNode> = inner_nodes
3149 .iter()
3150 .filter(|n| matches!(&n.kind, NodeKind::Style))
3151 .collect();
3152 if should_group_at_block(name, source, &style_inners, exact_homes, home_weight) {
3153 grouped.insert(node.start);
3154 }
3155 }
3156 if GATHERABLE_CONDITIONALS.contains(&name.as_str())
3157 || GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3158 {
3159 mark_grouped_at_blocks(source, &inner_nodes, exact_homes, home_weight, grouped);
3160 }
3161 }
3162 }
3163}
3164
3165fn style_belongs_to_home(
3166 source: &str,
3167 node: &SourceNode,
3168 base: &str,
3169 exact_homes: &[&str],
3170 home_weight: &HashMap<&str, usize>,
3171) -> bool {
3172 let sel = node.prelude(source).trim();
3173 sel == base || assign_gather_home(sel, exact_homes, home_weight) == Some(base)
3174}
3175
3176fn collect_gather_cluster(
3177 source: &str,
3178 nodes: &[SourceNode],
3179 base: &str,
3180 exact_homes: &[&str],
3181 home_weight: &HashMap<&str, usize>,
3182 grouped_at_blocks: &HashSet<usize>,
3183 cluster: &mut Vec<GatherMember>,
3184) {
3185 for node in nodes {
3186 if matches!(&node.kind, NodeKind::Style) {
3187 if style_belongs_to_home(source, node, base, exact_homes, home_weight) {
3188 cluster.push(GatherMember::Style(node.clone()));
3189 }
3190 } else if let NodeKind::AtBlock { name, .. } = &node.kind {
3191 let Some(body_range) = &node.body_range else {
3192 continue;
3193 };
3194 let inner_nodes = scan_nodes(source, body_range.clone());
3195 if GATHER_SCAN_CONTAINERS.contains(&name.as_str()) {
3196 collect_gather_cluster(
3197 source,
3198 &inner_nodes,
3199 base,
3200 exact_homes,
3201 home_weight,
3202 grouped_at_blocks,
3203 cluster,
3204 );
3205 continue;
3206 }
3207 if grouped_at_blocks.contains(&node.start) {
3208 continue;
3209 }
3210 if !GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3211 continue;
3212 }
3213 let style_inners: Vec<&SourceNode> = inner_nodes
3214 .iter()
3215 .filter(|n| matches!(n.kind, NodeKind::Style))
3216 .collect();
3217 let owned = style_inners
3218 .iter()
3219 .filter(|inner| {
3220 style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3221 })
3222 .count();
3223 let dominate = !style_inners.is_empty() && owned * 3 >= style_inners.len() * 2;
3224 let related: Vec<SourceNode> = inner_nodes
3225 .iter()
3226 .filter(|inner| {
3227 matches!(&inner.kind, NodeKind::Style) && {
3228 let sel = inner.prelude(source).trim();
3229 style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3230 || (dominate && is_absorbable_descendant(base, sel))
3231 }
3232 })
3233 .cloned()
3234 .collect();
3235 if !related.is_empty() {
3236 let delete_whole_at_block = related.len() == inner_nodes.len()
3237 || (dominate
3238 && style_inners.iter().all(|inner| {
3239 let sel = inner.prelude(source).trim();
3240 style_belongs_to_home(source, inner, base, exact_homes, home_weight)
3241 || is_absorbable_descendant(base, sel)
3242 }));
3243 for inner in related {
3244 cluster.push(GatherMember::Conditional {
3245 at_node: node.clone(),
3246 inner,
3247 delete_whole_at_block,
3248 });
3249 }
3250 }
3251 }
3252 }
3253}
3254
3255fn enclosing_container_end(
3256 source: &str,
3257 nodes: &[SourceNode],
3258 first: &SourceNode,
3259) -> Option<usize> {
3260 fn find(source: &str, nodes: &[SourceNode], first: &SourceNode) -> Option<usize> {
3261 for node in nodes {
3262 if let NodeKind::AtBlock { name, .. } = &node.kind
3263 && let Some(body_range) = &node.body_range
3264 && (GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3265 || GATHERABLE_CONDITIONALS.contains(&name.as_str()))
3266 && first.start >= body_range.start
3267 && first.end <= body_range.end
3268 {
3269 let inner = scan_nodes(source, body_range.clone());
3270 if let Some(deeper) = find(source, &inner, first) {
3271 return Some(deeper);
3272 }
3273 return Some(body_range.end);
3274 }
3275 }
3276 None
3277 }
3278 find(source, nodes, first)
3279}
3280
3281fn named_layer_ident(prelude: &str) -> Option<String> {
3282 let rest = prelude
3283 .trim()
3284 .strip_prefix("@layer")
3285 .unwrap_or(prelude)
3286 .trim();
3287 if rest.is_empty() || contains_top_level_comma(rest) {
3288 return None;
3289 }
3290 let ident: String = rest
3291 .chars()
3292 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3293 .collect();
3294 if ident.is_empty() { None } else { Some(ident) }
3295}
3296
3297#[derive(Clone)]
3298struct LayeredExactHit {
3299 selector: String,
3300 layer_path: Vec<String>,
3301 style: SourceNode,
3302 layer: SourceNode,
3303}
3304
3305fn collect_layered_exact_hits(
3306 source: &str,
3307 nodes: &[SourceNode],
3308 path: &[String],
3309 current_layer: Option<&SourceNode>,
3310 out: &mut Vec<LayeredExactHit>,
3311) {
3312 for node in nodes {
3313 if matches!(node.kind, NodeKind::Style) {
3314 if let Some(layer) = current_layer
3315 && !path.is_empty()
3316 {
3317 let sel = node.prelude(source).trim();
3318 if !sel.is_empty() && !contains_top_level_comma(sel) {
3319 out.push(LayeredExactHit {
3320 selector: sel.to_string(),
3321 layer_path: path.to_vec(),
3322 style: node.clone(),
3323 layer: layer.clone(),
3324 });
3325 }
3326 }
3327 continue;
3328 }
3329 if let NodeKind::AtBlock { name, .. } = &node.kind
3330 && name == "layer"
3331 && let Some(body_range) = &node.body_range
3332 && let Some(ident) = named_layer_ident(node.prelude(source))
3333 {
3334 let mut child = path.to_vec();
3335 child.push(ident);
3336 let inner = scan_nodes(source, body_range.clone());
3337 collect_layered_exact_hits(source, &inner, &child, Some(node), out);
3338 }
3339 }
3340}
3341
3342fn unwrap_style_block_body(block: &str) -> String {
3343 let open = match block.find('{') {
3344 Some(i) => i + 1,
3345 None => return block.to_string(),
3346 };
3347 let close = match block.rfind('}') {
3348 Some(i) => i,
3349 None => return block[open..].to_string(),
3350 };
3351 if close <= open {
3352 return String::new();
3353 }
3354 block[open..close].trim().to_string()
3355}
3356
3357fn reindent_owned(text: &str, indent: &str) -> Vec<String> {
3358 let lines: Vec<&str> = text.lines().collect();
3359 let min_pad = lines
3360 .iter()
3361 .filter_map(|l| {
3362 if l.trim().is_empty() {
3363 None
3364 } else {
3365 Some(l.len() - l.trim_start().len())
3366 }
3367 })
3368 .min()
3369 .unwrap_or(0);
3370 lines
3371 .into_iter()
3372 .map(|l| {
3373 if l.trim().is_empty() {
3374 String::new()
3375 } else {
3376 let rest = if l.len() >= min_pad {
3377 &l[min_pad..]
3378 } else {
3379 l.trim_start()
3380 };
3381 format!("{indent}{rest}")
3382 }
3383 })
3384 .collect()
3385}
3386
3387fn layer_body_cluster_for_selector(
3388 source: &str,
3389 layer: &SourceNode,
3390 selector: &str,
3391 exact_homes: &[&str],
3392 home_weight: &HashMap<&str, usize>,
3393) -> Vec<GatherMember> {
3394 let Some(body_range) = &layer.body_range else {
3395 return Vec::new();
3396 };
3397 let inner = scan_nodes(source, body_range.clone());
3398 let mut cluster = Vec::new();
3399 for node in &inner {
3400 if matches!(node.kind, NodeKind::Style) && node.prelude(source).trim() == selector {
3401 cluster.push(GatherMember::Style(node.clone()));
3402 } else if let NodeKind::AtBlock { name, .. } = &node.kind
3403 && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3404 && let Some(at_body) = &node.body_range
3405 {
3406 let at_inners = scan_nodes(source, at_body.clone());
3407 let related: Vec<SourceNode> = at_inners
3408 .iter()
3409 .filter(|n| {
3410 matches!(n.kind, NodeKind::Style)
3411 && (n.prelude(source).trim() == selector
3412 || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3413 })
3414 .cloned()
3415 .collect();
3416 if related.is_empty() {
3417 continue;
3418 }
3419 let delete_whole = related.len() == at_inners.len();
3420 for inner_style in related {
3421 cluster.push(GatherMember::Conditional {
3422 at_node: node.clone(),
3423 inner: inner_style,
3424 delete_whole_at_block: delete_whole,
3425 });
3426 }
3427 }
3428 }
3429 cluster
3430}
3431
3432fn layer_contains_only_selector_cluster(
3433 source: &str,
3434 layer: &SourceNode,
3435 selector: &str,
3436 exact_homes: &[&str],
3437 home_weight: &HashMap<&str, usize>,
3438) -> bool {
3439 let Some(body_range) = &layer.body_range else {
3440 return false;
3441 };
3442 let inner = scan_nodes(source, body_range.clone());
3443 if inner.is_empty() {
3444 return false;
3445 }
3446 inner.iter().all(|node| {
3447 if matches!(node.kind, NodeKind::Style) {
3448 return node.prelude(source).trim() == selector
3449 || style_belongs_to_home(source, node, selector, exact_homes, home_weight);
3450 }
3451 if let NodeKind::AtBlock { name, .. } = &node.kind
3452 && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3453 && let Some(at_body) = &node.body_range
3454 {
3455 let at_inners = scan_nodes(source, at_body.clone());
3456 return !at_inners.is_empty()
3457 && at_inners.iter().all(|n| {
3458 matches!(n.kind, NodeKind::Style)
3459 && (n.prelude(source).trim() == selector
3460 || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3461 });
3462 }
3463 false
3464 })
3465}
3466
3467fn intervening_unlayered(
3468 nodes: &[SourceNode],
3469 first_layer_start: usize,
3470 last_layer_end: usize,
3471) -> bool {
3472 nodes.iter().any(|n| {
3473 n.start > first_layer_start
3474 && n.end < last_layer_end
3475 && match &n.kind {
3476 NodeKind::Style => true,
3477 NodeKind::AtBlock { name, .. } => name != "layer",
3478 NodeKind::AtStatement { name, .. } => name != "layer",
3479 }
3480 })
3481}
3482
3483fn plan_nest_layer_by_selector(
3484 path: &Path,
3485 source: &str,
3486 nodes: &[SourceNode],
3487 enabled: &HashSet<RuleId>,
3488 plans: &mut Vec<PlanEntry>,
3489) {
3490 if !enabled.contains(&RuleId::NestLayerBySelector) {
3491 return;
3492 }
3493
3494 let mut hits = Vec::new();
3495 collect_layered_exact_hits(source, nodes, &[], None, &mut hits);
3496 if hits.is_empty() {
3497 return;
3498 }
3499
3500 let mut exact_homes: Vec<&str> = Vec::new();
3501 let mut home_weight: HashMap<&str, usize> = HashMap::new();
3502 collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3503
3504 let mut by_sel: HashMap<String, Vec<LayeredExactHit>> = HashMap::new();
3505 for hit in hits {
3506 by_sel.entry(hit.selector.clone()).or_default().push(hit);
3507 }
3508
3509 for (selector, mut group) in by_sel {
3510 group.sort_by_key(|h| h.style.start);
3511 let mut seen_paths: Vec<Vec<String>> = Vec::new();
3512 for hit in &group {
3513 if !seen_paths.iter().any(|p| p == &hit.layer_path) {
3514 seen_paths.push(hit.layer_path.clone());
3515 }
3516 }
3517 if seen_paths.len() < 2 {
3518 continue;
3519 }
3520
3521 let first_layer = &group[0].layer;
3522 let last_layer = &group.last().unwrap().layer;
3523 if intervening_unlayered(nodes, first_layer.start, last_layer.end) {
3524 continue;
3525 }
3526
3527 let parent_indent = String::new();
3528 let unit = " ".to_string();
3529 let mut layer_blocks = Vec::new();
3530 let mut deletes: Vec<(usize, usize, usize)> = Vec::new();
3531 let mut deleted_layers = HashSet::new();
3532
3533 for path in &seen_paths {
3534 let Some(hit) = group.iter().find(|h| &h.layer_path == path) else {
3535 continue;
3536 };
3537 let cluster = layer_body_cluster_for_selector(
3538 source,
3539 &hit.layer,
3540 &selector,
3541 &exact_homes,
3542 &home_weight,
3543 );
3544 if cluster.is_empty() {
3545 continue;
3546 }
3547 let merged = format_merged_rule(&selector, &parent_indent, &unit, &cluster, source);
3548 let body = unwrap_style_block_body(&merged);
3549 let header = format!("@layer {}", path.join("."));
3550 layer_blocks.push((header, body));
3551
3552 if layer_contains_only_selector_cluster(
3553 source,
3554 &hit.layer,
3555 &selector,
3556 &exact_homes,
3557 &home_weight,
3558 ) {
3559 if deleted_layers.insert(hit.layer.start) {
3560 deletes.push((
3561 hit.layer.start,
3562 extend_with_trailing_newline(source, hit.layer.end),
3563 hit.layer.start,
3564 ));
3565 }
3566 } else {
3567 for member in &cluster {
3568 match member {
3569 GatherMember::Style(n) => {
3570 let start = leading_block_comment(source, n.start)
3571 .map(|(s, _)| s)
3572 .unwrap_or(n.start);
3573 deletes.push((
3574 start,
3575 extend_with_trailing_newline(source, n.end),
3576 n.start,
3577 ));
3578 }
3579 GatherMember::Conditional {
3580 at_node,
3581 inner,
3582 delete_whole_at_block,
3583 } => {
3584 if *delete_whole_at_block {
3585 deletes.push((
3586 at_node.start,
3587 extend_with_trailing_newline(source, at_node.end),
3588 at_node.start,
3589 ));
3590 } else {
3591 deletes.push((
3592 inner.start,
3593 extend_with_trailing_newline(source, inner.end),
3594 inner.start,
3595 ));
3596 }
3597 }
3598 }
3599 }
3600 }
3601 }
3602
3603 if layer_blocks.len() < 2 {
3604 continue;
3605 }
3606
3607 let first_layer_only_ours = layer_contains_only_selector_cluster(
3608 source,
3609 first_layer,
3610 &selector,
3611 &exact_homes,
3612 &home_weight,
3613 );
3614 let insert_at = first_layer.start;
3615 let replace_end = if first_layer_only_ours {
3616 extend_with_trailing_newline(source, first_layer.end)
3617 } else {
3618 insert_at
3619 };
3620 deletes.retain(|(start, end, _)| !(*start == insert_at && *end == replace_end));
3621 let nested_indent = unit.clone();
3622 let inner_indent = format!("{unit}{unit}");
3623 let mut hoisted = format!("{selector} {{\n");
3624 for (i, (header, body)) in layer_blocks.iter().enumerate() {
3625 if i > 0 {
3626 hoisted.push('\n');
3627 }
3628 hoisted.push_str(&nested_indent);
3629 hoisted.push_str(header);
3630 hoisted.push_str(" {\n");
3631 if !body.trim().is_empty() {
3632 for line in reindent_owned(body, &inner_indent) {
3633 hoisted.push_str(&line);
3634 hoisted.push('\n');
3635 }
3636 }
3637 hoisted.push_str(&nested_indent);
3638 hoisted.push_str("}\n");
3639 }
3640 hoisted.push('}');
3641 if first_layer_only_ours {
3642 hoisted.push('\n');
3643 } else {
3644 hoisted.push('\n');
3645 hoisted.push('\n');
3646 }
3647
3648 plans.push(PlanEntry {
3649 id: String::new(),
3650 file: path.to_path_buf(),
3651 rules: vec![RuleId::NestLayerBySelector],
3652 safety: Safety::Review,
3653 source_range: SourceRange {
3654 start: insert_at,
3655 end: replace_end,
3656 },
3657 original: if first_layer_only_ours {
3658 source[insert_at..replace_end].to_string()
3659 } else {
3660 String::new()
3661 },
3662 proposed: hoisted,
3663 proof: Proof {
3664 selector_set_equivalent: true,
3665 specificity_equivalent: true,
3666 cascade_context_equivalent: true,
3667 source_order_equivalent: false,
3668 layer_equivalent: true,
3669 scope_equivalent: true,
3670 declarations_exact: true,
3671 important_exact: true,
3672 },
3673 warnings: vec![format!(
3674 "Hoisted '{}' out of {} named layers so nested @layer blocks do not create child layers.",
3675 selector,
3676 layer_blocks.len()
3677 )],
3678 reason: format!(
3679 "Nest {} named layers under shared selector '{}'.",
3680 layer_blocks.len(),
3681 selector
3682 ),
3683 selected: true,
3684 });
3685
3686 for (start, end, line_at) in deletes {
3687 plans.push(PlanEntry {
3688 id: String::new(),
3689 file: path.to_path_buf(),
3690 rules: vec![RuleId::NestLayerBySelector],
3691 safety: Safety::Review,
3692 source_range: SourceRange { start, end },
3693 original: source[start..end].to_string(),
3694 proposed: String::new(),
3695 proof: Proof::safe_local(),
3696 warnings: Vec::new(),
3697 reason: format!(
3698 "Remove layer-local '{}' after nesting layers at line {}.",
3699 selector,
3700 line_number(source, line_at)
3701 ),
3702 selected: true,
3703 });
3704 }
3705 }
3706}
3707
3708fn plan_gather_related_selector_rules(
3709 path: &Path,
3710 source: &str,
3711 nodes: &[SourceNode],
3712 enabled: &HashSet<RuleId>,
3713 plans: &mut Vec<PlanEntry>,
3714) {
3715 if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
3716 return;
3717 }
3718
3719 let mut exact_homes: Vec<&str> = Vec::new();
3720 let mut home_weight: HashMap<&str, usize> = HashMap::new();
3721 collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3722
3723 let mut grouped_at_blocks: HashSet<usize> = HashSet::new();
3724 mark_grouped_at_blocks(
3725 source,
3726 nodes,
3727 &exact_homes,
3728 &home_weight,
3729 &mut grouped_at_blocks,
3730 );
3731
3732 for base in exact_homes.clone() {
3733 let mut cluster: Vec<GatherMember> = Vec::new();
3734 collect_gather_cluster(
3735 source,
3736 nodes,
3737 base,
3738 &exact_homes,
3739 &home_weight,
3740 &grouped_at_blocks,
3741 &mut cluster,
3742 );
3743
3744 if cluster.len() > 1 {
3745 let mut is_non_adjacent = false;
3746 for window in cluster.windows(2) {
3747 let prev_end = window[0].outer_end();
3748 let next_start = window[1].outer_start();
3749 if next_start < prev_end {
3750 continue;
3751 }
3752 if !is_whitespace_only(source, prev_end..next_start) {
3753 is_non_adjacent = true;
3754 break;
3755 }
3756 }
3757
3758 let first_style = cluster.iter().find_map(|m| match m {
3759 GatherMember::Style(n) => Some(n),
3760 GatherMember::Conditional { .. } => None,
3761 });
3762
3763 let Some(first) = first_style else {
3764 continue;
3765 };
3766 let first_sel = first.prelude(source).trim();
3767 let has_non_style = cluster
3768 .iter()
3769 .any(|m| matches!(m, GatherMember::Conditional { .. }));
3770 let should_gather = is_non_adjacent || first_sel != base || has_non_style;
3771 if !should_gather {
3772 continue;
3773 }
3774
3775 let cluster_safe = cluster.iter().all(|member| {
3776 let sel = match member {
3777 GatherMember::Style(n) => n.prelude(source).trim(),
3778 GatherMember::Conditional { inner, .. } => inner.prelude(source).trim(),
3779 };
3780 can_nest_under_gather_home(base, sel)
3781 });
3782 if !cluster_safe {
3783 continue;
3784 }
3785 let parent_indent = line_indent(source, first.start);
3786 let unit = relative_indent_unit(source, first);
3787
3788 let mut merged = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
3789
3790 let style_members: Vec<&SourceNode> = cluster
3791 .iter()
3792 .filter_map(|m| match m {
3793 GatherMember::Style(n) => Some(n),
3794 GatherMember::Conditional { .. } => None,
3795 })
3796 .collect();
3797 let container_end = enclosing_container_end(source, nodes, first);
3798 let (local_styles, remote_styles): (Vec<&SourceNode>, Vec<&SourceNode>) =
3799 style_members.iter().copied().partition(|sec| {
3800 if let Some(limit) = container_end {
3801 sec.end <= limit
3802 } else {
3803 nodes.iter().any(|n| n.start == sec.start)
3804 }
3805 });
3806 let last_local = local_styles.last().copied().unwrap_or(first);
3807 let first_comment = leading_block_comment(source, first.start);
3808 let span_start =
3809 line_start(source, first_comment.map(|(s, _)| s).unwrap_or(first.start));
3810 let mut span_end = extend_with_trailing_newline(source, last_local.end);
3811 for member in &cluster {
3812 let GatherMember::Conditional {
3813 at_node,
3814 delete_whole_at_block,
3815 ..
3816 } = member
3817 else {
3818 continue;
3819 };
3820 if *delete_whole_at_block
3821 && at_node.start >= span_start
3822 && (container_end.is_none_or(|limit| at_node.end <= limit))
3823 {
3824 span_end = span_end.max(extend_with_trailing_newline(source, at_node.end));
3825 }
3826 }
3827 if let Some((_, cmt)) = first_comment {
3828 merged = format!("{parent_indent}{cmt}\n{merged}");
3829 }
3830
3831 let mut replacements = vec![(span_start, first.end, merged)];
3832 let mut cluster_starts = HashSet::new();
3833 for sec in &local_styles {
3834 cluster_starts.insert(sec.start);
3835 }
3836 for sec in local_styles.iter().skip(1) {
3837 let start = leading_block_comment(source, sec.start)
3838 .map(|(s, _)| s)
3839 .unwrap_or(sec.start);
3840 let end = extend_with_trailing_newline(source, sec.end);
3841 replacements.push((start, end, String::new()));
3842 }
3843 let mut replaced_at = HashSet::new();
3844 for member in &cluster {
3845 let GatherMember::Conditional {
3846 at_node,
3847 delete_whole_at_block,
3848 ..
3849 } = member
3850 else {
3851 continue;
3852 };
3853 if *delete_whole_at_block
3854 && at_node.start >= span_start
3855 && at_node.end <= span_end
3856 && replaced_at.insert(at_node.start)
3857 {
3858 replacements.push((
3859 at_node.start,
3860 extend_with_trailing_newline(source, at_node.end),
3861 String::new(),
3862 ));
3863 }
3864 }
3865 replacements.extend(leftover_style_replacements(
3866 source,
3867 nodes,
3868 span_start,
3869 span_end,
3870 &cluster_starts,
3871 ));
3872 let proposed = squeeze_excess_blank_lines(&apply_range_replacements(
3873 source,
3874 span_start,
3875 span_end,
3876 &replacements,
3877 ));
3878
3879 plans.push(PlanEntry {
3880 id: String::new(),
3881 file: path.to_path_buf(),
3882 rules: vec![RuleId::GatherRelatedSelectorRules],
3883 safety: Safety::Review,
3884 source_range: SourceRange {
3885 start: span_start,
3886 end: span_end,
3887 },
3888 original: source[span_start..span_end].to_string(),
3889 proposed,
3890 proof: Proof {
3891 selector_set_equivalent: true,
3892 specificity_equivalent: true,
3893 cascade_context_equivalent: false,
3894 source_order_equivalent: false,
3895 layer_equivalent: remote_styles.is_empty(),
3896 scope_equivalent: true,
3897 declarations_exact: true,
3898 important_exact: true,
3899 },
3900 warnings: vec![format!(
3901 "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
3902 cluster.len(),
3903 base
3904 )],
3905 reason: format!(
3906 "Gather {} related rules for '{}' into the canonical first selector block.",
3907 cluster.len(),
3908 base
3909 ),
3910 selected: true,
3911 });
3912
3913 let mut deleted_at_blocks = HashSet::new();
3914 for sec in &remote_styles {
3915 let start = leading_block_comment(source, sec.start)
3916 .map(|(s, _)| s)
3917 .unwrap_or(sec.start);
3918 let end = extend_with_trailing_newline(source, sec.end);
3919 plans.push(PlanEntry {
3920 id: String::new(),
3921 file: path.to_path_buf(),
3922 rules: vec![RuleId::GatherRelatedSelectorRules],
3923 safety: Safety::Review,
3924 source_range: SourceRange { start, end },
3925 original: source[start..end].to_string(),
3926 proposed: String::new(),
3927 proof: Proof::safe_local(),
3928 warnings: Vec::new(),
3929 reason: format!(
3930 "Remove non-adjacent gathered rule for '{}' at line {}.",
3931 base,
3932 line_number(source, sec.start)
3933 ),
3934 selected: true,
3935 });
3936 }
3937 for member in &cluster {
3938 let GatherMember::Conditional {
3939 at_node,
3940 inner,
3941 delete_whole_at_block,
3942 } = member
3943 else {
3944 continue;
3945 };
3946 let (sec_start, sec_end, line_at) = if *delete_whole_at_block {
3947 if !deleted_at_blocks.insert(at_node.start) {
3948 continue;
3949 }
3950 (
3951 at_node.start,
3952 extend_with_trailing_newline(source, at_node.end),
3953 at_node.start,
3954 )
3955 } else {
3956 (
3957 inner.start,
3958 extend_with_trailing_newline(source, inner.end),
3959 inner.start,
3960 )
3961 };
3962
3963 plans.push(PlanEntry {
3964 id: String::new(),
3965 file: path.to_path_buf(),
3966 rules: vec![RuleId::GatherRelatedSelectorRules],
3967 safety: Safety::Review,
3968 source_range: SourceRange {
3969 start: sec_start,
3970 end: sec_end,
3971 },
3972 original: source[sec_start..sec_end].to_string(),
3973 proposed: String::new(),
3974 proof: Proof::safe_local(),
3975 warnings: Vec::new(),
3976 reason: format!(
3977 "Remove non-adjacent gathered rule for '{}' at line {}.",
3978 base,
3979 line_number(source, line_at)
3980 ),
3981 selected: true,
3982 });
3983 }
3984 }
3985 }
3986}
3987
3988fn plan_factor_identical_states_with_is(
3989 path: &Path,
3990 source: &str,
3991 nodes: &[SourceNode],
3992 enabled: &HashSet<RuleId>,
3993 plans: &mut Vec<PlanEntry>,
3994) {
3995 if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
3996 return;
3997 }
3998 let mut i = 0;
3999 while i < nodes.len() {
4000 let first = &nodes[i];
4001 if matches!(&first.kind, NodeKind::Style) {
4002 let first_sel = first.prelude(source).trim();
4003 if let Some(colon_pos) = first_sel.find(':')
4004 && !first_sel[colon_pos..].starts_with("::")
4005 {
4006 let base = &first_sel[..colon_pos];
4007 if !base.is_empty() && !base.contains(' ') {
4008 let first_body = first.body(source).unwrap_or("").trim();
4009 let mut cluster = vec![first];
4010 let mut cursor = i + 1;
4011 let mut prev_end = first.end;
4012
4013 while cursor < nodes.len() {
4014 let next = &nodes[cursor];
4015 if !is_whitespace_only(source, prev_end..next.start) {
4016 break;
4017 }
4018 if matches!(&next.kind, NodeKind::Style) {
4019 let next_sel = next.prelude(source).trim();
4020 if next_sel.starts_with(base)
4021 && next_sel[base.len()..].starts_with(':')
4022 && !next_sel[base.len()..].starts_with("::")
4023 && next.body(source).unwrap_or("").trim() == first_body
4024 {
4025 cluster.push(next);
4026 prev_end = next.end;
4027 cursor += 1;
4028 continue;
4029 }
4030 }
4031 break;
4032 }
4033
4034 if cluster.len() > 1 {
4035 let last = cluster.last().unwrap();
4036 let pseudos: Vec<&str> = cluster
4037 .iter()
4038 .map(|c| {
4039 let s = c.prelude(source).trim();
4040 &s[base.len()..]
4041 })
4042 .collect();
4043 let is_inner = pseudos.join(", ");
4044 let parent_indent = line_indent(source, first.start);
4045 let first_body_range = first.body_range.as_ref().unwrap();
4046 let unit = detect_indent_unit(source, first_body_range.clone())
4047 .unwrap_or_else(|| " ".to_string());
4048 let nested_indent = format!("{parent_indent}{unit}");
4049 let inner_decl_indent = format!("{nested_indent}{unit}");
4050
4051 let mut decls = String::new();
4052 for line in source[first_body_range.clone()].lines() {
4053 let trimmed = line.trim();
4054 if !trimmed.is_empty() {
4055 decls.push_str(&inner_decl_indent);
4056 decls.push_str(&ensure_semicolon(trimmed));
4057 decls.push('\n');
4058 }
4059 }
4060
4061 let proposed = format!(
4062 "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
4063 );
4064 plans.push(PlanEntry {
4065 id: String::new(),
4066 file: path.to_path_buf(),
4067 rules: vec![RuleId::FactorIdenticalStatesWithIs],
4068 safety: Safety::Safe,
4069 source_range: SourceRange {
4070 start: first.start,
4071 end: last.end,
4072 },
4073 original: source[first.start..last.end].to_string(),
4074 proposed,
4075 proof: Proof::safe_local(),
4076 warnings: Vec::new(),
4077 reason: format!(
4078 "Factor {} identical state rules for '{}' into &:is({}) form.",
4079 cluster.len(),
4080 base,
4081 is_inner
4082 ),
4083 selected: true,
4084 });
4085 i = cursor;
4086 continue;
4087 }
4088 }
4089 }
4090 }
4091 i += 1;
4092 }
4093}
4094
4095fn plan_factor_multi_selector_cluster_with_is(
4096 path: &Path,
4097 source: &str,
4098 nodes: &[SourceNode],
4099 enabled: &HashSet<RuleId>,
4100 plans: &mut Vec<PlanEntry>,
4101) {
4102 if !enabled.contains(&RuleId::ModernizeIs) {
4103 return;
4104 }
4105
4106 let mut i = 0;
4107 while i < nodes.len() {
4108 let first = &nodes[i];
4109 if matches!(&first.kind, NodeKind::Style) {
4110 let first_sel = first.prelude(source).trim();
4111 if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
4112 let mut cluster = vec![(first, first_suffix)];
4113 let mut cursor = i + 1;
4114 let mut prev_end = first.end;
4115
4116 while cursor < nodes.len() {
4117 let next = &nodes[cursor];
4118 if !is_whitespace_only(source, prev_end..next.start) {
4119 break;
4120 }
4121 if matches!(&next.kind, NodeKind::Style) {
4122 let next_sel = next.prelude(source).trim();
4123 if let Some((next_prefixes, next_suffix)) =
4124 extract_multi_branch_pattern(next_sel)
4125 && next_prefixes == base_prefixes
4126 {
4127 cluster.push((next, next_suffix));
4128 prev_end = next.end;
4129 cursor += 1;
4130 continue;
4131 }
4132 }
4133 break;
4134 }
4135
4136 if cluster.len() > 1 {
4137 let (last_node, _) = cluster.last().unwrap();
4138 let parent_indent = line_indent(source, first.start);
4139 let first_body_range = first.body_range.as_ref().unwrap();
4140 let unit = detect_indent_unit(source, first_body_range.clone())
4141 .unwrap_or_else(|| " ".to_string());
4142 let nested_indent = format!("{parent_indent}{unit}");
4143 let inner_decl_indent = format!("{nested_indent}{unit}");
4144
4145 let is_header = format!(":is({})", base_prefixes.join(", "));
4146 let mut out = format!("{parent_indent}{is_header} {{\n");
4147 let mut has_direct_decls = false;
4148
4149 for &(c_node, ref suffix) in &cluster {
4151 if suffix.is_none()
4152 && let Some(c_body_range) = &c_node.body_range
4153 {
4154 for line in source[c_body_range.clone()].lines() {
4155 let trimmed = line.trim();
4156 if !trimmed.is_empty() {
4157 out.push_str(&nested_indent);
4158 out.push_str(&ensure_semicolon(trimmed));
4159 out.push('\n');
4160 has_direct_decls = true;
4161 }
4162 }
4163 }
4164 }
4165
4166 for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
4168 if let Some(sub_sel) = suffix {
4169 if has_direct_decls || c_idx > 0 {
4170 out.push('\n');
4171 }
4172 out.push_str(&nested_indent);
4173 out.push_str(sub_sel);
4174 out.push_str(" {\n");
4175 if let Some(c_body_range) = &c_node.body_range {
4176 for line in source[c_body_range.clone()].lines() {
4177 let trimmed = line.trim();
4178 if !trimmed.is_empty() {
4179 out.push_str(&inner_decl_indent);
4180 out.push_str(&ensure_semicolon(trimmed));
4181 out.push('\n');
4182 }
4183 }
4184 }
4185 out.push_str(&nested_indent);
4186 out.push_str("}\n");
4187 }
4188 }
4189
4190 out.push_str(&parent_indent);
4191 out.push('}');
4192
4193 let specificities: Vec<Specificity> = base_prefixes
4194 .iter()
4195 .map(|p| calculate_specificity(p))
4196 .collect();
4197 let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
4198
4199 plans.push(PlanEntry {
4200 id: String::new(),
4201 file: path.to_path_buf(),
4202 rules: vec![RuleId::ModernizeIs],
4203 safety: Safety::Safe,
4204 source_range: SourceRange {
4205 start: first.start,
4206 end: last_node.end,
4207 },
4208 original: source[first.start..last_node.end].to_string(),
4209 proposed: out,
4210 proof: Proof::safe_local(),
4211 warnings: if uniform { Vec::new() } else { vec!["Notice: :is() takes the specificity of its most specific argument.".into()] },
4212 reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
4213 selected: true,
4214 });
4215 i = cursor;
4216 continue;
4217 }
4218 }
4219 }
4220 i += 1;
4221 }
4222}
4223
4224fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
4225 let branches: Vec<&str> = split_top_level_comma(selector)
4226 .into_iter()
4227 .map(|s| s.trim())
4228 .collect();
4229 if branches.len() < 2 {
4230 return None;
4231 }
4232 if branches.iter().any(|b| b.contains("::")) {
4233 return None;
4234 }
4235
4236 let first = branches[0];
4237 if let Some(space_pos) = first.rfind(' ') {
4238 let suffix = &first[space_pos..];
4239 if branches.iter().all(|b| b.ends_with(suffix)) {
4240 let prefixes: Vec<String> = branches
4241 .iter()
4242 .map(|b| b[..b.len() - suffix.len()].trim().to_string())
4243 .collect();
4244 if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4245 return Some((prefixes, Some(suffix.trim().to_string())));
4246 }
4247 }
4248 }
4249
4250 if branches
4251 .iter()
4252 .all(|b| is_valid_selector_token(b) && !b.contains(' '))
4253 {
4254 let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
4255 return Some((prefixes, None));
4256 }
4257
4258 None
4259}
4260
4261fn plan_merge_identical_rule_bodies(
4262 path: &Path,
4263 source: &str,
4264 nodes: &[SourceNode],
4265 enabled: &HashSet<RuleId>,
4266 plans: &mut Vec<PlanEntry>,
4267) {
4268 if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
4269 return;
4270 }
4271 let mut i = 0;
4272 while i < nodes.len() {
4273 let first = &nodes[i];
4274 if matches!(&first.kind, NodeKind::Style) {
4275 let first_body = first.body(source).unwrap_or("").trim();
4276 if !first_body.is_empty() {
4277 let mut cluster = vec![first];
4278 let mut cursor = i + 1;
4279 let mut prev_end = first.end;
4280
4281 while cursor < nodes.len() {
4282 let next = &nodes[cursor];
4283 if !is_whitespace_only(source, prev_end..next.start) {
4284 break;
4285 }
4286 if matches!(&next.kind, NodeKind::Style)
4287 && next.body(source).unwrap_or("").trim() == first_body
4288 {
4289 cluster.push(next);
4290 prev_end = next.end;
4291 cursor += 1;
4292 continue;
4293 }
4294 break;
4295 }
4296
4297 if cluster.len() > 1 {
4298 let last = cluster.last().unwrap();
4299 let selectors: Vec<&str> =
4300 cluster.iter().map(|c| c.prelude(source).trim()).collect();
4301 let parent_indent = line_indent(source, first.start);
4302 let first_body_range = first.body_range.as_ref().unwrap();
4303 let unit = detect_indent_unit(source, first_body_range.clone())
4304 .unwrap_or_else(|| " ".to_string());
4305 let nested_indent = format!("{parent_indent}{unit}");
4306
4307 let mut decls = String::new();
4308 for line in source[first_body_range.clone()].lines() {
4309 let trimmed = line.trim();
4310 if !trimmed.is_empty() {
4311 decls.push_str(&nested_indent);
4312 decls.push_str(&ensure_semicolon(trimmed));
4313 decls.push('\n');
4314 }
4315 }
4316
4317 let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
4318 let proposed =
4319 format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
4320 plans.push(PlanEntry {
4321 id: String::new(),
4322 file: path.to_path_buf(),
4323 rules: vec![RuleId::MergeIdenticalRuleBodies],
4324 safety: Safety::Safe,
4325 source_range: SourceRange {
4326 start: first.start,
4327 end: last.end,
4328 },
4329 original: source[first.start..last.end].to_string(),
4330 proposed,
4331 proof: Proof::safe_local(),
4332 warnings: Vec::new(),
4333 reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
4334 selected: true,
4335 });
4336 i = cursor;
4337 continue;
4338 }
4339 }
4340 }
4341 i += 1;
4342 }
4343}
4344
4345pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
4346 let bytes = selector.as_bytes();
4347 let mut parts = Vec::new();
4348 let mut last = 0;
4349 let mut parens = 0usize;
4350 let mut brackets = 0usize;
4351 let mut quote: Option<u8> = None;
4352 let mut escaped = false;
4353 let mut i = 0usize;
4354
4355 while i < bytes.len() {
4356 let b = bytes[i];
4357 if let Some(q) = quote {
4358 if escaped {
4359 escaped = false;
4360 } else if b == b'\\' {
4361 escaped = true;
4362 } else if b == q {
4363 quote = None;
4364 }
4365 i += 1;
4366 continue;
4367 }
4368 match b {
4369 b'\'' | b'"' => quote = Some(b),
4370 b'(' => parens += 1,
4371 b')' => parens = parens.saturating_sub(1),
4372 b'[' => brackets += 1,
4373 b']' => brackets = brackets.saturating_sub(1),
4374 b',' if parens == 0 && brackets == 0 => {
4375 parts.push(&selector[last..i]);
4376 last = i + 1;
4377 }
4378 _ => {}
4379 }
4380 i += 1;
4381 }
4382 if last < selector.len() {
4383 parts.push(&selector[last..]);
4384 }
4385 parts
4386}
4387
4388pub fn factor_selector_list(
4389 selector: &str,
4390 body: &str,
4391 indent: &str,
4392 unit: &str,
4393) -> Option<String> {
4394 let branches: Vec<&str> = split_top_level_comma(selector)
4395 .into_iter()
4396 .map(|s| s.trim())
4397 .collect();
4398 if branches.len() < 2 {
4399 return None;
4400 }
4401 let base = branches[0];
4402 if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
4403 return None;
4404 }
4405
4406 let mut inner_selectors = Vec::new();
4407 for &branch in &branches {
4408 if branch == base {
4409 inner_selectors.push("&".to_string());
4410 } else {
4411 let rel = branch.strip_prefix(base)?;
4412 if rel.starts_with("::")
4413 || rel.starts_with(':')
4414 || rel.starts_with('[')
4415 || rel.starts_with('.')
4416 || rel.starts_with('#')
4417 {
4418 inner_selectors.push(format!("&{rel}"));
4419 } else {
4420 let trimmed = rel.strip_prefix(' ')?;
4421 inner_selectors.push(trimmed.trim_start().to_string());
4422 }
4423 }
4424 }
4425
4426 let nested_indent = format!("{indent}{unit}");
4427 let inner_decl_indent = format!("{nested_indent}{unit}");
4428 let mut out = String::new();
4429 out.push_str(base);
4430 out.push_str(" {\n");
4431 out.push_str(&nested_indent);
4432 out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
4433 out.push_str(" {\n");
4434 for line in body.lines() {
4435 let trimmed = line.trim();
4436 if !trimmed.is_empty() {
4437 out.push_str(&inner_decl_indent);
4438 out.push_str(&ensure_semicolon(trimmed));
4439 out.push('\n');
4440 }
4441 }
4442 out.push_str(&nested_indent);
4443 out.push_str("}\n");
4444 out.push_str(indent);
4445 out.push('}');
4446 Some(out)
4447}
4448
4449pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
4450 let branches: Vec<&str> = split_top_level_comma(selector)
4451 .into_iter()
4452 .map(|s| s.trim())
4453 .filter(|s| !s.is_empty())
4454 .collect();
4455 if branches.len() < 2 {
4456 return None;
4457 }
4458
4459 if branches.iter().any(|b| b.contains("::")) {
4460 return None;
4461 }
4462
4463 let specificities: Vec<Specificity> =
4464 branches.iter().map(|b| calculate_specificity(b)).collect();
4465 let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4466
4467 let first = branches[0];
4468
4469 if let Some(space_pos) = first.rfind(' ') {
4471 let suffix = &first[space_pos..];
4472 if branches.iter().all(|b| b.ends_with(suffix)) {
4473 let prefixes: Vec<&str> = branches
4474 .iter()
4475 .map(|b| b[..b.len() - suffix.len()].trim())
4476 .collect();
4477 if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4478 let is_inner = prefixes.join(", ");
4479 return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
4480 }
4481 }
4482 }
4483
4484 if let Some(space_pos) = first.rfind(' ') {
4486 let prefix = &first[..=space_pos];
4487 if branches.iter().all(|b| b.starts_with(prefix)) {
4488 let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
4489 if suffixes.iter().all(|s| is_valid_selector_token(s)) {
4490 let is_inner = suffixes.join(", ");
4491 return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
4492 }
4493 }
4494 }
4495
4496 if let Some(colon_pos) = first.find(':') {
4498 let base = &first[..colon_pos];
4499 if !base.is_empty()
4500 && !base.contains(' ')
4501 && branches.iter().all(|b| {
4502 b.starts_with(base)
4503 && b[base.len()..].starts_with(':')
4504 && !b[base.len()..].starts_with("::")
4505 })
4506 {
4507 let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4508 if pseudos
4509 .iter()
4510 .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
4511 {
4512 let is_inner = pseudos.join(", ");
4513 return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4514 }
4515 }
4516 }
4517
4518 if let Some(bracket_pos) = first.find('[') {
4520 let base = &first[..bracket_pos];
4521 if !base.is_empty()
4522 && !base.contains(' ')
4523 && branches
4524 .iter()
4525 .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
4526 {
4527 let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4528 if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
4529 let is_inner = attrs.join(", ");
4530 return Some((format!("{base}:is({is_inner})"), uniform_specificity));
4531 }
4532 }
4533 }
4534
4535 None
4536}
4537
4538fn is_valid_selector_token(s: &str) -> bool {
4539 if s.is_empty() {
4540 return false;
4541 }
4542 let first = s.chars().next().unwrap();
4543 first == '.'
4544 || first == '#'
4545 || first == '['
4546 || first == ':'
4547 || first.is_ascii_alphabetic()
4548 || first == '*'
4549 || first == '>'
4550 || first == '+'
4551 || first == '~'
4552}
4553
4554pub fn factor_with_where(selector: &str) -> Option<String> {
4555 let branches: Vec<&str> = split_top_level_comma(selector)
4556 .into_iter()
4557 .map(|s| s.trim())
4558 .collect();
4559 if branches.len() < 2 {
4560 return None;
4561 }
4562 if branches.iter().any(|b| b.contains("::")) {
4563 return None;
4564 }
4565 Some(format!(":where({})", branches.join(", ")))
4566}
4567
4568pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
4569 let mut result = prelude.to_string();
4570 let mut changed = false;
4571
4572 if let (Some((min_raw, min_val)), Some((max_raw, max_val))) = (
4574 extract_media_feature_and_raw(&result, "min-width"),
4575 extract_media_feature_and_raw(&result, "max-width"),
4576 ) {
4577 let pattern = format!("{min_raw} and {max_raw}");
4578 let replacement = format!("({min_val} <= width <= {max_val})");
4579 if result.contains(&pattern) {
4580 result = result.replace(&pattern, &replacement);
4581 changed = true;
4582 }
4583 }
4584
4585 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-width") {
4587 let replacement = format!("(width >= {val})");
4588 result = result.replacen(&raw, &replacement, 1);
4589 changed = true;
4590 }
4591
4592 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-width") {
4594 let replacement = format!("(width <= {val})");
4595 result = result.replacen(&raw, &replacement, 1);
4596 changed = true;
4597 }
4598
4599 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-height") {
4601 let replacement = format!("(height >= {val})");
4602 result = result.replacen(&raw, &replacement, 1);
4603 changed = true;
4604 }
4605
4606 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-height") {
4608 let replacement = format!("(height <= {val})");
4609 result = result.replacen(&raw, &replacement, 1);
4610 changed = true;
4611 }
4612
4613 if changed { Some(result) } else { None }
4614}
4615
4616fn extract_media_feature_and_raw(source: &str, feature: &str) -> Option<(String, String)> {
4617 let feat_idx = source.find(feature)?;
4618 let open_paren = source[..feat_idx].rfind('(')?;
4619 if source[open_paren..feat_idx].contains(')') {
4620 return None;
4621 }
4622 let colon_rel = source[feat_idx + feature.len()..].find(':')?;
4623 let colon_idx = feat_idx + feature.len() + colon_rel;
4624 let close_paren_rel = source[colon_idx..].find(')')?;
4625 let close_paren = colon_idx + close_paren_rel;
4626
4627 let raw = source[open_paren..=close_paren].to_string();
4628 let val = source[colon_idx + 1..close_paren].trim().to_string();
4629 Some((raw, val))
4630}
4631
4632fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
4633 let parent = parent.trim();
4634 let child = child.trim();
4635 if parent.is_empty()
4636 || child.is_empty()
4637 || contains_top_level_comma(parent)
4638 || contains_top_level_comma(child)
4639 || parent.contains("::")
4640 || child == parent
4641 {
4642 return None;
4643 }
4644
4645 if !child.starts_with(parent) {
4646 if is_weak_gather_base(parent) {
4647 return None;
4648 }
4649 return appended_selector_relation(parent, child);
4650 }
4651
4652 let remainder = &child[parent.len()..];
4653 let trimmed = remainder.trim_start();
4654 if trimmed.is_empty() {
4655 return None;
4656 }
4657
4658 let (relation, nested_selector) = if remainder.starts_with("::") {
4659 (RelationKind::PseudoElement, format!("&{remainder}"))
4660 } else if remainder.starts_with(':') {
4661 (RelationKind::PseudoClass, format!("&{remainder}"))
4662 } else if remainder.starts_with('[') {
4663 (RelationKind::Attribute, format!("&{remainder}"))
4664 } else if remainder.starts_with('.') || remainder.starts_with('#') {
4665 (RelationKind::Compound, format!("&{remainder}"))
4666 } else if let Some(first_char) = trimmed
4667 .chars()
4668 .next()
4669 .filter(|c| *c == '>' || *c == '+' || *c == '~')
4670 {
4671 let after_comb = trimmed[first_char.len_utf8()..].trim();
4672 if after_comb == parent {
4673 (RelationKind::Combinator, format!("{first_char} {parent}"))
4674 } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
4675 (
4676 RelationKind::Combinator,
4677 format!("{first_char} {parent}{after_parent}"),
4678 )
4679 } else {
4680 (
4681 RelationKind::Combinator,
4682 format!("{first_char} {after_comb}"),
4683 )
4684 }
4685 } else if remainder
4686 .as_bytes()
4687 .first()
4688 .is_some_and(|b| b.is_ascii_whitespace())
4689 {
4690 let descendant = remainder.trim();
4691 (RelationKind::Descendant, descendant.to_string())
4692 } else {
4693 return None;
4694 };
4695
4696 Some((relation, nested_selector))
4697}
4698
4699fn conditional_child(
4700 source: &str,
4701 parent_selector: &str,
4702 node: &SourceNode,
4703 enabled: &HashSet<RuleId>,
4704) -> Option<ClusterChild> {
4705 let (name, rule) = match &node.kind {
4706 NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
4707 NodeKind::AtBlock { name, .. } if name == "supports" => {
4708 (name.as_str(), RuleId::NestSupports)
4709 }
4710 NodeKind::AtBlock { name, .. } if name == "container" => {
4711 (name.as_str(), RuleId::NestContainer)
4712 }
4713 NodeKind::AtBlock { name, .. } if name == "starting-style" => {
4714 (name.as_str(), RuleId::NestStartingStyle)
4715 }
4716 _ => return None,
4717 };
4718 if !enabled.contains(&rule) {
4719 return None;
4720 }
4721
4722 let body_range = node.body_range.clone()?;
4723 let inner_nodes = scan_nodes(source, body_range.clone());
4724 if inner_nodes.is_empty() {
4725 return None;
4726 }
4727
4728 let mut inners = Vec::new();
4729 for inner in &inner_nodes {
4730 if !matches!(&inner.kind, NodeKind::Style) {
4731 return None;
4732 }
4733 let inner_prelude = inner.prelude(source);
4734 let inner_body = inner.body_range.clone()?;
4735 if inner_prelude == parent_selector.trim() {
4736 inners.push(ConditionalInner::Direct {
4737 body_range: inner_body,
4738 });
4739 } else if let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude) {
4740 inners.push(ConditionalInner::Nested {
4741 nested_selector: nested_sel,
4742 body_range: inner_body,
4743 });
4744 } else {
4745 return None;
4746 }
4747 }
4748
4749 debug_assert!(
4750 name == "media" || name == "supports" || name == "container" || name == "starting-style"
4751 );
4752 Some(ClusterChild::Conditional {
4753 node: node.clone(),
4754 rule,
4755 inners,
4756 })
4757}
4758
4759pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
4760 if !selector.contains(":not(") {
4761 return None;
4762 }
4763 let mut result = String::new();
4764 let mut i = 0;
4765 let bytes = selector.as_bytes();
4766 let mut changed = false;
4767 let mut uniform_specificity = true;
4768
4769 while i < bytes.len() {
4770 if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
4771 let mut args = Vec::new();
4772 let mut current_end = i;
4773
4774 while current_end + 5 <= bytes.len()
4775 && &selector[current_end..current_end + 5] == ":not("
4776 {
4777 let open = current_end + 4;
4778 if let Some(close) = find_matching_paren(selector, open) {
4779 let arg = selector[open + 1..close].trim();
4780 args.push(arg);
4781 current_end = close + 1;
4782 } else {
4783 break;
4784 }
4785 }
4786
4787 if args.len() > 1 {
4788 changed = true;
4789 let specs: Vec<Specificity> =
4790 args.iter().map(|a| calculate_specificity(a)).collect();
4791 if specs.windows(2).any(|w| w[0] != w[1]) {
4792 uniform_specificity = false;
4793 }
4794
4795 result.push_str(":not(");
4796 result.push_str(&args.join(", "));
4797 result.push(')');
4798 i = current_end;
4799 continue;
4800 }
4801 }
4802 let ch = selector[i..].chars().next().unwrap();
4803 result.push(ch);
4804 i += ch.len_utf8();
4805 }
4806
4807 if changed {
4808 Some((result, uniform_specificity))
4809 } else {
4810 None
4811 }
4812}
4813
4814fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
4815 let bytes = source.as_bytes();
4816 let mut depth = 1usize;
4817 let mut i = open + 1;
4818 let mut quote: Option<u8> = None;
4819 let mut escaped = false;
4820
4821 while i < bytes.len() {
4822 let b = bytes[i];
4823 if let Some(q) = quote {
4824 if escaped {
4825 escaped = false;
4826 } else if b == b'\\' {
4827 escaped = true;
4828 } else if b == q {
4829 quote = None;
4830 }
4831 i += 1;
4832 continue;
4833 }
4834
4835 match b {
4836 b'\'' | b'"' => quote = Some(b),
4837 b'(' => depth += 1,
4838 b')' => {
4839 depth -= 1;
4840 if depth == 0 {
4841 return Some(i);
4842 }
4843 }
4844 _ => {}
4845 }
4846 i += 1;
4847 }
4848 None
4849}
4850
4851#[derive(Debug, Clone)]
4852struct HierarchicalRule {
4853 relative_selector: String,
4854 body_lines: Vec<String>,
4855 sub_rules: Vec<HierarchicalRule>,
4856 conditional_header: Option<String>,
4857}
4858
4859fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
4860 let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
4861 let parent_indent = line_indent(source, parent.start);
4862 let unit = relative_indent_unit(source, parent);
4863 let nested_indent = format!("{parent_indent}{unit}");
4864
4865 let mut out = String::new();
4866 let open = parent_body_range.start - 1;
4867 out.push_str(&source[parent.start..=open]);
4868
4869 let parent_body = &source[parent_body_range.clone()];
4870 let trimmed_body = parent_body.trim();
4871 if !trimmed_body.is_empty() {
4872 out.push('\n');
4873 for line in parent_body.lines() {
4874 let trimmed_line = line.trim();
4875 if !trimmed_line.is_empty() {
4876 out.push_str(&nested_indent);
4877 out.push_str(&ensure_semicolon(trimmed_line));
4878 out.push('\n');
4879 }
4880 }
4881 }
4882
4883 let mut root_rules: Vec<HierarchicalRule> = Vec::new();
4885
4886 for child in children {
4887 match child {
4888 ClusterChild::Style {
4889 node,
4890 nested_selector,
4891 ..
4892 } => {
4893 let mut body_lines = Vec::new();
4894 if let Some(body_range) = &node.body_range {
4895 for line in source[body_range.clone()].lines() {
4896 let trimmed = line.trim();
4897 if !trimmed.is_empty() {
4898 body_lines.push(trimmed.to_string());
4899 }
4900 }
4901 }
4902 insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
4903 }
4904 ClusterChild::Conditional { node, inners, .. } => {
4905 let header = node.prelude(source).trim().to_string();
4906 let mut cond_sub_rules = Vec::new();
4907 for inner in inners {
4908 match inner {
4909 ConditionalInner::Direct { body_range } => {
4910 let mut lines = Vec::new();
4911 for line in source[body_range.clone()].lines() {
4912 let trimmed = line.trim();
4913 if !trimmed.is_empty() {
4914 lines.push(trimmed.to_string());
4915 }
4916 }
4917 cond_sub_rules.push(HierarchicalRule {
4918 relative_selector: String::new(),
4919 body_lines: lines,
4920 sub_rules: Vec::new(),
4921 conditional_header: None,
4922 });
4923 }
4924 ConditionalInner::Nested {
4925 nested_selector,
4926 body_range,
4927 } => {
4928 let mut lines = Vec::new();
4929 for line in source[body_range.clone()].lines() {
4930 let trimmed = line.trim();
4931 if !trimmed.is_empty() {
4932 lines.push(trimmed.to_string());
4933 }
4934 }
4935 cond_sub_rules.push(HierarchicalRule {
4936 relative_selector: nested_selector.trim().to_string(),
4937 body_lines: lines,
4938 sub_rules: Vec::new(),
4939 conditional_header: None,
4940 });
4941 }
4942 }
4943 }
4944 root_rules.push(HierarchicalRule {
4945 relative_selector: String::new(),
4946 body_lines: Vec::new(),
4947 sub_rules: cond_sub_rules,
4948 conditional_header: Some(header),
4949 });
4950 }
4951 }
4952 }
4953
4954 for rule in &root_rules {
4955 out.push('\n');
4956 render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
4957 }
4958
4959 out.push_str(&parent_indent);
4960 out.push('}');
4961 out
4962}
4963
4964fn insert_hierarchical_style(
4965 root_rules: &mut Vec<HierarchicalRule>,
4966 selector: &str,
4967 body_lines: Vec<String>,
4968) {
4969 if let Some(last_rule) = root_rules.last_mut()
4970 && last_rule.conditional_header.is_none()
4971 && !last_rule.relative_selector.is_empty()
4972 {
4973 let parent_sel = &last_rule.relative_selector;
4974 if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
4975 insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
4976 return;
4977 }
4978 }
4979
4980 root_rules.push(HierarchicalRule {
4981 relative_selector: selector.to_string(),
4982 body_lines,
4983 sub_rules: Vec::new(),
4984 conditional_header: None,
4985 });
4986}
4987
4988fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
4989 let parent = parent.trim();
4990 let child = child.trim();
4991 if child == parent || !child.starts_with(parent) {
4992 return None;
4993 }
4994 let remainder = &child[parent.len()..];
4995 let trimmed = remainder.trim_start();
4996 if trimmed.is_empty() {
4997 return None;
4998 }
4999
5000 if remainder.starts_with("::")
5001 || remainder.starts_with(':')
5002 || remainder.starts_with('[')
5003 || remainder.starts_with('.')
5004 || remainder.starts_with('#')
5005 {
5006 Some(format!("&{remainder}"))
5007 } else if let Some(first_char) = trimmed
5008 .chars()
5009 .next()
5010 .filter(|c| *c == '>' || *c == '+' || *c == '~')
5011 {
5012 let after_comb = trimmed[first_char.len_utf8()..].trim();
5013 Some(format!("{first_char} {after_comb}"))
5014 } else if remainder
5015 .as_bytes()
5016 .first()
5017 .is_some_and(|b| b.is_ascii_whitespace())
5018 {
5019 Some(trimmed.to_string())
5020 } else {
5021 None
5022 }
5023}
5024
5025fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
5026 let inner_indent = format!("{indent}{unit}");
5027
5028 if let Some(header) = &rule.conditional_header {
5029 out.push_str(indent);
5030 out.push_str(header);
5031 out.push_str(" {\n");
5032 for (idx, sub) in rule.sub_rules.iter().enumerate() {
5033 if idx > 0 {
5034 out.push('\n');
5035 }
5036 if sub.relative_selector.is_empty() {
5037 for line in &sub.body_lines {
5038 out.push_str(&inner_indent);
5039 out.push_str(&ensure_semicolon(line));
5040 out.push('\n');
5041 }
5042 } else {
5043 render_hierarchical_rule(out, sub, &inner_indent, unit);
5044 }
5045 }
5046 out.push_str(indent);
5047 out.push_str("}\n");
5048 } else {
5049 out.push_str(indent);
5050 out.push_str(&rule.relative_selector);
5051 out.push_str(" {\n");
5052
5053 for line in &rule.body_lines {
5054 out.push_str(&inner_indent);
5055 out.push_str(&ensure_semicolon(line));
5056 out.push('\n');
5057 }
5058
5059 for sub in &rule.sub_rules {
5060 out.push('\n');
5061 render_hierarchical_rule(out, sub, &inner_indent, unit);
5062 }
5063
5064 out.push_str(indent);
5065 out.push_str("}\n");
5066 }
5067}
5068
5069fn line_indent(source: &str, offset: usize) -> String {
5070 let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
5071 source[line_start..offset]
5072 .chars()
5073 .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
5074 .collect()
5075}
5076
5077fn ensure_semicolon(line: &str) -> std::borrow::Cow<'_, str> {
5081 let trimmed = line.trim_end();
5082 if trimmed.ends_with(';')
5083 || trimmed.ends_with('{')
5084 || trimmed.ends_with('}')
5085 || trimmed.ends_with(',')
5086 || trimmed.starts_with("//")
5087 || trimmed.starts_with("/*")
5088 || !trimmed.contains(':')
5089 {
5090 return std::borrow::Cow::Borrowed(line);
5091 }
5092 std::borrow::Cow::Owned(format!("{trimmed};"))
5093}
5094
5095fn line_number(source: &str, offset: usize) -> usize {
5096 source[..offset.min(source.len())].lines().count()
5097}
5098
5099fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
5100 for line in source[body].lines() {
5101 if line.trim().is_empty() {
5102 continue;
5103 }
5104 let indent: String = line
5105 .chars()
5106 .take_while(|c| *c == ' ' || *c == '\t')
5107 .collect();
5108 if !indent.is_empty() {
5109 return Some(indent);
5110 }
5111 }
5112 None
5113}
5114
5115fn contains_top_level_comma(selector: &str) -> bool {
5116 let bytes = selector.as_bytes();
5117 let mut parens = 0usize;
5118 let mut brackets = 0usize;
5119 let mut quote: Option<u8> = None;
5120 let mut escaped = false;
5121 let mut i = 0usize;
5122 while i < bytes.len() {
5123 let b = bytes[i];
5124 if let Some(q) = quote {
5125 if escaped {
5126 escaped = false;
5127 } else if b == b'\\' {
5128 escaped = true;
5129 } else if b == q {
5130 quote = None;
5131 }
5132 i += 1;
5133 continue;
5134 }
5135 match b {
5136 b'\'' | b'"' => quote = Some(b),
5137 b'(' => parens += 1,
5138 b')' => parens = parens.saturating_sub(1),
5139 b'[' => brackets += 1,
5140 b']' => brackets = brackets.saturating_sub(1),
5141 b',' if parens == 0 && brackets == 0 => return true,
5142 _ => {}
5143 }
5144 i += 1;
5145 }
5146 false
5147}
5148
5149pub fn apply_selected_plans(
5150 source: &str,
5151 plans: &[PlanEntry],
5152 include_review: bool,
5153) -> Result<String> {
5154 let selected: Vec<&PlanEntry> = plans
5155 .iter()
5156 .filter(|plan| {
5157 plan.selected
5158 && (plan.safety == Safety::Safe
5159 || (include_review && plan.safety == Safety::Review))
5160 })
5161 .collect();
5162 let owned: Vec<PlanEntry> = selected.into_iter().cloned().collect();
5163 let keep = select_disjoint_plan_indices(&owned);
5164 let mut non_overlapping: Vec<&PlanEntry> = keep.iter().map(|&i| &owned[i]).collect();
5165 non_overlapping.sort_by(|a, b| {
5166 a.source_range
5167 .start
5168 .cmp(&b.source_range.start)
5169 .then_with(|| b.source_range.end.cmp(&a.source_range.end))
5170 });
5171
5172 let mut output = source.to_string();
5173 for plan in non_overlapping.into_iter().rev() {
5174 if plan.source_range.start <= output.len()
5175 && plan.source_range.end <= output.len()
5176 && plan.source_range.start <= plan.source_range.end
5177 {
5178 output.replace_range(
5179 plan.source_range.start..plan.source_range.end,
5180 &plan.proposed,
5181 );
5182 }
5183 }
5184 Ok(output)
5185}
5186
5187pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
5188 TextDiff::from_lines(old, new)
5189 .unified_diff()
5190 .header(old_name, new_name)
5191 .to_string()
5192}
5193
5194#[cfg(test)]
5195mod tests {
5196 use super::*;
5197
5198 fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
5199 analyze_source(PathBuf::from("test.css"), css, rules)
5200 .unwrap()
5201 .plans
5202 }
5203
5204 #[test]
5205 fn nests_adjacent_pseudo_and_descendant_rules() {
5206 let css = ".card {\n color: red;\n}\n.card:hover {\n color: blue !important;\n}\n.card .title {\n font-weight: 700;\n}\n";
5207 let plans = plan(css, &RuleId::ALL);
5208 assert_eq!(plans.len(), 1);
5209 let output = apply_selected_plans(css, &plans, false).unwrap();
5210 assert!(output.contains("&:hover"));
5211 assert!(output.contains(".title"));
5212 assert!(output.contains("color: blue !important;"));
5213 }
5214
5215 #[test]
5216 fn nests_exact_full_modernize_example() {
5217 let original = r#".card {
5218 color: #222;
5219 padding: 1rem;
5220}
5221.card:hover {
5222 color: #111 !important;
5223}
5224.card::before {
5225 content: "";
5226}
5227.card[data-active] {
5228 border-color: currentColor;
5229}
5230.card.featured {
5231 box-shadow: 0 0 0 1px currentColor;
5232}
5233.card .title {
5234 font-weight: 700;
5235}
5236.card > .body {
5237 min-width: 0;
5238}
5239.card + .card {
5240 margin-top: 1rem;
5241}
5242@media (width >= 48rem) {
5243 .card {
5244 padding: 1.5rem;
5245 }
5246}
5247@supports (display: grid) {
5248 .card {
5249 display: grid;
5250 }
5251}
5252"#;
5253
5254 let expected = r#".card {
5255 color: #222;
5256 padding: 1rem;
5257
5258 &:hover {
5259 color: #111 !important;
5260 }
5261
5262 &::before {
5263 content: "";
5264 }
5265
5266 &[data-active] {
5267 border-color: currentColor;
5268 }
5269
5270 &.featured {
5271 box-shadow: 0 0 0 1px currentColor;
5272 }
5273
5274 .title {
5275 font-weight: 700;
5276 }
5277
5278 > .body {
5279 min-width: 0;
5280 }
5281
5282 + .card {
5283 margin-top: 1rem;
5284 }
5285
5286 @media (width >= 48rem) {
5287 padding: 1.5rem;
5288 }
5289
5290 @supports (display: grid) {
5291 display: grid;
5292 }
5293}"#;
5294
5295 let plans = plan(
5296 original,
5297 &[
5298 RuleId::NestPseudoClass,
5299 RuleId::NestPseudoElement,
5300 RuleId::NestAttribute,
5301 RuleId::NestCompound,
5302 RuleId::NestDescendant,
5303 RuleId::NestCombinator,
5304 RuleId::NestMedia,
5305 RuleId::NestSupports,
5306 ],
5307 );
5308 assert_eq!(plans.len(), 1);
5309 let output = apply_selected_plans(original, &plans, false).unwrap();
5310 assert_eq!(output.trim(), expected.trim());
5311 }
5312
5313 #[test]
5314 fn factors_selector_list_sharing_base() {
5315 let css = ".marker,\n.marker::before,\n.marker::after {\n box-sizing: border-box;\n}\n";
5316 let plans = plan(css, &[RuleId::FactorSelectorList]);
5317 assert_eq!(plans.len(), 1);
5318 let output = apply_selected_plans(css, &plans, false).unwrap();
5319 assert!(output.contains(".marker {"));
5320 assert!(output.contains("&,"));
5321 assert!(output.contains("&::before,"));
5322 assert!(output.contains("&::after {"));
5323 assert!(output.contains("box-sizing: border-box;"));
5324 }
5325
5326 #[test]
5327 fn modernizes_is_with_uniform_specificity() {
5328 let css = ".button:hover, .button:focus, .button:active {\n color: blue;\n}\n";
5329 let plans = plan(css, &[RuleId::ModernizeIs]);
5330 assert_eq!(plans.len(), 1);
5331 let output = apply_selected_plans(css, &plans, false).unwrap();
5332 assert!(output.contains(".button:is(:hover, :focus, :active)"));
5333 }
5334
5335 #[test]
5336 fn modernizes_media_range_syntax() {
5337 let css = "@media (min-width: 800px) {\n .card { padding: 2rem; }\n}\n";
5338 let plans = plan(css, &[RuleId::ModernizeMediaRange]);
5339 assert_eq!(plans.len(), 1);
5340 let output = apply_selected_plans(css, &plans, false).unwrap();
5341 assert!(output.contains("@media (width >= 800px)"));
5342 }
5343
5344 #[test]
5345 fn consolidates_not_selectors() {
5346 let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n border: 1px solid gray;\n}\n";
5347 let plans = plan(css, &[RuleId::ConsolidateNot]);
5348 assert_eq!(plans.len(), 1);
5349 assert_eq!(plans[0].safety, Safety::Review);
5350 let output = apply_selected_plans(css, &plans, true).unwrap();
5351 assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
5352 }
5353
5354 #[test]
5355 fn refuses_subtoken_is_factoring_false_positive() {
5356 let css = ".same-specificity-a,\n.same-specificity-b {\n color: black;\n}\n";
5357 let plans = plan(css, &[RuleId::ModernizeIs]);
5358 assert!(plans.is_empty());
5359 }
5360
5361 #[test]
5362 fn modernizes_descendant_is_alternatives() {
5363 let css = ".card .title, .card .subtitle, .card .description {\n color: black;\n}\n";
5364 let plans = plan(css, &[RuleId::ModernizeIs]);
5365 assert_eq!(plans.len(), 1);
5366 let output = apply_selected_plans(css, &plans, false).unwrap();
5367 assert!(output.contains(".card :is(.title, .subtitle, .description)"));
5368 }
5369
5370 #[test]
5371 fn modernizes_suffix_is_alternatives() {
5372 let css = ".alpha .title,\n#hero .title {\n color: rebeccapurple;\n}\n";
5373 let plans = plan(css, &[RuleId::ModernizeIs]);
5374 assert_eq!(plans.len(), 1);
5375 assert_eq!(plans[0].safety, Safety::Review);
5376 let output = apply_selected_plans(css, &plans, true).unwrap();
5377 assert!(output.contains(":is(.alpha, #hero) .title"));
5378 }
5379
5380 #[test]
5381 fn factors_multi_selector_cluster_with_is_and_nesting() {
5382 let css = r#".alpha .title,
5383#hero .title {
5384 color: rebeccapurple;
5385}
5386
5387.alpha .subtitle,
5388#hero .subtitle {
5389 color: slateblue;
5390}
5391
5392.alpha,
5393#hero {
5394 border-color: currentColor;
5395}
5396"#;
5397 let plans = plan(css, &[RuleId::ModernizeIs]);
5398 assert_eq!(plans.len(), 1);
5399 let output = apply_selected_plans(css, &plans, true).unwrap();
5400 assert!(output.contains(":is(.alpha, #hero) {"));
5401 assert!(output.contains("border-color: currentColor;"));
5402 assert!(output.contains(".title {"));
5403 assert!(output.contains("color: rebeccapurple;"));
5404 assert!(output.contains(".subtitle {"));
5405 assert!(output.contains("color: slateblue;"));
5406 }
5407
5408 #[test]
5409 fn refuses_bem_token_concatenation() {
5410 let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
5411 let plans = plan(css, &RuleId::ALL);
5412 assert!(plans.is_empty());
5413 }
5414
5415 #[test]
5416 fn merges_same_named_layer_blocks() {
5417 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";
5418 let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
5419 assert_eq!(plans.len(), 2);
5420 let output = apply_selected_plans(css, &plans, false).unwrap();
5421 assert!(output.contains("@layer overrides {"));
5422 assert!(output.contains(".layered-card {"));
5423 assert!(output.contains(".layer-important {"));
5424 }
5425
5426 #[test]
5427 fn merges_adjacent_media_queries() {
5428 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";
5429 let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
5430 assert_eq!(plans.len(), 1);
5431 let output = apply_selected_plans(css, &plans, false).unwrap();
5432 assert!(output.contains("@media (width >= 48rem) {"));
5433 assert!(output.contains(".card {"));
5434 assert!(output.contains(".panel {"));
5435 }
5436
5437 #[test]
5438 fn merges_adjacent_supports_queries() {
5439 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";
5440 let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
5441 assert_eq!(plans.len(), 1);
5442 let output = apply_selected_plans(css, &plans, false).unwrap();
5443 assert!(output.contains("@supports (display: grid) {"));
5444 assert!(output.contains(".card {"));
5445 assert!(output.contains(".panel {"));
5446 }
5447
5448 #[test]
5449 fn merges_adjacent_identical_selectors() {
5450 let css = ".card {\n color: black;\n}\n\n.card {\n padding: 1rem;\n}\n";
5451 let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
5452 assert_eq!(plans.len(), 1);
5453 let output = apply_selected_plans(css, &plans, false).unwrap();
5454 assert!(output.contains(".card {"));
5455 assert!(output.contains("color: black;"));
5456 assert!(output.contains("padding: 1rem;"));
5457 }
5458
5459 #[test]
5460 fn merges_identical_rule_bodies() {
5461 let css = ".card:hover {\n color: red;\n}\n\n.panel:hover {\n color: red;\n}\n";
5462 let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
5463 assert_eq!(plans.len(), 1);
5464 let output = apply_selected_plans(css, &plans, false).unwrap();
5465 assert!(output.contains(".card:hover,"));
5466 assert!(output.contains(".panel:hover {"));
5467 assert!(output.contains("color: red;"));
5468 }
5469
5470 #[test]
5471 fn factors_identical_states_with_is() {
5472 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";
5473 let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
5474 assert_eq!(plans.len(), 1);
5475 let output = apply_selected_plans(css, &plans, false).unwrap();
5476 assert!(output.contains(".card {"));
5477 assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
5478 assert!(output.contains("background: silver;"));
5479 }
5480
5481 #[test]
5482 fn nests_multi_level_tree_hierarchy() {
5483 let css = r#".tree {
5484 display: grid;
5485 gap: 0.5rem;
5486}
5487.tree .node {
5488 position: relative;
5489}
5490.tree .node .label {
5491 display: flex;
5492}
5493.tree .node .label:hover {
5494 color: var(--accent);
5495}
5496.tree .node > .children {
5497 margin-inline-start: 1.25rem;
5498}
5499.tree .node > .children > .node + .node {
5500 margin-block-start: 0.25rem;
5501}
5502"#;
5503 let plans = plan(
5504 css,
5505 &[
5506 RuleId::NestDescendant,
5507 RuleId::NestCombinator,
5508 RuleId::NestPseudoClass,
5509 ],
5510 );
5511 assert_eq!(plans.len(), 1);
5512 let output = apply_selected_plans(css, &plans, false).unwrap();
5513 assert!(output.contains(".node {"));
5514 assert!(output.contains(".label {"));
5515 assert!(output.contains("&:hover {"));
5516 assert!(output.contains("> .children {"));
5517 assert!(output.contains("> .node + .node {"));
5518 }
5519
5520 #[test]
5521 fn nests_in_place_input_states() {
5522 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";
5523 let plans = plan(css, &[RuleId::NestPseudoClass]);
5524 assert_eq!(plans.len(), 1);
5525 let output = apply_selected_plans(css, &plans, false).unwrap();
5526 assert!(output.contains("input {"));
5527 assert!(output.contains("&:user-invalid {"));
5528 assert!(output.contains("&:user-valid {"));
5529 assert!(output.contains("&:placeholder-shown {"));
5530 }
5531
5532 #[test]
5533 fn gathers_consecutive_conditions_by_selector() {
5534 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";
5535 let plans = plan(css, &[RuleId::NestMedia]);
5536 assert_eq!(plans.len(), 1);
5537 let output = apply_selected_plans(css, &plans, false).unwrap();
5538 assert!(output.contains(".responsive-grid {"));
5539 assert!(output.contains("@media (width >= 30rem) {"));
5540 assert!(output.contains("@media (width >= 80rem) {"));
5541 }
5542
5543 #[test]
5544 fn factors_selector_list_with_adjacent_hover() {
5545 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";
5546 let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
5547 assert_eq!(plans.len(), 1);
5548 let output = apply_selected_plans(css, &plans, false).unwrap();
5549 assert!(output.contains(".notice {"));
5550 assert!(output.contains("&,"));
5551 assert!(output.contains("&::before,"));
5552 assert!(output.contains("&::after {"));
5553 assert!(output.contains("&:hover {"));
5554 assert!(output.contains("background: color-mix"));
5555 }
5556
5557 #[test]
5558 fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
5559 let css = r#".skip-link {
5560 position: absolute;
5561 inset-block-start: -48px;
5562 inset-inline-start: 1rem;
5563 z-index: 10000000000;
5564 background: var(--bg-color);
5565 color: var(--text-color);
5566 border: 1px solid var(--border-color);
5567 border-radius: 0.5rem;
5568 padding: 0.55rem 0.8rem;
5569 text-decoration: none;
5570 font-weight: 700;
5571 transition: inset-block-start 0.2s ease;
5572
5573 &:focus-visible {
5574 inset-block-start: 0.75rem;
5575 }
5576}
5577
5578.unrelated-rule {
5579 color: red;
5580}
5581
5582.skip-link {
5583 font: optional;
5584
5585 &::after {
5586 content: '';
5587 }
5588
5589 :not(*) & {
5590 all: unset
5591 }
5592}
5593"#;
5594 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5595 assert_eq!(plans.len(), 1);
5596 let output = apply_selected_plans(css, &plans, true).unwrap();
5597 assert!(output.contains("font: optional;"));
5598 }
5599
5600 #[test]
5601 fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
5602 let css = r#".skip-link {
5603 position: absolute;
5604 inset-block-start: -48px;
5605 inset-inline-start: 1rem;
5606 z-index: 10000000000;
5607 background: var(--bg-color);
5608 color: var(--text-color);
5609 border: 1px solid var(--border-color);
5610 border-radius: 0.5rem;
5611 padding: 0.55rem 0.8rem;
5612 text-decoration: none;
5613 font-weight: 700;
5614 transition: inset-block-start 0.2s ease;
5615
5616 &:focus-visible {
5617 inset-block-start: 0.75rem;
5618 }
5619}
5620
5621.unrelated {
5622 color: red;
5623}
5624
5625.skip-link {
5626 font: optional;
5627
5628 &::after {
5629 content: '';
5630 }
5631
5632 :not(*) & {
5633 all: unset
5634 }
5635}
5636
5637.skip-link+* {
5638 display: block;
5639}
5640
5641.skip-link::backdrop {
5642 background-color: gray;
5643}
5644
5645.skip-link:has(*) {
5646 color: #27ca3f;
5647}
5648"#;
5649 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5650 assert_eq!(plans.len(), 1);
5651 let output = apply_selected_plans(css, &plans, true).unwrap();
5652 assert!(output.contains("font: optional;"));
5653 assert!(output.contains("+ * {"));
5654 assert!(output.contains("&::backdrop {"));
5655 assert!(output.contains("&:has(*) {"));
5656 }
5657
5658 #[test]
5659 fn test_modernizes_media_range_syntax_no_whitespace() {
5660 let input = "@media (max-width:60rem) { .content { width: 100%; } }";
5661 let plans = plan(input, &[RuleId::ModernizeMediaRange]);
5662 assert_eq!(plans.len(), 1);
5663 let output = apply_selected_plans(input, &plans, true).unwrap();
5664 assert!(output.contains("(width <= 60rem)"));
5665 }
5666
5667 #[test]
5668 fn plans_continue_when_lightningcss_rejects_picker_pseudo() {
5669 let css = r#".custom-select {
5672 appearance: none;
5673}
5674
5675.custom-select:hover {
5676 border-color: teal;
5677}
5678
5679.custom-select::picker(select) {
5680 background: white;
5681}
5682
5683.custom-select::picker(select)::-webkit-scrollbar {
5684 width: 6px;
5685}
5686
5687@media (max-width: 768px) {
5688 .custom-select {
5689 inline-size: 100%;
5690 }
5691}
5692"#;
5693 let report = analyze_source(PathBuf::from("picker.css"), css, &RuleId::ALL).unwrap();
5694 eprintln!(
5695 "parse_ok={} err={:?} plans={}",
5696 report.parse_ok,
5697 report.parse_error,
5698 report.plans.len()
5699 );
5700 for p in &report.plans {
5701 eprintln!(" {:?} {:?}", p.rules, p.reason);
5702 }
5703 assert!(
5704 !report.plans.is_empty(),
5705 "must still emit plans (parse_ok={:?} err={:?}): findings={:?}",
5706 report.parse_ok,
5707 report.parse_error,
5708 report.findings
5709 );
5710 let output = apply_selected_plans(css, &report.plans, true).unwrap();
5711 assert!(
5712 output.contains("&:hover") || output.contains("&::picker"),
5713 "custom-select relatives should nest: {output}"
5714 );
5715 assert!(
5716 output.contains("(width <= 768px)") || output.contains("inline-size: 100%"),
5717 "media-range or nest should still apply: {output}"
5718 );
5719 }
5720
5721 #[test]
5722 fn nesting_adds_semicolon_to_last_declaration_without_semicolon() {
5723 let css = r#".tabpanel {
5727 display: block !important
5728}
5729
5730.tabpanel+.tabpanel {
5731 margin-block-start: .5rem
5732}
5733"#;
5734 let rules = &[
5735 RuleId::NestCombinator,
5736 RuleId::NestDescendant,
5737 RuleId::NestPseudoClass,
5738 RuleId::NestPseudoElement,
5739 ];
5740 let plans = plan(css, rules);
5741 assert!(!plans.is_empty(), "expected at least one nesting plan");
5742 let output = apply_selected_plans(css, &plans, true).unwrap();
5743 assert!(
5745 !output.contains("!important+"),
5746 "semicolon missing before nested rule: {output}"
5747 );
5748 assert!(
5750 output.contains("!important;") || output.contains("!important\n"),
5751 "declaration should end with ';': {output}"
5752 );
5753 assert!(
5755 output.contains("+ .tabpanel {") || output.contains("+.tabpanel {"),
5756 "nested combinator rule missing: {output}"
5757 );
5758 }
5759
5760 #[test]
5761 fn gather_related_selector_rules_adds_semicolon_to_declarations_without_semicolon() {
5762 let css = r#".skip-link {
5765 position: absolute;
5766 font-weight: 700
5767}
5768
5769.unrelated { color: red }
5770
5771.skip-link:focus {
5772 outline: 2px solid currentColor
5773}
5774
5775.skip-link+* {
5776 display: block
5777}
5778"#;
5779 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5780 assert!(!plans.is_empty(), "expected gather plan");
5781 let output = apply_selected_plans(css, &plans, true).unwrap();
5782 assert!(
5784 !output.contains("700\n &") && !output.contains("700&") && !output.contains("700{"),
5785 "semicolon missing before nested pseudo/combinator: {output}"
5786 );
5787 assert!(
5788 !output.contains("currentColor\n + *") && !output.contains("currentColor{"),
5789 "semicolon missing before nested combinator: {output}"
5790 );
5791 assert!(
5793 output.contains("font-weight: 700;"),
5794 "missing ';' after font-weight: {output}"
5795 );
5796 assert!(
5797 output.contains("position: absolute;"),
5798 "missing ';' after position: {output}"
5799 );
5800 }
5801
5802 #[test]
5803 fn gathers_non_adjacent_related_rule_inside_media_query() {
5804 let css = r#".skip-link {
5805 position: absolute;
5806 font-weight: 700;
5807
5808 &:focus-visible {
5809 inset-block-start: 0.75rem;
5810 }
5811}
5812
5813.unrelated {
5814 color: red;
5815}
5816
5817@media (width <= 1024px) {
5818 .skip-link :not(*) {
5819 position: inherit
5820 }
5821}
5822"#;
5823 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5824 assert!(
5825 !plans.is_empty(),
5826 "expected gather plan for media-wrapped related rule"
5827 );
5828 let output = apply_selected_plans(css, &plans, true).unwrap();
5829 assert!(
5830 !output.contains(".skip-link :not(*)"),
5831 "flat media descendant should be gathered: {output}"
5832 );
5833 assert!(
5834 output.contains(":not(*) {"),
5835 "descendant :not(*) should nest under .skip-link: {output}"
5836 );
5837 assert!(
5838 output.contains("@media (width <= 1024px) {"),
5839 "media query should nest inside :not(*): {output}"
5840 );
5841 assert!(
5842 output.contains("position: inherit"),
5843 "declaration should be preserved: {output}"
5844 );
5845 let not_pos = output.find(":not(*) {").expect(":not(*)");
5846 let media_pos = output.find("@media (width <= 1024px) {").expect("@media");
5847 assert!(
5848 media_pos > not_pos,
5849 "media must nest inside :not(*), not the reverse: {output}"
5850 );
5851 }
5852
5853 #[test]
5854 fn gathers_exact_parent_inside_media_as_nested_at_rule() {
5855 let css = r#".skip-link {
5856 position: absolute;
5857}
5858
5859.unrelated { color: red }
5860
5861@media (width <= 600px) {
5862 .skip-link {
5863 display: none
5864 }
5865}
5866"#;
5867 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5868 let output = apply_selected_plans(css, &plans, true).unwrap();
5869 assert!(output.contains("@media (width <= 600px) {"), "{output}");
5870 assert!(output.contains("display: none;"), "{output}");
5871 assert!(
5872 !output.contains("@media (width <= 600px) {\n .skip-link"),
5873 "should invert to .skip-link {{ @media }}: {output}"
5874 );
5875 }
5876
5877 #[test]
5878 fn gather_media_leaves_unrelated_siblings_in_place() {
5879 let css = r#".skip-link {
5880 position: absolute;
5881}
5882
5883.unrelated { color: red }
5884
5885@media (width <= 1024px) {
5886 .skip-link :not(*) {
5887 position: inherit
5888 }
5889 .other {
5890 color: blue
5891 }
5892}
5893"#;
5894 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5895 let output = apply_selected_plans(css, &plans, true).unwrap();
5896 assert!(output.contains(":not(*) {"), "{output}");
5897 assert!(
5898 output.contains(".other") && output.contains("color: blue"),
5899 "unrelated sibling must stay in the media block: {output}"
5900 );
5901 }
5902
5903 #[test]
5904 fn nests_appended_nesting_selector_descendant() {
5905 let css = ".card {\n color: red;\n}\n.featured .card {\n border: 1px solid;\n}\n";
5907 let plans = plan(css, &[RuleId::NestDescendant]);
5908 assert_eq!(plans.len(), 1);
5909 let output = apply_selected_plans(css, &plans, false).unwrap();
5910 assert!(
5911 output.contains(".featured & {"),
5912 "expected appended &: {output}"
5913 );
5914 assert!(output.contains("border: 1px solid;"), "{output}");
5915 }
5916
5917 #[test]
5918 fn nests_appended_nesting_selector_not() {
5919 let css = ".card {\n color: red;\n}\n:not(.card) {\n display: none;\n}\n";
5921 let plans = plan(css, &[RuleId::NestPseudoClass]);
5922 assert_eq!(plans.len(), 1);
5923 let output = apply_selected_plans(css, &plans, false).unwrap();
5924 assert!(output.contains(":not(&) {"), "expected :not(&): {output}");
5925 assert!(output.contains("display: none;"), "{output}");
5926 }
5927
5928 #[test]
5929 fn nests_appended_compound_selector() {
5930 let css = ".card {\n color: red;\n}\n.featured.card {\n font-weight: 700;\n}\n";
5931 let plans = plan(css, &[RuleId::NestCompound]);
5932 assert_eq!(plans.len(), 1);
5933 let output = apply_selected_plans(css, &plans, false).unwrap();
5934 assert!(
5935 output.contains(".featured& {"),
5936 "expected compound appended &: {output}"
5937 );
5938 }
5939
5940 #[test]
5941 fn gathers_non_adjacent_appended_nesting_selector() {
5942 let css = r#".card {
5943 color: red;
5944}
5945
5946.unrelated { color: blue }
5947
5948.featured .card {
5949 border: 1px solid
5950}
5951
5952:not(.card) {
5953 display: none
5954}
5955"#;
5956 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
5957 assert!(!plans.is_empty());
5958 let output = apply_selected_plans(css, &plans, true).unwrap();
5959 assert!(output.contains(".featured & {"), "{output}");
5960 assert!(output.contains(":not(&) {"), "{output}");
5961 assert!(!output.contains(".featured .card"), "{output}");
5962 assert!(!output.contains(":not(.card)"), "{output}");
5963 }
5964
5965 #[test]
5966 fn gather_does_not_nest_selector_list_under_one_branch() {
5967 let css = r#"::view-transition-old(root),
5970::view-transition-new(root) {
5971 position: absolute;
5972 inset: 0;
5973 animation: 0.55s ease-in-out both;
5974}
5975
5976.unrelated { color: red }
5977
5978::view-transition-old(root) {
5979 animation-name: fadeOut;
5980}
5981
5982::view-transition-new(root) {
5983 animation-name: fadeIn;
5984}
5985"#;
5986 let plans = plan(
5987 css,
5988 &[
5989 RuleId::GatherRelatedSelectorRules,
5990 RuleId::FactorSelectorList,
5991 ],
5992 );
5993 let output = apply_selected_plans(css, &plans, true).unwrap();
5994 assert!(
5995 !output.contains("::view-transition-old(root),\n &")
5996 && !output.contains("::view-transition-old(root),\n & {")
5997 && !output.contains("::view-transition-old(root),\n &"),
5998 "selector list must not nest under one branch: {output}"
5999 );
6000 assert!(
6001 output.contains("::view-transition-old(root)")
6002 && output.contains("::view-transition-new(root)")
6003 && output.contains("animation-name: fadeOut")
6004 && output.contains("animation-name: fadeIn"),
6005 "both branches and their unique decls must remain: {output}"
6006 );
6007 assert!(
6008 output.contains("position: absolute"),
6009 "shared declarations must be kept: {output}"
6010 );
6011 }
6012
6013 #[test]
6014 fn gather_nests_not_focus_visible_as_non_relative_amp() {
6015 assert!(selector_contains_nesting_amp(".skip-link:focus:not(&)"));
6019 assert!(!selector_contains_nesting_amp(".skip-link:focus"));
6020
6021 let css = r#":focus-visible {
6022 outline: 2px solid blue;
6023}
6024
6025.unrelated { color: red }
6026
6027/* Preserve visible focus for skip-link activation (any modality). */
6028.skip-link:focus:not(:focus-visible) {
6029 outline: 2px solid blue;
6030}
6031"#;
6032 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6033 let output = apply_selected_plans(css, &plans, true).unwrap();
6034 assert!(
6035 output.contains(".skip-link:focus:not(&)"),
6036 "should nest as non-relative :not(&): {output}"
6037 );
6038 assert!(
6039 !output.contains(":focus-visible .skip-link")
6040 && !output.contains(":focus-visible .skip-link"),
6041 "must not insert a descendant combinator: {output}"
6042 );
6043 assert!(
6044 output.contains("Preserve visible focus for skip-link activation"),
6045 "leading comment must move with the gathered rule: {output}"
6046 );
6047 let cmt = output.find("Preserve visible focus").expect("comment");
6048 let nest = output.find(".skip-link:focus:not(&)").expect("nest");
6049 assert!(
6050 cmt < nest,
6051 "comment should precede the nested rule: {output}"
6052 );
6053 }
6054
6055 #[test]
6056 fn gather_nests_skip_link_focus_under_skip_link_not_focus_visible() {
6057 let css = r#":focus-visible {
6058 outline: 2px solid blue;
6059}
6060
6061.skip-link {
6062 position: fixed;
6063}
6064
6065.unrelated { color: red }
6066
6067.skip-link:focus:not(:focus-visible) {
6068 outline: 2px solid blue;
6069}
6070"#;
6071 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6072 let output = apply_selected_plans(css, &plans, true).unwrap();
6073 assert!(
6074 output.contains("&:focus:not(:focus-visible)"),
6075 "should nest under .skip-link: {output}"
6076 );
6077 assert!(
6078 !output.contains(".skip-link:focus:not(&)"),
6079 "must not nest under :focus-visible: {output}"
6080 );
6081 }
6082
6083 #[test]
6084 fn gather_merges_same_nested_selector_from_style_and_media() {
6085 let css = r#".demo-out-text {
6086 font-size: 1.5rem;
6087
6088 &.flash {
6089 animation: demo-out-flash .4s ease;
6090 }
6091}
6092
6093.unrelated { color: red }
6094
6095@media (prefers-reduced-motion: reduce) {
6096 .demo-out-text.flash {
6097 animation: none
6098 }
6099}
6100"#;
6101 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6102 let output = apply_selected_plans(css, &plans, true).unwrap();
6103 let flash_opens = output.matches("&.flash {").count();
6104 assert_eq!(
6105 flash_opens, 1,
6106 "duplicate &.flash nests should merge: {output}"
6107 );
6108 assert!(output.contains("animation: demo-out-flash"), "{output}");
6109 assert!(
6110 output.contains("@media (prefers-reduced-motion: reduce)"),
6111 "{output}"
6112 );
6113 assert!(output.contains("animation: none"), "{output}");
6114 }
6115
6116 #[test]
6117 fn gather_prefers_existing_specific_home_over_universal_append() {
6118 let css = r#".skip-link {
6122 position: absolute;
6123 font-weight: 700;
6124}
6125
6126* {
6127 margin: 0;
6128}
6129
6130.unrelated { color: red }
6131
6132.skip-link+* {
6133 display: block
6134}
6135
6136.skip-link:has(*) {
6137 color: #27ca3f
6138}
6139"#;
6140 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6141 let output = apply_selected_plans(css, &plans, true).unwrap();
6142 assert!(
6143 output.contains("+ * {") || output.contains("+* {"),
6144 "combinator should nest under .skip-link: {output}"
6145 );
6146 assert!(
6147 output.contains("&:has(*) {"),
6148 ":has(*) should nest under .skip-link: {output}"
6149 );
6150 assert!(
6151 !output.contains(".skip-link+&")
6152 && !output.contains(".skip-link + &")
6153 && !output.contains(".skip-link:has(&)"),
6154 "must not append the same rules into *: {output}"
6155 );
6156 let star = output.find("\n* {").or_else(|| output.find("* {"));
6157 if let Some(star_at) = star {
6158 let after_star = &output[star_at..];
6159 let star_body = after_star.split('}').next().unwrap_or(after_star);
6160 assert!(
6161 !star_body.contains("skip-link"),
6162 "* must not absorb skip-link rules: {output}"
6163 );
6164 }
6165 }
6166
6167 #[test]
6168 fn gather_appends_only_when_no_prefix_home_exists() {
6169 let css = r#".card {
6170 color: red;
6171}
6172
6173.unrelated { color: blue }
6174
6175.featured .card {
6176 border: 1px solid
6177}
6178"#;
6179 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6180 let output = apply_selected_plans(css, &plans, true).unwrap();
6181 assert!(output.contains(".featured & {"), "{output}");
6182 assert!(!output.contains(".featured .card"), "{output}");
6183 }
6184
6185 #[test]
6186 fn gather_keeps_supports_block_with_comma_list_and_mixed_homes() {
6187 let css = r#".custom-select {
6188 color: navy;
6189}
6190
6191.unrelated { color: red }
6192
6193@supports (appearance: base-select) {
6194 .custom-select,
6195 .custom-select::picker(select) {
6196 appearance: base-select;
6197 }
6198 .custom-select button { display: flex }
6199 selectedcontent { display: flex }
6200 .custom-select option { padding: 1rem }
6201}
6202"#;
6203 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6204 let output = apply_selected_plans(css, &plans, true).unwrap();
6205 assert!(
6206 output.contains("appearance: base-select"),
6207 "comma-list body must not be dropped: {output}"
6208 );
6209 assert!(
6210 output.contains("selectedcontent") && output.contains(".custom-select button")
6211 || output.contains("button {"),
6212 "mixed @supports inners must remain: {output}"
6213 );
6214 }
6215
6216 #[test]
6217 fn gather_keeps_busy_mixed_media_grouped() {
6218 let css = r#".nav-links { display: flex; }
6219.hamburger-menu { display: none; }
6220.nav-controls { gap: 1rem; }
6221
6222.unrelated { color: red }
6223
6224@media (width <= 1024px) {
6225 .nav-links { display: none }
6226 .hamburger-menu { display: flex }
6227 .nav-controls { margin-inline-start: auto }
6228}
6229"#;
6230 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6231 let output = apply_selected_plans(css, &plans, true).unwrap();
6232 assert!(
6233 output.contains("@media (width <= 1024px)"),
6234 "mixed media with 3+ selectors should stay grouped: {output}"
6235 );
6236 assert!(
6237 !output.contains(".nav-links {\n display: flex;\n\n @media")
6238 && !output.contains(".hamburger-menu {\n display: none;\n\n @media"),
6239 "should not explode a busy media query into each parent: {output}"
6240 );
6241 }
6242
6243 #[test]
6244 fn appended_nesting_does_not_match_ident_suffix() {
6245 let css = ".card {\n color: red;\n}\n.mycard {\n color: blue;\n}\n";
6246 let plans = plan(css, &[RuleId::NestCompound, RuleId::NestDescendant]);
6247 assert!(
6248 plans.is_empty(),
6249 ".mycard must not nest under .card: {:?}",
6250 plans.iter().map(|p| &p.proposed).collect::<Vec<_>>()
6251 );
6252 }
6253
6254 #[test]
6255 fn gather_preserves_nested_selector_lists() {
6256 let css = r#"* {
6258 margin: 0;
6259}
6260
6261.unrelated { color: red }
6262
6263@media (prefers-reduced-motion: reduce) {
6264 * {
6265 &,
6266 &::before,
6267 &::after {
6268 animation-duration: 0.001ms !important
6269 }
6270 }
6271}
6272"#;
6273 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6274 let output = apply_selected_plans(css, &plans, true).unwrap();
6275 assert!(
6276 !output.contains("&::before,\n ;")
6277 && !output.contains("&::before,;")
6278 && !output.contains("&,\n ;")
6279 && !output.contains("&,;"),
6280 "selector-list comma must not become a declaration terminator: {output}"
6281 );
6282 assert!(
6283 output.contains("&::before,") && output.contains("&::after {"),
6284 "compound pseudo selector list must stay intact: {output}"
6285 );
6286 let before = output.find("&::before,").expect("before");
6287 let after = output.find("&::after {").expect("after");
6288 let between = &output[before..after];
6289 assert!(
6290 !between.contains(';'),
6291 "no semicolon between selector-list items: {between:?} in {output}"
6292 );
6293 }
6294
6295 #[test]
6296 fn gather_keeps_every_custom_select_chrome_rule() {
6297 let css = r#".custom-select {
6298 color: navy;
6299}
6300
6301@supports (appearance: base-select) {
6302 .custom-select,
6303 .custom-select::picker(select) {
6304 appearance: base-select;
6305 }
6306 .custom-select button {
6307 display: flex;
6308 }
6309 .custom-select button:hover {
6310 border-color: teal;
6311 }
6312 .custom-select:focus button,
6313 .custom-select button:focus-visible {
6314 outline: none;
6315 }
6316 .custom-select .select-arrow {
6317 color: gray;
6318 }
6319 .custom-select:open .select-arrow {
6320 rotate: -180deg;
6321 }
6322 .custom-select:open button {
6323 border-color: teal;
6324 }
6325 selectedcontent {
6326 display: flex;
6327 }
6328 selectedcontent .opt-icon {
6329 display: none;
6330 }
6331 .custom-select::picker(select) {
6332 background: white;
6333 }
6334 .custom-select:not(:open)::picker(select) {
6335 opacity: 0;
6336 }
6337 .custom-select option {
6338 padding: 1rem;
6339 }
6340 .custom-select .dropdown-search-container {
6341 position: sticky;
6342 }
6343 .custom-select .select-search {
6344 inline-size: 100%;
6345 }
6346 .custom-select option .opt-icon {
6347 color: gray;
6348 }
6349 .custom-select option:hover .opt-icon,
6350 .custom-select option:checked .opt-icon {
6351 color: teal;
6352 }
6353 .custom-select::picker-icon {
6354 display: none;
6355 }
6356}
6357"#;
6358 let rules: Vec<RuleId> = RuleId::ALL
6359 .iter()
6360 .copied()
6361 .filter(|r| *r != RuleId::ModernizeWhere)
6362 .collect();
6363 let plans = plan(css, &rules);
6364 let output = apply_selected_plans(css, &plans, true).unwrap();
6365 for needle in [
6366 "appearance: base-select",
6367 "display: flex",
6368 "border-color: teal",
6369 "outline: none",
6370 "color: gray",
6371 "rotate: -180deg",
6372 "selectedcontent",
6373 "display: none",
6374 "background: white",
6375 "opacity: 0",
6376 "padding: 1rem",
6377 "position: sticky",
6378 "inline-size: 100%",
6379 "::picker-icon",
6380 ] {
6381 assert!(
6382 output.contains(needle),
6383 "lost `{needle}` after apply:\n{output}"
6384 );
6385 }
6386 assert!(
6387 output.contains("button") && output.contains("&:hover"),
6388 "button:hover should nest as button {{ &:hover }}:\n{output}"
6389 );
6390 assert!(
6391 !output.contains("&:open &") && !output.contains("&:focus &"),
6392 "must not rewrite :open/:focus descendants as appended &:\n{output}"
6393 );
6394 assert!(
6395 output.contains("&:open")
6396 && (output.contains("&:open {")
6397 || output.contains("&:open .select-arrow")
6398 || output.contains("&:open button")),
6399 ":open children should group under .custom-select:\n{output}"
6400 );
6401 assert!(
6402 output.contains("&:focus button") || output.contains("button:focus-visible"),
6403 "focus comma-list must stay as relative branches:\n{output}"
6404 );
6405 assert!(
6406 output.contains("selectedcontent")
6407 && (output.contains(".opt-icon") || output.contains("& .opt-icon")),
6408 "selectedcontent leftover must remain:\n{output}"
6409 );
6410 assert!(
6411 output.contains(".custom-select")
6412 && output.contains("@supports (appearance: base-select)"),
6413 "supports should invert under .custom-select:\n{output}"
6414 );
6415 let home = output.find(".custom-select {").expect("home");
6416 let supports = output
6417 .find("@supports (appearance: base-select)")
6418 .expect("supports");
6419 assert!(
6420 supports > home,
6421 "appearance supports should sit inside .custom-select:\n{output}"
6422 );
6423 assert!(
6424 !output.contains("@supports (appearance: base-select) {\n .custom-select"),
6425 "must not leave a wrapper .custom-select inside @supports:\n{output}"
6426 );
6427 }
6428
6429 #[test]
6430 fn gather_folds_unlayered_picker_into_layered_home() {
6431 let css = r#"@layer components {
6432 .custom-select {
6433 color: navy;
6434 &:is(:hover) { color: teal; }
6435 }
6436}
6437
6438.custom-select::picker(select) {
6439 scrollbar-width: thin;
6440}
6441.custom-select::picker(select)::-webkit-scrollbar {
6442 width: 6px;
6443}
6444"#;
6445 let plans = plan(
6446 css,
6447 &[
6448 RuleId::GatherRelatedSelectorRules,
6449 RuleId::NestLayerBySelector,
6450 ],
6451 );
6452 let output = apply_selected_plans(css, &plans, true).unwrap();
6453 assert!(
6454 output.contains("@layer components") && output.contains(".custom-select"),
6455 "layered home must stay in its layer:\n{output}"
6456 );
6457 assert!(
6458 output.contains(".custom-select::picker(select)")
6459 || output.contains("&::picker(select)"),
6460 "picker chrome must remain:\n{output}"
6461 );
6462 let layer_at = output.find("@layer components").expect("layer");
6463 let picker_flat = output.find(".custom-select::picker(select)");
6464 if let Some(picker_at) = picker_flat {
6465 assert!(
6466 picker_at < layer_at || !output[layer_at..].contains("scrollbar-width"),
6467 "unlayered picker must not be dumped into @layer components:\n{output}"
6468 );
6469 }
6470 }
6471
6472 #[test]
6473 fn gather_does_not_dump_root_from_base_into_tokens() {
6474 let css = r#"@layer tokens {
6475 :root {
6476 color-scheme: light dark;
6477 }
6478}
6479
6480@layer base {
6481 :root {
6482 scroll-behavior: smooth;
6483 }
6484 body { margin: 0; }
6485}
6486"#;
6487 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6488 let output = apply_selected_plans(css, &plans, true).unwrap();
6489 assert!(
6490 output.contains("@layer tokens") && output.contains("@layer base"),
6491 "both layers must remain:\n{output}"
6492 );
6493 let tokens = output.split("@layer base").next().unwrap_or(&output);
6494 assert!(
6495 !tokens.contains("scroll-behavior"),
6496 "base :root decls must not move into tokens:\n{output}"
6497 );
6498 assert!(
6499 output.contains("scroll-behavior: smooth"),
6500 "base :root decls must survive:\n{output}"
6501 );
6502 }
6503
6504 #[test]
6505 fn nest_layer_by_selector_preserves_layer_identity() {
6506 let css = r#"@layer tokens {
6507 :root {
6508 color-scheme: light dark;
6509 }
6510}
6511
6512@layer base {
6513 :root {
6514 scroll-behavior: smooth;
6515 }
6516 body { margin: 0; }
6517}
6518"#;
6519 let plans = plan(css, &[RuleId::NestLayerBySelector]);
6520 let output = apply_selected_plans(css, &plans, true).unwrap();
6521 assert!(
6522 output.contains(":root {")
6523 && output.contains("@layer tokens {")
6524 && output.contains("@layer base {"),
6525 "should hoist :root and nest named layers:\n{output}"
6526 );
6527 assert!(
6528 output.contains("color-scheme: light dark")
6529 && output.contains("scroll-behavior: smooth")
6530 && output.contains("body"),
6531 "all declarations must survive:\n{output}"
6532 );
6533 assert!(
6535 !output.contains("@layer tokens {\n :root")
6536 || output.find(":root {").unwrap() < output.find("@layer tokens {").unwrap_or(0)
6537 || output.matches("@layer tokens").count() >= 1,
6538 "{output}"
6539 );
6540 let root_at = output.find(":root {").expect("root");
6541 let tokens_inner = output[root_at..]
6542 .find("@layer tokens {")
6543 .expect("nested tokens");
6544 let base_inner = output[root_at..]
6545 .find("@layer base {")
6546 .expect("nested base");
6547 assert!(
6548 tokens_inner < base_inner,
6549 "layer order tokens then base must be preserved:\n{output}"
6550 );
6551 assert!(
6552 output.contains("body {") || output.contains("body{"),
6553 "unrelated base rules stay in @layer base:\n{output}"
6554 );
6555 }
6556
6557 #[test]
6558 fn nest_layer_blocks_are_not_factored_into_anonymous_layer() {
6559 let css = r#":root {
6563 @layer tokens {
6564 color-scheme: light dark;
6565 }
6566
6567 @layer base {
6568 scroll-behavior: smooth;
6569 }
6570}
6571
6572:root {
6573 scrollbar-width: thin;
6574}
6575"#;
6576 let plans = plan(
6577 css,
6578 &[
6579 RuleId::GatherRelatedSelectorRules,
6580 RuleId::NestLayerBySelector,
6581 ],
6582 );
6583 let output = apply_selected_plans(css, &plans, true).unwrap();
6584 assert!(
6585 output.contains("@layer tokens") && output.contains("@layer base"),
6586 "named layers must stay named:\n{output}"
6587 );
6588 assert!(
6589 !output.contains("@layer {\n tokens")
6590 && !output.contains("@layer {\n tokens")
6591 && !output.contains("@layer {\n base"),
6592 "must not wrap layer names as type selectors:\n{output}"
6593 );
6594 assert!(
6595 output.contains("color-scheme: light dark")
6596 && output.contains("scroll-behavior: smooth")
6597 && output.contains("scrollbar-width: thin"),
6598 "declarations must survive:\n{output}"
6599 );
6600 }
6601
6602 #[test]
6603 fn nest_layer_skips_when_unlayered_rule_intervenes() {
6604 let css = r#"@layer base {
6605 .container { color: red; }
6606}
6607
6608.some-rule { color: blue; }
6609
6610@layer layout {
6611 .container { color: green; }
6612}
6613"#;
6614 let plans = plan(css, &[RuleId::NestLayerBySelector]);
6615 let output = apply_selected_plans(css, &plans, true).unwrap();
6616 assert!(
6617 output.contains("@layer base")
6618 && output.contains("@layer layout")
6619 && output.contains(".some-rule"),
6620 "intervening unlayered rule blocks the hoist:\n{output}"
6621 );
6622 assert!(
6623 !output.contains(".container {\n @layer base"),
6624 "must not move layout across .some-rule:\n{output}"
6625 );
6626 }
6627
6628 #[test]
6629 fn gather_factors_compact_descendants_under_option_card() {
6630 let css = r#".option-card.compact .option-label {
6631 padding: 1rem;
6632}
6633
6634.option-card.compact .option-icon {
6635 inline-size: 24px;
6636}
6637
6638.unrelated { color: red }
6639
6640.option-card {
6641 position: relative;
6642 cursor: pointer;
6643}
6644"#;
6645 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6646 let output = apply_selected_plans(css, &plans, true).unwrap();
6647 assert!(
6648 output.contains("&.compact")
6649 && output.contains(".option-label")
6650 && output.contains(".option-icon"),
6651 "compact descendants should nest under .option-card:\n{output}"
6652 );
6653 assert!(
6654 output.contains("&.compact {")
6655 && output.contains(".option-label {")
6656 && output.contains(".option-icon {"),
6657 "shared &.compact prefix should be factored:\n{output}"
6658 );
6659 assert!(output.contains("position: relative"), "{output}");
6660 assert!(output.contains("padding: 1rem"), "{output}");
6661 assert!(output.contains("inline-size: 24px"), "{output}");
6662 }
6663
6664 #[test]
6665 fn gather_does_not_orphan_deletes_when_shorter_nest_wins() {
6666 let css = r#".custom-select button {
6667 display: flex;
6668}
6669.custom-select button:hover {
6670 color: red;
6671}
6672
6673.unrelated { color: blue }
6674
6675.custom-select::picker(select) {
6676 background: white;
6677}
6678.custom-select::picker-icon {
6679 display: none;
6680}
6681"#;
6682 let rules = &[
6683 RuleId::NestPseudoClass,
6684 RuleId::NestDescendant,
6685 RuleId::GatherRelatedSelectorRules,
6686 ];
6687 let plans = plan(css, rules);
6688 let output = apply_selected_plans(css, &plans, true).unwrap();
6689 assert!(
6690 output.contains("background: white") && output.contains("display: none"),
6691 "picker chrome must survive mixed nest+gather:\n{output}"
6692 );
6693 assert!(
6694 output.contains("display: flex") && output.contains("color: red"),
6695 "button rules must survive:\n{output}"
6696 );
6697 }
6698}