Skip to content

Commit 295efb9

Browse files
authored
feat(biome_css_analyze): implement noDescendingSpecificity (#4097)
1 parent b2d46a5 commit 295efb9

File tree

22 files changed

+939
-85
lines changed

22 files changed

+939
-85
lines changed

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 93 additions & 71 deletions
Large diffs are not rendered by default.

crates/biome_css_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use biome_analyze::declare_lint_group;
44

5+
pub mod no_descending_specificity;
56
pub mod no_duplicate_custom_properties;
67
pub mod no_irregular_whitespace;
78
pub mod no_missing_var_function;
@@ -13,6 +14,7 @@ declare_lint_group! {
1314
pub Nursery {
1415
name : "nursery" ,
1516
rules : [
17+
self :: no_descending_specificity :: NoDescendingSpecificity ,
1618
self :: no_duplicate_custom_properties :: NoDuplicateCustomProperties ,
1719
self :: no_irregular_whitespace :: NoIrregularWhitespace ,
1820
self :: no_missing_var_function :: NoMissingVarFunction ,
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
use rustc_hash::{FxHashMap, FxHashSet};
2+
3+
use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource};
4+
use biome_console::markup;
5+
use biome_css_semantic::model::{Rule as CssSemanticRule, RuleId, SemanticModel, Specificity};
6+
use biome_css_syntax::{AnyCssSelector, CssRoot};
7+
use biome_rowan::TextRange;
8+
9+
use biome_rowan::AstNode;
10+
11+
use crate::services::semantic::Semantic;
12+
13+
declare_lint_rule! {
14+
/// Disallow a lower specificity selector from coming after a higher specificity selector.
15+
///
16+
/// This rule prohibits placing selectors with lower specificity after selectors with higher specificity.
17+
/// By maintaining the order of the source and specificity as consistently as possible, it enhances readability.
18+
///
19+
/// ## Examples
20+
///
21+
/// ### Invalid
22+
///
23+
/// ```css,expect_diagnostic
24+
/// b a { color: red; }
25+
/// a { color: red; }
26+
/// ```
27+
///
28+
/// ```css,expect_diagnostic
29+
/// a {
30+
/// & > b { color: red; }
31+
/// }
32+
/// b { color: red; }
33+
/// ```
34+
///
35+
/// ```css,expect_diagnostic
36+
/// :root input {
37+
/// color: red;
38+
/// }
39+
/// html input {
40+
/// color: red;
41+
/// }
42+
/// ```
43+
///
44+
///
45+
/// ### Valid
46+
///
47+
/// ```css
48+
/// a { color: red; }
49+
/// b a { color: red; }
50+
/// ```
51+
///
52+
/// ```css
53+
/// b { color: red; }
54+
/// a {
55+
/// & > b { color: red; }
56+
/// }
57+
/// ```
58+
///
59+
/// ```css
60+
/// a:hover { color: red; }
61+
/// a { color: red; }
62+
/// ```
63+
///
64+
/// ```css
65+
/// a b {
66+
/// color: red;
67+
/// }
68+
/// /* This selector is overwritten by the one above it, but this is not an error because the rule only evaluates it as a compound selector */
69+
/// :where(a) :is(b) {
70+
/// color: blue;
71+
/// }
72+
/// ```
73+
///
74+
pub NoDescendingSpecificity {
75+
version: "next",
76+
name: "noDescendingSpecificity",
77+
language: "css",
78+
recommended: true,
79+
sources: &[RuleSource::Stylelint("no-descending-specificity")],
80+
}
81+
}
82+
83+
#[derive(Debug)]
84+
pub struct DescendingSelector {
85+
high: (TextRange, Specificity),
86+
low: (TextRange, Specificity),
87+
}
88+
/// find tail selector
89+
/// ```css
90+
/// a b:hover {
91+
/// ^^^^^^^
92+
/// }
93+
/// ```
94+
fn find_tail_selector(selector: &AnyCssSelector) -> Option<String> {
95+
match selector {
96+
AnyCssSelector::CssCompoundSelector(s) => {
97+
let simple = s
98+
.simple_selector()
99+
.map_or(String::new(), |s| s.syntax().text_trimmed().to_string());
100+
let sub = s.sub_selectors().syntax().text_trimmed().to_string();
101+
102+
let last_selector = [simple, sub].join("");
103+
Some(last_selector)
104+
}
105+
AnyCssSelector::CssComplexSelector(s) => {
106+
s.right().as_ref().ok().and_then(find_tail_selector)
107+
}
108+
_ => None,
109+
}
110+
}
111+
112+
/// This function traverses the CSS rules starting from the given rule and checks for selectors that have the same tail selector.
113+
/// For each selector, it compares its specificity with the previously encountered specificity of the same tail selector.
114+
/// If a lower specificity selector is found after a higher specificity selector with the same tail selector, it records this as a descending selector.
115+
fn find_descending_selector(
116+
rule: &CssSemanticRule,
117+
model: &SemanticModel,
118+
visited_rules: &mut FxHashSet<RuleId>,
119+
visited_selectors: &mut FxHashMap<String, (TextRange, Specificity)>,
120+
descending_selectors: &mut Vec<DescendingSelector>,
121+
) {
122+
if visited_rules.contains(&rule.id) {
123+
return;
124+
} else {
125+
visited_rules.insert(rule.id);
126+
};
127+
128+
for selector in &rule.selectors {
129+
let tail_selector = if let Some(s) = find_tail_selector(&selector.original) {
130+
s
131+
} else {
132+
continue;
133+
};
134+
135+
if let Some((last_textrange, last_specificity)) = visited_selectors.get(&tail_selector) {
136+
if last_specificity > &selector.specificity {
137+
descending_selectors.push(DescendingSelector {
138+
high: (*last_textrange, last_specificity.clone()),
139+
low: (selector.range, selector.specificity.clone()),
140+
});
141+
}
142+
} else {
143+
visited_selectors.insert(
144+
tail_selector,
145+
(selector.range, selector.specificity.clone()),
146+
);
147+
}
148+
}
149+
150+
for child_id in &rule.child_ids {
151+
if let Some(child_rule) = model.get_rule_by_id(*child_id) {
152+
find_descending_selector(
153+
child_rule,
154+
model,
155+
visited_rules,
156+
visited_selectors,
157+
descending_selectors,
158+
);
159+
}
160+
}
161+
}
162+
163+
impl Rule for NoDescendingSpecificity {
164+
type Query = Semantic<CssRoot>;
165+
type State = DescendingSelector;
166+
type Signals = Vec<Self::State>;
167+
type Options = ();
168+
169+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
170+
let model = ctx.model();
171+
let mut visited_rules = FxHashSet::default();
172+
let mut visited_selectors = FxHashMap::default();
173+
let mut descending_selectors = Vec::new();
174+
for rule in model.rules() {
175+
find_descending_selector(
176+
rule,
177+
model,
178+
&mut visited_rules,
179+
&mut visited_selectors,
180+
&mut descending_selectors,
181+
);
182+
}
183+
184+
descending_selectors
185+
}
186+
187+
fn diagnostic(_: &RuleContext<Self>, node: &Self::State) -> Option<RuleDiagnostic> {
188+
Some(
189+
RuleDiagnostic::new(
190+
rule_category!(),
191+
node.low.0,
192+
markup! {
193+
"Descending specificity selector found. This selector specificity is "{node.low.1.to_string()}
194+
},
195+
).detail(node.high.0, markup!(
196+
"This selector specificity is "{node.high.1.to_string()}
197+
))
198+
.note(markup! {
199+
"Descending specificity selector may not applied. Consider rearranging the order of the selectors. See "<Hyperlink href="https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity">"MDN web docs"</Hyperlink>" for more details."
200+
}),
201+
)
202+
}
203+
}

crates/biome_css_analyze/src/options.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
b a {
2+
color: red;
3+
}
4+
5+
a {
6+
color: red;
7+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
source: crates/biome_css_analyze/tests/spec_tests.rs
3+
expression: complex_selector.invalid.css
4+
---
5+
# Input
6+
```css
7+
b a {
8+
color: red;
9+
}
10+
11+
a {
12+
color: red;
13+
}
14+
15+
```
16+
17+
# Diagnostics
18+
```
19+
complex_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20+
21+
! Descending specificity selector found. This selector specificity is (0, 0, 1)
22+
23+
3 │ }
24+
4 │
25+
> 5 │ a {
26+
^
27+
6color: red;
28+
7}
29+
30+
i This selector specificity is (0, 0, 2)
31+
32+
> 1 │ b a {
33+
^^^
34+
2color: red;
35+
3}
36+
37+
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
38+
39+
40+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
:is(#a, a) f {
2+
color: red;
3+
}
4+
5+
:is(a, b, c, d) f {
6+
color: red;
7+
}
8+
9+
:is(#fake#fake#fake#fake#fake#fake, *) g {
10+
color: red;
11+
}
12+
13+
:where(*) g {
14+
color: red;
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
source: crates/biome_css_analyze/tests/spec_tests.rs
3+
expression: function_pseudo_selector.invalid.css
4+
---
5+
# Input
6+
```css
7+
:is(#a, a) f {
8+
color: red;
9+
}
10+
11+
:is(a, b, c, d) f {
12+
color: red;
13+
}
14+
15+
:is(#fake#fake#fake#fake#fake#fake, *) g {
16+
color: red;
17+
}
18+
19+
:where(*) g {
20+
color: red;
21+
}
22+
```
23+
24+
# Diagnostics
25+
```
26+
function_pseudo_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━
27+
28+
! Descending specificity selector found. This selector specificity is (0, 0, 2)
29+
30+
3 │ }
31+
4 │
32+
> 5 │ :is(a, b, c, d) f {
33+
^^^^^^^^^^^^^^^^^
34+
6color: red;
35+
7}
36+
37+
i This selector specificity is (1, 0, 1)
38+
39+
> 1 │ :is(#a, a) f {
40+
^^^^^^^^^^^^
41+
2color: red;
42+
3}
43+
44+
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
45+
46+
47+
```
48+
49+
```
50+
function_pseudo_selector.invalid.css:13:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━
51+
52+
! Descending specificity selector found. This selector specificity is (0, 0, 1)
53+
54+
11 │ }
55+
12 │
56+
> 13 │ :where(*) g {
57+
^^^^^^^^^^^
58+
14color: red;
59+
15}
60+
61+
i This selector specificity is (6, 0, 1)
62+
63+
7 │ }
64+
8 │
65+
> 9 │ :is(#fake#fake#fake#fake#fake#fake, *) g {
66+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
67+
10color: red;
68+
11}
69+
70+
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
71+
72+
73+
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
a {
2+
& > b {
3+
color: red;
4+
}
5+
}
6+
7+
b {
8+
color: red;
9+
}

0 commit comments

Comments
 (0)