Skip to content

Commit

Permalink
feat(biome_css_analyze): implement noDescendingSpecificity (#4097)
Browse files Browse the repository at this point in the history
  • Loading branch information
tunamaguro authored Sep 30, 2024
1 parent b2d46a5 commit 295efb9
Show file tree
Hide file tree
Showing 22 changed files with 939 additions and 85 deletions.
164 changes: 93 additions & 71 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/biome_css_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use biome_analyze::declare_lint_group;

pub mod no_descending_specificity;
pub mod no_duplicate_custom_properties;
pub mod no_irregular_whitespace;
pub mod no_missing_var_function;
Expand All @@ -13,6 +14,7 @@ declare_lint_group! {
pub Nursery {
name : "nursery" ,
rules : [
self :: no_descending_specificity :: NoDescendingSpecificity ,
self :: no_duplicate_custom_properties :: NoDuplicateCustomProperties ,
self :: no_irregular_whitespace :: NoIrregularWhitespace ,
self :: no_missing_var_function :: NoMissingVarFunction ,
Expand Down
203 changes: 203 additions & 0 deletions crates/biome_css_analyze/src/lint/nursery/no_descending_specificity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use rustc_hash::{FxHashMap, FxHashSet};

use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource};
use biome_console::markup;
use biome_css_semantic::model::{Rule as CssSemanticRule, RuleId, SemanticModel, Specificity};
use biome_css_syntax::{AnyCssSelector, CssRoot};
use biome_rowan::TextRange;

use biome_rowan::AstNode;

use crate::services::semantic::Semantic;

declare_lint_rule! {
/// Disallow a lower specificity selector from coming after a higher specificity selector.
///
/// This rule prohibits placing selectors with lower specificity after selectors with higher specificity.
/// By maintaining the order of the source and specificity as consistently as possible, it enhances readability.
///
/// ## Examples
///
/// ### Invalid
///
/// ```css,expect_diagnostic
/// b a { color: red; }
/// a { color: red; }
/// ```
///
/// ```css,expect_diagnostic
/// a {
/// & > b { color: red; }
/// }
/// b { color: red; }
/// ```
///
/// ```css,expect_diagnostic
/// :root input {
/// color: red;
/// }
/// html input {
/// color: red;
/// }
/// ```
///
///
/// ### Valid
///
/// ```css
/// a { color: red; }
/// b a { color: red; }
/// ```
///
/// ```css
/// b { color: red; }
/// a {
/// & > b { color: red; }
/// }
/// ```
///
/// ```css
/// a:hover { color: red; }
/// a { color: red; }
/// ```
///
/// ```css
/// a b {
/// color: red;
/// }
/// /* 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 */
/// :where(a) :is(b) {
/// color: blue;
/// }
/// ```
///
pub NoDescendingSpecificity {
version: "next",
name: "noDescendingSpecificity",
language: "css",
recommended: true,
sources: &[RuleSource::Stylelint("no-descending-specificity")],
}
}

#[derive(Debug)]
pub struct DescendingSelector {
high: (TextRange, Specificity),
low: (TextRange, Specificity),
}
/// find tail selector
/// ```css
/// a b:hover {
/// ^^^^^^^
/// }
/// ```
fn find_tail_selector(selector: &AnyCssSelector) -> Option<String> {
match selector {
AnyCssSelector::CssCompoundSelector(s) => {
let simple = s
.simple_selector()
.map_or(String::new(), |s| s.syntax().text_trimmed().to_string());
let sub = s.sub_selectors().syntax().text_trimmed().to_string();

let last_selector = [simple, sub].join("");
Some(last_selector)
}
AnyCssSelector::CssComplexSelector(s) => {
s.right().as_ref().ok().and_then(find_tail_selector)
}
_ => None,
}
}

/// This function traverses the CSS rules starting from the given rule and checks for selectors that have the same tail selector.
/// For each selector, it compares its specificity with the previously encountered specificity of the same tail selector.
/// If a lower specificity selector is found after a higher specificity selector with the same tail selector, it records this as a descending selector.
fn find_descending_selector(
rule: &CssSemanticRule,
model: &SemanticModel,
visited_rules: &mut FxHashSet<RuleId>,
visited_selectors: &mut FxHashMap<String, (TextRange, Specificity)>,
descending_selectors: &mut Vec<DescendingSelector>,
) {
if visited_rules.contains(&rule.id) {
return;
} else {
visited_rules.insert(rule.id);
};

for selector in &rule.selectors {
let tail_selector = if let Some(s) = find_tail_selector(&selector.original) {
s
} else {
continue;
};

if let Some((last_textrange, last_specificity)) = visited_selectors.get(&tail_selector) {
if last_specificity > &selector.specificity {
descending_selectors.push(DescendingSelector {
high: (*last_textrange, last_specificity.clone()),
low: (selector.range, selector.specificity.clone()),
});
}
} else {
visited_selectors.insert(
tail_selector,
(selector.range, selector.specificity.clone()),
);
}
}

for child_id in &rule.child_ids {
if let Some(child_rule) = model.get_rule_by_id(*child_id) {
find_descending_selector(
child_rule,
model,
visited_rules,
visited_selectors,
descending_selectors,
);
}
}
}

impl Rule for NoDescendingSpecificity {
type Query = Semantic<CssRoot>;
type State = DescendingSelector;
type Signals = Vec<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let model = ctx.model();
let mut visited_rules = FxHashSet::default();
let mut visited_selectors = FxHashMap::default();
let mut descending_selectors = Vec::new();
for rule in model.rules() {
find_descending_selector(
rule,
model,
&mut visited_rules,
&mut visited_selectors,
&mut descending_selectors,
);
}

descending_selectors
}

fn diagnostic(_: &RuleContext<Self>, node: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
node.low.0,
markup! {
"Descending specificity selector found. This selector specificity is "{node.low.1.to_string()}
},
).detail(node.high.0, markup!(
"This selector specificity is "{node.high.1.to_string()}
))
.note(markup! {
"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."
}),
)
}
}
1 change: 1 addition & 0 deletions crates/biome_css_analyze/src/options.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
b a {
color: red;
}

