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