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) || selector_contains_nesting_amp(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 base = base.trim();
1677 let candidate_sel = candidate_sel.trim();
1678 if candidate_sel.ends_with(base)
1683 && let Some(home) = extract_base_target(candidate_sel)
1684 && home != base
1685 {
1686 return None;
1687 }
1688 let nested = appended_nesting_selector_raw(base, candidate_sel)?;
1689 selector_contains_nesting_amp(&nested).then_some(nested)
1692}
1693
1694fn appended_selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
1695 let nested = appended_nesting_selector(parent, child)?;
1696 let before_amp = nested.strip_suffix('&').unwrap_or(nested.as_str());
1697 let kind = if nested.contains(":not(")
1698 || nested.contains(":is(")
1699 || nested.contains(":where(")
1700 || nested.contains(":has(")
1701 {
1702 RelationKind::PseudoClass
1703 } else if before_amp.contains('>') || before_amp.contains('+') || before_amp.contains('~') {
1704 RelationKind::Combinator
1705 } else if nested.ends_with('&')
1706 && before_amp
1707 .chars()
1708 .last()
1709 .is_some_and(|c| !c.is_whitespace())
1710 {
1711 RelationKind::Compound
1712 } else {
1713 RelationKind::Descendant
1714 };
1715 Some((kind, nested))
1716}
1717
1718#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1719enum RelatedKind {
1720 Prefix,
1721 Appended,
1722}
1723
1724fn explicit_relative_combinator(combinator: char, selector: &str) -> String {
1730 format!("& {combinator} {}", selector.trim())
1731}
1732
1733fn starts_with_explicit_combinator(selector: &str) -> bool {
1734 let Some(rest) = selector.trim().strip_prefix('&') else {
1735 return false;
1736 };
1737 rest.trim_start()
1738 .chars()
1739 .next()
1740 .is_some_and(|c| matches!(c, '>' | '+' | '~'))
1741}
1742
1743fn prefix_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1744 if candidate_sel == base || !candidate_sel.starts_with(base) {
1745 return None;
1746 }
1747 let rem_raw = &candidate_sel[base.len()..];
1748 let rem = rem_raw.trim_start();
1749 if rem.is_empty() {
1750 return None;
1751 }
1752 let directly_attached = !rem_raw.starts_with(|c: char| c.is_whitespace());
1755 if rem.starts_with(':') || rem.starts_with('[') || rem.starts_with('.') || rem.starts_with('#')
1756 {
1757 if directly_attached {
1758 return Some(format!("&{rem}"));
1759 } else {
1760 return Some(rem.to_string());
1761 }
1762 }
1763 if rem.starts_with('+') || rem.starts_with('>') || rem.starts_with('~') {
1764 let first_char = rem.chars().next()?;
1765 let rest = rem[1..].trim_start();
1766 return Some(explicit_relative_combinator(first_char, rest));
1767 }
1768 if rem_raw.starts_with(' ') {
1769 return Some(rem.to_string());
1770 }
1771 None
1772}
1773
1774fn classify_related_nested_one(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1775 if candidate_sel == base || contains_top_level_comma(candidate_sel) {
1776 return None;
1777 }
1778 if let Some(rel) = prefix_related_nested_selector(base, candidate_sel) {
1779 return Some((RelatedKind::Prefix, rel));
1780 }
1781 appended_nesting_selector(base, candidate_sel).map(|rel| (RelatedKind::Appended, rel))
1782}
1783
1784fn classify_related_comma_list(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1785 let parts: Vec<&str> = split_top_level_comma(candidate_sel)
1786 .into_iter()
1787 .map(str::trim)
1788 .filter(|s| !s.is_empty())
1789 .collect();
1790 if parts.len() < 2 {
1791 return None;
1792 }
1793 let mut rels = Vec::with_capacity(parts.len());
1794 let mut all_prefix = true;
1795 for part in parts {
1796 if part == base {
1797 rels.push("&".to_string());
1798 continue;
1799 }
1800 let (kind, rel) = classify_related_nested_one(base, part)?;
1801 if kind != RelatedKind::Prefix {
1802 all_prefix = false;
1803 }
1804 rels.push(rel);
1805 }
1806 let kind = if all_prefix {
1807 RelatedKind::Prefix
1808 } else {
1809 RelatedKind::Appended
1810 };
1811 Some((kind, rels.join(", ")))
1812}
1813
1814fn classify_related_nested(base: &str, candidate_sel: &str) -> Option<(RelatedKind, String)> {
1815 if candidate_sel == base {
1816 return None;
1817 }
1818 if contains_top_level_comma(candidate_sel) {
1819 return classify_related_comma_list(base, candidate_sel);
1820 }
1821 classify_related_nested_one(base, candidate_sel)
1822}
1823
1824fn extract_related_nested_selector(base: &str, candidate_sel: &str) -> Option<String> {
1825 classify_related_nested(base, candidate_sel).map(|(_, rel)| rel)
1826}
1827
1828fn is_weak_gather_base(base: &str) -> bool {
1829 matches!(base.trim(), "*" | "html" | "body" | ":root" | ":host")
1830}
1831
1832fn selector_contains_nesting_amp(selector: &str) -> bool {
1838 let bytes = selector.as_bytes();
1839 let mut i = 0;
1840 let mut quote: Option<u8> = None;
1841 let mut escaped = false;
1842 while i < bytes.len() {
1843 let b = bytes[i];
1844 if let Some(q) = quote {
1845 if escaped {
1846 escaped = false;
1847 } else if b == b'\\' {
1848 escaped = true;
1849 } else if b == q {
1850 quote = None;
1851 }
1852 i += 1;
1853 continue;
1854 }
1855 match b {
1856 b'\'' | b'"' => quote = Some(b),
1857 b'&' => return true,
1858 _ => {}
1859 }
1860 i += 1;
1861 }
1862 false
1863}
1864
1865fn leading_block_comment(source: &str, node_start: usize) -> Option<(usize, &str)> {
1867 let before = source.get(..node_start)?;
1868 let start = before.rfind("/*")?;
1869 let close_rel = source.get(start + 2..node_start)?.find("*/")?;
1870 let end = start + 2 + close_rel + 2;
1871 if !source[end..node_start]
1872 .bytes()
1873 .all(|b| b.is_ascii_whitespace())
1874 {
1875 return None;
1876 }
1877 Some((start, source[start..end].trim_end()))
1878}
1879
1880fn is_simple_compound_selector(sel: &str) -> bool {
1881 let sel = sel.trim();
1882 if sel.is_empty()
1883 || sel.starts_with('&')
1884 || sel.starts_with('+')
1885 || sel.starts_with('>')
1886 || sel.starts_with('~')
1887 || contains_top_level_comma(sel)
1888 {
1889 return false;
1890 }
1891 let mut paren = 0usize;
1892 let mut brack = 0usize;
1893 for c in sel.chars() {
1894 match c {
1895 '(' => paren += 1,
1896 ')' => paren = paren.saturating_sub(1),
1897 '[' => brack += 1,
1898 ']' => brack = brack.saturating_sub(1),
1899 _ if paren == 0
1900 && brack == 0
1901 && (c.is_whitespace() || matches!(c, '+' | '>' | '~')) =>
1902 {
1903 return false;
1904 }
1905 _ => {}
1906 }
1907 }
1908 true
1909}
1910
1911fn first_compound_stripped(sel: &str) -> Option<&str> {
1912 let sel = sel.trim();
1913 if sel.is_empty() || sel.starts_with('&') {
1914 return None;
1915 }
1916 let mut paren = 0usize;
1917 let mut brack = 0usize;
1918 let mut end = sel.len();
1919 for (i, c) in sel.char_indices() {
1920 match c {
1921 '(' => paren += 1,
1922 ')' => paren = paren.saturating_sub(1),
1923 '[' => brack += 1,
1924 ']' => brack = brack.saturating_sub(1),
1925 _ if paren == 0
1926 && brack == 0
1927 && (c.is_whitespace() || matches!(c, '+' | '>' | '~' | ',')) =>
1928 {
1929 end = i;
1930 break;
1931 }
1932 _ => {}
1933 }
1934 }
1935 let head = sel[..end].trim();
1936 if head.is_empty() || head.starts_with(':') || head.starts_with('[') {
1937 return None;
1938 }
1939 paren = 0;
1940 brack = 0;
1941 for (i, c) in head.char_indices() {
1942 match c {
1943 '(' => paren += 1,
1944 ')' => paren = paren.saturating_sub(1),
1945 '[' if paren == 0 && i > 0 => return Some(&head[..i]),
1946 ':' if paren == 0 && brack == 0 && i > 0 => return Some(&head[..i]),
1947 _ => {}
1948 }
1949 }
1950 Some(head)
1951}
1952
1953fn style_body_weight(source: &str, node: &SourceNode) -> usize {
1954 node.body(source)
1955 .map(|b| b.lines().filter(|l| !l.trim().is_empty()).count())
1956 .unwrap_or(0)
1957}
1958
1959fn assign_gather_home<'a>(
1963 sel: &str,
1964 exact_homes: &[&'a str],
1965 home_weight: &HashMap<&'a str, usize>,
1966) -> Option<&'a str> {
1967 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1968 struct Rank {
1969 spec: Specificity,
1970 is_prefix: bool,
1971 weight: usize,
1972 len: usize,
1973 }
1974
1975 let mut best: Option<(Rank, &'a str)> = None;
1976 let consider =
1977 |best: &mut Option<(Rank, &'a str)>, home: &'a str, kind: Option<RelatedKind>| {
1978 let is_prefix = matches!(kind, None | Some(RelatedKind::Prefix));
1979 if matches!(kind, Some(RelatedKind::Appended)) && is_weak_gather_base(home) {
1980 return;
1981 }
1982 let rank = Rank {
1983 spec: calculate_specificity(home),
1984 is_prefix,
1985 weight: home_weight.get(home).copied().unwrap_or(0),
1986 len: home.len(),
1987 };
1988 if best.as_ref().is_none_or(|(cur, _)| rank > *cur) {
1989 *best = Some((rank, home));
1990 }
1991 };
1992
1993 for &home in exact_homes {
1994 if sel == home || home_weight.get(home).copied().unwrap_or(0) == 0 {
1995 continue;
1996 }
1997 if let Some((kind, _)) = classify_related_nested(home, sel) {
1998 consider(&mut best, home, Some(kind));
1999 }
2000 }
2001 if best.is_some() {
2002 return best.map(|(_, home)| home);
2003 }
2004 for &home in exact_homes {
2008 if sel == home || home_weight.get(home).copied().unwrap_or(0) != 0 {
2009 continue;
2010 }
2011 if let Some((kind, _)) = classify_related_nested(home, sel) {
2012 consider(&mut best, home, Some(kind));
2013 }
2014 }
2015 best.map(|(_, home)| home)
2016}
2017
2018const GATHERABLE_CONDITIONALS: &[&str] = &["media", "supports", "container", "starting-style"];
2019
2020fn is_gatherable_conditional(node: &SourceNode) -> bool {
2021 matches!(
2022 &node.kind,
2023 NodeKind::AtBlock { name, .. } if GATHERABLE_CONDITIONALS.contains(&name.as_str())
2024 )
2025}
2026
2027fn collect_conditional_style_leaves(source: &str, node: &SourceNode, out: &mut Vec<SourceNode>) {
2028 let Some(body_range) = &node.body_range else {
2029 return;
2030 };
2031 for child in scan_nodes(source, body_range.clone()) {
2032 match &child.kind {
2033 NodeKind::Style => out.push(child),
2034 NodeKind::AtBlock { .. } if is_gatherable_conditional(&child) => {
2035 collect_conditional_style_leaves(source, &child, out);
2036 }
2037 _ => {}
2038 }
2039 }
2040}
2041
2042fn conditional_tree_is_pure(source: &str, node: &SourceNode) -> bool {
2043 let Some(body_range) = &node.body_range else {
2044 return false;
2045 };
2046 let inner = scan_nodes(source, body_range.clone());
2047 if inner.is_empty() {
2048 return false;
2049 }
2050 let has_style = inner.iter().any(|n| matches!(n.kind, NodeKind::Style));
2051 let has_nested_at = inner.iter().any(is_gatherable_conditional);
2052 if inner
2053 .iter()
2054 .any(|n| !matches!(n.kind, NodeKind::Style) && !is_gatherable_conditional(n))
2055 {
2056 return false;
2057 }
2058 if has_style && has_nested_at {
2062 return false;
2063 }
2064 inner
2065 .iter()
2066 .filter(|n| is_gatherable_conditional(n))
2067 .all(|n| conditional_tree_is_pure(source, n))
2068}
2069const GATHER_SCAN_CONTAINERS: &[&str] = &[];
2073
2074fn is_strong_gather_home(sel: &str) -> bool {
2075 let sel = sel.trim();
2076 sel.starts_with('.')
2077 || sel.starts_with('#')
2078 || sel.starts_with('[')
2079 || (sel.starts_with(':') && !sel.starts_with("::"))
2080}
2081
2082fn is_absorbable_descendant(base: &str, sel: &str) -> bool {
2083 let sel = sel.trim();
2084 if sel.is_empty() || sel == base {
2085 return false;
2086 }
2087 if contains_top_level_comma(sel) {
2088 return split_top_level_comma(sel)
2089 .into_iter()
2090 .map(str::trim)
2091 .filter(|s| !s.is_empty())
2092 .all(|part| is_absorbable_descendant(base, part));
2093 }
2094 if extract_related_nested_selector(base, sel).is_some() {
2095 return true;
2096 }
2097 let head = first_compound_stripped(sel).unwrap_or(sel);
2098 if is_weak_gather_base(head) {
2099 return false;
2100 }
2101 if is_strong_gather_home(head) && head != first_compound_stripped(base).unwrap_or(base) {
2102 return false;
2103 }
2104 is_simple_compound_selector(sel) || first_compound_stripped(sel).is_some()
2105}
2106
2107fn can_nest_under_gather_home(base: &str, sel: &str) -> bool {
2108 if selector_contains_nesting_amp(sel) {
2109 return false;
2110 }
2111 sel == base
2112 || extract_related_nested_selector(base, sel).is_some()
2113 || is_absorbable_descendant(base, sel)
2114}
2115
2116enum GatherMember {
2117 Style(SourceNode),
2118 Conditional {
2119 at_node: SourceNode,
2120 inner: SourceNode,
2121 delete_whole_at_block: bool,
2122 },
2123}
2124
2125impl GatherMember {
2126 fn outer_start(&self) -> usize {
2127 match self {
2128 Self::Style(n) => n.start,
2129 Self::Conditional { at_node, .. } => at_node.start,
2130 }
2131 }
2132
2133 fn outer_end(&self) -> usize {
2134 match self {
2135 Self::Style(n) => n.end,
2136 Self::Conditional { at_node, .. } => at_node.end,
2137 }
2138 }
2139}
2140
2141fn extend_with_trailing_newline(source: &str, end: usize) -> usize {
2142 if source[end..].starts_with("\r\n") {
2143 end + 2
2144 } else if source[end..].starts_with('\n') {
2145 end + 1
2146 } else {
2147 end
2148 }
2149}
2150
2151fn push_relative_body_as_nest(rel_sel: &str, body_str: &str, all_nested_rules: &mut Vec<String>) {
2152 let (decls, nested) = parse_rule_body_items(body_str);
2153 if nested.is_empty() {
2154 let mut rel_body = String::new();
2155 for d in &decls {
2156 rel_body.push_str(&format!("{}\n", ensure_semicolon(d)));
2157 }
2158 all_nested_rules.push(format!("{rel_sel} {{\n {rel_body}}}"));
2159 } else {
2160 let mut rel_body_lines = Vec::new();
2161 for d in &decls {
2162 rel_body_lines.push(format!(" {}", ensure_semicolon(d)));
2163 }
2164 for nr in &nested {
2165 rel_body_lines.push(nr.clone());
2166 }
2167 let rel_body = rel_body_lines.join("\n");
2168 all_nested_rules.push(format!("{rel_sel} {{\n{rel_body}\n}}"));
2169 }
2170}
2171
2172fn push_style_member_into_merge(
2173 first_sel: &str,
2174 cand_sel: &str,
2175 body_str: &str,
2176 all_decls: &mut Vec<String>,
2177 all_nested_rules: &mut Vec<String>,
2178) {
2179 if cand_sel == first_sel {
2180 let (decls, nested) = parse_rule_body_items(body_str);
2181 all_decls.extend(decls);
2182 all_nested_rules.extend(nested);
2183 } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, cand_sel) {
2184 push_relative_body_as_nest(&rel_sel, body_str, all_nested_rules);
2185 } else if is_absorbable_descendant(first_sel, cand_sel) {
2186 push_relative_body_as_nest(cand_sel, body_str, all_nested_rules);
2187 }
2188}
2189
2190fn inverted_conditional_body_lines(
2191 first_sel: &str,
2192 inners: &[&SourceNode],
2193 source: &str,
2194 inner_indent: &str,
2195 unit: &str,
2196) -> Vec<String> {
2197 let mut direct_decls = Vec::new();
2198 let mut nested = Vec::new();
2199
2200 for inner in inners {
2201 let Some(body_range) = &inner.body_range else {
2202 continue;
2203 };
2204 let inner_sel = inner.prelude(source).trim();
2205 let inner_body = &source[body_range.clone()];
2206 if inner_sel == first_sel {
2207 let (decls, nrs) = parse_rule_body_items(inner_body);
2208 direct_decls.extend(decls);
2209 nested.extend(nrs);
2210 } else if let Some(rel_sel) = extract_related_nested_selector(first_sel, inner_sel) {
2211 push_relative_body_as_nest(&rel_sel, inner_body, &mut nested);
2212 }
2213 }
2214
2215 let nested = factor_related_nested_rules(merge_same_prelude_nests(nested));
2216 let mut lines = Vec::new();
2217 for d in &direct_decls {
2218 lines.push(format!("{inner_indent}{}", ensure_semicolon(d)));
2219 }
2220 for nr in &nested {
2221 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2222 let item = NestedRuleItem {
2223 comment: nested_rule_comment_prefix(nr),
2224 sel,
2225 body: nested_rule_inner(nr),
2226 };
2227 for line in render_item_indented(&item, inner_indent, unit).lines() {
2228 lines.push(line.to_string());
2229 }
2230 } else {
2231 for line in nr.lines() {
2232 let trimmed = line.trim();
2233 if !trimmed.is_empty() {
2234 lines.push(format!("{inner_indent}{}", ensure_semicolon(trimmed)));
2235 }
2236 }
2237 }
2238 }
2239 lines
2240}
2241
2242fn format_conditional_tree(
2243 first_sel: &str,
2244 at_node: &SourceNode,
2245 inners: &[&SourceNode],
2246 source: &str,
2247 nested_indent: &str,
2248 unit: &str,
2249) -> Vec<String> {
2250 let owned: HashSet<usize> = inners.iter().map(|n| n.start).collect();
2251 format_at_subtree(first_sel, at_node, &owned, source, nested_indent, unit)
2252}
2253
2254fn format_at_subtree(
2255 first_sel: &str,
2256 at_node: &SourceNode,
2257 owned: &HashSet<usize>,
2258 source: &str,
2259 nested_indent: &str,
2260 unit: &str,
2261) -> Vec<String> {
2262 let Some(body_range) = &at_node.body_range else {
2263 return Vec::new();
2264 };
2265 let header = at_node.prelude(source).trim();
2266 let inner_nodes = scan_nodes(source, body_range.clone());
2267 let level2 = format!("{nested_indent}{unit}");
2268 let direct: Vec<&SourceNode> = inner_nodes
2269 .iter()
2270 .filter(|n| matches!(n.kind, NodeKind::Style) && owned.contains(&n.start))
2271 .collect();
2272 let mut lines = Vec::new();
2273 lines.push(format!("{nested_indent}{header} {{"));
2274 if !direct.is_empty() {
2275 lines.extend(inverted_conditional_body_lines(
2276 first_sel, &direct, source, &level2, unit,
2277 ));
2278 }
2279 for child in &inner_nodes {
2280 if is_gatherable_conditional(child) {
2281 lines.extend(format_at_subtree(
2282 first_sel, child, owned, source, &level2, unit,
2283 ));
2284 }
2285 }
2286 lines.push(format!("{nested_indent}}}"));
2287 lines
2288}
2289
2290fn format_conditional_into_lines(
2291 first_sel: &str,
2292 at_header: &str,
2293 inner_sel: &str,
2294 inner_body: &str,
2295 nested_indent: &str,
2296 unit: &str,
2297) -> Vec<String> {
2298 if inner_sel != first_sel {
2299 return Vec::new();
2300 }
2301 let (decls, nested) = parse_rule_body_items(inner_body);
2302 let level2 = format!("{nested_indent}{unit}");
2303 let mut lines = Vec::new();
2304 lines.push(format!("{nested_indent}{at_header} {{"));
2305 for d in &decls {
2306 lines.push(format!("{level2}{}", ensure_semicolon(d)));
2307 }
2308 for nr in &nested {
2309 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
2310 let item = NestedRuleItem {
2311 comment: nested_rule_comment_prefix(nr),
2312 sel,
2313 body: nested_rule_inner(nr),
2314 };
2315 for line in render_item_indented(&item, &level2, unit).lines() {
2316 lines.push(line.to_string());
2317 }
2318 } else {
2319 for line in nr.lines() {
2320 let trimmed = line.trim();
2321 if trimmed.is_empty() {
2322 lines.push(String::new());
2323 } else {
2324 lines.push(format!("{level2}{}", ensure_semicolon(trimmed)));
2325 }
2326 }
2327 }
2328 }
2329 lines.push(format!("{nested_indent}}}"));
2330 lines
2331}
2332
2333fn nested_rule_prelude(nr: &str) -> Option<&str> {
2334 let mut i = 0usize;
2335 while i < nr.len() {
2336 while i < nr.len() && nr.as_bytes()[i].is_ascii_whitespace() {
2337 i += 1;
2338 }
2339 if nr[i..].starts_with("/*") {
2340 i += nr[i..].find("*/")? + 2;
2341 continue;
2342 }
2343 if nr[i..].starts_with("//") {
2344 i += nr[i..].find('\n').map(|n| n + 1).unwrap_or(nr.len() - i);
2345 continue;
2346 }
2347 break;
2348 }
2349 let rest = nr.get(i..)?;
2350 let brace = rest.find('{')?;
2351 let head = rest[..brace].trim();
2352 if head.is_empty() { None } else { Some(head) }
2353}
2354
2355fn nested_rule_inner(nr: &str) -> String {
2356 let lines: Vec<&str> = nr.lines().collect();
2357 let open = lines.iter().position(|line| {
2358 let t = line.trim();
2359 t.ends_with('{') && !t.starts_with("/*") && !t.starts_with("//")
2360 });
2361 let Some(open) = open else {
2362 return String::new();
2363 };
2364 if lines.len() <= open + 2 {
2365 return String::new();
2366 }
2367 lines[open + 1..lines.len() - 1]
2368 .iter()
2369 .map(|l| l.trim_end())
2370 .collect::<Vec<_>>()
2371 .join("\n")
2372}
2373
2374fn nested_rule_comment_prefix(nr: &str) -> String {
2375 let mut out = String::new();
2376 for line in nr.lines() {
2377 let t = line.trim();
2378 if t.is_empty() || t.starts_with("/*") || t.starts_with("//") {
2379 if !out.is_empty() {
2380 out.push('\n');
2381 }
2382 out.push_str(t);
2383 } else {
2384 break;
2385 }
2386 }
2387 out
2388}
2389
2390fn merge_same_prelude_nests(rules: Vec<String>) -> Vec<String> {
2392 let mut order: Vec<String> = Vec::new();
2393 let mut merged: HashMap<String, String> = HashMap::new();
2394 let mut comments: HashMap<String, String> = HashMap::new();
2395 let mut leftovers = Vec::new();
2396
2397 for nr in rules {
2398 let Some(prelude) = nested_rule_prelude(&nr).map(str::to_string) else {
2399 leftovers.push(nr);
2400 continue;
2401 };
2402 let inner = nested_rule_inner(&nr);
2403 let prefix = nested_rule_comment_prefix(&nr);
2404 if let Some(existing) = merged.get_mut(&prelude) {
2405 if !existing.is_empty() && !inner.is_empty() {
2406 existing.push('\n');
2407 }
2408 existing.push_str(&inner);
2409 } else {
2410 order.push(prelude.clone());
2411 merged.insert(prelude.clone(), inner);
2412 if !prefix.is_empty() {
2413 comments.insert(prelude, prefix);
2414 }
2415 }
2416 }
2417
2418 let mut out: Vec<String> = order
2419 .into_iter()
2420 .map(|prelude| {
2421 let inner = merged.remove(&prelude).unwrap_or_default();
2422 let comment = comments.remove(&prelude).unwrap_or_default();
2423 let head = if comment.is_empty() {
2424 String::new()
2425 } else {
2426 format!("{comment}\n")
2427 };
2428 if inner.is_empty() {
2429 format!("{head}{prelude} {{}}")
2430 } else {
2431 format!("{head}{prelude} {{\n{inner}\n}}")
2432 }
2433 })
2434 .collect();
2435 out.extend(leftovers);
2436 out
2437}
2438
2439fn conditional_as_nested_rule(
2440 first_sel: &str,
2441 at_header: &str,
2442 inner_sel: &str,
2443 inner_body: &str,
2444) -> Option<String> {
2445 if inner_sel == first_sel {
2446 return None;
2447 }
2448 let rel_sel = extract_related_nested_selector(first_sel, inner_sel)?;
2449 let (decls, nested) = parse_rule_body_items(inner_body);
2450 let mut at_inner = String::new();
2451 for d in &decls {
2452 at_inner.push_str(" ");
2453 at_inner.push_str(&ensure_semicolon(d));
2454 at_inner.push('\n');
2455 }
2456 for nr in &nested {
2457 for line in nr.lines() {
2458 let trimmed = line.trim();
2459 if !trimmed.is_empty() {
2460 at_inner.push_str(" ");
2461 at_inner.push_str(&ensure_semicolon(trimmed));
2462 at_inner.push('\n');
2463 }
2464 }
2465 }
2466 Some(format!(
2467 "{rel_sel} {{\n {at_header} {{\n{at_inner} }}\n}}"
2468 ))
2469}
2470
2471#[derive(Debug, Clone)]
2472struct NestedRuleItem {
2473 comment: String,
2474 sel: String,
2475 body: String,
2476}
2477
2478fn render_nested_rule_item(item: &NestedRuleItem) -> String {
2479 let head = if item.comment.is_empty() {
2480 String::new()
2481 } else {
2482 format!("{}\n", item.comment)
2483 };
2484 if item.body.trim().is_empty() {
2485 format!("{head}{} {{}}", item.sel)
2486 } else {
2487 format!("{head}{} {{\n{}\n}}", item.sel, item.body)
2488 }
2489}
2490
2491fn append_child_rule(parent: &mut NestedRuleItem, rel_sel: &str, child_body: &str) {
2492 let child = NestedRuleItem {
2493 comment: String::new(),
2494 sel: rel_sel.to_string(),
2495 body: child_body.to_string(),
2496 };
2497 if !parent.body.is_empty() {
2498 parent.body.push('\n');
2499 }
2500 parent.body.push_str(&render_nested_rule_item(&child));
2501}
2502
2503fn split_relative_combinator(sel: &str) -> Option<(String, String)> {
2504 let mut paren = 0usize;
2505 let mut brack = 0usize;
2506 for (i, c) in sel.char_indices() {
2507 match c {
2508 '(' => paren += 1,
2509 ')' => paren = paren.saturating_sub(1),
2510 '[' => brack += 1,
2511 ']' => brack = brack.saturating_sub(1),
2512 _ if paren == 0 && brack == 0 => {
2513 if c.is_whitespace() {
2514 let right = sel[i..].trim_start();
2515 let left = sel[..i].trim();
2516 if left.is_empty() || right.is_empty() {
2517 return None;
2518 }
2519 return Some((left.to_string(), right.to_string()));
2520 }
2521 if matches!(c, '+' | '>' | '~') && i > 0 {
2522 let right = sel[i + c.len_utf8()..].trim_start();
2523 let left = sel[..i].trim();
2524 if left.is_empty() || right.is_empty() {
2525 return None;
2526 }
2527 return Some((left.to_string(), explicit_relative_combinator(c, right)));
2528 }
2529 }
2530 _ => {}
2531 }
2532 }
2533 None
2534}
2535
2536fn is_at_rule_prelude(sel: &str) -> bool {
2537 sel.trim_start().starts_with('@')
2538}
2539
2540fn split_compound_pseudo(sel: &str) -> Option<(String, String)> {
2541 let sel = sel.trim();
2542 if sel.is_empty() || selector_contains_nesting_amp(sel) {
2543 return None;
2544 }
2545 let mut paren = 0usize;
2546 let mut brack = 0usize;
2547 for (i, c) in sel.char_indices() {
2548 match c {
2549 '(' => paren += 1,
2550 ')' => paren = paren.saturating_sub(1),
2551 '[' => brack += 1,
2552 ']' => brack = brack.saturating_sub(1),
2553 ':' if paren == 0 && brack == 0 && i > 0 => {
2554 let rest = &sel[i..];
2555 if rest.starts_with(":not(")
2556 || rest.starts_with(":is(")
2557 || rest.starts_with(":where(")
2558 || rest.starts_with(":has(")
2559 {
2560 continue;
2561 }
2562 let parent = sel[..i].trim();
2563 if parent.is_empty() {
2564 return None;
2565 }
2566 return Some((parent.to_string(), format!("&{rest}")));
2567 }
2568 _ => {}
2569 }
2570 }
2571 None
2572}
2573
2574fn synthesizable_parent(sel: &str) -> Option<(String, String)> {
2575 let sel = sel.trim();
2576 if sel.is_empty()
2580 || sel == "&"
2581 || starts_with_explicit_combinator(sel)
2582 || is_at_rule_prelude(sel)
2583 {
2584 return None;
2585 }
2586 if contains_top_level_comma(sel) {
2587 let parts: Vec<&str> = split_top_level_comma(sel)
2588 .into_iter()
2589 .map(str::trim)
2590 .filter(|s| !s.is_empty())
2591 .collect();
2592 if parts.len() < 2 {
2593 return None;
2594 }
2595 let mut parent: Option<String> = None;
2596 let mut children = Vec::new();
2597 for part in parts {
2598 let (p, c) = synthesizable_parent(part)?;
2599 match &parent {
2600 Some(existing) if existing != &p => return None,
2601 None => parent = Some(p),
2602 _ => {}
2603 }
2604 children.push(c);
2605 }
2606 return Some((parent?, children.join(", ")));
2607 }
2608 split_relative_combinator(sel).or_else(|| split_compound_pseudo(sel))
2609}
2610
2611fn nest_items_under_existing(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2612 let n = items.len();
2613 if n < 2 {
2614 return items;
2615 }
2616
2617 let mut parent_of: Vec<Option<usize>> = vec![None; n];
2618 for i in 0..n {
2619 let mut best: Option<(usize, usize)> = None;
2620 for j in 0..n {
2621 if i == j {
2622 continue;
2623 }
2624 if matches!(
2628 classify_related_nested(&items[j].sel, &items[i].sel),
2629 Some((RelatedKind::Prefix, _))
2630 ) {
2631 let len = items[j].sel.len();
2632 if best.is_none_or(|(_, l)| len >= l) {
2633 best = Some((j, len));
2634 }
2635 }
2636 }
2637 if let Some((j, _)) = best {
2638 parent_of[i] = Some(j);
2639 }
2640 }
2641
2642 for i in 0..n {
2644 if let Some(j) = parent_of[i]
2645 && parent_of[j].is_some()
2646 {
2647 parent_of[i] = None;
2648 }
2649 }
2650
2651 let mut out = Vec::new();
2652 let mut out_idx = vec![None; n];
2653 for i in 0..n {
2654 if parent_of[i].is_none() {
2655 out_idx[i] = Some(out.len());
2656 out.push(items[i].clone());
2657 }
2658 }
2659 for i in 0..n {
2660 if let Some(j) = parent_of[i]
2661 && let Some(out_j) = out_idx[j]
2662 && let Some((RelatedKind::Prefix, rel)) =
2663 classify_related_nested(&out[out_j].sel, &items[i].sel)
2664 {
2665 append_child_rule(&mut out[out_j], &rel, &items[i].body);
2666 }
2667 }
2668 out
2669}
2670
2671fn wrap_shared_virtual_parents(items: Vec<NestedRuleItem>) -> Vec<NestedRuleItem> {
2672 #[derive(Clone)]
2673 enum Bucket {
2674 Atomic(NestedRuleItem),
2675 Shared {
2676 parent: String,
2677 originals: Vec<NestedRuleItem>,
2678 children: Vec<NestedRuleItem>,
2679 },
2680 }
2681
2682 let mut buckets: Vec<Bucket> = Vec::new();
2683 let mut shared_at: HashMap<String, usize> = HashMap::new();
2684
2685 for item in items {
2686 match synthesizable_parent(&item.sel) {
2687 Some((parent, child)) => {
2688 if let Some(&idx) = shared_at.get(&parent) {
2689 if let Bucket::Shared {
2690 originals,
2691 children,
2692 ..
2693 } = &mut buckets[idx]
2694 {
2695 originals.push(item.clone());
2696 children.push(NestedRuleItem {
2697 comment: item.comment.clone(),
2698 sel: child,
2699 body: item.body,
2700 });
2701 }
2702 } else {
2703 shared_at.insert(parent.clone(), buckets.len());
2704 buckets.push(Bucket::Shared {
2705 parent,
2706 originals: vec![item.clone()],
2707 children: vec![NestedRuleItem {
2708 comment: item.comment.clone(),
2709 sel: child,
2710 body: item.body,
2711 }],
2712 });
2713 }
2714 }
2715 None => buckets.push(Bucket::Atomic(item)),
2716 }
2717 }
2718
2719 let mut out = Vec::new();
2720 for bucket in buckets {
2721 match bucket {
2722 Bucket::Atomic(item) => out.push(item),
2723 Bucket::Shared {
2724 parent,
2725 originals,
2726 children,
2727 } => {
2728 if children.len() >= 2 {
2729 let body = children
2730 .iter()
2731 .map(render_nested_rule_item)
2732 .collect::<Vec<_>>()
2733 .join("\n");
2734 out.push(NestedRuleItem {
2735 comment: String::new(),
2736 sel: parent,
2737 body,
2738 });
2739 } else {
2740 out.extend(originals);
2741 }
2742 }
2743 }
2744 }
2745 out
2746}
2747
2748fn factor_related_nested_rules(rules: Vec<String>) -> Vec<String> {
2749 let mut items = Vec::new();
2750 let mut leftovers = Vec::new();
2751 for nr in rules {
2752 if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2753 items.push(NestedRuleItem {
2754 comment: nested_rule_comment_prefix(&nr),
2755 sel,
2756 body: nested_rule_inner(&nr),
2757 });
2758 } else {
2759 leftovers.push(nr);
2760 }
2761 }
2762
2763 for _ in 0..8 {
2764 let before = items.len();
2765 items = nest_items_under_existing(items);
2766 items = wrap_shared_virtual_parents(items);
2767 if items.len() == before {
2768 break;
2769 }
2770 }
2771
2772 let mut out: Vec<String> = items.iter().map(render_nested_rule_item).collect();
2773 out.extend(leftovers);
2774 out
2775}
2776
2777fn line_start(source: &str, pos: usize) -> usize {
2778 source[..pos.min(source.len())]
2779 .rfind('\n')
2780 .map(|i| i + 1)
2781 .unwrap_or(0)
2782}
2783
2784fn relative_indent_unit(source: &str, node: &SourceNode) -> String {
2785 let parent = line_indent(source, node.start);
2786 let Some(body) = node.body_range.clone() else {
2787 return " ".to_string();
2788 };
2789 match detect_indent_unit(source, body) {
2790 Some(raw) => raw
2791 .strip_prefix(parent.as_str())
2792 .filter(|rest| !rest.is_empty())
2793 .unwrap_or(" ")
2794 .to_string(),
2795 None => " ".to_string(),
2796 }
2797}
2798
2799fn squeeze_excess_blank_lines(s: &str) -> String {
2800 let ends_nl = s.ends_with('\n');
2801 let mut out = String::new();
2802 let mut blank_run = 0usize;
2803 for line in s.lines() {
2804 if line.trim().is_empty() {
2805 blank_run += 1;
2806 if blank_run > 1 {
2807 continue;
2808 }
2809 out.push('\n');
2810 } else {
2811 blank_run = 0;
2812 out.push_str(line);
2813 out.push('\n');
2814 }
2815 }
2816 if !ends_nl && out.ends_with('\n') {
2817 out.pop();
2818 }
2819 out
2820}
2821
2822fn render_item_indented(item: &NestedRuleItem, indent: &str, unit: &str) -> String {
2823 let mut out = String::new();
2824 if !item.comment.is_empty() {
2825 for line in item.comment.lines() {
2826 out.push_str(indent);
2827 out.push_str(line.trim());
2828 out.push('\n');
2829 }
2830 }
2831 out.push_str(indent);
2832 out.push_str(&item.sel);
2833 out.push_str(" {\n");
2834 let inner = format!("{indent}{unit}");
2835 let (decls, nested) = parse_rule_body_items(&item.body);
2836 for d in decls {
2837 out.push_str(&inner);
2838 out.push_str(&ensure_semicolon(&d));
2839 out.push('\n');
2840 }
2841 for nr in nested {
2842 if let Some(sel) = nested_rule_prelude(&nr).map(str::to_string) {
2843 let child = NestedRuleItem {
2844 comment: nested_rule_comment_prefix(&nr),
2845 sel,
2846 body: nested_rule_inner(&nr),
2847 };
2848 out.push_str(&render_item_indented(&child, &inner, unit));
2849 out.push('\n');
2850 }
2851 }
2852 out.push_str(indent);
2853 out.push('}');
2854 out
2855}
2856
2857#[allow(dead_code)]
2858fn leftover_style_replacements(
2859 source: &str,
2860 nodes: &[SourceNode],
2861 span_start: usize,
2862 span_end: usize,
2863 cluster_starts: &HashSet<usize>,
2864) -> Vec<(usize, usize, String)> {
2865 let leftovers: Vec<&SourceNode> = nodes
2866 .iter()
2867 .filter(|n| {
2868 matches!(n.kind, NodeKind::Style)
2869 && n.start >= span_start
2870 && n.end <= span_end
2871 && !cluster_starts.contains(&n.start)
2872 })
2873 .collect();
2874
2875 let mut reps = Vec::new();
2876 let mut i = 0;
2877 while i < leftovers.len() {
2878 let parent = leftovers[i];
2879 let parent_sel = parent.prelude(source);
2880 if contains_top_level_comma(parent_sel) || parent_sel.contains("::") {
2881 i += 1;
2882 continue;
2883 }
2884 let mut children = Vec::new();
2885 let mut j = i + 1;
2886 let mut prev_end = parent.end;
2887 while j < leftovers.len() {
2888 let next = leftovers[j];
2889 if !is_whitespace_only(source, prev_end..next.start) {
2890 break;
2891 }
2892 if let Some((relation, nested_selector)) =
2893 selector_relation(parent_sel, next.prelude(source))
2894 {
2895 children.push(ClusterChild::Style {
2896 node: (*next).clone(),
2897 relation,
2898 nested_selector,
2899 });
2900 prev_end = next.end;
2901 j += 1;
2902 continue;
2903 }
2904 break;
2905 }
2906 if !children.is_empty() {
2907 let last_end = children.last().expect("non-empty").node().end;
2908 reps.push((
2909 parent.start,
2910 last_end,
2911 render_cluster(source, parent, &children),
2912 ));
2913 i = j;
2914 } else {
2915 i += 1;
2916 }
2917 }
2918 reps
2919}
2920
2921fn apply_range_replacements(
2922 source: &str,
2923 start: usize,
2924 end: usize,
2925 replacements: &[(usize, usize, String)],
2926) -> String {
2927 let mut reps: Vec<(usize, usize, &str)> = replacements
2928 .iter()
2929 .map(|(s, e, t)| (*s, *e, t.as_str()))
2930 .filter(|(s, e, _)| *s >= start && *e <= end && *s <= *e)
2931 .collect();
2932 reps.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
2933 let mut out = String::new();
2934 let mut cur = start;
2935 for (s, e, text) in reps {
2936 if s < cur {
2937 continue;
2938 }
2939 if s > cur {
2940 out.push_str(&source[cur..s]);
2941 }
2942 out.push_str(text);
2943 cur = e;
2944 }
2945 if cur < end {
2946 out.push_str(&source[cur..end]);
2947 }
2948 out
2949}
2950
2951fn is_gather_delete(plan: &PlanEntry) -> bool {
2952 plan.proposed.is_empty() && plan.rules.contains(&RuleId::GatherRelatedSelectorRules)
2953}
2954
2955fn gather_target_key(plan: &PlanEntry) -> Option<String> {
2956 if !plan.rules.contains(&RuleId::GatherRelatedSelectorRules) {
2957 return None;
2958 }
2959 let start = plan.reason.find("for '")? + 5;
2960 let rest = plan.reason.get(start..)?;
2961 let end = rest.find('\'')?;
2962 Some(rest[..end].to_string())
2963}
2964
2965fn ranges_overlap(a: &SourceRange, b: &SourceRange) -> bool {
2966 a.start < b.end && b.start < a.end
2967}
2968
2969fn plan_safety_rank(plan: &PlanEntry) -> u8 {
2970 match plan.safety {
2971 Safety::Safe => 0,
2972 Safety::NoOp => 1,
2973 Safety::Review => 2,
2974 Safety::Unsafe => 3,
2975 Safety::Unsupported => 4,
2976 }
2977}
2978
2979fn select_disjoint_plan_indices(plans: &[PlanEntry]) -> Vec<usize> {
2980 let mut primary: Vec<usize> = (0..plans.len())
2981 .filter(|&i| !is_gather_delete(&plans[i]))
2982 .collect();
2983 primary.sort_by(|&i, &j| {
2989 plan_safety_rank(&plans[i])
2990 .cmp(&plan_safety_rank(&plans[j]))
2991 .then_with(|| {
2992 plans[i]
2993 .source_range
2994 .start
2995 .cmp(&plans[j].source_range.start)
2996 })
2997 .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
2998 });
2999
3000 let mut kept: Vec<usize> = Vec::new();
3001 for i in primary {
3002 if kept
3003 .iter()
3004 .any(|&k| ranges_overlap(&plans[k].source_range, &plans[i].source_range))
3005 {
3006 continue;
3007 }
3008 kept.push(i);
3009 }
3010
3011 let mut kept_gather_keys = HashSet::new();
3012 for &i in &kept {
3013 if !plans[i].proposed.is_empty()
3014 && let Some(key) = gather_target_key(&plans[i])
3015 {
3016 kept_gather_keys.insert((plans[i].file.clone(), key));
3017 }
3018 }
3019
3020 let mut deletes: Vec<usize> = (0..plans.len())
3021 .filter(|&i| is_gather_delete(&plans[i]))
3022 .collect();
3023 deletes.sort_by(|&i, &j| {
3024 plans[i]
3025 .source_range
3026 .start
3027 .cmp(&plans[j].source_range.start)
3028 });
3029
3030 for i in deletes {
3031 let Some(key) = gather_target_key(&plans[i]) else {
3032 continue;
3033 };
3034 if !kept_gather_keys.contains(&(plans[i].file.clone(), key)) {
3035 continue;
3036 }
3037 if kept
3038 .iter()
3039 .any(|&k| ranges_overlap(&plans[k].source_range, &plans[i].source_range))
3040 {
3041 continue;
3042 }
3043 kept.push(i);
3044 }
3045
3046 kept.sort_by(|&i, &j| {
3047 plans[i]
3048 .source_range
3049 .start
3050 .cmp(&plans[j].source_range.start)
3051 .then_with(|| plans[j].source_range.end.cmp(&plans[i].source_range.end))
3052 });
3053 kept
3054}
3055
3056fn format_merged_rule(
3057 first_sel: &str,
3058 parent_indent: &str,
3059 unit: &str,
3060 cluster: &[GatherMember],
3061 source: &str,
3062) -> String {
3063 let nested_indent = format!("{parent_indent}{unit}");
3064
3065 let mut all_decls = Vec::new();
3066 let mut all_nested_rules = Vec::new();
3067 let mut absorbed_nests = Vec::new();
3068 let mut conditional_lines = Vec::new();
3069
3070 for member in cluster {
3071 let GatherMember::Style(c) = member else {
3072 continue;
3073 };
3074 if let Some(body_range) = &c.body_range {
3075 let cand_sel = c.prelude(source).trim();
3076 let body_str = &source[body_range.clone()];
3077 let before_len = all_nested_rules.len();
3078 push_style_member_into_merge(
3079 first_sel,
3080 cand_sel,
3081 body_str,
3082 &mut all_decls,
3083 &mut all_nested_rules,
3084 );
3085 if all_nested_rules.len() > before_len
3086 && let Some((_, cmt)) = leading_block_comment(source, c.start)
3087 && let Some(last) = all_nested_rules.last_mut()
3088 {
3089 *last = format!("{cmt}\n{last}");
3090 }
3091 }
3092 }
3093
3094 let mut cond_order: Vec<usize> = Vec::new();
3095 let mut cond_groups: HashMap<usize, (&SourceNode, Vec<&SourceNode>)> = HashMap::new();
3096 for member in cluster {
3097 let GatherMember::Conditional { at_node, inner, .. } = member else {
3098 continue;
3099 };
3100 cond_groups
3101 .entry(at_node.start)
3102 .and_modify(|(_, inners)| inners.push(inner))
3103 .or_insert_with(|| {
3104 cond_order.push(at_node.start);
3105 (at_node, vec![inner])
3106 });
3107 }
3108
3109 for key in cond_order {
3110 let Some((at_node, inners)) = cond_groups.remove(&key) else {
3111 continue;
3112 };
3113 let at_header = at_node.prelude(source).trim();
3114 let mut related = Vec::new();
3115 let mut absorbed = Vec::new();
3116 for inner in inners {
3117 let sel = inner.prelude(source).trim();
3118 if sel == first_sel || extract_related_nested_selector(first_sel, sel).is_some() {
3119 related.push(inner);
3120 } else {
3121 absorbed.push(inner);
3122 }
3123 }
3124
3125 if !related.is_empty() {
3126 let invert_group = related.len() >= 2
3127 || related
3128 .iter()
3129 .any(|inner| inner.prelude(source).trim() == first_sel);
3130 if invert_group {
3131 let extra = format_conditional_tree(
3132 first_sel,
3133 at_node,
3134 &related,
3135 source,
3136 &nested_indent,
3137 unit,
3138 );
3139 if !conditional_lines.is_empty() && !extra.is_empty() {
3140 conditional_lines.push(String::new());
3141 }
3142 conditional_lines.extend(extra);
3143 } else {
3144 for inner in &related {
3145 if let Some(body_range) = &inner.body_range {
3146 let inner_sel = inner.prelude(source).trim();
3147 let inner_body = &source[body_range.clone()];
3148 if let Some(nr) =
3149 conditional_as_nested_rule(first_sel, at_header, inner_sel, inner_body)
3150 {
3151 all_nested_rules.push(nr);
3152 } else {
3153 let extra = format_conditional_into_lines(
3154 first_sel,
3155 at_header,
3156 inner_sel,
3157 inner_body,
3158 &nested_indent,
3159 unit,
3160 );
3161 if !conditional_lines.is_empty() && !extra.is_empty() {
3162 conditional_lines.push(String::new());
3163 }
3164 conditional_lines.extend(extra);
3165 }
3166 }
3167 }
3168 }
3169 }
3170
3171 for inner in absorbed {
3172 if let Some(body_range) = &inner.body_range {
3173 push_style_member_into_merge(
3174 first_sel,
3175 inner.prelude(source).trim(),
3176 &source[body_range.clone()],
3177 &mut all_decls,
3178 &mut absorbed_nests,
3179 );
3180 }
3181 }
3182 }
3183
3184 let all_nested_rules = factor_related_nested_rules(merge_same_prelude_nests(all_nested_rules));
3185 let absorbed_nests = factor_related_nested_rules(merge_same_prelude_nests(absorbed_nests));
3186
3187 let mut body_lines = Vec::new();
3188
3189 for d in &all_decls {
3190 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(d)));
3191 }
3192
3193 if !all_decls.is_empty() && !all_nested_rules.is_empty() {
3194 body_lines.push(String::new());
3195 }
3196
3197 for (idx, nr) in all_nested_rules.iter().enumerate() {
3198 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
3199 let item = NestedRuleItem {
3200 comment: nested_rule_comment_prefix(nr),
3201 sel,
3202 body: nested_rule_inner(nr),
3203 };
3204 body_lines.push(render_item_indented(&item, &nested_indent, unit));
3205 } else {
3206 for line in nr.lines() {
3207 let trimmed = line.trim();
3208 if trimmed.is_empty() {
3209 body_lines.push(String::new());
3210 } else {
3211 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
3212 }
3213 }
3214 }
3215
3216 if idx < all_nested_rules.len() - 1 {
3217 body_lines.push(String::new());
3218 }
3219 }
3220
3221 if !conditional_lines.is_empty() {
3222 if !body_lines.is_empty() {
3223 body_lines.push(String::new());
3224 }
3225 body_lines.extend(conditional_lines);
3226 }
3227
3228 if !absorbed_nests.is_empty() {
3229 if !body_lines.is_empty() {
3230 body_lines.push(String::new());
3231 }
3232 for (idx, nr) in absorbed_nests.iter().enumerate() {
3233 if let Some(sel) = nested_rule_prelude(nr).map(str::to_string) {
3234 let item = NestedRuleItem {
3235 comment: nested_rule_comment_prefix(nr),
3236 sel,
3237 body: nested_rule_inner(nr),
3238 };
3239 body_lines.push(render_item_indented(&item, &nested_indent, unit));
3240 } else {
3241 for line in nr.lines() {
3242 let trimmed = line.trim();
3243 if trimmed.is_empty() {
3244 body_lines.push(String::new());
3245 } else {
3246 body_lines.push(format!("{nested_indent}{}", ensure_semicolon(trimmed)));
3247 }
3248 }
3249 }
3250 if idx < absorbed_nests.len() - 1 {
3251 body_lines.push(String::new());
3252 }
3253 }
3254 }
3255
3256 let body_content = body_lines.join("\n");
3257 format!("{parent_indent}{first_sel} {{\n{body_content}\n{parent_indent}}}")
3258}
3259
3260fn plan_merge_adjacent_identical_selectors(
3261 path: &Path,
3262 source: &str,
3263 nodes: &[SourceNode],
3264 enabled: &HashSet<RuleId>,
3265 plans: &mut Vec<PlanEntry>,
3266) {
3267 if !enabled.contains(&RuleId::MergeAdjacentIdenticalSelector) {
3268 return;
3269 }
3270 let mut i = 0;
3271 while i < nodes.len() {
3272 let first = &nodes[i];
3273 if matches!(&first.kind, NodeKind::Style) {
3274 let first_sel = first.prelude(source).trim();
3275 let mut cluster = vec![first];
3276 let mut cursor = i + 1;
3277 let mut prev_end = first.end;
3278
3279 while cursor < nodes.len() {
3280 let next = &nodes[cursor];
3281 if !is_whitespace_only(source, prev_end..next.start) {
3282 break;
3283 }
3284 if matches!(&next.kind, NodeKind::Style) && next.prelude(source).trim() == first_sel
3285 {
3286 cluster.push(next);
3287 prev_end = next.end;
3288 cursor += 1;
3289 continue;
3290 }
3291 break;
3292 }
3293
3294 if cluster.len() > 1 {
3295 let last = cluster.last().unwrap();
3296 let parent_indent = line_indent(source, first.start);
3297 let first_body_range = first.body_range.as_ref().unwrap();
3298 let unit = detect_indent_unit(source, first_body_range.clone())
3299 .unwrap_or_else(|| " ".to_string());
3300
3301 let members: Vec<GatherMember> = cluster
3302 .iter()
3303 .map(|n| GatherMember::Style((*n).clone()))
3304 .collect();
3305 let proposed =
3306 format_merged_rule(first_sel, &parent_indent, &unit, &members, source);
3307
3308 plans.push(PlanEntry {
3309 id: String::new(),
3310 file: path.to_path_buf(),
3311 rules: vec![RuleId::MergeAdjacentIdenticalSelector],
3312 safety: Safety::Safe,
3313 source_range: SourceRange {
3314 start: first.start,
3315 end: last.end,
3316 },
3317 original: source[first.start..last.end].to_string(),
3318 proposed,
3319 proof: Proof::safe_local(),
3320 warnings: Vec::new(),
3321 reason: format!(
3322 "Merge {} adjacent identical selector rules for '{}' into a single block.",
3323 cluster.len(),
3324 first_sel
3325 ),
3326 selected: true,
3327 });
3328 i = cursor;
3329 continue;
3330 }
3331 }
3332 i += 1;
3333 }
3334}
3335
3336fn note_gather_home<'s>(
3337 source: &'s str,
3338 node: &SourceNode,
3339 exact_homes: &mut Vec<&'s str>,
3340 home_weight: &mut HashMap<&'s str, usize>,
3341) {
3342 let sel = node.prelude(source).trim();
3343 let core = first_compound_stripped(sel).unwrap_or(sel);
3344 if is_simple_compound_selector(sel) && core == sel && !sel.contains("::") {
3345 if !exact_homes.contains(&sel) {
3346 exact_homes.push(sel);
3347 }
3348 *home_weight.entry(sel).or_insert(0) += style_body_weight(source, node);
3349 }
3350 if let Some(stripped) = first_compound_stripped(sel)
3351 && !exact_homes.contains(&stripped)
3352 && is_simple_compound_selector(stripped)
3353 && !stripped.contains("::")
3354 {
3355 exact_homes.push(stripped);
3356 home_weight.entry(stripped).or_insert(0);
3357 }
3358}
3359
3360fn collect_gather_homes<'s>(
3361 source: &'s str,
3362 nodes: &[SourceNode],
3363 exact_homes: &mut Vec<&'s str>,
3364 home_weight: &mut HashMap<&'s str, usize>,
3365) {
3366 for node in nodes {
3367 if matches!(&node.kind, NodeKind::Style) {
3368 note_gather_home(source, node, exact_homes, home_weight);
3369 } else if let NodeKind::AtBlock { name, .. } = &node.kind
3370 && GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3371 && let Some(body_range) = &node.body_range
3372 {
3373 let inner = scan_nodes(source, body_range.clone());
3374 collect_gather_homes(source, &inner, exact_homes, home_weight);
3375 }
3376 }
3377}
3378
3379fn should_group_at_block(
3380 name: &str,
3381 source: &str,
3382 style_inners: &[&SourceNode],
3383 exact_homes: &[&str],
3384 home_weight: &HashMap<&str, usize>,
3385) -> bool {
3386 if style_inners.len() < 3 {
3387 return false;
3388 }
3389 if name == "starting-style"
3394 && style_inners.iter().any(|inner| {
3395 let sel = inner.prelude(source).trim();
3396 assign_gather_home(sel, exact_homes, home_weight).is_some_and(|home| home != sel)
3397 })
3398 {
3399 return false;
3400 }
3401 let mut assigned: HashMap<&str, usize> = HashMap::new();
3402 let mut unsafe_extract = false;
3403 for inner in style_inners {
3404 let sel = inner.prelude(source).trim();
3405 match assign_gather_home(sel, exact_homes, home_weight) {
3406 Some(home) => {
3407 *assigned.entry(home).or_insert(0) += 1;
3408 if home != sel
3409 && extract_related_nested_selector(home, sel).is_none()
3410 && !is_absorbable_descendant(home, sel)
3411 {
3412 unsafe_extract = true;
3413 }
3414 }
3415 None => {
3416 if contains_top_level_comma(sel)
3417 && exact_homes
3418 .iter()
3419 .any(|home| extract_related_nested_selector(home, sel).is_some())
3420 {
3421 } else {
3423 *assigned.entry("").or_insert(0) += 1;
3424 unsafe_extract = true;
3425 }
3426 }
3427 }
3428 }
3429 let strong: Vec<(&str, usize)> = assigned
3430 .iter()
3431 .filter(|(home, _)| !home.is_empty() && is_strong_gather_home(home))
3432 .map(|(h, c)| (*h, *c))
3433 .collect();
3434 let total = style_inners.len();
3435 let dominant = strong.iter().any(|(_, c)| *c * 3 >= total * 2);
3436
3437 if name == "media" || name == "container" {
3438 return unsafe_extract || strong.len() >= 2;
3439 }
3440 if strong.len() >= 2 && !dominant {
3442 return true;
3443 }
3444 unsafe_extract && strong.is_empty()
3445}
3446
3447fn mark_grouped_at_blocks(
3448 source: &str,
3449 nodes: &[SourceNode],
3450 exact_homes: &[&str],
3451 home_weight: &HashMap<&str, usize>,
3452 grouped: &mut HashSet<usize>,
3453) {
3454 for node in nodes {
3455 if let NodeKind::AtBlock { name, .. } = &node.kind
3456 && let Some(body_range) = &node.body_range
3457 {
3458 let inner_nodes = scan_nodes(source, body_range.clone());
3459 if GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3460 let style_inners: Vec<&SourceNode> = inner_nodes
3461 .iter()
3462 .filter(|n| matches!(&n.kind, NodeKind::Style))
3463 .collect();
3464 if should_group_at_block(name, source, &style_inners, exact_homes, home_weight) {
3465 grouped.insert(node.start);
3466 }
3467 }
3468 if GATHERABLE_CONDITIONALS.contains(&name.as_str())
3469 || GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3470 {
3471 mark_grouped_at_blocks(source, &inner_nodes, exact_homes, home_weight, grouped);
3472 }
3473 }
3474 }
3475}
3476
3477fn style_belongs_to_home(
3478 source: &str,
3479 node: &SourceNode,
3480 base: &str,
3481 exact_homes: &[&str],
3482 home_weight: &HashMap<&str, usize>,
3483) -> bool {
3484 let sel = node.prelude(source).trim();
3485 sel == base || assign_gather_home(sel, exact_homes, home_weight) == Some(base)
3486}
3487
3488fn collect_gather_cluster(
3489 source: &str,
3490 nodes: &[SourceNode],
3491 base: &str,
3492 exact_homes: &[&str],
3493 home_weight: &HashMap<&str, usize>,
3494 grouped_at_blocks: &HashSet<usize>,
3495 cluster: &mut Vec<GatherMember>,
3496) {
3497 for node in nodes {
3498 if matches!(&node.kind, NodeKind::Style) {
3499 if style_belongs_to_home(source, node, base, exact_homes, home_weight) {
3500 cluster.push(GatherMember::Style(node.clone()));
3501 }
3502 } else if let NodeKind::AtBlock { name, .. } = &node.kind {
3503 let Some(body_range) = &node.body_range else {
3504 continue;
3505 };
3506 let inner_nodes = scan_nodes(source, body_range.clone());
3507 if GATHER_SCAN_CONTAINERS.contains(&name.as_str()) {
3508 collect_gather_cluster(
3509 source,
3510 &inner_nodes,
3511 base,
3512 exact_homes,
3513 home_weight,
3514 grouped_at_blocks,
3515 cluster,
3516 );
3517 continue;
3518 }
3519 if grouped_at_blocks.contains(&node.start) {
3520 continue;
3521 }
3522 if !GATHERABLE_CONDITIONALS.contains(&name.as_str()) {
3523 continue;
3524 }
3525 if inner_nodes.iter().any(|inner| {
3529 !matches!(inner.kind, NodeKind::Style) && !is_gatherable_conditional(inner)
3530 }) {
3531 continue;
3532 }
3533 if inner_nodes.iter().any(is_gatherable_conditional) {
3534 if !conditional_tree_is_pure(source, node) {
3535 continue;
3536 }
3537 let mut leaves = Vec::new();
3538 collect_conditional_style_leaves(source, node, &mut leaves);
3539 if leaves.is_empty()
3540 || !leaves.iter().all(|leaf| {
3541 style_belongs_to_home(source, leaf, base, exact_homes, home_weight)
3542 })
3543 {
3544 continue;
3545 }
3546 for inner in leaves {
3547 cluster.push(GatherMember::Conditional {
3548 at_node: node.clone(),
3549 inner,
3550 delete_whole_at_block: true,
3551 });
3552 }
3553 continue;
3554 }
3555 let style_inners: Vec<&SourceNode> = inner_nodes
3556 .iter()
3557 .filter(|n| matches!(n.kind, NodeKind::Style))
3558 .collect();
3559 let owned = style_inners
3560 .iter()
3561 .filter(|inner| {
3562 let sel = inner.prelude(source).trim();
3563 (name != "starting-style"
3564 && style_belongs_to_home(source, inner, base, exact_homes, home_weight))
3565 || (name == "starting-style" && sel == base)
3566 })
3567 .count();
3568 let dominate = !style_inners.is_empty() && owned * 3 >= style_inners.len() * 2;
3569 let related: Vec<SourceNode> = inner_nodes
3570 .iter()
3571 .filter(|inner| {
3572 matches!(&inner.kind, NodeKind::Style) && {
3573 let sel = inner.prelude(source).trim();
3574 ((name != "starting-style"
3575 && style_belongs_to_home(
3576 source,
3577 inner,
3578 base,
3579 exact_homes,
3580 home_weight,
3581 ))
3582 || (name == "starting-style" && sel == base))
3583 || (name != "starting-style"
3584 && dominate
3585 && is_absorbable_descendant(base, sel))
3586 }
3587 })
3588 .cloned()
3589 .collect();
3590 if !related.is_empty() {
3591 let delete_whole_at_block = related.len() == inner_nodes.len()
3592 || (dominate
3593 && style_inners.iter().all(|inner| {
3594 let sel = inner.prelude(source).trim();
3595 ((name != "starting-style"
3596 && style_belongs_to_home(
3597 source,
3598 inner,
3599 base,
3600 exact_homes,
3601 home_weight,
3602 ))
3603 || (name == "starting-style" && sel == base))
3604 || (name != "starting-style" && is_absorbable_descendant(base, sel))
3605 }));
3606 for inner in related {
3607 cluster.push(GatherMember::Conditional {
3608 at_node: node.clone(),
3609 inner,
3610 delete_whole_at_block,
3611 });
3612 }
3613 }
3614 }
3615 }
3616}
3617
3618#[allow(dead_code)]
3619fn enclosing_container_end(
3620 source: &str,
3621 nodes: &[SourceNode],
3622 first: &SourceNode,
3623) -> Option<usize> {
3624 fn find(source: &str, nodes: &[SourceNode], first: &SourceNode) -> Option<usize> {
3625 for node in nodes {
3626 if let NodeKind::AtBlock { name, .. } = &node.kind
3627 && let Some(body_range) = &node.body_range
3628 && (GATHER_SCAN_CONTAINERS.contains(&name.as_str())
3629 || GATHERABLE_CONDITIONALS.contains(&name.as_str()))
3630 && first.start >= body_range.start
3631 && first.end <= body_range.end
3632 {
3633 let inner = scan_nodes(source, body_range.clone());
3634 if let Some(deeper) = find(source, &inner, first) {
3635 return Some(deeper);
3636 }
3637 return Some(body_range.end);
3638 }
3639 }
3640 None
3641 }
3642 find(source, nodes, first)
3643}
3644
3645fn named_layer_ident(prelude: &str) -> Option<String> {
3646 let rest = prelude
3647 .trim()
3648 .strip_prefix("@layer")
3649 .unwrap_or(prelude)
3650 .trim();
3651 if rest.is_empty() || contains_top_level_comma(rest) {
3652 return None;
3653 }
3654 let ident: String = rest
3655 .chars()
3656 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
3657 .collect();
3658 if ident.is_empty() { None } else { Some(ident) }
3659}
3660
3661#[derive(Clone)]
3662struct LayeredExactHit {
3663 selector: String,
3664 layer_path: Vec<String>,
3665 style: SourceNode,
3666 layer: SourceNode,
3667}
3668
3669fn collect_layered_exact_hits(
3670 source: &str,
3671 nodes: &[SourceNode],
3672 path: &[String],
3673 current_layer: Option<&SourceNode>,
3674 out: &mut Vec<LayeredExactHit>,
3675) {
3676 for node in nodes {
3677 if matches!(node.kind, NodeKind::Style) {
3678 if let Some(layer) = current_layer
3679 && !path.is_empty()
3680 {
3681 let sel = node.prelude(source).trim();
3682 if !sel.is_empty() && !contains_top_level_comma(sel) {
3683 out.push(LayeredExactHit {
3684 selector: sel.to_string(),
3685 layer_path: path.to_vec(),
3686 style: node.clone(),
3687 layer: layer.clone(),
3688 });
3689 }
3690 }
3691 continue;
3692 }
3693 if let NodeKind::AtBlock { name, .. } = &node.kind
3694 && name == "layer"
3695 && let Some(body_range) = &node.body_range
3696 && let Some(ident) = named_layer_ident(node.prelude(source))
3697 {
3698 let mut child = path.to_vec();
3699 child.push(ident);
3700 let inner = scan_nodes(source, body_range.clone());
3701 collect_layered_exact_hits(source, &inner, &child, Some(node), out);
3702 }
3703 }
3704}
3705
3706fn unwrap_style_block_body(block: &str) -> String {
3707 let open = match block.find('{') {
3708 Some(i) => i + 1,
3709 None => return block.to_string(),
3710 };
3711 let close = match block.rfind('}') {
3712 Some(i) => i,
3713 None => return block[open..].to_string(),
3714 };
3715 if close <= open {
3716 return String::new();
3717 }
3718 block[open..close].trim().to_string()
3719}
3720
3721fn reindent_owned(text: &str, indent: &str) -> Vec<String> {
3722 let lines: Vec<&str> = text.lines().collect();
3723 let min_pad = lines
3724 .iter()
3725 .filter_map(|l| {
3726 if l.trim().is_empty() {
3727 None
3728 } else {
3729 Some(l.len() - l.trim_start().len())
3730 }
3731 })
3732 .min()
3733 .unwrap_or(0);
3734 lines
3735 .into_iter()
3736 .map(|l| {
3737 if l.trim().is_empty() {
3738 String::new()
3739 } else {
3740 let rest = if l.len() >= min_pad {
3741 &l[min_pad..]
3742 } else {
3743 l.trim_start()
3744 };
3745 format!("{indent}{rest}")
3746 }
3747 })
3748 .collect()
3749}
3750
3751fn layer_body_cluster_for_selector(
3752 source: &str,
3753 layer: &SourceNode,
3754 selector: &str,
3755 exact_homes: &[&str],
3756 home_weight: &HashMap<&str, usize>,
3757) -> Vec<GatherMember> {
3758 let Some(body_range) = &layer.body_range else {
3759 return Vec::new();
3760 };
3761 let inner = scan_nodes(source, body_range.clone());
3762 let mut cluster = Vec::new();
3763 for node in &inner {
3764 if matches!(node.kind, NodeKind::Style) && node.prelude(source).trim() == selector {
3765 cluster.push(GatherMember::Style(node.clone()));
3766 } else if let NodeKind::AtBlock { name, .. } = &node.kind
3767 && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3768 && let Some(at_body) = &node.body_range
3769 {
3770 let at_inners = scan_nodes(source, at_body.clone());
3771 let related: Vec<SourceNode> = at_inners
3772 .iter()
3773 .filter(|n| {
3774 matches!(n.kind, NodeKind::Style)
3775 && (n.prelude(source).trim() == selector
3776 || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3777 })
3778 .cloned()
3779 .collect();
3780 if related.is_empty() {
3781 continue;
3782 }
3783 let delete_whole = related.len() == at_inners.len();
3784 for inner_style in related {
3785 cluster.push(GatherMember::Conditional {
3786 at_node: node.clone(),
3787 inner: inner_style,
3788 delete_whole_at_block: delete_whole,
3789 });
3790 }
3791 }
3792 }
3793 cluster
3794}
3795
3796fn layer_contains_only_selector_cluster(
3797 source: &str,
3798 layer: &SourceNode,
3799 selector: &str,
3800 exact_homes: &[&str],
3801 home_weight: &HashMap<&str, usize>,
3802) -> bool {
3803 let Some(body_range) = &layer.body_range else {
3804 return false;
3805 };
3806 let inner = scan_nodes(source, body_range.clone());
3807 if inner.is_empty() {
3808 return false;
3809 }
3810 inner.iter().all(|node| {
3811 if matches!(node.kind, NodeKind::Style) {
3812 return node.prelude(source).trim() == selector
3813 || style_belongs_to_home(source, node, selector, exact_homes, home_weight);
3814 }
3815 if let NodeKind::AtBlock { name, .. } = &node.kind
3816 && GATHERABLE_CONDITIONALS.contains(&name.as_str())
3817 && let Some(at_body) = &node.body_range
3818 {
3819 let at_inners = scan_nodes(source, at_body.clone());
3820 return !at_inners.is_empty()
3821 && at_inners.iter().all(|n| {
3822 matches!(n.kind, NodeKind::Style)
3823 && (n.prelude(source).trim() == selector
3824 || style_belongs_to_home(source, n, selector, exact_homes, home_weight))
3825 });
3826 }
3827 false
3828 })
3829}
3830
3831fn intervening_unlayered(
3832 nodes: &[SourceNode],
3833 first_layer_start: usize,
3834 last_layer_end: usize,
3835) -> bool {
3836 nodes.iter().any(|n| {
3837 n.start > first_layer_start
3838 && n.end < last_layer_end
3839 && match &n.kind {
3840 NodeKind::Style => true,
3841 NodeKind::AtBlock { name, .. } => name != "layer",
3842 NodeKind::AtStatement { name, .. } => name != "layer",
3843 }
3844 })
3845}
3846
3847fn plan_nest_layer_by_selector(
3848 path: &Path,
3849 source: &str,
3850 nodes: &[SourceNode],
3851 enabled: &HashSet<RuleId>,
3852 plans: &mut Vec<PlanEntry>,
3853) {
3854 if !enabled.contains(&RuleId::NestLayerBySelector) {
3855 return;
3856 }
3857
3858 let mut hits = Vec::new();
3859 collect_layered_exact_hits(source, nodes, &[], None, &mut hits);
3860 if hits.is_empty() {
3861 return;
3862 }
3863
3864 let mut exact_homes: Vec<&str> = Vec::new();
3865 let mut home_weight: HashMap<&str, usize> = HashMap::new();
3866 collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
3867
3868 let mut by_sel: HashMap<String, Vec<LayeredExactHit>> = HashMap::new();
3869 for hit in hits {
3870 by_sel.entry(hit.selector.clone()).or_default().push(hit);
3871 }
3872
3873 for (selector, mut group) in by_sel {
3874 group.sort_by_key(|h| h.style.start);
3875 let mut seen_paths: Vec<Vec<String>> = Vec::new();
3876 for hit in &group {
3877 if !seen_paths.iter().any(|p| p == &hit.layer_path) {
3878 seen_paths.push(hit.layer_path.clone());
3879 }
3880 }
3881 if seen_paths.len() < 2 {
3882 continue;
3883 }
3884
3885 let first_layer = &group[0].layer;
3886 let last_layer = &group.last().unwrap().layer;
3887 if intervening_unlayered(nodes, first_layer.start, last_layer.end) {
3888 continue;
3889 }
3890
3891 let parent_indent = String::new();
3892 let unit = " ".to_string();
3893 let mut layer_blocks = Vec::new();
3894 let mut deletes: Vec<(usize, usize, usize)> = Vec::new();
3895 let mut deleted_layers = HashSet::new();
3896
3897 for path in &seen_paths {
3898 let Some(hit) = group.iter().find(|h| &h.layer_path == path) else {
3899 continue;
3900 };
3901 let cluster = layer_body_cluster_for_selector(
3902 source,
3903 &hit.layer,
3904 &selector,
3905 &exact_homes,
3906 &home_weight,
3907 );
3908 if cluster.is_empty() {
3909 continue;
3910 }
3911 let merged = format_merged_rule(&selector, &parent_indent, &unit, &cluster, source);
3912 let body = unwrap_style_block_body(&merged);
3913 let header = format!("@layer {}", path.join("."));
3914 layer_blocks.push((header, body));
3915
3916 if layer_contains_only_selector_cluster(
3917 source,
3918 &hit.layer,
3919 &selector,
3920 &exact_homes,
3921 &home_weight,
3922 ) {
3923 if deleted_layers.insert(hit.layer.start) {
3924 deletes.push((
3925 hit.layer.start,
3926 extend_with_trailing_newline(source, hit.layer.end),
3927 hit.layer.start,
3928 ));
3929 }
3930 } else {
3931 for member in &cluster {
3932 match member {
3933 GatherMember::Style(n) => {
3934 let start = leading_block_comment(source, n.start)
3935 .map(|(s, _)| s)
3936 .unwrap_or(n.start);
3937 deletes.push((
3938 start,
3939 extend_with_trailing_newline(source, n.end),
3940 n.start,
3941 ));
3942 }
3943 GatherMember::Conditional {
3944 at_node,
3945 inner,
3946 delete_whole_at_block,
3947 } => {
3948 if *delete_whole_at_block {
3949 deletes.push((
3950 at_node.start,
3951 extend_with_trailing_newline(source, at_node.end),
3952 at_node.start,
3953 ));
3954 } else {
3955 deletes.push((
3956 inner.start,
3957 extend_with_trailing_newline(source, inner.end),
3958 inner.start,
3959 ));
3960 }
3961 }
3962 }
3963 }
3964 }
3965 }
3966
3967 if layer_blocks.len() < 2 {
3968 continue;
3969 }
3970
3971 let first_layer_only_ours = layer_contains_only_selector_cluster(
3972 source,
3973 first_layer,
3974 &selector,
3975 &exact_homes,
3976 &home_weight,
3977 );
3978 let insert_at = first_layer.start;
3979 let replace_end = if first_layer_only_ours {
3980 extend_with_trailing_newline(source, first_layer.end)
3981 } else {
3982 insert_at
3983 };
3984 deletes.retain(|(start, end, _)| !(*start == insert_at && *end == replace_end));
3985 let nested_indent = unit.clone();
3986 let inner_indent = format!("{unit}{unit}");
3987 let mut hoisted = format!("{selector} {{\n");
3988 for (i, (header, body)) in layer_blocks.iter().enumerate() {
3989 if i > 0 {
3990 hoisted.push('\n');
3991 }
3992 hoisted.push_str(&nested_indent);
3993 hoisted.push_str(header);
3994 hoisted.push_str(" {\n");
3995 if !body.trim().is_empty() {
3996 for line in reindent_owned(body, &inner_indent) {
3997 hoisted.push_str(&line);
3998 hoisted.push('\n');
3999 }
4000 }
4001 hoisted.push_str(&nested_indent);
4002 hoisted.push_str("}\n");
4003 }
4004 hoisted.push('}');
4005 if first_layer_only_ours {
4006 hoisted.push('\n');
4007 } else {
4008 hoisted.push('\n');
4009 hoisted.push('\n');
4010 }
4011
4012 plans.push(PlanEntry {
4013 id: String::new(),
4014 file: path.to_path_buf(),
4015 rules: vec![RuleId::NestLayerBySelector],
4016 safety: Safety::Review,
4017 source_range: SourceRange {
4018 start: insert_at,
4019 end: replace_end,
4020 },
4021 original: if first_layer_only_ours {
4022 source[insert_at..replace_end].to_string()
4023 } else {
4024 String::new()
4025 },
4026 proposed: hoisted,
4027 proof: Proof {
4028 selector_set_equivalent: true,
4029 specificity_equivalent: true,
4030 cascade_context_equivalent: true,
4031 source_order_equivalent: false,
4032 layer_equivalent: true,
4033 scope_equivalent: true,
4034 declarations_exact: true,
4035 important_exact: true,
4036 },
4037 warnings: vec![format!(
4038 "Hoisted '{}' out of {} named layers so nested @layer blocks do not create child layers.",
4039 selector,
4040 layer_blocks.len()
4041 )],
4042 reason: format!(
4043 "Nest {} named layers under shared selector '{}'.",
4044 layer_blocks.len(),
4045 selector
4046 ),
4047 selected: true,
4048 });
4049
4050 for (start, end, line_at) in deletes {
4051 plans.push(PlanEntry {
4052 id: String::new(),
4053 file: path.to_path_buf(),
4054 rules: vec![RuleId::NestLayerBySelector],
4055 safety: Safety::Review,
4056 source_range: SourceRange { start, end },
4057 original: source[start..end].to_string(),
4058 proposed: String::new(),
4059 proof: Proof::safe_local(),
4060 warnings: Vec::new(),
4061 reason: format!(
4062 "Remove layer-local '{}' after nesting layers at line {}.",
4063 selector,
4064 line_number(source, line_at)
4065 ),
4066 selected: true,
4067 });
4068 }
4069 }
4070}
4071
4072fn plan_gather_related_selector_rules(
4073 path: &Path,
4074 source: &str,
4075 nodes: &[SourceNode],
4076 enabled: &HashSet<RuleId>,
4077 plans: &mut Vec<PlanEntry>,
4078) {
4079 if !enabled.contains(&RuleId::GatherRelatedSelectorRules) {
4080 return;
4081 }
4082
4083 if nodes.len() > 256 {
4087 return;
4088 }
4089
4090 let mut exact_homes: Vec<&str> = Vec::new();
4091 let mut home_weight: HashMap<&str, usize> = HashMap::new();
4092 collect_gather_homes(source, nodes, &mut exact_homes, &mut home_weight);
4093
4094 let mut grouped_at_blocks: HashSet<usize> = HashSet::new();
4095 mark_grouped_at_blocks(
4096 source,
4097 nodes,
4098 &exact_homes,
4099 &home_weight,
4100 &mut grouped_at_blocks,
4101 );
4102
4103 for base in exact_homes.clone() {
4104 if is_weak_gather_base(base) {
4110 continue;
4111 }
4112 let mut cluster: Vec<GatherMember> = Vec::new();
4113 collect_gather_cluster(
4114 source,
4115 nodes,
4116 base,
4117 &exact_homes,
4118 &home_weight,
4119 &grouped_at_blocks,
4120 &mut cluster,
4121 );
4122
4123 if cluster.len() > 1 {
4124 let mut is_non_adjacent = false;
4125 for window in cluster.windows(2) {
4126 let prev_end = window[0].outer_end();
4127 let next_start = window[1].outer_start();
4128 if next_start < prev_end {
4129 continue;
4130 }
4131 if !is_whitespace_only(source, prev_end..next_start) {
4132 is_non_adjacent = true;
4133 break;
4134 }
4135 }
4136
4137 let first_style = cluster.iter().find_map(|m| match m {
4138 GatherMember::Style(n) => Some(n),
4139 GatherMember::Conditional { .. } => None,
4140 });
4141
4142 let Some(first) = first_style else {
4143 continue;
4144 };
4145 let first_sel = first.prelude(source).trim();
4146 let has_non_style = cluster
4147 .iter()
4148 .any(|m| matches!(m, GatherMember::Conditional { .. }));
4149 let should_gather = is_non_adjacent || first_sel != base || has_non_style;
4150 if !should_gather {
4151 continue;
4152 }
4153
4154 let cluster_safe = cluster.iter().all(|member| {
4155 let sel = match member {
4156 GatherMember::Style(n) => n.prelude(source).trim(),
4157 GatherMember::Conditional { inner, .. } => inner.prelude(source).trim(),
4158 };
4159 can_nest_under_gather_home(base, sel)
4160 });
4161 if !cluster_safe {
4162 continue;
4163 }
4164 let parent_indent = line_indent(source, first.start);
4165 let unit = relative_indent_unit(source, first);
4166
4167 let mut merged = format_merged_rule(base, &parent_indent, &unit, &cluster, source);
4168
4169 let style_members: Vec<&SourceNode> = cluster
4170 .iter()
4171 .filter_map(|m| match m {
4172 GatherMember::Style(n) => Some(n),
4173 GatherMember::Conditional { .. } => None,
4174 })
4175 .collect();
4176 let uses_appended = cluster.iter().any(|member| {
4177 let sel = match member {
4178 GatherMember::Style(n) => n.prelude(source).trim(),
4179 GatherMember::Conditional { inner, .. } => inner.prelude(source).trim(),
4180 };
4181 matches!(
4182 classify_related_nested(base, sel),
4183 Some((RelatedKind::Appended, _))
4184 )
4185 });
4186 let gather_safety = if cluster_safe && !uses_appended {
4187 Safety::Safe
4188 } else {
4189 Safety::Review
4190 };
4191 let first_comment = leading_block_comment(source, first.start);
4192 let span_start =
4193 line_start(source, first_comment.map(|(s, _)| s).unwrap_or(first.start));
4194 let span_end = extend_with_trailing_newline(source, first.end);
4198 if let Some((_, cmt)) = first_comment {
4199 merged = format!("{parent_indent}{cmt}\n{merged}");
4200 }
4201
4202 let replacements = vec![(span_start, first.end, merged)];
4203 let proposed = squeeze_excess_blank_lines(&apply_range_replacements(
4204 source,
4205 span_start,
4206 span_end,
4207 &replacements,
4208 ));
4209
4210 plans.push(PlanEntry {
4211 id: String::new(),
4212 file: path.to_path_buf(),
4213 rules: vec![RuleId::GatherRelatedSelectorRules],
4214 safety: gather_safety,
4215 source_range: SourceRange {
4216 start: span_start,
4217 end: span_end,
4218 },
4219 original: source[span_start..span_end].to_string(),
4220 proposed,
4221 proof: Proof {
4222 selector_set_equivalent: true,
4223 specificity_equivalent: true,
4224 cascade_context_equivalent: !is_non_adjacent,
4225 source_order_equivalent: !is_non_adjacent,
4226 layer_equivalent: true,
4227 scope_equivalent: true,
4228 declarations_exact: true,
4229 important_exact: true,
4230 },
4231 warnings: if is_non_adjacent {
4232 vec![format!(
4233 "Gathered {} related occurrences of '{}' across lines; review cascade ordering.",
4234 cluster.len(),
4235 base
4236 )]
4237 } else {
4238 Vec::new()
4239 },
4240 reason: format!(
4241 "Gather {} related rules for '{}' into the canonical first selector block.",
4242 cluster.len(),
4243 base
4244 ),
4245 selected: true,
4246 });
4247
4248 let mut deleted_at_blocks = HashSet::new();
4249 for sec in style_members.iter().skip(1) {
4250 let start = leading_block_comment(source, sec.start)
4251 .map(|(s, _)| s)
4252 .unwrap_or(sec.start);
4253 let end = extend_with_trailing_newline(source, sec.end);
4254 plans.push(PlanEntry {
4255 id: String::new(),
4256 file: path.to_path_buf(),
4257 rules: vec![RuleId::GatherRelatedSelectorRules],
4258 safety: gather_safety,
4259 source_range: SourceRange { start, end },
4260 original: source[start..end].to_string(),
4261 proposed: String::new(),
4262 proof: Proof::safe_local(),
4263 warnings: Vec::new(),
4264 reason: format!(
4265 "Remove non-adjacent gathered rule for '{}' at line {}.",
4266 base,
4267 line_number(source, sec.start)
4268 ),
4269 selected: true,
4270 });
4271 }
4272 for member in &cluster {
4273 let GatherMember::Conditional {
4274 at_node,
4275 inner,
4276 delete_whole_at_block,
4277 } = member
4278 else {
4279 continue;
4280 };
4281 let (sec_start, sec_end, line_at) = if *delete_whole_at_block {
4282 if !deleted_at_blocks.insert(at_node.start) {
4283 continue;
4284 }
4285 (
4286 at_node.start,
4287 extend_with_trailing_newline(source, at_node.end),
4288 at_node.start,
4289 )
4290 } else {
4291 (
4292 inner.start,
4293 extend_with_trailing_newline(source, inner.end),
4294 inner.start,
4295 )
4296 };
4297
4298 plans.push(PlanEntry {
4299 id: String::new(),
4300 file: path.to_path_buf(),
4301 rules: vec![RuleId::GatherRelatedSelectorRules],
4302 safety: gather_safety,
4303 source_range: SourceRange {
4304 start: sec_start,
4305 end: sec_end,
4306 },
4307 original: source[sec_start..sec_end].to_string(),
4308 proposed: String::new(),
4309 proof: Proof::safe_local(),
4310 warnings: Vec::new(),
4311 reason: format!(
4312 "Remove non-adjacent gathered rule for '{}' at line {}.",
4313 base,
4314 line_number(source, line_at)
4315 ),
4316 selected: true,
4317 });
4318 }
4319 }
4320 }
4321}
4322
4323fn plan_factor_identical_states_with_is(
4324 path: &Path,
4325 source: &str,
4326 nodes: &[SourceNode],
4327 enabled: &HashSet<RuleId>,
4328 plans: &mut Vec<PlanEntry>,
4329) {
4330 if !enabled.contains(&RuleId::FactorIdenticalStatesWithIs) {
4331 return;
4332 }
4333 let mut i = 0;
4334 while i < nodes.len() {
4335 let first = &nodes[i];
4336 if matches!(&first.kind, NodeKind::Style) {
4337 let first_sel = first.prelude(source).trim();
4338 if let Some(colon_pos) = first_sel.find(':')
4339 && !first_sel[colon_pos..].starts_with("::")
4340 {
4341 let base = &first_sel[..colon_pos];
4342 if !base.is_empty() && !base.contains(' ') {
4343 let first_body = first.body(source).unwrap_or("").trim();
4344 let mut cluster = vec![first];
4345 let mut cursor = i + 1;
4346 let mut prev_end = first.end;
4347
4348 while cursor < nodes.len() {
4349 let next = &nodes[cursor];
4350 if !is_whitespace_only(source, prev_end..next.start) {
4351 break;
4352 }
4353 if matches!(&next.kind, NodeKind::Style) {
4354 let next_sel = next.prelude(source).trim();
4355 if next_sel.starts_with(base)
4356 && next_sel[base.len()..].starts_with(':')
4357 && !next_sel[base.len()..].starts_with("::")
4358 && next.body(source).unwrap_or("").trim() == first_body
4359 {
4360 cluster.push(next);
4361 prev_end = next.end;
4362 cursor += 1;
4363 continue;
4364 }
4365 }
4366 break;
4367 }
4368
4369 if cluster.len() > 1 {
4370 let last = cluster.last().unwrap();
4371 let pseudos: Vec<&str> = cluster
4372 .iter()
4373 .map(|c| {
4374 let s = c.prelude(source).trim();
4375 &s[base.len()..]
4376 })
4377 .collect();
4378 let is_inner = pseudos.join(", ");
4379 let parent_indent = line_indent(source, first.start);
4380 let first_body_range = first.body_range.as_ref().unwrap();
4381 let unit = detect_indent_unit(source, first_body_range.clone())
4382 .unwrap_or_else(|| " ".to_string());
4383 let nested_indent = format!("{parent_indent}{unit}");
4384 let inner_decl_indent = format!("{nested_indent}{unit}");
4385
4386 let mut decls = String::new();
4387 for line in source[first_body_range.clone()].lines() {
4388 let trimmed = line.trim();
4389 if !trimmed.is_empty() {
4390 decls.push_str(&inner_decl_indent);
4391 decls.push_str(&ensure_semicolon(trimmed));
4392 decls.push('\n');
4393 }
4394 }
4395
4396 let proposed = format!(
4397 "{parent_indent}{base} {{\n{nested_indent}&:is({is_inner}) {{\n{decls}{nested_indent}}}\n{parent_indent}}}"
4398 );
4399 plans.push(PlanEntry {
4400 id: String::new(),
4401 file: path.to_path_buf(),
4402 rules: vec![RuleId::FactorIdenticalStatesWithIs],
4403 safety: Safety::Safe,
4404 source_range: SourceRange {
4405 start: first.start,
4406 end: last.end,
4407 },
4408 original: source[first.start..last.end].to_string(),
4409 proposed,
4410 proof: Proof::safe_local(),
4411 warnings: Vec::new(),
4412 reason: format!(
4413 "Factor {} identical state rules for '{}' into &:is({}) form.",
4414 cluster.len(),
4415 base,
4416 is_inner
4417 ),
4418 selected: true,
4419 });
4420 i = cursor;
4421 continue;
4422 }
4423 }
4424 }
4425 }
4426 i += 1;
4427 }
4428}
4429
4430fn plan_factor_multi_selector_cluster_with_is(
4431 path: &Path,
4432 source: &str,
4433 nodes: &[SourceNode],
4434 enabled: &HashSet<RuleId>,
4435 plans: &mut Vec<PlanEntry>,
4436) {
4437 if !enabled.contains(&RuleId::ModernizeIs) {
4438 return;
4439 }
4440
4441 let mut i = 0;
4442 while i < nodes.len() {
4443 let first = &nodes[i];
4444 if matches!(&first.kind, NodeKind::Style) {
4445 let first_sel = first.prelude(source).trim();
4446 if let Some((base_prefixes, first_suffix)) = extract_multi_branch_pattern(first_sel) {
4447 let mut cluster = vec![(first, first_suffix)];
4448 let mut cursor = i + 1;
4449 let mut prev_end = first.end;
4450
4451 while cursor < nodes.len() {
4452 let next = &nodes[cursor];
4453 if !is_whitespace_only(source, prev_end..next.start) {
4454 break;
4455 }
4456 if matches!(&next.kind, NodeKind::Style) {
4457 let next_sel = next.prelude(source).trim();
4458 if let Some((next_prefixes, next_suffix)) =
4459 extract_multi_branch_pattern(next_sel)
4460 && next_prefixes == base_prefixes
4461 {
4462 cluster.push((next, next_suffix));
4463 prev_end = next.end;
4464 cursor += 1;
4465 continue;
4466 }
4467 }
4468 break;
4469 }
4470
4471 if cluster.len() > 1 {
4472 let (last_node, _) = cluster.last().unwrap();
4473 let parent_indent = line_indent(source, first.start);
4474 let first_body_range = first.body_range.as_ref().unwrap();
4475 let unit = detect_indent_unit(source, first_body_range.clone())
4476 .unwrap_or_else(|| " ".to_string());
4477 let nested_indent = format!("{parent_indent}{unit}");
4478 let inner_decl_indent = format!("{nested_indent}{unit}");
4479
4480 let is_header = format!(":is({})", base_prefixes.join(", "));
4481 let mut out = format!("{parent_indent}{is_header} {{\n");
4482 let mut has_direct_decls = false;
4483
4484 for &(c_node, ref suffix) in &cluster {
4486 if suffix.is_none()
4487 && let Some(c_body_range) = &c_node.body_range
4488 {
4489 for line in source[c_body_range.clone()].lines() {
4490 let trimmed = line.trim();
4491 if !trimmed.is_empty() {
4492 out.push_str(&nested_indent);
4493 out.push_str(&ensure_semicolon(trimmed));
4494 out.push('\n');
4495 has_direct_decls = true;
4496 }
4497 }
4498 }
4499 }
4500
4501 for (c_idx, &(c_node, ref suffix)) in cluster.iter().enumerate() {
4503 if let Some(sub_sel) = suffix {
4504 if has_direct_decls || c_idx > 0 {
4505 out.push('\n');
4506 }
4507 out.push_str(&nested_indent);
4508 out.push_str(sub_sel);
4509 out.push_str(" {\n");
4510 if let Some(c_body_range) = &c_node.body_range {
4511 for line in source[c_body_range.clone()].lines() {
4512 let trimmed = line.trim();
4513 if !trimmed.is_empty() {
4514 out.push_str(&inner_decl_indent);
4515 out.push_str(&ensure_semicolon(trimmed));
4516 out.push('\n');
4517 }
4518 }
4519 }
4520 out.push_str(&nested_indent);
4521 out.push_str("}\n");
4522 }
4523 }
4524
4525 out.push_str(&parent_indent);
4526 out.push('}');
4527
4528 let specificities: Vec<Specificity> = base_prefixes
4529 .iter()
4530 .map(|p| calculate_specificity(p))
4531 .collect();
4532 let uniform = specificities.windows(2).all(|w| w[0] == w[1]);
4533
4534 plans.push(PlanEntry {
4535 id: String::new(),
4536 file: path.to_path_buf(),
4537 rules: vec![RuleId::ModernizeIs],
4538 safety: if uniform { Safety::Safe } else { Safety::Review },
4539 source_range: SourceRange {
4540 start: first.start,
4541 end: last_node.end,
4542 },
4543 original: source[first.start..last_node.end].to_string(),
4544 proposed: out,
4545 proof: Proof {
4546 specificity_equivalent: uniform,
4547 ..Proof::safe_local()
4548 },
4549 warnings: if uniform {
4550 Vec::new()
4551 } else {
4552 vec!["Mixed selector specificity: :is() takes the specificity of its most specific argument.".into()]
4553 },
4554 reason: format!("Factor multi-selector cluster for {} into :is(...) with nested rules.", is_header),
4555 selected: true,
4556 });
4557 i = cursor;
4558 continue;
4559 }
4560 }
4561 }
4562 i += 1;
4563 }
4564}
4565
4566fn extract_multi_branch_pattern(selector: &str) -> Option<(Vec<String>, Option<String>)> {
4567 let branches: Vec<&str> = split_top_level_comma(selector)
4568 .into_iter()
4569 .map(|s| s.trim())
4570 .collect();
4571 if branches.len() < 2 {
4572 return None;
4573 }
4574 if branches.iter().any(|b| b.contains("::")) {
4575 return None;
4576 }
4577
4578 let first = branches[0];
4579 if let Some(space_pos) = first.rfind(' ') {
4580 let suffix = &first[space_pos..];
4581 if branches.iter().all(|b| b.ends_with(suffix)) {
4582 let prefixes: Vec<String> = branches
4583 .iter()
4584 .map(|b| b[..b.len() - suffix.len()].trim().to_string())
4585 .collect();
4586 if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4587 return Some((prefixes, Some(suffix.trim().to_string())));
4588 }
4589 }
4590 }
4591
4592 if branches
4593 .iter()
4594 .all(|b| is_valid_selector_token(b) && !b.contains(' '))
4595 {
4596 let prefixes: Vec<String> = branches.iter().map(|b| b.to_string()).collect();
4597 return Some((prefixes, None));
4598 }
4599
4600 None
4601}
4602
4603fn plan_merge_identical_rule_bodies(
4604 path: &Path,
4605 source: &str,
4606 nodes: &[SourceNode],
4607 enabled: &HashSet<RuleId>,
4608 plans: &mut Vec<PlanEntry>,
4609) {
4610 if !enabled.contains(&RuleId::MergeIdenticalRuleBodies) {
4611 return;
4612 }
4613 let mut i = 0;
4614 while i < nodes.len() {
4615 let first = &nodes[i];
4616 if matches!(&first.kind, NodeKind::Style) {
4617 let first_body = first.body(source).unwrap_or("").trim();
4618 if !first_body.is_empty() {
4619 let mut cluster = vec![first];
4620 let mut cursor = i + 1;
4621 let mut prev_end = first.end;
4622
4623 while cursor < nodes.len() {
4624 let next = &nodes[cursor];
4625 if !is_whitespace_only(source, prev_end..next.start) {
4626 break;
4627 }
4628 if matches!(&next.kind, NodeKind::Style)
4629 && next.body(source).unwrap_or("").trim() == first_body
4630 {
4631 cluster.push(next);
4632 prev_end = next.end;
4633 cursor += 1;
4634 continue;
4635 }
4636 break;
4637 }
4638
4639 if cluster.len() > 1 {
4640 let last = cluster.last().unwrap();
4641 let selectors: Vec<&str> =
4642 cluster.iter().map(|c| c.prelude(source).trim()).collect();
4643 let specificities: Vec<Specificity> =
4644 selectors.iter().map(|s| calculate_specificity(s)).collect();
4645 let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4646 let parent_indent = line_indent(source, first.start);
4647 let first_body_range = first.body_range.as_ref().unwrap();
4648 let unit = detect_indent_unit(source, first_body_range.clone())
4649 .unwrap_or_else(|| " ".to_string());
4650 let nested_indent = format!("{parent_indent}{unit}");
4651
4652 let mut decls = String::new();
4653 for line in source[first_body_range.clone()].lines() {
4654 let trimmed = line.trim();
4655 if !trimmed.is_empty() {
4656 decls.push_str(&nested_indent);
4657 decls.push_str(&ensure_semicolon(trimmed));
4658 decls.push('\n');
4659 }
4660 }
4661
4662 let joined_sel = selectors.join(&format!(",\n{parent_indent}"));
4663 let proposed =
4664 format!("{parent_indent}{joined_sel} {{\n{decls}{parent_indent}}}");
4665 plans.push(PlanEntry {
4666 id: String::new(),
4667 file: path.to_path_buf(),
4668 rules: vec![RuleId::MergeIdenticalRuleBodies],
4669 safety: if uniform_specificity {
4670 Safety::Safe
4671 } else {
4672 Safety::Review
4673 },
4674 source_range: SourceRange {
4675 start: first.start,
4676 end: last.end,
4677 },
4678 original: source[first.start..last.end].to_string(),
4679 proposed,
4680 proof: Proof {
4681 specificity_equivalent: uniform_specificity,
4682 ..Proof::safe_local()
4683 },
4684 warnings: if uniform_specificity {
4685 Vec::new()
4686 } else {
4687 vec!["Mixed selector specificity: combining these selectors changes cascade weight.".into()]
4688 },
4689 reason: format!("Merge {} rules with identical declaration bodies into a single comma-separated rule.", cluster.len()),
4690 selected: true,
4691 });
4692 i = cursor;
4693 continue;
4694 }
4695 }
4696 }
4697 i += 1;
4698 }
4699}
4700
4701pub fn split_top_level_comma(selector: &str) -> Vec<&str> {
4702 let bytes = selector.as_bytes();
4703 let mut parts = Vec::new();
4704 let mut last = 0;
4705 let mut parens = 0usize;
4706 let mut brackets = 0usize;
4707 let mut quote: Option<u8> = None;
4708 let mut escaped = false;
4709 let mut i = 0usize;
4710
4711 while i < bytes.len() {
4712 let b = bytes[i];
4713 if let Some(q) = quote {
4714 if escaped {
4715 escaped = false;
4716 } else if b == b'\\' {
4717 escaped = true;
4718 } else if b == q {
4719 quote = None;
4720 }
4721 i += 1;
4722 continue;
4723 }
4724 match b {
4725 b'\'' | b'"' => quote = Some(b),
4726 b'(' => parens += 1,
4727 b')' => parens = parens.saturating_sub(1),
4728 b'[' => brackets += 1,
4729 b']' => brackets = brackets.saturating_sub(1),
4730 b',' if parens == 0 && brackets == 0 => {
4731 parts.push(&selector[last..i]);
4732 last = i + 1;
4733 }
4734 _ => {}
4735 }
4736 i += 1;
4737 }
4738 if last < selector.len() {
4739 parts.push(&selector[last..]);
4740 }
4741 parts
4742}
4743
4744pub fn factor_selector_list(
4745 selector: &str,
4746 body: &str,
4747 indent: &str,
4748 unit: &str,
4749) -> Option<String> {
4750 let branches: Vec<&str> = split_top_level_comma(selector)
4751 .into_iter()
4752 .map(|s| s.trim())
4753 .collect();
4754 if branches.len() < 2 {
4755 return None;
4756 }
4757 let base = branches[0];
4758 if contains_top_level_comma(base) || base.contains("::") || base.is_empty() {
4759 return None;
4760 }
4761
4762 let mut inner_selectors = Vec::new();
4763 for &branch in &branches {
4764 if branch == base {
4765 inner_selectors.push("&".to_string());
4766 } else {
4767 let rel = branch.strip_prefix(base)?;
4768 if rel.starts_with("::")
4769 || rel.starts_with(':')
4770 || rel.starts_with('[')
4771 || rel.starts_with('.')
4772 || rel.starts_with('#')
4773 {
4774 inner_selectors.push(format!("&{rel}"));
4775 } else {
4776 let trimmed = rel.strip_prefix(' ')?;
4777 inner_selectors.push(trimmed.trim_start().to_string());
4778 }
4779 }
4780 }
4781
4782 let nested_indent = format!("{indent}{unit}");
4783 let inner_decl_indent = format!("{nested_indent}{unit}");
4784 let mut out = String::new();
4785 out.push_str(base);
4786 out.push_str(" {\n");
4787 out.push_str(&nested_indent);
4788 out.push_str(&inner_selectors.join(&format!(",\n{nested_indent}")));
4789 out.push_str(" {\n");
4790 for line in preserve_body_lines(body) {
4791 out.push_str(&inner_decl_indent);
4792 out.push_str(&ensure_semicolon(&line));
4793 out.push('\n');
4794 }
4795 out.push_str(&nested_indent);
4796 out.push_str("}\n");
4797 out.push_str(indent);
4798 out.push('}');
4799 Some(out)
4800}
4801
4802fn split_outer_function_call(selector: &str) -> Option<(&str, &'static str, &str, &str)> {
4806 let functions = [
4807 (":is(", "is"),
4808 (":where(", "where"),
4809 (":has(", "has"),
4810 (":not(", "not"),
4811 ];
4812 let bytes = selector.as_bytes();
4813 let mut depth = 0usize;
4814 let mut quote = None;
4815 let mut escaped = false;
4816 let mut i = 0usize;
4817
4818 while i < bytes.len() {
4819 let b = bytes[i];
4820 if let Some(q) = quote {
4821 if escaped {
4822 escaped = false;
4823 } else if b == b'\\' {
4824 escaped = true;
4825 } else if b == q {
4826 quote = None;
4827 }
4828 i += 1;
4829 continue;
4830 }
4831 if b == b'\'' || b == b'"' {
4832 quote = Some(b);
4833 i += 1;
4834 continue;
4835 }
4836 if b == b'(' {
4837 depth += 1;
4838 i += 1;
4839 continue;
4840 }
4841 if b == b')' {
4842 depth = depth.saturating_sub(1);
4843 i += 1;
4844 continue;
4845 }
4846 if depth == 0 {
4847 for (needle, name) in functions {
4848 if selector[i..].starts_with(needle) {
4849 let open = i + needle.len() - 1;
4850 let close = find_matching_paren(selector, open)?;
4851 return Some((
4852 &selector[..i],
4853 name,
4854 &selector[open + 1..close],
4855 &selector[close + 1..],
4856 ));
4857 }
4858 }
4859 }
4860 i += 1;
4861 }
4862 None
4863}
4864
4865fn factor_outer_function_arguments(
4866 branches: &[&str],
4867 uniform_specificity: bool,
4868) -> Option<(String, bool)> {
4869 let (first_prefix, first_name, first_args, first_suffix) =
4870 split_outer_function_call(branches[0])?;
4871 if first_name == "not" || first_args.trim().is_empty() {
4874 return None;
4875 }
4876
4877 let mut arguments = vec![first_args.trim()];
4878 for branch in &branches[1..] {
4879 let (prefix, name, args, suffix) = split_outer_function_call(branch)?;
4880 if prefix != first_prefix
4881 || name != first_name
4882 || suffix != first_suffix
4883 || args.trim().is_empty()
4884 {
4885 return None;
4886 }
4887 arguments.push(args.trim());
4888 }
4889
4890 let combined = format!(
4891 "{first_prefix}:{first_name}({}){first_suffix}",
4892 arguments.join(", ")
4893 );
4894 let safe = first_name == "where" || uniform_specificity;
4896 Some((combined, safe))
4897}
4898
4899fn contains_pseudo_element_syntax(selector: &str) -> bool {
4900 if selector.contains("::") {
4901 return true;
4902 }
4903
4904 const LEGACY_PSEUDO_ELEMENTS: &[&str] = &[":before", ":after", ":first-line", ":first-letter"];
4908 LEGACY_PSEUDO_ELEMENTS.iter().any(|pseudo| {
4909 let mut offset = 0;
4910 while let Some(relative) = selector[offset..].find(pseudo) {
4911 let start = offset + relative;
4912 let end = start + pseudo.len();
4913 let boundary = selector[end..].chars().next();
4914 if boundary.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_') {
4915 return true;
4916 }
4917 offset = end;
4918 }
4919 false
4920 })
4921}
4922
4923pub fn factor_with_is(selector: &str) -> Option<(String, bool)> {
4924 let branches: Vec<&str> = split_top_level_comma(selector)
4925 .into_iter()
4926 .map(|s| s.trim())
4927 .filter(|s| !s.is_empty())
4928 .collect();
4929 if branches.len() < 2 {
4930 return None;
4931 }
4932
4933 if branches.iter().any(|b| contains_pseudo_element_syntax(b)) {
4934 return None;
4935 }
4936
4937 let specificities: Vec<Specificity> =
4938 branches.iter().map(|b| calculate_specificity(b)).collect();
4939 let uniform_specificity = specificities.windows(2).all(|w| w[0] == w[1]);
4940
4941 if let Some(result) = factor_outer_function_arguments(&branches, uniform_specificity) {
4942 return Some(result);
4943 }
4944
4945 if branches.iter().any(|branch| {
4949 [":is(", ":where(", ":has(", ":not("]
4950 .iter()
4951 .any(|function| branch.contains(function))
4952 }) {
4953 return None;
4954 }
4955
4956 let first = branches[0];
4957
4958 if let Some(space_pos) = first.rfind(' ') {
4960 let suffix = &first[space_pos..];
4961 if branches.iter().all(|b| b.ends_with(suffix)) {
4962 let prefixes: Vec<&str> = branches
4963 .iter()
4964 .map(|b| b[..b.len() - suffix.len()].trim())
4965 .collect();
4966 if prefixes.iter().all(|p| is_valid_selector_token(p)) {
4967 let is_inner = prefixes.join(", ");
4968 return Some((format!(":is({is_inner}){suffix}"), uniform_specificity));
4969 }
4970 }
4971 }
4972
4973 if let Some(space_pos) = first.rfind(' ') {
4975 let prefix = &first[..=space_pos];
4976 if branches.iter().all(|b| b.starts_with(prefix)) {
4977 let suffixes: Vec<&str> = branches.iter().map(|b| b[prefix.len()..].trim()).collect();
4978 if suffixes.iter().all(|s| is_valid_selector_token(s)) {
4979 let is_inner = suffixes.join(", ");
4980 return Some((format!("{prefix}:is({is_inner})"), uniform_specificity));
4981 }
4982 }
4983 }
4984
4985 if let Some(colon_pos) = first.find(':') {
4987 let base = &first[..colon_pos];
4988 if !base.is_empty()
4989 && !base.contains(' ')
4990 && branches.iter().all(|b| {
4991 b.starts_with(base)
4992 && b[base.len()..].starts_with(':')
4993 && !b[base.len()..].starts_with("::")
4994 })
4995 {
4996 let pseudos: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
4997 if pseudos
4998 .iter()
4999 .all(|p| p.starts_with(':') && !p.starts_with("::") && !p.contains(' '))
5000 {
5001 let is_inner = pseudos.join(", ");
5002 return Some((format!("{base}:is({is_inner})"), uniform_specificity));
5003 }
5004 }
5005 }
5006
5007 if let Some(bracket_pos) = first.find('[') {
5009 let base = &first[..bracket_pos];
5010 if !base.is_empty()
5011 && !base.contains(' ')
5012 && branches
5013 .iter()
5014 .all(|b| b.starts_with(base) && b[base.len()..].starts_with('['))
5015 {
5016 let attrs: Vec<&str> = branches.iter().map(|b| b[base.len()..].trim()).collect();
5017 if attrs.iter().all(|a| a.starts_with('[') && a.ends_with(']')) {
5018 let is_inner = attrs.join(", ");
5019 return Some((format!("{base}:is({is_inner})"), uniform_specificity));
5020 }
5021 }
5022 }
5023
5024 None
5025}
5026
5027fn is_valid_selector_token(s: &str) -> bool {
5028 if s.is_empty() {
5029 return false;
5030 }
5031 let first = s.chars().next().unwrap();
5032 first == '.'
5033 || first == '#'
5034 || first == '['
5035 || first == ':'
5036 || first.is_ascii_alphabetic()
5037 || first == '*'
5038 || first == '>'
5039 || first == '+'
5040 || first == '~'
5041}
5042
5043pub fn factor_with_where(selector: &str) -> Option<String> {
5044 let branches: Vec<&str> = split_top_level_comma(selector)
5045 .into_iter()
5046 .map(|s| s.trim())
5047 .collect();
5048 if branches.len() < 2 {
5049 return None;
5050 }
5051 if branches.iter().any(|b| b.contains("::")) {
5052 return None;
5053 }
5054 Some(format!(":where({})", branches.join(", ")))
5055}
5056
5057pub fn modernize_media_query_str(prelude: &str) -> Option<String> {
5058 let mut result = prelude.to_string();
5059 let mut changed = false;
5060
5061 if let (Some((min_raw, min_val)), Some((max_raw, max_val))) = (
5063 extract_media_feature_and_raw(&result, "min-width"),
5064 extract_media_feature_and_raw(&result, "max-width"),
5065 ) {
5066 let pattern = format!("{min_raw} and {max_raw}");
5067 let replacement = format!("({min_val} <= width <= {max_val})");
5068 if result.contains(&pattern) {
5069 result = result.replace(&pattern, &replacement);
5070 changed = true;
5071 }
5072 }
5073
5074 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-width") {
5076 let replacement = format!("(width >= {val})");
5077 result = result.replacen(&raw, &replacement, 1);
5078 changed = true;
5079 }
5080
5081 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-width") {
5083 let replacement = format!("(width <= {val})");
5084 result = result.replacen(&raw, &replacement, 1);
5085 changed = true;
5086 }
5087
5088 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "min-height") {
5090 let replacement = format!("(height >= {val})");
5091 result = result.replacen(&raw, &replacement, 1);
5092 changed = true;
5093 }
5094
5095 while let Some((raw, val)) = extract_media_feature_and_raw(&result, "max-height") {
5097 let replacement = format!("(height <= {val})");
5098 result = result.replacen(&raw, &replacement, 1);
5099 changed = true;
5100 }
5101
5102 if changed { Some(result) } else { None }
5103}
5104
5105fn extract_media_feature_and_raw(source: &str, feature: &str) -> Option<(String, String)> {
5106 let feat_idx = source.find(feature)?;
5107 let open_paren = source[..feat_idx].rfind('(')?;
5108 if source[open_paren..feat_idx].contains(')') {
5109 return None;
5110 }
5111 let colon_rel = source[feat_idx + feature.len()..].find(':')?;
5112 let colon_idx = feat_idx + feature.len() + colon_rel;
5113 let close_paren_rel = source[colon_idx..].find(')')?;
5114 let close_paren = colon_idx + close_paren_rel;
5115
5116 let raw = source[open_paren..=close_paren].to_string();
5117 let val = source[colon_idx + 1..close_paren].trim().to_string();
5118 Some((raw, val))
5119}
5120
5121fn selector_relation(parent: &str, child: &str) -> Option<(RelationKind, String)> {
5122 let parent = parent.trim();
5123 let child = child.trim();
5124 if parent.is_empty()
5125 || child.is_empty()
5126 || contains_top_level_comma(parent)
5127 || contains_top_level_comma(child)
5128 || parent.contains("::")
5129 || child == parent
5130 || selector_contains_nesting_amp(child)
5131 {
5132 return None;
5133 }
5134
5135 if !child.starts_with(parent) {
5136 if is_weak_gather_base(parent) {
5137 return None;
5138 }
5139 return appended_selector_relation(parent, child);
5140 }
5141
5142 let remainder = &child[parent.len()..];
5143 let trimmed = remainder.trim_start();
5144 if trimmed.is_empty() {
5145 return None;
5146 }
5147
5148 let (relation, nested_selector) = if remainder.starts_with("::") {
5149 (RelationKind::PseudoElement, format!("&{remainder}"))
5150 } else if remainder.starts_with(':') {
5151 (RelationKind::PseudoClass, format!("&{remainder}"))
5152 } else if remainder.starts_with('[') {
5153 (RelationKind::Attribute, format!("&{remainder}"))
5154 } else if remainder.starts_with('.') || remainder.starts_with('#') {
5155 (RelationKind::Compound, format!("&{remainder}"))
5156 } else if let Some(first_char) = trimmed
5157 .chars()
5158 .next()
5159 .filter(|c| *c == '>' || *c == '+' || *c == '~')
5160 {
5161 let after_comb = trimmed[first_char.len_utf8()..].trim();
5162 if after_comb == parent {
5163 (
5164 RelationKind::Combinator,
5165 explicit_relative_combinator(first_char, parent),
5166 )
5167 } else if let Some(after_parent) = after_comb.strip_prefix(parent) {
5168 (
5169 RelationKind::Combinator,
5170 explicit_relative_combinator(first_char, &format!("{parent}{after_parent}")),
5171 )
5172 } else {
5173 (
5174 RelationKind::Combinator,
5175 explicit_relative_combinator(first_char, after_comb),
5176 )
5177 }
5178 } else if remainder
5179 .as_bytes()
5180 .first()
5181 .is_some_and(|b| b.is_ascii_whitespace())
5182 {
5183 let descendant = remainder.trim();
5184 (RelationKind::Descendant, descendant.to_string())
5185 } else {
5186 return None;
5187 };
5188
5189 Some((relation, nested_selector))
5190}
5191
5192fn conditional_child(
5193 source: &str,
5194 parent_selector: &str,
5195 node: &SourceNode,
5196 enabled: &HashSet<RuleId>,
5197) -> Option<ClusterChild> {
5198 let (name, rule) = match &node.kind {
5199 NodeKind::AtBlock { name, .. } if name == "media" => (name.as_str(), RuleId::NestMedia),
5200 NodeKind::AtBlock { name, .. } if name == "supports" => {
5201 (name.as_str(), RuleId::NestSupports)
5202 }
5203 NodeKind::AtBlock { name, .. } if name == "container" => {
5204 (name.as_str(), RuleId::NestContainer)
5205 }
5206 NodeKind::AtBlock { name, .. } if name == "starting-style" => {
5207 (name.as_str(), RuleId::NestStartingStyle)
5208 }
5209 _ => return None,
5210 };
5211 if !enabled.contains(&rule) {
5212 return None;
5213 }
5214
5215 let body_range = node.body_range.clone()?;
5216 let inner_nodes = scan_nodes(source, body_range.clone());
5217 if inner_nodes.is_empty() {
5218 return None;
5219 }
5220
5221 let mut inners = Vec::new();
5222 for inner in &inner_nodes {
5223 if !matches!(&inner.kind, NodeKind::Style) {
5224 return None;
5225 }
5226 let inner_prelude = inner.prelude(source);
5227 let inner_body = inner.body_range.clone()?;
5228 if inner_prelude == parent_selector.trim() {
5229 inners.push(ConditionalInner::Direct {
5230 body_range: inner_body,
5231 });
5232 } else if name != "starting-style"
5233 && let Some((_rel, nested_sel)) = selector_relation(parent_selector, inner_prelude)
5234 {
5235 inners.push(ConditionalInner::Nested {
5236 nested_selector: nested_sel,
5237 body_range: inner_body,
5238 });
5239 } else {
5240 return None;
5241 }
5242 }
5243
5244 debug_assert!(
5245 name == "media" || name == "supports" || name == "container" || name == "starting-style"
5246 );
5247 Some(ClusterChild::Conditional {
5248 node: node.clone(),
5249 rule,
5250 inners,
5251 })
5252}
5253
5254pub fn consolidate_not_in_selector(selector: &str) -> Option<(String, bool)> {
5255 if !selector.contains(":not(") {
5256 return None;
5257 }
5258 let mut result = String::new();
5259 let mut i = 0;
5260 let bytes = selector.as_bytes();
5261 let mut changed = false;
5262 let mut uniform_specificity = true;
5263
5264 while i < bytes.len() {
5265 if i + 5 <= bytes.len() && &selector[i..i + 5] == ":not(" {
5266 let mut args = Vec::new();
5267 let mut current_end = i;
5268
5269 while current_end + 5 <= bytes.len()
5270 && &selector[current_end..current_end + 5] == ":not("
5271 {
5272 let open = current_end + 4;
5273 if let Some(close) = find_matching_paren(selector, open) {
5274 let arg = selector[open + 1..close].trim();
5275 args.push(arg);
5276 current_end = close + 1;
5277 } else {
5278 break;
5279 }
5280 }
5281
5282 if args.len() > 1 {
5283 changed = true;
5284 let specs: Vec<Specificity> =
5285 args.iter().map(|a| calculate_specificity(a)).collect();
5286 if specs.windows(2).any(|w| w[0] != w[1]) {
5287 uniform_specificity = false;
5288 }
5289
5290 result.push_str(":not(");
5291 result.push_str(&args.join(", "));
5292 result.push(')');
5293 i = current_end;
5294 continue;
5295 }
5296 }
5297 let ch = selector[i..].chars().next().unwrap();
5298 result.push(ch);
5299 i += ch.len_utf8();
5300 }
5301
5302 if changed {
5303 Some((result, uniform_specificity))
5304 } else {
5305 None
5306 }
5307}
5308
5309fn find_matching_paren(source: &str, open: usize) -> Option<usize> {
5310 let bytes = source.as_bytes();
5311 let mut depth = 1usize;
5312 let mut i = open + 1;
5313 let mut quote: Option<u8> = None;
5314 let mut escaped = false;
5315
5316 while i < bytes.len() {
5317 let b = bytes[i];
5318 if let Some(q) = quote {
5319 if escaped {
5320 escaped = false;
5321 } else if b == b'\\' {
5322 escaped = true;
5323 } else if b == q {
5324 quote = None;
5325 }
5326 i += 1;
5327 continue;
5328 }
5329
5330 match b {
5331 b'\'' | b'"' => quote = Some(b),
5332 b'(' => depth += 1,
5333 b')' => {
5334 depth -= 1;
5335 if depth == 0 {
5336 return Some(i);
5337 }
5338 }
5339 _ => {}
5340 }
5341 i += 1;
5342 }
5343 None
5344}
5345
5346#[derive(Debug, Clone)]
5347struct HierarchicalRule {
5348 relative_selector: String,
5349 body_lines: Vec<String>,
5350 sub_rules: Vec<HierarchicalRule>,
5351 conditional_header: Option<String>,
5352}
5353
5354fn preserve_body_lines(body: &str) -> Vec<String> {
5355 let base_indent = body
5356 .lines()
5357 .find(|line| !line.trim().is_empty())
5358 .map(|line| line.len() - line.trim_start().len())
5359 .unwrap_or(0);
5360 body.lines()
5361 .filter(|line| !line.trim().is_empty())
5362 .map(|line| {
5363 line.get(base_indent..)
5364 .unwrap_or(line)
5365 .trim_end()
5366 .to_string()
5367 })
5368 .collect()
5369}
5370
5371fn render_cluster(source: &str, parent: &SourceNode, children: &[ClusterChild]) -> String {
5372 let parent_body_range = parent.body_range.as_ref().expect("style rules have bodies");
5373 let parent_indent = line_indent(source, parent.start);
5374 let unit = relative_indent_unit(source, parent);
5375 let nested_indent = format!("{parent_indent}{unit}");
5376
5377 let mut out = String::new();
5378 let open = parent_body_range.start - 1;
5379 out.push_str(&source[parent.start..=open]);
5380
5381 let parent_body = &source[parent_body_range.clone()];
5382 let trimmed_body = parent_body.trim();
5383 if !trimmed_body.is_empty() {
5384 out.push('\n');
5385 for line in preserve_body_lines(parent_body) {
5386 out.push_str(&nested_indent);
5387 out.push_str(&ensure_semicolon(&line));
5388 out.push('\n');
5389 }
5390 }
5391
5392 let mut root_rules: Vec<HierarchicalRule> = Vec::new();
5394
5395 for child in children {
5396 match child {
5397 ClusterChild::Style {
5398 node,
5399 nested_selector,
5400 ..
5401 } => {
5402 let mut body_lines = Vec::new();
5403 if let Some(body_range) = &node.body_range {
5404 body_lines = preserve_body_lines(&source[body_range.clone()]);
5405 }
5406 insert_hierarchical_style(&mut root_rules, nested_selector.trim(), body_lines);
5407 }
5408 ClusterChild::Conditional { node, inners, .. } => {
5409 let header = node.prelude(source).trim().to_string();
5410 let mut cond_sub_rules = Vec::new();
5411 for inner in inners {
5412 match inner {
5413 ConditionalInner::Direct { body_range } => {
5414 let lines = preserve_body_lines(&source[body_range.clone()]);
5415 cond_sub_rules.push(HierarchicalRule {
5416 relative_selector: String::new(),
5417 body_lines: lines,
5418 sub_rules: Vec::new(),
5419 conditional_header: None,
5420 });
5421 }
5422 ConditionalInner::Nested {
5423 nested_selector,
5424 body_range,
5425 } => {
5426 let lines = preserve_body_lines(&source[body_range.clone()]);
5427 cond_sub_rules.push(HierarchicalRule {
5428 relative_selector: nested_selector.trim().to_string(),
5429 body_lines: lines,
5430 sub_rules: Vec::new(),
5431 conditional_header: None,
5432 });
5433 }
5434 }
5435 }
5436 root_rules.push(HierarchicalRule {
5437 relative_selector: String::new(),
5438 body_lines: Vec::new(),
5439 sub_rules: cond_sub_rules,
5440 conditional_header: Some(header),
5441 });
5442 }
5443 }
5444 }
5445
5446 for rule in &root_rules {
5447 out.push('\n');
5448 render_hierarchical_rule(&mut out, rule, &nested_indent, &unit);
5449 }
5450
5451 out.push_str(&parent_indent);
5452 out.push('}');
5453 out
5454}
5455
5456fn insert_hierarchical_style(
5457 root_rules: &mut Vec<HierarchicalRule>,
5458 selector: &str,
5459 body_lines: Vec<String>,
5460) {
5461 if let Some(last_rule) = root_rules.last_mut()
5462 && last_rule.conditional_header.is_none()
5463 && !last_rule.relative_selector.is_empty()
5464 {
5465 let parent_sel = &last_rule.relative_selector;
5466 if let Some(rel) = extract_relative_subselector(parent_sel, selector) {
5467 insert_hierarchical_style(&mut last_rule.sub_rules, &rel, body_lines);
5468 return;
5469 }
5470 }
5471
5472 root_rules.push(HierarchicalRule {
5473 relative_selector: selector.to_string(),
5474 body_lines,
5475 sub_rules: Vec::new(),
5476 conditional_header: None,
5477 });
5478}
5479
5480fn extract_relative_subselector(parent: &str, child: &str) -> Option<String> {
5481 let parent = parent.trim();
5482 let child = child.trim();
5483 if child == parent || !child.starts_with(parent) {
5484 return None;
5485 }
5486 let remainder = &child[parent.len()..];
5487 let trimmed = remainder.trim_start();
5488 if trimmed.is_empty() {
5489 return None;
5490 }
5491
5492 if remainder.starts_with("::")
5493 || remainder.starts_with(':')
5494 || remainder.starts_with('[')
5495 || remainder.starts_with('.')
5496 || remainder.starts_with('#')
5497 {
5498 Some(format!("&{remainder}"))
5499 } else if let Some(first_char) = trimmed
5500 .chars()
5501 .next()
5502 .filter(|c| *c == '>' || *c == '+' || *c == '~')
5503 {
5504 let after_comb = trimmed[first_char.len_utf8()..].trim();
5505 Some(explicit_relative_combinator(first_char, after_comb))
5506 } else if remainder
5507 .as_bytes()
5508 .first()
5509 .is_some_and(|b| b.is_ascii_whitespace())
5510 {
5511 Some(trimmed.to_string())
5512 } else {
5513 None
5514 }
5515}
5516
5517fn render_hierarchical_rule(out: &mut String, rule: &HierarchicalRule, indent: &str, unit: &str) {
5518 let inner_indent = format!("{indent}{unit}");
5519
5520 if let Some(header) = &rule.conditional_header {
5521 out.push_str(indent);
5522 out.push_str(header);
5523 out.push_str(" {\n");
5524 for (idx, sub) in rule.sub_rules.iter().enumerate() {
5525 if idx > 0 {
5526 out.push('\n');
5527 }
5528 if sub.relative_selector.is_empty() {
5529 for line in &sub.body_lines {
5530 out.push_str(&inner_indent);
5531 out.push_str(&ensure_semicolon(line));
5532 out.push('\n');
5533 }
5534 } else {
5535 render_hierarchical_rule(out, sub, &inner_indent, unit);
5536 }
5537 }
5538 out.push_str(indent);
5539 out.push_str("}\n");
5540 } else {
5541 out.push_str(indent);
5542 out.push_str(&rule.relative_selector);
5543 out.push_str(" {\n");
5544
5545 for line in &rule.body_lines {
5546 out.push_str(&inner_indent);
5547 out.push_str(&ensure_semicolon(line));
5548 out.push('\n');
5549 }
5550
5551 for sub in &rule.sub_rules {
5552 out.push('\n');
5553 render_hierarchical_rule(out, sub, &inner_indent, unit);
5554 }
5555
5556 out.push_str(indent);
5557 out.push_str("}\n");
5558 }
5559}
5560
5561fn line_indent(source: &str, offset: usize) -> String {
5562 let line_start = source[..offset].rfind('\n').map_or(0, |idx| idx + 1);
5563 source[line_start..offset]
5564 .chars()
5565 .take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
5566 .collect()
5567}
5568
5569fn ensure_semicolon(line: &str) -> std::borrow::Cow<'_, str> {
5573 let trimmed = line.trim_end();
5574 if trimmed.ends_with(';')
5575 || trimmed.ends_with('{')
5576 || trimmed.ends_with('}')
5577 || trimmed.ends_with(',')
5578 || trimmed.ends_with(':')
5579 || trimmed.starts_with("//")
5580 || trimmed.starts_with("/*")
5581 || !trimmed.contains(':')
5582 {
5583 return std::borrow::Cow::Borrowed(line);
5584 }
5585 std::borrow::Cow::Owned(format!("{trimmed};"))
5586}
5587
5588fn line_number(source: &str, offset: usize) -> usize {
5589 source[..offset.min(source.len())].lines().count()
5590}
5591
5592fn detect_indent_unit(source: &str, body: Range<usize>) -> Option<String> {
5593 for line in source[body].lines() {
5594 if line.trim().is_empty() {
5595 continue;
5596 }
5597 let indent: String = line
5598 .chars()
5599 .take_while(|c| *c == ' ' || *c == '\t')
5600 .collect();
5601 if !indent.is_empty() {
5602 return Some(indent);
5603 }
5604 }
5605 None
5606}
5607
5608fn contains_top_level_comma(selector: &str) -> bool {
5609 let bytes = selector.as_bytes();
5610 let mut parens = 0usize;
5611 let mut brackets = 0usize;
5612 let mut quote: Option<u8> = None;
5613 let mut escaped = false;
5614 let mut i = 0usize;
5615 while i < bytes.len() {
5616 let b = bytes[i];
5617 if let Some(q) = quote {
5618 if escaped {
5619 escaped = false;
5620 } else if b == b'\\' {
5621 escaped = true;
5622 } else if b == q {
5623 quote = None;
5624 }
5625 i += 1;
5626 continue;
5627 }
5628 match b {
5629 b'\'' | b'"' => quote = Some(b),
5630 b'(' => parens += 1,
5631 b')' => parens = parens.saturating_sub(1),
5632 b'[' => brackets += 1,
5633 b']' => brackets = brackets.saturating_sub(1),
5634 b',' if parens == 0 && brackets == 0 => return true,
5635 _ => {}
5636 }
5637 i += 1;
5638 }
5639 false
5640}
5641
5642pub fn apply_selected_plans(
5643 source: &str,
5644 plans: &[PlanEntry],
5645 include_review: bool,
5646) -> Result<String> {
5647 let selected: Vec<&PlanEntry> = plans
5648 .iter()
5649 .filter(|plan| {
5650 plan.selected
5651 && (plan.safety == Safety::Safe
5652 || (include_review && plan.safety == Safety::Review))
5653 })
5654 .collect();
5655 let owned: Vec<PlanEntry> = selected.into_iter().cloned().collect();
5656 let keep = select_disjoint_plan_indices(&owned);
5657 let mut non_overlapping: Vec<&PlanEntry> = keep.iter().map(|&i| &owned[i]).collect();
5658 non_overlapping.sort_by(|a, b| {
5659 a.source_range
5660 .start
5661 .cmp(&b.source_range.start)
5662 .then_with(|| b.source_range.end.cmp(&a.source_range.end))
5663 });
5664
5665 let mut output = source.to_string();
5666 for plan in non_overlapping.into_iter().rev() {
5667 if plan.source_range.start <= output.len()
5668 && plan.source_range.end <= output.len()
5669 && plan.source_range.start <= plan.source_range.end
5670 {
5671 output.replace_range(
5672 plan.source_range.start..plan.source_range.end,
5673 &plan.proposed,
5674 );
5675 }
5676 }
5677 Ok(output)
5678}
5679
5680pub fn apply_until_stable(
5684 path: &Path,
5685 source: &str,
5686 enabled_rules: &[RuleId],
5687 include_review: bool,
5688) -> Result<String> {
5689 let mut current = source.to_string();
5690
5691 for _ in 0..32 {
5692 let report = analyze_content(path.to_path_buf(), ¤t, enabled_rules)?;
5693 let mut plans = report.plans;
5694 for plan in &mut plans {
5695 plan.selected =
5696 plan.safety == Safety::Safe || (include_review && plan.safety == Safety::Review);
5697 }
5698
5699 let next = apply_selected_plans(¤t, &plans, include_review)?;
5700 if next == current {
5701 return Ok(current);
5702 }
5703 current = next;
5704 }
5705
5706 anyhow::bail!(
5707 "CSS transformations did not reach a stable result after 32 passes for {}",
5708 path.display()
5709 )
5710}
5711
5712pub fn unified_diff(old: &str, new: &str, old_name: &str, new_name: &str) -> String {
5713 TextDiff::from_lines(old, new)
5714 .unified_diff()
5715 .header(old_name, new_name)
5716 .to_string()
5717}
5718
5719#[cfg(test)]
5720mod tests {
5721 use super::*;
5722
5723 fn plan(css: &str, rules: &[RuleId]) -> Vec<PlanEntry> {
5724 analyze_source(PathBuf::from("test.css"), css, rules)
5725 .unwrap()
5726 .plans
5727 }
5728
5729 #[test]
5730 fn extracts_style_blocks_from_template_sources() {
5731 let source = r#"@php($title = 'demo')
5732<div>
5733 <style scoped>
5734 .card { color: red; }
5735 </style>
5736 <style>.badge { color: blue; }</style>
5737</div>
5738"#;
5739 let blocks = extract_style_blocks(source);
5740 assert_eq!(blocks.len(), 2);
5741 assert_eq!(
5742 &source[blocks[0].clone()],
5743 "\n .card { color: red; }\n "
5744 );
5745 assert_eq!(&source[blocks[1].clone()], ".badge { color: blue; }");
5746 }
5747
5748 #[test]
5749 fn transforms_only_embedded_style_contents() {
5750 let source = r#"@php($title = 'demo')
5751<div class="card">
5752 <style>
5753 .button { color: red; }
5754 .button:hover { color: blue; }
5755 </style>
5756 {{ $title }}
5757</div>
5758"#;
5759 let output = apply_until_stable(
5760 Path::new("view.blade.php"),
5761 source,
5762 &[RuleId::NestPseudoClass],
5763 false,
5764 )
5765 .unwrap();
5766 assert!(output.starts_with("@php($title = 'demo')\n<div class=\"card\">\n <style>"));
5767 assert!(
5768 output.contains("&:hover"),
5769 "embedded CSS was not transformed: {output}"
5770 );
5771 assert!(output.contains("{{ $title }}"));
5772 assert!(output.ends_with("</div>\n"));
5773 }
5774
5775 #[test]
5776 fn plans_selector_lists_inside_nested_style_rules() {
5777 let source = r#"<div>
5778 <style>
5779 .daterangepicker {
5780 .next span,
5781 .prev span {
5782 border-color: #64748b;
5783 }
5784 }
5785 </style>
5786</div>
5787"#;
5788 let report = analyze_content(
5789 PathBuf::from("view.blade.php"),
5790 source,
5791 &[RuleId::ModernizeIs],
5792 )
5793 .unwrap();
5794 assert_eq!(report.plans.len(), 1);
5795 assert_eq!(
5796 &source[report.plans[0].source_range.start..report.plans[0].source_range.end],
5797 ".next span,\n .prev span "
5798 );
5799
5800 let output = apply_until_stable(
5801 Path::new("view.blade.php"),
5802 source,
5803 &[RuleId::ModernizeIs],
5804 false,
5805 )
5806 .unwrap();
5807 assert!(output.contains(":is(.next, .prev) span"));
5808 assert!(output.contains("<div>"));
5809 assert!(output.contains("</style>"));
5810 }
5811
5812 #[test]
5813 fn plans_rules_inside_nested_starting_style_at_rules() {
5814 let source = r#".dialog {
5815 @starting-style {
5816 .dialog-panel:hover,
5817 .dialog-panel:focus {
5818 opacity: 0;
5819 }
5820 }
5821}
5822"#;
5823 let report = analyze_content(
5824 PathBuf::from("nested-starting-style.css"),
5825 source,
5826 &[RuleId::ModernizeIs],
5827 )
5828 .unwrap();
5829 assert_eq!(report.plans.len(), 1);
5830 assert_eq!(report.plans[0].rules, vec![RuleId::ModernizeIs]);
5831
5832 let output = apply_until_stable(
5833 Path::new("nested-starting-style.css"),
5834 source,
5835 &[RuleId::ModernizeIs],
5836 false,
5837 )
5838 .unwrap();
5839 assert!(output.contains(".dialog-panel:is(:hover, :focus)"));
5840 assert!(output.contains("@starting-style"));
5841 }
5842
5843 #[test]
5844 fn preserves_nested_starting_style_when_processing_conditional_styles() {
5845 let source = r#"@supports selector(details::details-content) {
5846 .faq-list {
5847 details {
5848 &[open]::details-content {
5849 height: auto;
5850 }
5851 }
5852
5853 @starting-style {
5854 details[open]::details-content {
5855 height: 0;
5856 }
5857 }
5858 }
5859}
5860"#;
5861 let output = apply_until_stable(
5862 Path::new("nested-starting-style.css"),
5863 source,
5864 &RuleId::ALL,
5865 true,
5866 )
5867 .unwrap();
5868 assert_eq!(output.matches("@starting-style").count(), 1, "{output}");
5869 assert!(
5870 output.contains("details[open]::details-content") && output.contains("height: 0;"),
5871 "nested starting-style declaration was lost or changed:\n{output}"
5872 );
5873 }
5874
5875 #[test]
5876 fn preserves_utf8_declaration_values_during_nesting() {
5877 let source = "input[type=checkbox] { content: '✓'; }\n";
5878 let output = apply_until_stable(
5879 Path::new("utf8.css"),
5880 source,
5881 &[RuleId::NestAttribute],
5882 false,
5883 )
5884 .unwrap();
5885 assert!(
5886 output.contains("content: '✓';"),
5887 "UTF-8 value changed: {output}"
5888 );
5889 assert!(!output.contains("Ã"), "mojibake introduced: {output}");
5890 }
5891
5892 #[test]
5893 fn does_not_treat_relative_or_already_nested_selectors_as_state_bases() {
5894 assert_eq!(extract_base_target("> [class*='col-']"), None);
5895 assert_eq!(extract_base_target(":is(.prev, .next):hover"), None);
5896 assert_eq!(extract_base_target("&:hover"), None);
5897 assert_eq!(extract_base_target("tr:last-child &"), None);
5898 assert_eq!(extract_base_target(".button:hover"), Some(".button"));
5899 }
5900
5901 #[test]
5902 fn nests_row_state_cells_from_the_row_not_reversed_td_amp() {
5903 let css = r#".archive-table {
5907 width: 100%;
5908}
5909
5910.archive-table th {
5911 color: gray;
5912}
5913
5914.archive-table td {
5915 padding: 1rem;
5916 border-bottom: 1px solid;
5917}
5918
5919.archive-table tr:last-child td {
5920 border-bottom: none;
5921}
5922
5923.archive-table tr:hover td {
5924 background: red;
5925 color: white;
5926}
5927"#;
5928 let output = apply_until_stable(
5929 Path::new("test.css"),
5930 css,
5931 &[
5932 RuleId::NestDescendant,
5933 RuleId::NestPseudoClass,
5934 RuleId::NestCompound,
5935 RuleId::NestAttribute,
5936 ],
5937 false,
5938 )
5939 .unwrap();
5940 assert!(
5941 !output.contains("&:last-child &") && !output.contains("&:hover &"),
5942 "must not reverse td into &:last-child &: {output}"
5943 );
5944 assert!(
5945 output.contains("&:last-child td") && output.contains("&:hover td"),
5946 "must nest from the row: {output}"
5947 );
5948 assert!(
5949 output.contains(".archive-table {") && output.contains("tr {"),
5950 "expected .archive-table {{ tr {{ ... }} }}: {output}"
5951 );
5952 }
5953
5954 #[test]
5955 fn does_not_reparent_existing_nesting_selectors() {
5956 assert_eq!(
5957 selector_relation(".card-body", "&.compact .card-body"),
5958 None
5959 );
5960 assert_eq!(selector_relation("select", "&:hover"), None);
5961 }
5962
5963 #[test]
5964 fn marks_mixed_specificity_identical_body_merges_for_review() {
5965 let css =
5966 ".form-select option { color: white; }\nselect.form-control option { color: white; }\n";
5967 let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
5968 assert_eq!(plans.len(), 1);
5969 assert_eq!(plans[0].safety, Safety::Review);
5970 assert!(!plans[0].proof.specificity_equivalent);
5971 }
5972
5973 #[test]
5974 fn already_nested_selector_lists_reach_a_fixed_point() {
5975 let css = ".card {
5976 &,
5977 &.active {
5978 color: red;
5979 }
5980}
5981";
5982 let output = apply_until_stable(Path::new("test.css"), css, &RuleId::ALL, false).unwrap();
5983 assert_eq!(output, css);
5984 }
5985
5986 #[test]
5987 fn nests_adjacent_pseudo_and_descendant_rules() {
5988 let css = ".card {\n color: red;\n}\n.card:hover {\n color: blue !important;\n}\n.card .title {\n font-weight: 700;\n}\n";
5989 let plans = plan(css, &RuleId::ALL);
5990 assert_eq!(plans.len(), 1);
5991 let output = apply_selected_plans(css, &plans, false).unwrap();
5992 assert!(output.contains("&:hover"));
5993 assert!(output.contains(".title"));
5994 assert!(output.contains("color: blue !important;"));
5995 }
5996
5997 #[test]
5998 fn calculates_functional_pseudo_specificity_from_the_highest_argument() {
5999 assert_eq!(
6000 calculate_specificity(":is(.a, #b, div)"),
6001 Specificity {
6002 ids: 1,
6003 classes: 0,
6004 elements: 0,
6005 }
6006 );
6007 assert_eq!(
6008 calculate_specificity(":has(> .a, #b)"),
6009 Specificity {
6010 ids: 1,
6011 classes: 0,
6012 elements: 0,
6013 }
6014 );
6015 assert_eq!(
6016 calculate_specificity(":not(.a, #b)"),
6017 Specificity {
6018 ids: 1,
6019 classes: 0,
6020 elements: 0,
6021 }
6022 );
6023 assert_eq!(
6024 calculate_specificity(":where(#header, .nav, div)"),
6025 Specificity::default()
6026 );
6027 assert_eq!(
6028 calculate_specificity(".parent > :has(.a, #b)"),
6029 Specificity {
6030 ids: 1,
6031 classes: 1,
6032 elements: 0,
6033 }
6034 );
6035 }
6036
6037 #[test]
6038 fn preserves_not_matching_logic_when_consolidating_only_chained_negations() {
6039 assert_eq!(
6040 consolidate_not_in_selector(".card:not(.a):not(.b)"),
6041 Some((".card:not(.a, .b)".to_string(), true))
6042 );
6043 assert_eq!(consolidate_not_in_selector(".card:not(.a, .b)"), None);
6044 assert_eq!(
6045 consolidate_not_in_selector(".card:not(.a), .card:not(.b)"),
6046 None
6047 );
6048 }
6049
6050 #[test]
6051 fn keeps_has_arguments_as_relative_selectors_without_rewriting_the_logic() {
6052 let selector = ".parent:has(> .a, + #b, .c .d)";
6053 assert_eq!(
6054 calculate_specificity(selector),
6055 Specificity {
6056 ids: 1,
6057 classes: 1,
6058 elements: 0,
6059 }
6060 );
6061 assert_eq!(factor_with_is(selector), None);
6062 assert_eq!(factor_with_where(selector), None);
6063 }
6064
6065 #[test]
6066 fn applies_overlapping_plans_until_the_source_is_stable() {
6067 let css = r#".states {
6068 border: 1px solid transparent;
6069}
6070
6071.states > .bolt:is(:hover, :focus-visible) {
6072 outline: 2px solid currentColor;
6073}
6074
6075.states > .bolt,
6076.states > .spark {
6077 min-block-size: 2rem;
6078}
6079"#;
6080 let output = apply_until_stable(Path::new("test.css"), css, &RuleId::ALL, false).unwrap();
6081
6082 assert!(output.contains("& > .bolt:is(:hover, :focus-visible) {"));
6083 assert!(output.contains("& > :is(.bolt, .spark) {"));
6084 assert!(!output.contains(".states > .bolt,\n.states > .spark"));
6085 }
6086
6087 #[test]
6088 fn nests_exact_full_modernize_example() {
6089 let original = r#".card {
6090 color: #222;
6091 padding: 1rem;
6092}
6093.card:hover {
6094 color: #111 !important;
6095}
6096.card::before {
6097 content: "";
6098}
6099.card[data-active] {
6100 border-color: currentColor;
6101}
6102.card.featured {
6103 box-shadow: 0 0 0 1px currentColor;
6104}
6105.card .title {
6106 font-weight: 700;
6107}
6108.card > .body {
6109 min-width: 0;
6110}
6111.card + .card {
6112 margin-top: 1rem;
6113}
6114@media (width >= 48rem) {
6115 .card {
6116 padding: 1.5rem;
6117 }
6118}
6119@supports (display: grid) {
6120 .card {
6121 display: grid;
6122 }
6123}
6124"#;
6125
6126 let expected = r#".card {
6127 color: #222;
6128 padding: 1rem;
6129
6130 &:hover {
6131 color: #111 !important;
6132 }
6133
6134 &::before {
6135 content: "";
6136 }
6137
6138 &[data-active] {
6139 border-color: currentColor;
6140 }
6141
6142 &.featured {
6143 box-shadow: 0 0 0 1px currentColor;
6144 }
6145
6146 .title {
6147 font-weight: 700;
6148 }
6149
6150 & > .body {
6151 min-width: 0;
6152 }
6153
6154 & + .card {
6155 margin-top: 1rem;
6156 }
6157
6158 @media (width >= 48rem) {
6159 padding: 1.5rem;
6160 }
6161
6162 @supports (display: grid) {
6163 display: grid;
6164 }
6165}"#;
6166
6167 let plans = plan(
6168 original,
6169 &[
6170 RuleId::NestPseudoClass,
6171 RuleId::NestPseudoElement,
6172 RuleId::NestAttribute,
6173 RuleId::NestCompound,
6174 RuleId::NestDescendant,
6175 RuleId::NestCombinator,
6176 RuleId::NestMedia,
6177 RuleId::NestSupports,
6178 ],
6179 );
6180 assert_eq!(plans.len(), 1);
6181 let output = apply_selected_plans(original, &plans, false).unwrap();
6182 assert_eq!(output.trim(), expected.trim());
6183 }
6184
6185 #[test]
6186 fn factors_selector_list_sharing_base() {
6187 let css = ".marker,\n.marker::before,\n.marker::after {\n box-sizing: border-box;\n}\n";
6188 let plans = plan(css, &[RuleId::FactorSelectorList]);
6189 assert_eq!(plans.len(), 1);
6190 let output = apply_selected_plans(css, &plans, false).unwrap();
6191 assert!(output.contains(".marker {"));
6192 assert!(output.contains("&,"));
6193 assert!(output.contains("&::before,"));
6194 assert!(output.contains("&::after {"));
6195 assert!(output.contains("box-sizing: border-box;"));
6196 assert!(output.contains(" box-sizing: border-box;"));
6197 }
6198
6199 #[test]
6200 fn modernizes_is_with_uniform_specificity() {
6201 let css = ".button:hover, .button:focus, .button:active {\n color: blue;\n}\n";
6202 let plans = plan(css, &[RuleId::ModernizeIs]);
6203 assert_eq!(plans.len(), 1);
6204 let output = apply_selected_plans(css, &plans, false).unwrap();
6205 assert!(output.contains(".button:is(:hover, :focus, :active)"));
6206 }
6207
6208 #[test]
6209 fn never_factors_pseudo_elements_into_functional_pseudo_classes() {
6210 for selector in [
6211 ".icon:before, .icon:after",
6212 ".icon::before, .icon::after",
6213 ".icon:first-line, .icon:first-letter",
6214 ".icon:is(:before), .icon:is(:after)",
6215 ] {
6216 assert_eq!(
6217 factor_with_is(selector),
6218 None,
6219 "pseudo-element selector was incorrectly factored: {selector}"
6220 );
6221 }
6222 }
6223
6224 #[test]
6225 fn does_not_factor_inside_functional_pseudo_arguments() {
6226 let selector = ".equivalent-has:has(> .alpha), .equivalent-has:has(> .beta)";
6227 assert_eq!(
6228 factor_with_is(selector),
6229 Some((".equivalent-has:has(> .alpha, > .beta)".to_string(), true))
6230 );
6231
6232 let css = format!("{selector} {{\n color: seagreen;\n}}\n");
6233 let plans = plan(&css, &[RuleId::ModernizeIs]);
6234 assert_eq!(plans.len(), 1);
6235 let output = apply_selected_plans(&css, &plans, false).unwrap();
6236 assert!(output.contains(".equivalent-has:has(> .alpha, > .beta)"));
6237 }
6238
6239 #[test]
6240 fn combines_uniform_is_and_where_function_arguments() {
6241 assert_eq!(
6242 factor_with_is(".card:is(.alpha), .card:is(.beta)"),
6243 Some((".card:is(.alpha, .beta)".to_string(), true))
6244 );
6245 assert_eq!(
6246 factor_with_is(".card:where(#alpha), .card:where(.beta)"),
6247 Some((".card:where(#alpha, .beta)".to_string(), true))
6248 );
6249 }
6250
6251 #[test]
6252 fn refuses_to_combine_separate_not_functions() {
6253 assert_eq!(factor_with_is(".card:not(.alpha), .card:not(.beta)"), None);
6254 }
6255
6256 #[test]
6257 fn modernizes_media_range_syntax() {
6258 let css = "@media (min-width: 800px) {\n .card { padding: 2rem; }\n}\n";
6259 let plans = plan(css, &[RuleId::ModernizeMediaRange]);
6260 assert_eq!(plans.len(), 1);
6261 let output = apply_selected_plans(css, &plans, false).unwrap();
6262 assert!(output.contains("@media (width >= 800px)"));
6263 }
6264
6265 #[test]
6266 fn consolidates_not_selectors() {
6267 let css = "input:not([type=\"checkbox\"]):not([type=\"radio\"]) {\n border: 1px solid gray;\n}\n";
6268 let plans = plan(css, &[RuleId::ConsolidateNot]);
6269 assert_eq!(plans.len(), 1);
6270 assert_eq!(plans[0].safety, Safety::Review);
6271 let output = apply_selected_plans(css, &plans, true).unwrap();
6272 assert!(output.contains("input:not([type=\"checkbox\"], [type=\"radio\"])"));
6273 }
6274
6275 #[test]
6276 fn refuses_subtoken_is_factoring_false_positive() {
6277 let css = ".same-specificity-a,\n.same-specificity-b {\n color: black;\n}\n";
6278 let plans = plan(css, &[RuleId::ModernizeIs]);
6279 assert!(plans.is_empty());
6280 }
6281
6282 #[test]
6283 fn modernizes_descendant_is_alternatives() {
6284 let css = ".card .title, .card .subtitle, .card .description {\n color: black;\n}\n";
6285 let plans = plan(css, &[RuleId::ModernizeIs]);
6286 assert_eq!(plans.len(), 1);
6287 let output = apply_selected_plans(css, &plans, false).unwrap();
6288 assert!(output.contains(".card :is(.title, .subtitle, .description)"));
6289 }
6290
6291 #[test]
6292 fn modernizes_suffix_is_alternatives() {
6293 let css = ".alpha .title,\n#hero .title {\n color: rebeccapurple;\n}\n";
6294 let plans = plan(css, &[RuleId::ModernizeIs]);
6295 assert_eq!(plans.len(), 1);
6296 assert_eq!(plans[0].safety, Safety::Review);
6297 let output = apply_selected_plans(css, &plans, true).unwrap();
6298 assert!(output.contains(":is(.alpha, #hero) .title"));
6299 }
6300
6301 #[test]
6302 fn factors_multi_selector_cluster_with_is_and_nesting() {
6303 let css = r#".alpha .title,
6304#hero .title {
6305 color: rebeccapurple;
6306}
6307
6308.alpha .subtitle,
6309#hero .subtitle {
6310 color: slateblue;
6311}
6312
6313.alpha,
6314#hero {
6315 border-color: currentColor;
6316}
6317"#;
6318 let plans = plan(css, &[RuleId::ModernizeIs]);
6319 assert_eq!(plans.len(), 1);
6320 let output = apply_selected_plans(css, &plans, true).unwrap();
6321 assert!(output.contains(":is(.alpha, #hero) {"));
6322 assert!(output.contains("border-color: currentColor;"));
6323 assert!(output.contains(".title {"));
6324 assert!(output.contains("color: rebeccapurple;"));
6325 assert!(output.contains(".subtitle {"));
6326 assert!(output.contains("color: slateblue;"));
6327 }
6328
6329 #[test]
6330 fn refuses_bem_token_concatenation() {
6331 let css = ".card { color: red; }\n.card__title { font-weight: 700; }\n";
6332 let plans = plan(css, &RuleId::ALL);
6333 assert!(plans.is_empty());
6334 }
6335
6336 #[test]
6337 fn merges_same_named_layer_blocks() {
6338 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";
6339 let plans = plan(css, &[RuleId::MergeSameNamedLayer]);
6340 assert_eq!(plans.len(), 2);
6341 let output = apply_selected_plans(css, &plans, false).unwrap();
6342 assert!(output.contains("@layer overrides {"));
6343 assert!(output.contains(".layered-card {"));
6344 assert!(output.contains(".layer-important {"));
6345 }
6346
6347 #[test]
6348 fn merges_adjacent_media_queries() {
6349 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";
6350 let plans = plan(css, &[RuleId::MergeAdjacentMedia]);
6351 assert_eq!(plans.len(), 1);
6352 let output = apply_selected_plans(css, &plans, false).unwrap();
6353 assert!(output.contains("@media (width >= 48rem) {"));
6354 assert!(output.contains(".card {"));
6355 assert!(output.contains(".panel {"));
6356 }
6357
6358 #[test]
6359 fn merges_adjacent_supports_queries() {
6360 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";
6361 let plans = plan(css, &[RuleId::MergeAdjacentSupports]);
6362 assert_eq!(plans.len(), 1);
6363 let output = apply_selected_plans(css, &plans, false).unwrap();
6364 assert!(output.contains("@supports (display: grid) {"));
6365 assert!(output.contains(".card {"));
6366 assert!(output.contains(".panel {"));
6367 }
6368
6369 #[test]
6370 fn merges_adjacent_identical_selectors() {
6371 let css = ".card {\n color: black;\n}\n\n.card {\n padding: 1rem;\n}\n";
6372 let plans = plan(css, &[RuleId::MergeAdjacentIdenticalSelector]);
6373 assert_eq!(plans.len(), 1);
6374 let output = apply_selected_plans(css, &plans, false).unwrap();
6375 assert!(output.contains(".card {"));
6376 assert!(output.contains("color: black;"));
6377 assert!(output.contains("padding: 1rem;"));
6378 }
6379
6380 #[test]
6381 fn merges_identical_rule_bodies() {
6382 let css = ".card:hover {\n color: red;\n}\n\n.panel:hover {\n color: red;\n}\n";
6383 let plans = plan(css, &[RuleId::MergeIdenticalRuleBodies]);
6384 assert_eq!(plans.len(), 1);
6385 let output = apply_selected_plans(css, &plans, false).unwrap();
6386 assert!(output.contains(".card:hover,"));
6387 assert!(output.contains(".panel:hover {"));
6388 assert!(output.contains("color: red;"));
6389 }
6390
6391 #[test]
6392 fn factors_identical_states_with_is() {
6393 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";
6394 let plans = plan(css, &[RuleId::FactorIdenticalStatesWithIs]);
6395 assert_eq!(plans.len(), 1);
6396 let output = apply_selected_plans(css, &plans, false).unwrap();
6397 assert!(output.contains(".card {"));
6398 assert!(output.contains("&:is(:hover, :focus, :focus-visible) {"));
6399 assert!(output.contains("background: silver;"));
6400 }
6401
6402 #[test]
6403 fn nests_multi_level_tree_hierarchy() {
6404 let css = r#".tree {
6405 display: grid;
6406 gap: 0.5rem;
6407}
6408.tree .node {
6409 position: relative;
6410}
6411.tree .node .label {
6412 display: flex;
6413}
6414.tree .node .label:hover {
6415 color: var(--accent);
6416}
6417.tree .node > .children {
6418 margin-inline-start: 1.25rem;
6419}
6420.tree .node > .children > .node + .node {
6421 margin-block-start: 0.25rem;
6422}
6423"#;
6424 let plans = plan(
6425 css,
6426 &[
6427 RuleId::NestDescendant,
6428 RuleId::NestCombinator,
6429 RuleId::NestPseudoClass,
6430 ],
6431 );
6432 assert_eq!(plans.len(), 1);
6433 let output = apply_selected_plans(css, &plans, false).unwrap();
6434 assert!(output.contains(".node {"));
6435 assert!(output.contains(".label {"));
6436 assert!(output.contains("&:hover {"));
6437 assert!(output.contains("> .children {"));
6438 assert!(output.contains("> .node + .node {"));
6439 }
6440
6441 #[test]
6442 fn emits_explicit_nesting_selector_for_child_combinator() {
6443 let css = r#".c-command-hero {
6444 display: grid;
6445}
6446
6447.c-command-hero > .c-bolt {
6448 z-index: 4;
6449}
6450
6451.c-command-hero + .c-command-hero {
6452 margin-block-start: 1rem;
6453}
6454
6455.c-command-hero ~ .c-command-note {
6456 color: gray;
6457}
6458
6459.c-command-hero > .c-bolt > .c-bolt__icon {
6460 inline-size: 1rem;
6461}
6462"#;
6463 let plans = plan(css, &[RuleId::NestCombinator, RuleId::NestDescendant]);
6464 let output = apply_selected_plans(css, &plans, false).unwrap();
6465
6466 assert!(output.contains("& > .c-bolt {"), "{output}");
6467 assert!(output.contains("& + .c-command-hero {"), "{output}");
6468 assert!(output.contains("& ~ .c-command-note {"), "{output}");
6469 assert!(
6470 output.contains("& > .c-bolt > .c-bolt__icon {"),
6471 "multi-level combinator should preserve its full selector: {output}"
6472 );
6473 assert!(
6474 !output.contains("& {\n"),
6475 "combinator must not be split into a nested block: {output}"
6476 );
6477 assert!(!output.contains("\n > {"), "{output}");
6478 assert!(!output.contains("\n + {"), "{output}");
6479 assert!(!output.contains("\n ~ {"), "{output}");
6480 }
6481
6482 #[test]
6483 fn nests_in_place_input_states() {
6484 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";
6485 let plans = plan(css, &[RuleId::NestPseudoClass]);
6486 assert_eq!(plans.len(), 1);
6487 let output = apply_selected_plans(css, &plans, false).unwrap();
6488 assert!(output.contains("input {"));
6489 assert!(output.contains("&:user-invalid {"));
6490 assert!(output.contains("&:user-valid {"));
6491 assert!(output.contains("&:placeholder-shown {"));
6492 }
6493
6494 #[test]
6495 fn gathers_consecutive_conditions_by_selector() {
6496 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";
6497 let plans = plan(css, &[RuleId::NestMedia]);
6498 assert_eq!(plans.len(), 1);
6499 let output = apply_selected_plans(css, &plans, false).unwrap();
6500 assert!(output.contains(".responsive-grid {"));
6501 assert!(output.contains("@media (width >= 30rem) {"));
6502 assert!(output.contains("@media (width >= 80rem) {"));
6503 }
6504
6505 #[test]
6506 fn factors_selector_list_with_adjacent_hover() {
6507 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";
6508 let plans = plan(css, &[RuleId::FactorSelectorList, RuleId::NestPseudoClass]);
6509 assert_eq!(plans.len(), 1);
6510 let output = apply_selected_plans(css, &plans, false).unwrap();
6511 assert!(output.contains(".notice {"));
6512 assert!(output.contains("&,"));
6513 assert!(output.contains("&::before,"));
6514 assert!(output.contains("&::after {"));
6515 assert!(output.contains("&:hover {"));
6516 assert!(output.contains("background: color-mix"));
6517 }
6518
6519 #[test]
6520 fn gathers_non_adjacent_related_selector_rules_with_nested_blocks() {
6521 let css = r#".skip-link {
6522 position: absolute;
6523 inset-block-start: -48px;
6524 inset-inline-start: 1rem;
6525 z-index: 10000000000;
6526 background: var(--bg-color);
6527 color: var(--text-color);
6528 border: 1px solid var(--border-color);
6529 border-radius: 0.5rem;
6530 padding: 0.55rem 0.8rem;
6531 text-decoration: none;
6532 font-weight: 700;
6533 transition: inset-block-start 0.2s ease;
6534
6535 &:focus-visible {
6536 inset-block-start: 0.75rem;
6537 }
6538}
6539
6540.unrelated-rule {
6541 color: red;
6542}
6543
6544.skip-link {
6545 font: optional;
6546
6547 &::after {
6548 content: '';
6549 }
6550
6551 :not(*) & {
6552 all: unset
6553 }
6554}
6555"#;
6556 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6557 assert!(!plans.is_empty());
6558 let output = apply_selected_plans(css, &plans, true).unwrap();
6559 assert!(output.contains("font: optional;"));
6560 assert_eq!(
6561 output.matches(".skip-link {").count(),
6562 1,
6563 "expected a single gathered home: {output}"
6564 );
6565 }
6566
6567 #[test]
6568 fn gathers_non_adjacent_related_pseudo_and_combinator_rules() {
6569 let css = r#".skip-link {
6570 position: absolute;
6571 inset-block-start: -48px;
6572 inset-inline-start: 1rem;
6573 z-index: 10000000000;
6574 background: var(--bg-color);
6575 color: var(--text-color);
6576 border: 1px solid var(--border-color);
6577 border-radius: 0.5rem;
6578 padding: 0.55rem 0.8rem;
6579 text-decoration: none;
6580 font-weight: 700;
6581 transition: inset-block-start 0.2s ease;
6582
6583 &:focus-visible {
6584 inset-block-start: 0.75rem;
6585 }
6586}
6587
6588.unrelated {
6589 color: red;
6590}
6591
6592.skip-link {
6593 font: optional;
6594
6595 &::after {
6596 content: '';
6597 }
6598
6599 :not(*) & {
6600 all: unset
6601 }
6602}
6603
6604.skip-link+* {
6605 display: block;
6606}
6607
6608.skip-link::backdrop {
6609 background-color: gray;
6610}
6611
6612.skip-link:has(*) {
6613 color: #27ca3f;
6614}
6615"#;
6616 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6617 assert!(!plans.is_empty());
6618 let output = apply_selected_plans(css, &plans, true).unwrap();
6619 assert!(output.contains("font: optional;"));
6620 assert!(output.contains("+ * {"));
6621 assert!(output.contains("&::backdrop {"));
6622 assert!(output.contains("&:has(*) {"));
6623 assert_eq!(
6624 output.matches(".skip-link {").count(),
6625 1,
6626 "expected a single gathered home: {output}"
6627 );
6628 }
6629
6630 #[test]
6631 fn gather_keeps_explicit_combinators_as_complete_nested_selectors() {
6632 let css = r#".c-command-hero {
6633 display: grid;
6634}
6635
6636.c-command-hero > .c-bolt {
6637 z-index: 4;
6638}
6639
6640.unrelated {
6641 color: gray;
6642}
6643
6644.c-command-hero {
6645 border-radius: 0.75rem;
6646}
6647
6648.c-command-hero + .c-command-hero {
6649 margin-block-start: 1rem;
6650}
6651"#;
6652 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6653 let output = apply_selected_plans(css, &plans, true).unwrap();
6654
6655 assert!(output.contains("& > .c-bolt {"), "{output}");
6656 assert!(
6657 !output.contains("& {\n"),
6658 "explicit combinator must not be split: {output}"
6659 );
6660 assert!(!output.contains("\n > .c-bolt {"), "{output}");
6661 }
6662
6663 #[test]
6664 fn test_modernizes_media_range_syntax_no_whitespace() {
6665 let input = "@media (max-width:60rem) { .content { width: 100%; } }";
6666 let plans = plan(input, &[RuleId::ModernizeMediaRange]);
6667 assert_eq!(plans.len(), 1);
6668 let output = apply_selected_plans(input, &plans, true).unwrap();
6669 assert!(output.contains("(width <= 60rem)"));
6670 }
6671
6672 #[test]
6673 fn plans_continue_when_lightningcss_rejects_picker_pseudo() {
6674 let css = r#".custom-select {
6677 appearance: none;
6678}
6679
6680.custom-select:hover {
6681 border-color: teal;
6682}
6683
6684.custom-select::picker(select) {
6685 background: white;
6686}
6687
6688.custom-select::picker(select)::-webkit-scrollbar {
6689 width: 6px;
6690}
6691
6692@media (max-width: 768px) {
6693 .custom-select {
6694 inline-size: 100%;
6695 }
6696}
6697"#;
6698 let report = analyze_source(PathBuf::from("picker.css"), css, &RuleId::ALL).unwrap();
6699 eprintln!(
6700 "parse_ok={} err={:?} plans={}",
6701 report.parse_ok,
6702 report.parse_error,
6703 report.plans.len()
6704 );
6705 for p in &report.plans {
6706 eprintln!(" {:?} {:?}", p.rules, p.reason);
6707 }
6708 assert!(
6709 !report.plans.is_empty(),
6710 "must still emit plans (parse_ok={:?} err={:?}): findings={:?}",
6711 report.parse_ok,
6712 report.parse_error,
6713 report.findings
6714 );
6715 let output = apply_selected_plans(css, &report.plans, true).unwrap();
6716 assert!(
6717 output.contains("&:hover") || output.contains("&::picker"),
6718 "custom-select relatives should nest: {output}"
6719 );
6720 assert!(
6721 output.contains("(width <= 768px)") || output.contains("inline-size: 100%"),
6722 "media-range or nest should still apply: {output}"
6723 );
6724 }
6725
6726 #[test]
6727 fn nesting_adds_semicolon_to_last_declaration_without_semicolon() {
6728 let css = r#".tabpanel {
6732 display: block !important
6733}
6734
6735.tabpanel+.tabpanel {
6736 margin-block-start: .5rem
6737}
6738"#;
6739 let rules = &[
6740 RuleId::NestCombinator,
6741 RuleId::NestDescendant,
6742 RuleId::NestPseudoClass,
6743 RuleId::NestPseudoElement,
6744 ];
6745 let plans = plan(css, rules);
6746 assert!(!plans.is_empty(), "expected at least one nesting plan");
6747 let output = apply_selected_plans(css, &plans, true).unwrap();
6748 assert!(
6750 !output.contains("!important+"),
6751 "semicolon missing before nested rule: {output}"
6752 );
6753 assert!(
6755 output.contains("!important;") || output.contains("!important\n"),
6756 "declaration should end with ';': {output}"
6757 );
6758 assert!(
6760 output.contains("+ .tabpanel {") || output.contains("+.tabpanel {"),
6761 "nested combinator rule missing: {output}"
6762 );
6763 }
6764
6765 #[test]
6766 fn preserves_multiline_custom_property_values_when_nesting() {
6767 let css = r#".card {
6768 --shadow:
6769 0 1px 2px rgb(0 0 0 / 8%),
6770 0 4px 12px rgb(0 0 0 / 5%);
6771}
6772
6773.card:hover {
6774 color: blue;
6775}
6776"#;
6777 let plans = plan(css, &[RuleId::NestPseudoClass]);
6778 assert!(!plans.is_empty(), "expected a pseudo-class nesting plan");
6779 let output = apply_selected_plans(css, &plans, true).unwrap();
6780 assert!(
6781 output.contains("--shadow:\n"),
6782 "custom property was altered: {output}"
6783 );
6784 assert!(
6785 output.contains("0 1px 2px rgb(0 0 0 / 8%),\n 0 4px 12px rgb(0 0 0 / 5%);")
6786 || output.contains("0 1px 2px rgb(0 0 0 / 8%),\n 0 4px 12px rgb(0 0 0 / 5%);"),
6787 "custom property value was reformatted: {output}"
6788 );
6789 assert!(
6790 !output.contains("--shadow:;"),
6791 "multiline value was terminated early: {output}"
6792 );
6793 }
6794
6795 #[test]
6796 fn gather_related_selector_rules_adds_semicolon_to_declarations_without_semicolon() {
6797 let css = r#".skip-link {
6800 position: absolute;
6801 font-weight: 700
6802}
6803
6804.unrelated { color: red }
6805
6806.skip-link:focus {
6807 outline: 2px solid currentColor
6808}
6809
6810.skip-link+* {
6811 display: block
6812}
6813"#;
6814 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6815 assert!(!plans.is_empty(), "expected gather plan");
6816 let output = apply_selected_plans(css, &plans, true).unwrap();
6817 assert!(
6819 !output.contains("700\n &") && !output.contains("700&") && !output.contains("700{"),
6820 "semicolon missing before nested pseudo/combinator: {output}"
6821 );
6822 assert!(
6823 !output.contains("currentColor\n + *") && !output.contains("currentColor{"),
6824 "semicolon missing before nested combinator: {output}"
6825 );
6826 assert!(
6828 output.contains("font-weight: 700;"),
6829 "missing ';' after font-weight: {output}"
6830 );
6831 assert!(
6832 output.contains("position: absolute;"),
6833 "missing ';' after position: {output}"
6834 );
6835 }
6836
6837 #[test]
6838 fn gathers_non_adjacent_related_rule_inside_media_query() {
6839 let css = r#".skip-link {
6840 position: absolute;
6841 font-weight: 700;
6842
6843 &:focus-visible {
6844 inset-block-start: 0.75rem;
6845 }
6846}
6847
6848.unrelated {
6849 color: red;
6850}
6851
6852@media (width <= 1024px) {
6853 .skip-link :not(*) {
6854 position: inherit
6855 }
6856}
6857"#;
6858 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6859 assert!(
6860 !plans.is_empty(),
6861 "expected gather plan for media-wrapped related rule"
6862 );
6863 let output = apply_selected_plans(css, &plans, true).unwrap();
6864 assert!(
6865 !output.contains(".skip-link :not(*)"),
6866 "flat media descendant should be gathered: {output}"
6867 );
6868 assert!(
6869 output.contains(":not(*) {"),
6870 "descendant :not(*) should nest under .skip-link: {output}"
6871 );
6872 assert!(
6873 output.contains("@media (width <= 1024px) {"),
6874 "media query should nest inside :not(*): {output}"
6875 );
6876 assert!(
6877 output.contains("position: inherit"),
6878 "declaration should be preserved: {output}"
6879 );
6880 let not_pos = output.find(":not(*) {").expect(":not(*)");
6881 let media_pos = output.find("@media (width <= 1024px) {").expect("@media");
6882 assert!(
6883 media_pos > not_pos,
6884 "media must nest inside :not(*), not the reverse: {output}"
6885 );
6886 }
6887
6888 #[test]
6889 fn gathers_exact_parent_inside_media_as_nested_at_rule() {
6890 let css = r#".skip-link {
6891 position: absolute;
6892}
6893
6894.unrelated { color: red }
6895
6896@media (width <= 600px) {
6897 .skip-link {
6898 display: none
6899 }
6900}
6901"#;
6902 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6903 let output = apply_selected_plans(css, &plans, true).unwrap();
6904 assert!(output.contains("@media (width <= 600px) {"), "{output}");
6905 assert!(output.contains("display: none;"), "{output}");
6906 assert!(
6907 !output.contains("@media (width <= 600px) {\n .skip-link"),
6908 "should invert to .skip-link {{ @media }}: {output}"
6909 );
6910 }
6911
6912 #[test]
6913 fn gather_media_leaves_unrelated_siblings_in_place() {
6914 let css = r#".skip-link {
6915 position: absolute;
6916}
6917
6918.unrelated { color: red }
6919
6920@media (width <= 1024px) {
6921 .skip-link :not(*) {
6922 position: inherit
6923 }
6924 .other {
6925 color: blue
6926 }
6927}
6928"#;
6929 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6930 let output = apply_selected_plans(css, &plans, true).unwrap();
6931 assert!(output.contains(":not(*) {"), "{output}");
6932 assert!(
6933 output.contains(".other") && output.contains("color: blue"),
6934 "unrelated sibling must stay in the media block: {output}"
6935 );
6936 }
6937
6938 #[test]
6939 fn nests_appended_nesting_selector_descendant() {
6940 let css = ".card {\n color: red;\n}\n.featured .card {\n border: 1px solid;\n}\n";
6942 let plans = plan(css, &[RuleId::NestDescendant]);
6943 assert_eq!(plans.len(), 1);
6944 let output = apply_selected_plans(css, &plans, false).unwrap();
6945 assert!(
6946 output.contains(".featured & {"),
6947 "expected appended &: {output}"
6948 );
6949 assert!(output.contains("border: 1px solid;"), "{output}");
6950 }
6951
6952 #[test]
6953 fn nests_appended_nesting_selector_not() {
6954 let css = ".card {\n color: red;\n}\n:not(.card) {\n display: none;\n}\n";
6956 let plans = plan(css, &[RuleId::NestPseudoClass]);
6957 assert_eq!(plans.len(), 1);
6958 let output = apply_selected_plans(css, &plans, false).unwrap();
6959 assert!(output.contains(":not(&) {"), "expected :not(&): {output}");
6960 assert!(output.contains("display: none;"), "{output}");
6961 }
6962
6963 #[test]
6964 fn nests_appended_compound_selector() {
6965 let css = ".card {\n color: red;\n}\n.featured.card {\n font-weight: 700;\n}\n";
6966 let plans = plan(css, &[RuleId::NestCompound]);
6967 assert_eq!(plans.len(), 1);
6968 let output = apply_selected_plans(css, &plans, false).unwrap();
6969 assert!(
6970 output.contains(".featured& {"),
6971 "expected compound appended &: {output}"
6972 );
6973 }
6974
6975 #[test]
6976 fn gathers_non_adjacent_appended_nesting_selector() {
6977 let css = r#".card {
6978 color: red;
6979}
6980
6981.unrelated { color: blue }
6982
6983.featured .card {
6984 border: 1px solid
6985}
6986
6987:not(.card) {
6988 display: none
6989}
6990"#;
6991 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
6992 assert!(!plans.is_empty());
6993 let output = apply_selected_plans(css, &plans, true).unwrap();
6994 assert!(output.contains(".featured & {"), "{output}");
6995 assert!(output.contains(":not(&) {"), "{output}");
6996 assert!(!output.contains(".featured .card"), "{output}");
6997 assert!(!output.contains(":not(.card)"), "{output}");
6998 }
6999
7000 #[test]
7001 fn gather_does_not_nest_selector_list_under_one_branch() {
7002 let css = r#"::view-transition-old(root),
7005::view-transition-new(root) {
7006 position: absolute;
7007 inset: 0;
7008 animation: 0.55s ease-in-out both;
7009}
7010
7011.unrelated { color: red }
7012
7013::view-transition-old(root) {
7014 animation-name: fadeOut;
7015}
7016
7017::view-transition-new(root) {
7018 animation-name: fadeIn;
7019}
7020"#;
7021 let plans = plan(
7022 css,
7023 &[
7024 RuleId::GatherRelatedSelectorRules,
7025 RuleId::FactorSelectorList,
7026 ],
7027 );
7028 let output = apply_selected_plans(css, &plans, true).unwrap();
7029 assert!(
7030 !output.contains("::view-transition-old(root),\n &")
7031 && !output.contains("::view-transition-old(root),\n & {")
7032 && !output.contains("::view-transition-old(root),\n &"),
7033 "selector list must not nest under one branch: {output}"
7034 );
7035 assert!(
7036 output.contains("::view-transition-old(root)")
7037 && output.contains("::view-transition-new(root)")
7038 && output.contains("animation-name: fadeOut")
7039 && output.contains("animation-name: fadeIn"),
7040 "both branches and their unique decls must remain: {output}"
7041 );
7042 assert!(
7043 output.contains("position: absolute"),
7044 "shared declarations must be kept: {output}"
7045 );
7046 }
7047
7048 #[test]
7049 fn gather_nests_not_focus_visible_as_non_relative_amp() {
7050 assert!(selector_contains_nesting_amp(".skip-link:focus:not(&)"));
7054 assert!(!selector_contains_nesting_amp(".skip-link:focus"));
7055
7056 let css = r#":focus-visible {
7057 outline: 2px solid blue;
7058}
7059
7060.unrelated { color: red }
7061
7062/* Preserve visible focus for skip-link activation (any modality). */
7063.skip-link:focus:not(:focus-visible) {
7064 outline: 2px solid blue;
7065}
7066"#;
7067 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7068 let output = apply_selected_plans(css, &plans, true).unwrap();
7069 assert!(
7070 output.contains(".skip-link:focus:not(&)"),
7071 "should nest as non-relative :not(&): {output}"
7072 );
7073 assert!(
7074 !output.contains(":focus-visible .skip-link")
7075 && !output.contains(":focus-visible .skip-link"),
7076 "must not insert a descendant combinator: {output}"
7077 );
7078 assert!(
7079 output.contains("Preserve visible focus for skip-link activation"),
7080 "leading comment must move with the gathered rule: {output}"
7081 );
7082 let cmt = output.find("Preserve visible focus").expect("comment");
7083 let nest = output.find(".skip-link:focus:not(&)").expect("nest");
7084 assert!(
7085 cmt < nest,
7086 "comment should precede the nested rule: {output}"
7087 );
7088 }
7089
7090 #[test]
7091 fn gather_nests_skip_link_focus_under_skip_link_not_focus_visible() {
7092 let css = r#":focus-visible {
7093 outline: 2px solid blue;
7094}
7095
7096.skip-link {
7097 position: fixed;
7098}
7099
7100.unrelated { color: red }
7101
7102.skip-link:focus:not(:focus-visible) {
7103 outline: 2px solid blue;
7104}
7105"#;
7106 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7107 let output = apply_selected_plans(css, &plans, true).unwrap();
7108 assert!(
7109 output.contains("&:focus:not(:focus-visible)"),
7110 "should nest under .skip-link: {output}"
7111 );
7112 assert!(
7113 !output.contains(".skip-link:focus:not(&)"),
7114 "must not nest under :focus-visible: {output}"
7115 );
7116 }
7117
7118 #[test]
7119 fn gather_merges_same_nested_selector_from_style_and_media() {
7120 let css = r#".demo-out-text {
7121 font-size: 1.5rem;
7122
7123 &.flash {
7124 animation: demo-out-flash .4s ease;
7125 }
7126}
7127
7128.unrelated { color: red }
7129
7130@media (prefers-reduced-motion: reduce) {
7131 .demo-out-text.flash {
7132 animation: none
7133 }
7134}
7135"#;
7136 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7137 let output = apply_selected_plans(css, &plans, true).unwrap();
7138 let flash_opens = output.matches("&.flash {").count();
7139 assert_eq!(
7140 flash_opens, 1,
7141 "duplicate &.flash nests should merge: {output}"
7142 );
7143 assert!(output.contains("animation: demo-out-flash"), "{output}");
7144 assert!(
7145 output.contains("@media (prefers-reduced-motion: reduce)"),
7146 "{output}"
7147 );
7148 assert!(output.contains("animation: none"), "{output}");
7149 }
7150
7151 #[test]
7152 fn gather_prefers_existing_specific_home_over_universal_append() {
7153 let css = r#".skip-link {
7157 position: absolute;
7158 font-weight: 700;
7159}
7160
7161* {
7162 margin: 0;
7163}
7164
7165.unrelated { color: red }
7166
7167.skip-link+* {
7168 display: block
7169}
7170
7171.skip-link:has(*) {
7172 color: #27ca3f
7173}
7174"#;
7175 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7176 let output = apply_selected_plans(css, &plans, true).unwrap();
7177 assert!(
7178 output.contains("+ * {") || output.contains("+* {"),
7179 "combinator should nest under .skip-link: {output}"
7180 );
7181 assert!(
7182 output.contains("&:has(*) {"),
7183 ":has(*) should nest under .skip-link: {output}"
7184 );
7185 assert!(
7186 !output.contains(".skip-link+&")
7187 && !output.contains(".skip-link + &")
7188 && !output.contains(".skip-link:has(&)"),
7189 "must not append the same rules into *: {output}"
7190 );
7191 let star = output.find("\n* {").or_else(|| output.find("* {"));
7192 if let Some(star_at) = star {
7193 let after_star = &output[star_at..];
7194 let star_body = after_star.split('}').next().unwrap_or(after_star);
7195 assert!(
7196 !star_body.contains("skip-link"),
7197 "* must not absorb skip-link rules: {output}"
7198 );
7199 }
7200 }
7201
7202 #[test]
7203 fn precise_conditional_home_is_not_hidden_by_universal_gather() {
7204 let css = r#"*
7205{
7206 margin: 0;
7207}
7208
7209.faq-list {
7210 display: grid;
7211}
7212
7213@supports selector(details::details-content) {
7214 .faq-list {
7215 details {
7216 &::details-content {
7217 height: 0;
7218 }
7219 }
7220
7221 @starting-style {
7222 details[open]::details-content {
7223 height: 0;
7224 }
7225 }
7226 }
7227}
7228"#;
7229 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7230 assert!(
7231 plans.iter().any(|p| p.reason.contains("'.faq-list'")),
7232 "precise conditional home was suppressed: {plans:?}"
7233 );
7234 assert!(
7235 !plans.iter().any(|p| p.reason.contains("'*'")),
7236 "universal gather should not be planned: {plans:?}"
7237 );
7238 }
7239
7240 #[test]
7241 fn gather_appends_only_when_no_prefix_home_exists() {
7242 let css = r#".card {
7243 color: red;
7244}
7245
7246.unrelated { color: blue }
7247
7248.featured .card {
7249 border: 1px solid
7250}
7251"#;
7252 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7253 let output = apply_selected_plans(css, &plans, true).unwrap();
7254 assert!(output.contains(".featured & {"), "{output}");
7255 assert!(!output.contains(".featured .card"), "{output}");
7256 }
7257
7258 #[test]
7259 fn gather_keeps_supports_block_with_comma_list_and_mixed_homes() {
7260 let css = r#".custom-select {
7261 color: navy;
7262}
7263
7264.unrelated { color: red }
7265
7266@supports (appearance: base-select) {
7267 .custom-select,
7268 .custom-select::picker(select) {
7269 appearance: base-select;
7270 }
7271 .custom-select button { display: flex }
7272 selectedcontent { display: flex }
7273 .custom-select option { padding: 1rem }
7274}
7275"#;
7276 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7277 let output = apply_selected_plans(css, &plans, true).unwrap();
7278 assert!(
7279 output.contains("appearance: base-select"),
7280 "comma-list body must not be dropped: {output}"
7281 );
7282 assert!(
7283 output.contains("selectedcontent") && output.contains(".custom-select button")
7284 || output.contains("button {"),
7285 "mixed @supports inners must remain: {output}"
7286 );
7287 }
7288
7289 #[test]
7290 fn gather_keeps_busy_mixed_media_grouped() {
7291 let css = r#".nav-links { display: flex; }
7292.hamburger-menu { display: none; }
7293.nav-controls { gap: 1rem; }
7294
7295.unrelated { color: red }
7296
7297@media (width <= 1024px) {
7298 .nav-links { display: none }
7299 .hamburger-menu { display: flex }
7300 .nav-controls { margin-inline-start: auto }
7301}
7302"#;
7303 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7304 let output = apply_selected_plans(css, &plans, true).unwrap();
7305 assert!(
7306 output.contains("@media (width <= 1024px)"),
7307 "mixed media with 3+ selectors should stay grouped: {output}"
7308 );
7309 assert!(
7310 !output.contains(".nav-links {\n display: flex;\n\n @media")
7311 && !output.contains(".hamburger-menu {\n display: none;\n\n @media"),
7312 "should not explode a busy media query into each parent: {output}"
7313 );
7314 }
7315
7316 #[test]
7317 fn appended_nesting_does_not_match_ident_suffix() {
7318 let css = ".card {\n color: red;\n}\n.mycard {\n color: blue;\n}\n";
7319 let plans = plan(css, &[RuleId::NestCompound, RuleId::NestDescendant]);
7320 assert!(
7321 plans.is_empty(),
7322 ".mycard must not nest under .card: {:?}",
7323 plans.iter().map(|p| &p.proposed).collect::<Vec<_>>()
7324 );
7325 }
7326
7327 #[test]
7328 fn gathers_stack_card_supports_media_into_home_without_reversed_amp() {
7329 let css = r#".stack-card {
7330 position: sticky;
7331 margin-bottom: 75px;
7332
7333 &:last-child {
7334 margin-bottom: 0;
7335 }
7336}
7337
7338@supports (animation-timeline:view()) {
7339 @media (prefers-reduced-motion:no-preference) {
7340 .stack-card .card-inner {
7341 animation-name: recede;
7342 animation-timeline: --stack;
7343 }
7344
7345 .stack-card:last-child .card-inner {
7346 animation: none;
7347 }
7348 }
7349}
7350
7351.stack-card {
7352 &:hover .project-card-marvel__media img {
7353 transform: scale(1.035);
7354 }
7355
7356 &:hover .project-card-marvel__media::after {
7357 left: 160%;
7358 }
7359}
7360"#;
7361 let rules = [
7362 RuleId::NestPseudoClass,
7363 RuleId::NestCompound,
7364 RuleId::NestDescendant,
7365 RuleId::NestSupports,
7366 RuleId::NestMedia,
7367 RuleId::GatherRelatedSelectorRules,
7368 ];
7369 let output = apply_until_stable(Path::new("test.css"), css, &rules, false).unwrap();
7370 let home = output.find(".stack-card {").unwrap_or_else(|| {
7371 panic!("home missing: {output}");
7372 });
7373 let supports = output
7374 .find("@supports (animation-timeline:view())")
7375 .unwrap_or_else(|| {
7376 panic!("supports missing: {output}");
7377 });
7378 assert!(
7379 supports > home,
7380 "supports must invert under .stack-card: {output}"
7381 );
7382 assert!(
7383 !output.contains("@supports (animation-timeline:view()) {\n .stack-card")
7384 && !output.contains("@supports (animation-timeline:view()) {\n .stack-card"),
7385 "must not wrap a second .stack-card inside @supports: {output}"
7386 );
7387 assert!(
7388 !output.contains("&:last-child &"),
7389 "must not reverse .card-inner into &:last-child &: {output}"
7390 );
7391 assert!(
7392 output.contains("&:last-child .card-inner")
7393 || (output.contains("&:last-child") && output.contains("animation: none")),
7394 "last-child card-inner must stay at .stack-card scope: {output}"
7395 );
7396 assert!(
7397 output.contains("animation-name: recede") && output.contains("animation: none"),
7398 "view-timeline animation must survive: {output}"
7399 );
7400 assert!(
7401 output.contains("transform: scale(1.035)") && output.contains("left: 160%"),
7402 "hover media rules must survive: {output}"
7403 );
7404 assert!(
7405 output.matches(".stack-card {").count() == 1,
7406 "expected a single .stack-card home: {output}"
7407 );
7408 }
7409
7410 #[test]
7411 fn review_gather_span_does_not_hide_unrelated_safe_nests() {
7412 let css = r#".stack-card:hover .media {
7416 opacity: 1;
7417}
7418
7419.terminal-line {
7420 display: flex;
7421}
7422
7423.terminal-line .prompt {
7424 color: red;
7425}
7426
7427.terminal-line.dim {
7428 opacity: 0.5;
7429}
7430
7431.stack-card {
7432 position: sticky;
7433}
7434
7435.stack-card:last-child {
7436 margin-bottom: 0;
7437}
7438"#;
7439 let rules = [
7440 RuleId::NestPseudoClass,
7441 RuleId::NestCompound,
7442 RuleId::NestDescendant,
7443 RuleId::GatherRelatedSelectorRules,
7444 ];
7445 let plans = plan(css, &rules);
7446 assert!(
7447 plans.iter().any(|p| {
7448 p.safety == Safety::Safe
7449 && p.proposed.contains(".terminal-line")
7450 && (p.proposed.contains(".prompt") || p.proposed.contains("&.dim"))
7451 }),
7452 "safe terminal-line nest must survive overlapping review gather: {:?}",
7453 plans
7454 .iter()
7455 .map(|p| (&p.safety, &p.reason, &p.proposed))
7456 .collect::<Vec<_>>()
7457 );
7458
7459 let output = apply_until_stable(Path::new("test.css"), css, &rules, false).unwrap();
7460 assert!(
7461 output.contains(".terminal-line {") && output.contains(".prompt {"),
7462 "apply without review must nest adjacent descendants: {output}"
7463 );
7464 assert!(
7465 output.contains(".stack-card {") && output.contains("&:last-child {"),
7466 "apply without review must nest adjacent pseudo-class: {output}"
7467 );
7468 }
7469
7470 #[test]
7471 fn gather_preserves_nested_selector_lists() {
7472 let css = r#"* {
7474 margin: 0;
7475}
7476
7477.unrelated { color: red }
7478
7479@media (prefers-reduced-motion: reduce) {
7480 * {
7481 &,
7482 &::before,
7483 &::after {
7484 animation-duration: 0.001ms !important
7485 }
7486 }
7487}
7488"#;
7489 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7490 let output = apply_selected_plans(css, &plans, true).unwrap();
7491 assert!(
7492 !output.contains("&::before,\n ;")
7493 && !output.contains("&::before,;")
7494 && !output.contains("&,\n ;")
7495 && !output.contains("&,;"),
7496 "selector-list comma must not become a declaration terminator: {output}"
7497 );
7498 assert!(
7499 output.contains("&::before,") && output.contains("&::after {"),
7500 "compound pseudo selector list must stay intact: {output}"
7501 );
7502 let before = output.find("&::before,").expect("before");
7503 let after = output.find("&::after {").expect("after");
7504 let between = &output[before..after];
7505 assert!(
7506 !between.contains(';'),
7507 "no semicolon between selector-list items: {between:?} in {output}"
7508 );
7509 }
7510
7511 #[test]
7512 fn gather_keeps_every_custom_select_chrome_rule() {
7513 let css = r#".custom-select {
7514 color: navy;
7515}
7516
7517@supports (appearance: base-select) {
7518 .custom-select,
7519 .custom-select::picker(select) {
7520 appearance: base-select;
7521 }
7522 .custom-select button {
7523 display: flex;
7524 }
7525 .custom-select button:hover {
7526 border-color: teal;
7527 }
7528 .custom-select:focus button,
7529 .custom-select button:focus-visible {
7530 outline: none;
7531 }
7532 .custom-select .select-arrow {
7533 color: gray;
7534 }
7535 .custom-select:open .select-arrow {
7536 rotate: -180deg;
7537 }
7538 .custom-select:open button {
7539 border-color: teal;
7540 }
7541 selectedcontent {
7542 display: flex;
7543 }
7544 selectedcontent .opt-icon {
7545 display: none;
7546 }
7547 .custom-select::picker(select) {
7548 background: white;
7549 }
7550 .custom-select:not(:open)::picker(select) {
7551 opacity: 0;
7552 }
7553 .custom-select option {
7554 padding: 1rem;
7555 }
7556 .custom-select .dropdown-search-container {
7557 position: sticky;
7558 }
7559 .custom-select .select-search {
7560 inline-size: 100%;
7561 }
7562 .custom-select option .opt-icon {
7563 color: gray;
7564 }
7565 .custom-select option:hover .opt-icon,
7566 .custom-select option:checked .opt-icon {
7567 color: teal;
7568 }
7569 .custom-select::picker-icon {
7570 display: none;
7571 }
7572}
7573"#;
7574 let rules: Vec<RuleId> = RuleId::ALL
7575 .iter()
7576 .copied()
7577 .filter(|r| *r != RuleId::ModernizeWhere)
7578 .collect();
7579 let plans = plan(css, &rules);
7580 let output = apply_selected_plans(css, &plans, true).unwrap();
7581 for needle in [
7582 "appearance: base-select",
7583 "display: flex",
7584 "border-color: teal",
7585 "outline: none",
7586 "color: gray",
7587 "rotate: -180deg",
7588 "selectedcontent",
7589 "display: none",
7590 "background: white",
7591 "opacity: 0",
7592 "padding: 1rem",
7593 "position: sticky",
7594 "inline-size: 100%",
7595 "::picker-icon",
7596 ] {
7597 assert!(
7598 output.contains(needle),
7599 "lost `{needle}` after apply:\n{output}"
7600 );
7601 }
7602 assert!(
7603 output.contains("button") && output.contains("&:hover"),
7604 "button:hover should nest as button {{ &:hover }}:\n{output}"
7605 );
7606 assert!(
7607 !output.contains("&:open &") && !output.contains("&:focus &"),
7608 "must not rewrite :open/:focus descendants as appended &:\n{output}"
7609 );
7610 assert!(
7611 output.contains("&:open")
7612 && (output.contains("&:open {")
7613 || output.contains("&:open .select-arrow")
7614 || output.contains("&:open button")),
7615 ":open children should group under .custom-select:\n{output}"
7616 );
7617 assert!(
7618 output.contains("&:focus button") || output.contains("button:focus-visible"),
7619 "focus comma-list must stay as relative branches:\n{output}"
7620 );
7621 assert!(
7622 output.contains("selectedcontent")
7623 && (output.contains(".opt-icon") || output.contains("& .opt-icon")),
7624 "selectedcontent leftover must remain:\n{output}"
7625 );
7626 assert!(
7627 output.contains(".custom-select")
7628 && output.contains("@supports (appearance: base-select)"),
7629 "supports should invert under .custom-select:\n{output}"
7630 );
7631 let home = output.find(".custom-select {").expect("home");
7632 let supports = output
7633 .find("@supports (appearance: base-select)")
7634 .expect("supports");
7635 assert!(
7636 supports > home,
7637 "appearance supports should sit inside .custom-select:\n{output}"
7638 );
7639 assert!(
7640 !output.contains("@supports (appearance: base-select) {\n .custom-select"),
7641 "must not leave a wrapper .custom-select inside @supports:\n{output}"
7642 );
7643 }
7644
7645 #[test]
7646 fn gather_folds_unlayered_picker_into_layered_home() {
7647 let css = r#"@layer components {
7648 .custom-select {
7649 color: navy;
7650 &:is(:hover) { color: teal; }
7651 }
7652}
7653
7654.custom-select::picker(select) {
7655 scrollbar-width: thin;
7656}
7657.custom-select::picker(select)::-webkit-scrollbar {
7658 width: 6px;
7659}
7660"#;
7661 let plans = plan(
7662 css,
7663 &[
7664 RuleId::GatherRelatedSelectorRules,
7665 RuleId::NestLayerBySelector,
7666 ],
7667 );
7668 let output = apply_selected_plans(css, &plans, true).unwrap();
7669 assert!(
7670 output.contains("@layer components") && output.contains(".custom-select"),
7671 "layered home must stay in its layer:\n{output}"
7672 );
7673 assert!(
7674 output.contains(".custom-select::picker(select)")
7675 || output.contains("&::picker(select)"),
7676 "picker chrome must remain:\n{output}"
7677 );
7678 let layer_at = output.find("@layer components").expect("layer");
7679 let picker_flat = output.find(".custom-select::picker(select)");
7680 if let Some(picker_at) = picker_flat {
7681 assert!(
7682 picker_at < layer_at || !output[layer_at..].contains("scrollbar-width"),
7683 "unlayered picker must not be dumped into @layer components:\n{output}"
7684 );
7685 }
7686 }
7687
7688 #[test]
7689 fn gather_does_not_dump_root_from_base_into_tokens() {
7690 let css = r#"@layer tokens {
7691 :root {
7692 color-scheme: light dark;
7693 }
7694}
7695
7696@layer base {
7697 :root {
7698 scroll-behavior: smooth;
7699 }
7700 body { margin: 0; }
7701}
7702"#;
7703 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7704 let output = apply_selected_plans(css, &plans, true).unwrap();
7705 assert!(
7706 output.contains("@layer tokens") && output.contains("@layer base"),
7707 "both layers must remain:\n{output}"
7708 );
7709 let tokens = output.split("@layer base").next().unwrap_or(&output);
7710 assert!(
7711 !tokens.contains("scroll-behavior"),
7712 "base :root decls must not move into tokens:\n{output}"
7713 );
7714 assert!(
7715 output.contains("scroll-behavior: smooth"),
7716 "base :root decls must survive:\n{output}"
7717 );
7718 }
7719
7720 #[test]
7721 fn nest_layer_by_selector_preserves_layer_identity() {
7722 let css = r#"@layer tokens {
7723 :root {
7724 color-scheme: light dark;
7725 }
7726}
7727
7728@layer base {
7729 :root {
7730 scroll-behavior: smooth;
7731 }
7732 body { margin: 0; }
7733}
7734"#;
7735 let plans = plan(css, &[RuleId::NestLayerBySelector]);
7736 let output = apply_selected_plans(css, &plans, true).unwrap();
7737 assert!(
7738 output.contains(":root {")
7739 && output.contains("@layer tokens {")
7740 && output.contains("@layer base {"),
7741 "should hoist :root and nest named layers:\n{output}"
7742 );
7743 assert!(
7744 output.contains("color-scheme: light dark")
7745 && output.contains("scroll-behavior: smooth")
7746 && output.contains("body"),
7747 "all declarations must survive:\n{output}"
7748 );
7749 assert!(
7751 !output.contains("@layer tokens {\n :root")
7752 || output.find(":root {").unwrap() < output.find("@layer tokens {").unwrap_or(0)
7753 || output.matches("@layer tokens").count() >= 1,
7754 "{output}"
7755 );
7756 let root_at = output.find(":root {").expect("root");
7757 let tokens_inner = output[root_at..]
7758 .find("@layer tokens {")
7759 .expect("nested tokens");
7760 let base_inner = output[root_at..]
7761 .find("@layer base {")
7762 .expect("nested base");
7763 assert!(
7764 tokens_inner < base_inner,
7765 "layer order tokens then base must be preserved:\n{output}"
7766 );
7767 assert!(
7768 output.contains("body {") || output.contains("body{"),
7769 "unrelated base rules stay in @layer base:\n{output}"
7770 );
7771 }
7772
7773 #[test]
7774 fn nest_layer_blocks_are_not_factored_into_anonymous_layer() {
7775 let css = r#":root {
7779 @layer tokens {
7780 color-scheme: light dark;
7781 }
7782
7783 @layer base {
7784 scroll-behavior: smooth;
7785 }
7786}
7787
7788:root {
7789 scrollbar-width: thin;
7790}
7791"#;
7792 let plans = plan(
7793 css,
7794 &[
7795 RuleId::GatherRelatedSelectorRules,
7796 RuleId::NestLayerBySelector,
7797 ],
7798 );
7799 let output = apply_selected_plans(css, &plans, true).unwrap();
7800 assert!(
7801 output.contains("@layer tokens") && output.contains("@layer base"),
7802 "named layers must stay named:\n{output}"
7803 );
7804 assert!(
7805 !output.contains("@layer {\n tokens")
7806 && !output.contains("@layer {\n tokens")
7807 && !output.contains("@layer {\n base"),
7808 "must not wrap layer names as type selectors:\n{output}"
7809 );
7810 assert!(
7811 output.contains("color-scheme: light dark")
7812 && output.contains("scroll-behavior: smooth")
7813 && output.contains("scrollbar-width: thin"),
7814 "declarations must survive:\n{output}"
7815 );
7816 }
7817
7818 #[test]
7819 fn nest_layer_skips_when_unlayered_rule_intervenes() {
7820 let css = r#"@layer base {
7821 .container { color: red; }
7822}
7823
7824.some-rule { color: blue; }
7825
7826@layer layout {
7827 .container { color: green; }
7828}
7829"#;
7830 let plans = plan(css, &[RuleId::NestLayerBySelector]);
7831 let output = apply_selected_plans(css, &plans, true).unwrap();
7832 assert!(
7833 output.contains("@layer base")
7834 && output.contains("@layer layout")
7835 && output.contains(".some-rule"),
7836 "intervening unlayered rule blocks the hoist:\n{output}"
7837 );
7838 assert!(
7839 !output.contains(".container {\n @layer base"),
7840 "must not move layout across .some-rule:\n{output}"
7841 );
7842 }
7843
7844 #[test]
7845 fn gather_factors_compact_descendants_under_option_card() {
7846 let css = r#".option-card.compact .option-label {
7847 padding: 1rem;
7848}
7849
7850.option-card.compact .option-icon {
7851 inline-size: 24px;
7852}
7853
7854.unrelated { color: red }
7855
7856.option-card {
7857 position: relative;
7858 cursor: pointer;
7859}
7860"#;
7861 let plans = plan(css, &[RuleId::GatherRelatedSelectorRules]);
7862 let output = apply_selected_plans(css, &plans, true).unwrap();
7863 assert!(
7864 output.contains("&.compact")
7865 && output.contains(".option-label")
7866 && output.contains(".option-icon"),
7867 "compact descendants should nest under .option-card:\n{output}"
7868 );
7869 assert!(
7870 output.contains("&.compact {")
7871 && output.contains(".option-label {")
7872 && output.contains(".option-icon {"),
7873 "shared &.compact prefix should be factored:\n{output}"
7874 );
7875 assert!(output.contains("position: relative"), "{output}");
7876 assert!(output.contains("padding: 1rem"), "{output}");
7877 assert!(output.contains("inline-size: 24px"), "{output}");
7878 }
7879
7880 #[test]
7881 fn gather_does_not_orphan_deletes_when_shorter_nest_wins() {
7882 let css = r#".custom-select button {
7883 display: flex;
7884}
7885.custom-select button:hover {
7886 color: red;
7887}
7888
7889.unrelated { color: blue }
7890
7891.custom-select::picker(select) {
7892 background: white;
7893}
7894.custom-select::picker-icon {
7895 display: none;
7896}
7897"#;
7898 let rules = &[
7899 RuleId::NestPseudoClass,
7900 RuleId::NestDescendant,
7901 RuleId::GatherRelatedSelectorRules,
7902 ];
7903 let plans = plan(css, rules);
7904 let output = apply_selected_plans(css, &plans, true).unwrap();
7905 assert!(
7906 output.contains("background: white") && output.contains("display: none"),
7907 "picker chrome must survive mixed nest+gather:\n{output}"
7908 );
7909 assert!(
7910 output.contains("display: flex") && output.contains("color: red"),
7911 "button rules must survive:\n{output}"
7912 );
7913 }
7914}