a {
color: red;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
source: crates/biome_css_analyze/tests/spec_tests.rs
expression: complex_selector.invalid.css
---
# Input
```css
b a {
color: red;
}
a {
color: red;
}
```

# Diagnostics
```
complex_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
! Descending specificity selector found. This selector specificity is (0, 0, 1)
3 │ }
4 │
> 5 │ a {
^
6color: red;
7}
i This selector specificity is (0, 0, 2)
> 1 │ b a {
^^^
2color: red;
3}
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
:is(#a, a) f {
color: red;
}

:is(a, b, c, d) f {
color: red;
}

:is(#fake#fake#fake#fake#fake#fake, *) g {
color: red;
}

:where(*) g {
color: red;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
source: crates/biome_css_analyze/tests/spec_tests.rs
expression: function_pseudo_selector.invalid.css
---
# Input
```css
:is(#a, a) f {
color: red;
}
:is(a, b, c, d) f {
color: red;
}
:is(#fake#fake#fake#fake#fake#fake, *) g {
color: red;
}
:where(*) g {
color: red;
}
```

# Diagnostics
```
function_pseudo_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━
! Descending specificity selector found. This selector specificity is (0, 0, 2)
3 │ }
4 │
> 5 │ :is(a, b, c, d) f {
^^^^^^^^^^^^^^^^^
6color: red;
7}
i This selector specificity is (1, 0, 1)
> 1 │ :is(#a, a) f {
^^^^^^^^^^^^
2color: red;
3}
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
```

```
function_pseudo_selector.invalid.css:13:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━
! Descending specificity selector found. This selector specificity is (0, 0, 1)
11 │ }
12 │
> 13 │ :where(*) g {
^^^^^^^^^^^
14color: red;
15}
i This selector specificity is (6, 0, 1)
7 │ }
8 │
> 9 │ :is(#fake#fake#fake#fake#fake#fake, *) g {
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10color: red;
11}
i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
a {
& > b {
color: red;
}
}

b {
color: red;
}
Loading

0 comments on commit 295efb9

Please sign in to comment.