diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index 33ed6308178b..03bed2084de5 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2674,6 +2674,9 @@ pub struct Nursery { #[doc = "Disallow two keys with the same name inside a JSON object."] #[serde(skip_serializing_if = "Option::is_none")] pub no_duplicate_json_keys: Option>, + #[doc = "Disallow duplicate selectors. This rule checks for two types of duplication:"] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_duplicate_selectors: Option>, #[doc = "Disallow duplicate selectors within keyframe blocks."] #[serde(skip_serializing_if = "Option::is_none")] pub no_duplicate_selectors_keyframe_block: @@ -2777,6 +2780,7 @@ impl Nursery { "noDuplicateElseIf", "noDuplicateFontNames", "noDuplicateJsonKeys", + "noDuplicateSelectors", "noDuplicateSelectorsKeyframeBlock", "noEvolvingAny", "noFlatMapIdentity", @@ -2809,6 +2813,7 @@ impl Nursery { "noDuplicateElseIf", "noDuplicateFontNames", "noDuplicateJsonKeys", + "noDuplicateSelectors", "noDuplicateSelectorsKeyframeBlock", "noEvolvingAny", "noFlatMapIdentity", @@ -2831,12 +2836,13 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -2872,6 +2878,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -2933,126 +2940,131 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.no_duplicate_selectors_keyframe_block.as_ref() { + if let Some(rule) = self.no_duplicate_selectors.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_selectors_keyframe_block.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_flat_map_identity.as_ref() { + if let Some(rule) = self.no_evolving_any.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.no_important_in_keyframe.as_ref() { + if let Some(rule) = self.no_flat_map_identity.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } } - if let Some(rule) = self.no_misplaced_assertion.as_ref() { + if let Some(rule) = self.no_important_in_keyframe.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } - if let Some(rule) = self.no_nodejs_modules.as_ref() { + if let Some(rule) = self.no_misplaced_assertion.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } } - if let Some(rule) = self.no_react_specific_props.as_ref() { + if let Some(rule) = self.no_nodejs_modules.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_react_specific_props.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_undeclared_dependencies.as_ref() { + if let Some(rule) = self.no_restricted_imports.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_unknown_function.as_ref() { + if let Some(rule) = self.no_undeclared_dependencies.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_unknown_property.as_ref() { + if let Some(rule) = self.no_unknown_function.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_unknown_selector_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_property.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_unknown_unit.as_ref() { + if let Some(rule) = self.no_unknown_selector_pseudo_element.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.no_unmatchable_anb_selector.as_ref() { + if let Some(rule) = self.no_unknown_unit.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.no_useless_string_concat.as_ref() { + if let Some(rule) = self.no_unmatchable_anb_selector.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.no_useless_undefined_initialization.as_ref() { + if let Some(rule) = self.no_useless_string_concat.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_array_literals.as_ref() { + if let Some(rule) = self.no_useless_undefined_initialization.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.use_consistent_builtin_instantiation.as_ref() { + if let Some(rule) = self.use_array_literals.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_default_switch_clause.as_ref() { + if let Some(rule) = self.use_consistent_builtin_instantiation.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.use_explicit_length_check.as_ref() { + if let Some(rule) = self.use_default_switch_clause.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.use_focusable_interactive.as_ref() { + if let Some(rule) = self.use_explicit_length_check.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - if let Some(rule) = self.use_generic_font_names.as_ref() { + if let Some(rule) = self.use_focusable_interactive.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_generic_font_names.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_import_restrictions.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -3102,126 +3114,131 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.no_duplicate_selectors_keyframe_block.as_ref() { + if let Some(rule) = self.no_duplicate_selectors.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_selectors_keyframe_block.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_flat_map_identity.as_ref() { + if let Some(rule) = self.no_evolving_any.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.no_important_in_keyframe.as_ref() { + if let Some(rule) = self.no_flat_map_identity.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } } - if let Some(rule) = self.no_misplaced_assertion.as_ref() { + if let Some(rule) = self.no_important_in_keyframe.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } - if let Some(rule) = self.no_nodejs_modules.as_ref() { + if let Some(rule) = self.no_misplaced_assertion.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } } - if let Some(rule) = self.no_react_specific_props.as_ref() { + if let Some(rule) = self.no_nodejs_modules.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_react_specific_props.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_undeclared_dependencies.as_ref() { + if let Some(rule) = self.no_restricted_imports.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_unknown_function.as_ref() { + if let Some(rule) = self.no_undeclared_dependencies.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_unknown_property.as_ref() { + if let Some(rule) = self.no_unknown_function.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_unknown_selector_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_property.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_unknown_unit.as_ref() { + if let Some(rule) = self.no_unknown_selector_pseudo_element.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.no_unmatchable_anb_selector.as_ref() { + if let Some(rule) = self.no_unknown_unit.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.no_useless_string_concat.as_ref() { + if let Some(rule) = self.no_unmatchable_anb_selector.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.no_useless_undefined_initialization.as_ref() { + if let Some(rule) = self.no_useless_string_concat.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_array_literals.as_ref() { + if let Some(rule) = self.no_useless_undefined_initialization.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } - if let Some(rule) = self.use_consistent_builtin_instantiation.as_ref() { + if let Some(rule) = self.use_array_literals.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_default_switch_clause.as_ref() { + if let Some(rule) = self.use_consistent_builtin_instantiation.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } } - if let Some(rule) = self.use_explicit_length_check.as_ref() { + if let Some(rule) = self.use_default_switch_clause.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.use_focusable_interactive.as_ref() { + if let Some(rule) = self.use_explicit_length_check.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - if let Some(rule) = self.use_generic_font_names.as_ref() { + if let Some(rule) = self.use_focusable_interactive.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_generic_font_names.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_import_restrictions.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3294,6 +3311,10 @@ impl Nursery { .no_duplicate_json_keys .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noDuplicateSelectors" => self + .no_duplicate_selectors + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noDuplicateSelectorsKeyframeBlock" => self .no_duplicate_selectors_keyframe_block .as_ref() diff --git a/crates/biome_css_analyze/src/lint/nursery.rs b/crates/biome_css_analyze/src/lint/nursery.rs index c7ddaee16c99..b9cdbb2e2597 100644 --- a/crates/biome_css_analyze/src/lint/nursery.rs +++ b/crates/biome_css_analyze/src/lint/nursery.rs @@ -6,6 +6,7 @@ pub mod no_color_invalid_hex; pub mod no_css_empty_block; pub mod no_duplicate_at_import_rules; pub mod no_duplicate_font_names; +pub mod no_duplicate_selectors; pub mod no_duplicate_selectors_keyframe_block; pub mod no_important_in_keyframe; pub mod no_unknown_function; @@ -23,6 +24,7 @@ declare_group! { self :: no_css_empty_block :: NoCssEmptyBlock , self :: no_duplicate_at_import_rules :: NoDuplicateAtImportRules , self :: no_duplicate_font_names :: NoDuplicateFontNames , + self :: no_duplicate_selectors :: NoDuplicateSelectors , self :: no_duplicate_selectors_keyframe_block :: NoDuplicateSelectorsKeyframeBlock , self :: no_important_in_keyframe :: NoImportantInKeyframe , self :: no_unknown_function :: NoUnknownFunction , diff --git a/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors.rs b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors.rs new file mode 100644 index 000000000000..6d44fcaaf721 --- /dev/null +++ b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors.rs @@ -0,0 +1,395 @@ +use std::borrow::Borrow; +use std::collections::HashSet; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::vec; + +use biome_analyze::Ast; +use biome_analyze::{context::RuleContext, declare_rule, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_css_syntax::{ + AnyCssAtRule, AnyCssRelativeSelector, AnyCssRule, AnyCssSelector, CssComplexSelector, + CssRelativeSelector, CssRelativeSelectorList, CssRoot, CssSelectorList, CssSyntaxNode, +}; +use biome_deserialize_macros::Deserializable; +use biome_rowan::{declare_node_union, AstNode, SyntaxNodeCast}; + +use serde::{Deserialize, Serialize}; + +declare_rule! { + /// Disallow duplicate selectors. This rule checks for two types of duplication: + /// - Duplication of a selector list within a stylesheet, e.g. `a, b {} a, b {}`. Duplicates are found even if the selectors come in different orders or have different spacing, e.g. `a d, b > c {} b>c, a d {}`. + /// - Duplication of a single selector with a rule's selector list, e.g. `a, b, a {}`. (See options below, this is disabled by default) + /// + /// The same selector is allowed to repeat in the following circumstances: + /// - It is used in different selector lists, e.g. `a {} a, b {}`. + /// - The duplicates are in rules with different parent nodes, e.g. inside and outside of a media query. + /// + /// This rule resolves nested selectors. So `a b {} a { & b {} }` counts as a problem, because the resolved selectors end up with a duplicate. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```css,expect_diagnostic + /// .abc, + /// .def, + /// .abc { /* declaration */ } + /// ``` + /// + /// ### Valid + /// + /// ``` + /// .foo { /* declaration */ } + /// .bar { /* declaration */ } + /// ``` + /// + /// ## Options + /// + /// If true, disallow duplicate selectors within selector lists. The following settings: + /// + /// ```json5, ignore + /// { + /// "noDuplicateSelectors": { + /// "options": { + /// "disallowInList": true + /// } + /// } + /// } + /// ``` + /// + /// Will result in the following failing: + /// + /// ```css, ignore + /// input, textarea {}; textarea {} + /// ``` + /// + pub NoDuplicateSelectors { + version: "next", + name: "noDuplicateSelectors", + recommended: true, + sources: &[RuleSource::Stylelint("no-duplicate-selectors")], + } +} + +#[derive(Debug, Default, Clone, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NoDuplicateSelectorsOptions { + /// If set to `true` this rule will check for duplicate selectors within selector lists. + pub disallow_in_list: bool, +} + +declare_node_union! { + pub AnySelectorLike = AnyCssSelector | AnyCssRelativeSelector +} + +/// Object containing the resolved selector and the relative node for that resolved selector. +/// +/// This struct has a hash function which returns the hash of `selector_node`. +#[derive(Debug, Eq)] +struct ResolvedSelector { + selector_text: String, + selector_node: CssSyntaxNode, +} + +impl PartialEq for ResolvedSelector { + fn eq(&self, other: &ResolvedSelector) -> bool { + self.selector_text == other.selector_text + } +} +impl Hash for ResolvedSelector { + fn hash(&self, state: &mut H) { + self.selector_text.hash(state); + } +} +impl Borrow for ResolvedSelector { + fn borrow(&self) -> &String { + &self.selector_text + } +} + +pub struct DuplicateSelector { + first: CssSyntaxNode, + duplicate: CssSyntaxNode, +} + +impl Rule for NoDuplicateSelectors { + type Query = Ast; + type State = DuplicateSelector; + type Signals = Vec; + type Options = NoDuplicateSelectorsOptions; + + fn run(ctx: &RuleContext) -> Vec { + let node = ctx.query(); + let options = ctx.options(); + + let mut resolved_list: HashSet = HashSet::new(); + let mut output: Vec = vec![]; + + if options.disallow_in_list { + let selectors = node + .rules() + .syntax() + .descendants() + .filter_map(|x| AnySelectorLike::cast_ref(&x)); + + for (selector, selector_list) in selectors.filter_map(|selector| { + let parent = selector.clone().into_syntax().parent()?; + if parent.clone().cast::().is_some() + || parent.clone().cast::().is_some() + { + return None; + } + Some((selector, parent)) + }) { + let Some(this_rule) = selector_list.parent() else { + continue; + }; + + let selector_text = match selector.clone() { + AnySelectorLike::AnyCssSelector(selector) => { + normalize_complex_selector(selector) + } + AnySelectorLike::AnyCssRelativeSelector(selector) => selector.text(), + }; + + for r in resolve_nested_selectors(selector_text, this_rule) { + let split: Vec<&str> = r.split_whitespace().collect(); + let normalized = split.join(" ").to_lowercase(); + + if let Some(first) = resolved_list.get(&normalized) { + output.push(DuplicateSelector { + first: first.selector_node.clone(), + duplicate: selector.clone().into_syntax(), + }); + } else { + resolved_list.insert(ResolvedSelector { + selector_text: normalized.clone(), + selector_node: selector.clone().into_syntax(), + }); + } + } + } + } else { + // Union node with CssSelectorList and CssRelativeSelectorList does not have overlapping From/Into + let selector_lists = node.rules().syntax().descendants().filter(|x| { + x.clone().cast::().is_some() + || x.clone().cast::().is_some() + }); + + for (selector_list, rule) in selector_lists.filter_map(|selector_list| { + let parent = selector_list.clone().parent()?; + Some((selector_list, parent)) + }) { + let mut this_list_resolved_list: HashSet = HashSet::new(); + + let mut selector_list_mapped: Vec = selector_list + .clone() + .children() + .filter_map(|child| { + let selector_text = if let Some(selector) = AnyCssSelector::cast_ref(&child) + { + normalize_complex_selector(selector.clone()) + } else { + child + .clone() + .cast::() + .unwrap() + .text() + }; + + if let Some(first) = this_list_resolved_list.get(&selector_text) { + output.push(DuplicateSelector { + first: first.selector_node.clone(), + duplicate: child.clone(), + }); + return None; + } + + this_list_resolved_list.insert(ResolvedSelector { + selector_text: selector_text.clone(), + selector_node: child, + }); + Some(selector_text) + }) + .collect(); + selector_list_mapped.sort(); + + for r in resolve_nested_selectors(selector_list_mapped.join(","), rule) { + let split: Vec<&str> = r.split_whitespace().collect(); + let normalized = split.join(" ").to_lowercase(); + if let Some(first) = resolved_list.get(&normalized) { + output.push(DuplicateSelector { + first: first.selector_node.clone(), + duplicate: selector_list.clone(), + }); + } else { + resolved_list.insert(ResolvedSelector { + selector_text: normalized.clone(), + selector_node: selector_list.clone(), + }); + } + } + } + } + output + } + + fn diagnostic(_: &RuleContext, node: &Self::State) -> Option { + let duplicate_text = if let Some(duplicate) = AnySelectorLike::cast_ref(&node.duplicate) { + duplicate.text() + } else if let Some(duplicate) = CssSelectorList::cast_ref(&node.duplicate) { + duplicate.text() + } else if let Some(duplicate) = CssRelativeSelectorList::cast_ref(&node.duplicate) { + duplicate.text() + } else { + node.duplicate.to_string() + }; + + Some( + RuleDiagnostic::new( + rule_category!(), + node.duplicate.text_trimmed_range(), + markup! { + "Duplicate selectors may result in unintentionally overriding rules: "{ duplicate_text } + }, + ) + .detail(node.first.text_trimmed_range(), "Please consider moving the rule's contents to the first occurence:") + .note(markup! { + "Remove duplicate selectors within the rule" + }), + ) + } +} + +/// Resolves nested selectors into an equivalent flat selector. +/// If there is no parent rule, return the selector string originally passed. +/// E.g. +/// ```css, ignore +/// a { b { c { /* declaration */ } } } +/// ``` +/// +/// is resolved to: +/// ```css, ignore +/// a b c { +/// /* declaration */ +/// } +/// ``` +/// +/// When trying to resolve this_rule of type [AnyCssAtRule], this function will generate a hash to replace the selector name: +/// ```css, ignore +/// @media print { +/// selector { /* declaration */} +/// } +/// ``` +/// +/// is resolved to: +/// ```css, ignore +/// selector { /* declaration */} +/// ``` +/// +/// [AnyCssAtRule] is resolved based on the text range. +/// The same rule will not be resolved to the same hash because these are considered to belong to a separate context. +/// +/// Returns the resolved selector as a string. +fn resolve_nested_selectors(selector: String, this_rule: CssSyntaxNode) -> Vec { + let mut parent_selectors: Vec = vec![]; + let parent_rule = this_rule.parent().and_then(|parent| parent.grand_parent()); + + match &parent_rule { + None => vec![selector], + Some(parent_rule) => { + if let Some(parent_rule) = AnyCssAtRule::cast_ref(parent_rule) { + let mut hasher = DefaultHasher::new(); + parent_rule.range().hash(&mut hasher); + // Each @rule is unique scope + // Use a hash to create the comparable scope + parent_selectors.push(hasher.finish().to_string()); + } else if let Some(parent_rule) = AnyCssRule::cast_ref(parent_rule) { + match parent_rule { + AnyCssRule::CssNestedQualifiedRule(parent_rule) => { + parent_selectors.extend( + parent_rule + .prelude() + .into_iter() + .filter_map(|selector| selector.ok()) + .map(|selector| selector.text()), + ); + } + AnyCssRule::CssQualifiedRule(parent_rule) => { + parent_selectors.extend( + parent_rule + .prelude() + .into_iter() + .filter_map(|selector| selector.ok()) + .map(|selector| selector.text()), + ); + } + _ => { + // Bogus rules are not handled + // AtRule is handled by AnyCssAtRule above + } + } + } + + let resolved_selectors: Vec = + parent_selectors + .iter() + .fold(vec![], |result: Vec, parent_selector| { + if selector.contains('&') { + let resolved_parent_selectors = resolve_nested_selectors( + parent_selector.to_string(), + parent_rule.clone(), + ); + let resolved = resolved_parent_selectors + .into_iter() + .map(|newly_resolved| selector.replace('&', &newly_resolved)) + .collect(); + [result, resolved].concat() + } else { + let combined_selectors = parent_selector.to_owned() + " " + &selector; + let resolved = + resolve_nested_selectors(combined_selectors, parent_rule.clone()); + [result, resolved].concat() + } + }); + if !resolved_selectors.is_empty() { + return resolved_selectors; + } + vec![selector] + } + } +} + +/// Checks if [AnyCssSelector] can be cast to [CssComplexSelector]. +/// If it is able to cast, trim the combinator, e.g. +/// +/// ``` css, ignore +/// a > b, c > d { /* declaration */ } +/// ``` +/// +/// is normalized to: +/// ``` css, ignore +/// a>b, c>d { /* declaration */ } +/// ``` +/// +/// It will return `selector.text()` if it is unable to cast. +/// Returns the selector as a string. +fn normalize_complex_selector(selector: AnyCssSelector) -> String { + let mut selector_text = String::new(); + + if let AnyCssSelector::CssComplexSelector(complex_selector) = selector { + if let Ok(left) = complex_selector.left() { + selector_text.push_str(&left.text()); + } + if let Ok(combinator) = complex_selector.combinator() { + let combinator = combinator.text_trimmed(); + selector_text.push_str(combinator); + } + if let Ok(right) = complex_selector.right() { + selector_text.push_str(&right.text()); + } + return selector_text; + } + selector.text() +} diff --git a/crates/biome_css_analyze/src/options.rs b/crates/biome_css_analyze/src/options.rs index 1f9b8d11202a..054eb9a669b9 100644 --- a/crates/biome_css_analyze/src/options.rs +++ b/crates/biome_css_analyze/src/options.rs @@ -9,6 +9,8 @@ pub type NoCssEmptyBlock = pub type NoDuplicateAtImportRules = < lint :: nursery :: no_duplicate_at_import_rules :: NoDuplicateAtImportRules as biome_analyze :: Rule > :: Options ; pub type NoDuplicateFontNames = ::Options; +pub type NoDuplicateSelectors = + ::Options; pub type NoDuplicateSelectorsKeyframeBlock = < lint :: nursery :: no_duplicate_selectors_keyframe_block :: NoDuplicateSelectorsKeyframeBlock as biome_analyze :: Rule > :: Options ; pub type NoImportantInKeyframe = < lint :: nursery :: no_important_in_keyframe :: NoImportantInKeyframe as biome_analyze :: Rule > :: Options ; pub type NoUnknownFunction = diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css new file mode 100644 index 000000000000..83019dd5248d --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css @@ -0,0 +1,19 @@ +/* valid cases: should not error */ +th, td {}; tr {} +*::a {}; a {} +*::c, b {}; c {} +d e, f {}; d {} + +/* duplicate within a grouping selector */ +input, textarea {}; textarea {} + +/* duplicate within a grouping selector, reversed */ +button {}; selector, button {} + +/* duplicate within a grouping selector. multiline */ +span, div {}; + h1, section {}; +h1 {} + +/* test regular case for regression */ +v w, x>test {} v { w {} } x > test {} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css.snap.new b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css.snap.new new file mode 100644 index 000000000000..437cbd8af568 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.css.snap.new @@ -0,0 +1,142 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +assertion_line: 83 +expression: disallowInList.css +--- +# Input +```css +/* valid cases: should not error */ +th, td {}; tr {} +*::a {}; a {} +*::c, b {}; c {} +d e, f {}; d {} + +/* duplicate within a grouping selector */ +input, textarea {}; textarea {} + +/* duplicate within a grouping selector, reversed */ +button {}; selector, button {} + +/* duplicate within a grouping selector. multiline */ +span, div {}; + h1, section {}; +h1 {} + +/* test regular case for regression */ +v w, x>test {} v { w {} } x > test {} +``` + +# Diagnostics +``` +disallowInList.css:8:21 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: textarea + + 7 │ /* duplicate within a grouping selector */ + > 8 │ input, textarea {}; textarea {} + │ ^^^^^^^^ + 9 │ + 10 │ /* duplicate within a grouping selector, reversed */ + + i Please consider moving the rule's contents to the first occurence: + + 7 │ /* duplicate within a grouping selector */ + > 8 │ input, textarea {}; textarea {} + │ ^^^^^^^^ + 9 │ + 10 │ /* duplicate within a grouping selector, reversed */ + + i Remove duplicate selectors within the rule + + +``` + +``` +disallowInList.css:11:22 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: button + + 10 │ /* duplicate within a grouping selector, reversed */ + > 11 │ button {}; selector, button {} + │ ^^^^^^ + 12 │ + 13 │ /* duplicate within a grouping selector. multiline */ + + i Please consider moving the rule's contents to the first occurence: + + 10 │ /* duplicate within a grouping selector, reversed */ + > 11 │ button {}; selector, button {} + │ ^^^^^^ + 12 │ + 13 │ /* duplicate within a grouping selector. multiline */ + + i Remove duplicate selectors within the rule + + +``` + +``` +disallowInList.css:16:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: h1 + + 14 │ span, div {}; + 15 │ h1, section {}; + > 16 │ h1 {} + │ ^^ + 17 │ + 18 │ /* test regular case for regression */ + + i Please consider moving the rule's contents to the first occurence: + + 13 │ /* duplicate within a grouping selector. multiline */ + 14 │ span, div {}; + > 15 │ h1, section {}; + │ ^^ + 16 │ h1 {} + 17 │ + + i Remove duplicate selectors within the rule + + +``` + +``` +disallowInList.css:19:20 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: w + + 18 │ /* test regular case for regression */ + > 19 │ v w, x>test {} v { w {} } x > test {} + │ ^ + + i Please consider moving the rule's contents to the first occurence: + + 18 │ /* test regular case for regression */ + > 19 │ v w, x>test {} v { w {} } x > test {} + │ ^^^ + + i Remove duplicate selectors within the rule + + +``` + +``` +disallowInList.css:19:27 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: x > test + + 18 │ /* test regular case for regression */ + > 19 │ v w, x>test {} v { w {} } x > test {} + │ ^^^^^^^^ + + i Please consider moving the rule's contents to the first occurence: + + 18 │ /* test regular case for regression */ + > 19 │ v w, x>test {} v { w {} } x > test {} + │ ^^^^^^ + + i Remove duplicate selectors within the rule + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.options.json b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.options.json new file mode 100644 index 000000000000..1c5b1b9db7d2 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/disallowInList.options.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "nursery": { + "noDuplicateSelectors": { + "level": "error", + "options": { + "disallowInList": true + } + } + } + } + } +} diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css new file mode 100644 index 000000000000..a7615dcec711 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css @@ -0,0 +1,51 @@ +/* duplicate within one rule's selector list */ +a, a {} + +/* duplicate within one rule's selector list. multiline */ +b, +b {} + +/* duplicate within one rule's selector list. multiline */ +c, +d, +d {} + +/* duplicated selectors within one rule's selector list. 2 duplicates */ +.e, +.e, +.e {} + +/* duplicate simple selectors with another rule between */ +f {} g {} f {} + +/* duplicate simple selectors with another rule between */ +h {} +i {} +h {} + +/* duplicate selector lists with different order */ +j, k {} k, j {} + +/* duplicate selectors with multiple components */ +m n {} +m n {} + +/* essentially duplicate selector lists with varied spacing */ +.foo p, q > .bar, +#baz {} + #baz, + .foo p,q>.bar {} + +/* duplicate within a media query, in the same rule */ +s {} +@media print { s, s {} } + +/* duplicate within a media query, in different rules */ +t {} +@media screen and (min-width: 900px) { t {} t {} } + +/* duplicate caused by nesting */ +v w {} v { w {} } + +/* duplicate caused by &-parent selector */ +x { & {} } \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css.snap.new b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css.snap.new new file mode 100644 index 000000000000..91a45dd9fa0b --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/invalid.css.snap.new @@ -0,0 +1,409 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +assertion_line: 83 +expression: invalid.css +--- +# Input +```css +/* duplicate within one rule's selector list */ +a, a {} + +/* duplicate within one rule's selector list. multiline */ +b, +b {} + +/* duplicate within one rule's selector list. multiline */ +c, +d, +d {} + +/* duplicated selectors within one rule's selector list. 2 duplicates */ +.e, +.e, +.e {} + +/* duplicate simple selectors with another rule between */ +f {} g {} f {} + +/* duplicate simple selectors with another rule between */ +h {} +i {} +h {} + +/* duplicate selector lists with different order */ +j, k {} k, j {} + +/* duplicate selectors with multiple components */ +m n {} +m n {} + +/* essentially duplicate selector lists with varied spacing */ +.foo p, q > .bar, +#baz {} + #baz, + .foo p,q>.bar {} + +/* duplicate within a media query, in the same rule */ +s {} +@media print { s, s {} } + +/* duplicate within a media query, in different rules */ +t {} +@media screen and (min-width: 900px) { t {} t {} } + +/* duplicate caused by nesting */ +v w {} v { w {} } + +/* duplicate caused by &-parent selector */ +x { & {} } +``` + +# Diagnostics +``` +invalid.css:2:4 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: a + + 1 │ /* duplicate within one rule's selector list */ + > 2 │ a, a {} + │ ^ + 3 │ + 4 │ /* duplicate within one rule's selector list. multiline */ + + i Please consider moving the rule's contents to the first occurence: + + 1 │ /* duplicate within one rule's selector list */ + > 2 │ a, a {} + │ ^ + 3 │ + 4 │ /* duplicate within one rule's selector list. multiline */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:6:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: b + + 4 │ /* duplicate within one rule's selector list. multiline */ + 5 │ b, + > 6 │ b {} + │ ^ + 7 │ + 8 │ /* duplicate within one rule's selector list. multiline */ + + i Please consider moving the rule's contents to the first occurence: + + 4 │ /* duplicate within one rule's selector list. multiline */ + > 5 │ b, + │ ^ + 6 │ b {} + 7 │ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:11:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: d + + 9 │ c, + 10 │ d, + > 11 │ d {} + │ ^ + 12 │ + 13 │ /* duplicated selectors within one rule's selector list. 2 duplicates */ + + i Please consider moving the rule's contents to the first occurence: + + 8 │ /* duplicate within one rule's selector list. multiline */ + 9 │ c, + > 10 │ d, + │ ^ + 11 │ d {} + 12 │ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:15:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: .e + + 13 │ /* duplicated selectors within one rule's selector list. 2 duplicates */ + 14 │ .e, + > 15 │ .e, + │ ^^ + 16 │ .e {} + 17 │ + + i Please consider moving the rule's contents to the first occurence: + + 13 │ /* duplicated selectors within one rule's selector list. 2 duplicates */ + > 14 │ .e, + │ ^^ + 15 │ .e, + 16 │ .e {} + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:16:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: .e + + 14 │ .e, + 15 │ .e, + > 16 │ .e {} + │ ^^ + 17 │ + 18 │ /* duplicate simple selectors with another rule between */ + + i Please consider moving the rule's contents to the first occurence: + + 13 │ /* duplicated selectors within one rule's selector list. 2 duplicates */ + > 14 │ .e, + │ ^^ + 15 │ .e, + 16 │ .e {} + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:19:11 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: f + + 18 │ /* duplicate simple selectors with another rule between */ + > 19 │ f {} g {} f {} + │ ^ + 20 │ + 21 │ /* duplicate simple selectors with another rule between */ + + i Please consider moving the rule's contents to the first occurence: + + 18 │ /* duplicate simple selectors with another rule between */ + > 19 │ f {} g {} f {} + │ ^ + 20 │ + 21 │ /* duplicate simple selectors with another rule between */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:24:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: h + + 22 │ h {} + 23 │ i {} + > 24 │ h {} + │ ^ + 25 │ + 26 │ /* duplicate selector lists with different order */ + + i Please consider moving the rule's contents to the first occurence: + + 21 │ /* duplicate simple selectors with another rule between */ + > 22 │ h {} + │ ^ + 23 │ i {} + 24 │ h {} + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:27:9 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: k, j + + 26 │ /* duplicate selector lists with different order */ + > 27 │ j, k {} k, j {} + │ ^^^^ + 28 │ + 29 │ /* duplicate selectors with multiple components */ + + i Please consider moving the rule's contents to the first occurence: + + 26 │ /* duplicate selector lists with different order */ + > 27 │ j, k {} k, j {} + │ ^^^^ + 28 │ + 29 │ /* duplicate selectors with multiple components */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:31:1 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: m n + + 29 │ /* duplicate selectors with multiple components */ + 30 │ m n {} + > 31 │ m n {} + │ ^^^ + 32 │ + 33 │ /* essentially duplicate selector lists with varied spacing */ + + i Please consider moving the rule's contents to the first occurence: + + 29 │ /* duplicate selectors with multiple components */ + > 30 │ m n {} + │ ^^^ + 31 │ m n {} + 32 │ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:36:3 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: #baz, + .foo p,q>.bar + + 34 │ .foo p, q > .bar, + 35 │ #baz {} + > 36 │ #baz, + │ ^^^^^ + > 37 │ .foo p,q>.bar {} + │ ^^^^^^^^^^^^^^^^^ + 38 │ + 39 │ /* duplicate within a media query, in the same rule */ + + i Please consider moving the rule's contents to the first occurence: + + 33 │ /* essentially duplicate selector lists with varied spacing */ + > 34 │ .foo p, q > .bar, + │ ^^^^^^^^^^^^^^^^^^^ + > 35 │ #baz {} + │ ^^^^ + 36 │ #baz, + 37 │ .foo p,q>.bar {} + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:41:19 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: s + + 39 │ /* duplicate within a media query, in the same rule */ + 40 │ s {} + > 41 │ @media print { s, s {} } + │ ^ + 42 │ + 43 │ /* duplicate within a media query, in different rules */ + + i Please consider moving the rule's contents to the first occurence: + + 39 │ /* duplicate within a media query, in the same rule */ + 40 │ s {} + > 41 │ @media print { s, s {} } + │ ^ + 42 │ + 43 │ /* duplicate within a media query, in different rules */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:45:45 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: t + + 43 │ /* duplicate within a media query, in different rules */ + 44 │ t {} + > 45 │ @media screen and (min-width: 900px) { t {} t {} } + │ ^ + 46 │ + 47 │ /* duplicate caused by nesting */ + + i Please consider moving the rule's contents to the first occurence: + + 43 │ /* duplicate within a media query, in different rules */ + 44 │ t {} + > 45 │ @media screen and (min-width: 900px) { t {} t {} } + │ ^ + 46 │ + 47 │ /* duplicate caused by nesting */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:48:12 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: w + + 47 │ /* duplicate caused by nesting */ + > 48 │ v w {} v { w {} } + │ ^ + 49 │ + 50 │ /* duplicate caused by &-parent selector */ + + i Please consider moving the rule's contents to the first occurence: + + 47 │ /* duplicate caused by nesting */ + > 48 │ v w {} v { w {} } + │ ^^^ + 49 │ + 50 │ /* duplicate caused by &-parent selector */ + + i Remove duplicate selectors within the rule + + +``` + +``` +invalid.css:51:5 lint/nursery/noDuplicateSelectors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Duplicate selectors may result in unintentionally overriding rules: & + + 50 │ /* duplicate caused by &-parent selector */ + > 51 │ x { & {} } + │ ^ + + i Please consider moving the rule's contents to the first occurence: + + 50 │ /* duplicate caused by &-parent selector */ + > 51 │ x { & {} } + │ ^ + + i Remove duplicate selectors within the rule + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css new file mode 100644 index 000000000000..51e77df7653c --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css @@ -0,0 +1,42 @@ +/* no duplicates */ +a {} b {} c {} d, e, f {} + +/* duplicate inside media query */ +g {} +@media print { g {} } + +/* duplicates inside separate media query */ +@media print { + gg.dev {} +} +@media print { + gg.dev {} +} + +/* duplicate inside keyframes */ +@keyframes h { 0% {} } +@keyframes h { 0% {} } + +/* duplicates inside nested rules */ +i { i { i {} } } + +/* selectors using parts of other selectors */ +.foo .bar {} +.foo {} +.bar {} +.bar .foo {} + +/* selectors reused in other non-equivalent selector lists */ +j {} j, k {} +u {} v, u {} + +/* nested resolution */ +m n { top: 0; } m { n, p { color: pink; } } + +@mixin abc { &:hover {} } @mixin xyz { &:hover {} } + +/* pass if default setting i.e. disallowInList: false */ +ul, ol {} ul {} + +/* attribute selector */ +[disabled].goo, [disabled] .goo {} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css.snap.new b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css.snap.new new file mode 100644 index 000000000000..6b02fb5775b7 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectors/valid.css.snap.new @@ -0,0 +1,50 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +assertion_line: 83 +expression: valid.css +--- +# Input +```css +/* no duplicates */ +a {} b {} c {} d, e, f {} + +/* duplicate inside media query */ +g {} +@media print { g {} } + +/* duplicates inside separate media query */ +@media print { + gg.dev {} +} +@media print { + gg.dev {} +} + +/* duplicate inside keyframes */ +@keyframes h { 0% {} } +@keyframes h { 0% {} } + +/* duplicates inside nested rules */ +i { i { i {} } } + +/* selectors using parts of other selectors */ +.foo .bar {} +.foo {} +.bar {} +.bar .foo {} + +/* selectors reused in other non-equivalent selector lists */ +j {} j, k {} +u {} v, u {} + +/* nested resolution */ +m n { top: 0; } m { n, p { color: pink; } } + +@mixin abc { &:hover {} } @mixin xyz { &:hover {} } + +/* pass if default setting i.e. disallowInList: false */ +ul, ol {} ul {} + +/* attribute selector */ +[disabled].goo, [disabled] .goo {} +``` diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 2eaa80a08ca8..343c113f646f 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -119,6 +119,7 @@ define_categories! { "lint/nursery/noDuplicateElseIf": "https://biomejs.dev/linter/rules/no-duplicate-else-if", "lint/nursery/noDuplicateFontNames": "https://biomejs.dev/linter/rules/no-font-family-duplicate-names", "lint/nursery/noDuplicateJsonKeys": "https://biomejs.dev/linter/rules/no-duplicate-json-keys", + "lint/nursery/noDuplicateSelectors": "https://biomejs.dev/linter/rules/no-duplicate-selectors", "lint/nursery/noDuplicateSelectorsKeyframeBlock": "https://biomejs.dev/linter/rules/no-duplicate-selectors-keyframe-block", "lint/nursery/noEvolvingAny": "https://biomejs.dev/linter/rules/no-evolving-any", "lint/nursery/noFlatMapIdentity": "https://biomejs.dev/linter/rules/no-flat-map-identity", diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 9e6b5dfa7258..da9ba8684f14 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -944,6 +944,10 @@ export interface Nursery { * Disallow two keys with the same name inside a JSON object. */ noDuplicateJsonKeys?: RuleConfiguration_for_Null; + /** + * Disallow duplicate selectors. This rule checks for two types of duplication: + */ + noDuplicateSelectors?: RuleConfiguration_for_NoDuplicateSelectorsOptions; /** * Disallow duplicate selectors within keyframe blocks. */ @@ -1582,6 +1586,9 @@ export type RuleConfiguration_for_DeprecatedHooksOptions = export type RuleConfiguration_for_NoCssEmptyBlockOptions = | RulePlainConfiguration | RuleWithOptions_for_NoCssEmptyBlockOptions; +export type RuleConfiguration_for_NoDuplicateSelectorsOptions = + | RulePlainConfiguration + | RuleWithOptions_for_NoDuplicateSelectorsOptions; export type RuleConfiguration_for_RestrictedImportsOptions = | RulePlainConfiguration | RuleWithOptions_for_RestrictedImportsOptions; @@ -1625,6 +1632,10 @@ export interface RuleWithOptions_for_NoCssEmptyBlockOptions { level: RulePlainConfiguration; options: NoCssEmptyBlockOptions; } +export interface RuleWithOptions_for_NoDuplicateSelectorsOptions { + level: RulePlainConfiguration; + options: NoDuplicateSelectorsOptions; +} export interface RuleWithOptions_for_RestrictedImportsOptions { level: RulePlainConfiguration; options: RestrictedImportsOptions; @@ -1678,6 +1689,12 @@ export interface DeprecatedHooksOptions {} export interface NoCssEmptyBlockOptions { allowComments: boolean; } +export interface NoDuplicateSelectorsOptions { + /** + * If set to `true` this rule will check for duplicate selectors within selector lists. + */ + disallowInList: boolean; +} /** * Options for the rule `noRestrictedImports`. */ @@ -2018,6 +2035,7 @@ export type Category = | "lint/nursery/noDuplicateElseIf" | "lint/nursery/noDuplicateFontNames" | "lint/nursery/noDuplicateJsonKeys" + | "lint/nursery/noDuplicateSelectors" | "lint/nursery/noDuplicateSelectorsKeyframeBlock" | "lint/nursery/noEvolvingAny" | "lint/nursery/noFlatMapIdentity" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 7078e0ec7564..72325bf775f3 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1425,6 +1425,23 @@ "properties": { "allowComments": { "type": "boolean" } }, "additionalProperties": false }, + "NoDuplicateSelectorsConfiguration": { + "anyOf": [ + { "$ref": "#/definitions/RulePlainConfiguration" }, + { "$ref": "#/definitions/RuleWithNoDuplicateSelectorsOptions" } + ] + }, + "NoDuplicateSelectorsOptions": { + "type": "object", + "required": ["disallowInList"], + "properties": { + "disallowInList": { + "description": "If set to `true` this rule will check for duplicate selectors within selector lists.", + "type": "boolean" + } + }, + "additionalProperties": false + }, "Nursery": { "description": "A list of rules that belong to this group", "type": "object", @@ -1496,6 +1513,13 @@ { "type": "null" } ] }, + "noDuplicateSelectors": { + "description": "Disallow duplicate selectors. This rule checks for two types of duplication:", + "anyOf": [ + { "$ref": "#/definitions/NoDuplicateSelectorsConfiguration" }, + { "type": "null" } + ] + }, "noDuplicateSelectorsKeyframeBlock": { "description": "Disallow duplicate selectors within keyframe blocks.", "anyOf": [ @@ -1980,6 +2004,15 @@ }, "additionalProperties": false }, + "RuleWithNoDuplicateSelectorsOptions": { + "type": "object", + "required": ["level", "options"], + "properties": { + "level": { "$ref": "#/definitions/RulePlainConfiguration" }, + "options": { "$ref": "#/definitions/NoDuplicateSelectorsOptions" } + }, + "additionalProperties": false + }, "RuleWithNoOptions": { "type": "object", "required": ["level"],