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