diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index 315debd03366..bf0ce4e26aea 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2671,6 +2671,10 @@ 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 within keyframe blocks."] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_duplicate_selectors_keyframe_block: + Option>, #[doc = "Disallow variables from evolving into any type through reassignments."] #[serde(skip_serializing_if = "Option::is_none")] pub no_evolving_any: Option>, @@ -2724,6 +2728,7 @@ impl Nursery { "noDuplicateElseIf", "noDuplicateFontNames", "noDuplicateJsonKeys", + "noDuplicateSelectorsKeyframeBlock", "noEvolvingAny", "noFlatMapIdentity", "noMisplacedAssertion", @@ -2740,6 +2745,7 @@ impl Nursery { "noDuplicateElseIf", "noDuplicateFontNames", "noDuplicateJsonKeys", + "noDuplicateSelectorsKeyframeBlock", "noEvolvingAny", "noFlatMapIdentity", ]; @@ -2751,6 +2757,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -2770,6 +2777,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -2826,51 +2834,56 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - 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[8])); } } - 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[9])); } } - if let Some(rule) = self.no_misplaced_assertion.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[10])); } } - 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[11])); } } - 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[12])); } } - 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[13])); } } - 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[14])); } } - if let Some(rule) = self.use_import_restrictions.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[15])); } } - 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[16])); } } + 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[17])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -2915,51 +2928,56 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - 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[8])); } } - 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[9])); } } - if let Some(rule) = self.no_misplaced_assertion.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[10])); } } - 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[11])); } } - 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[12])); } } - 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[13])); } } - 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[14])); } } - if let Some(rule) = self.use_import_restrictions.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[15])); } } - 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[16])); } } + 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[17])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3028,6 +3046,10 @@ impl Nursery { .no_duplicate_json_keys .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noDuplicateSelectorsKeyframeBlock" => self + .no_duplicate_selectors_keyframe_block + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noEvolvingAny" => self .no_evolving_any .as_ref() diff --git a/crates/biome_css_analyze/src/lint/nursery.rs b/crates/biome_css_analyze/src/lint/nursery.rs index 9a64205ed6ca..febf115bfbb4 100644 --- a/crates/biome_css_analyze/src/lint/nursery.rs +++ b/crates/biome_css_analyze/src/lint/nursery.rs @@ -5,6 +5,7 @@ use biome_analyze::declare_group; pub mod no_color_invalid_hex; pub mod no_css_empty_block; pub mod no_duplicate_font_names; +pub mod no_duplicate_selectors_keyframe_block; declare_group! { pub Nursery { @@ -13,6 +14,7 @@ declare_group! { self :: no_color_invalid_hex :: NoColorInvalidHex , self :: no_css_empty_block :: NoCssEmptyBlock , self :: no_duplicate_font_names :: NoDuplicateFontNames , + self :: no_duplicate_selectors_keyframe_block :: NoDuplicateSelectorsKeyframeBlock , ] } } diff --git a/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors_keyframe_block.rs b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors_keyframe_block.rs new file mode 100644 index 000000000000..8f7c70a8e620 --- /dev/null +++ b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_selectors_keyframe_block.rs @@ -0,0 +1,82 @@ +use std::collections::HashSet; + +use biome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_css_syntax::{AnyCssKeyframesItem, AnyCssKeyframesSelector, CssKeyframesBlock}; +use biome_rowan::AstNode; + +declare_rule! { + /// Disallow duplicate selectors within keyframe blocks. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```css,expect_diagnostic + /// @keyframes foo { from {} from {} } + /// ``` + /// + /// ```css,expect_diagnostic + /// @keyframes foo { from {} FROM {} } + /// ``` + /// + /// ```css,expect_diagnostic + /// @keyframes foo { 0% {} 0% {} } + /// ``` + /// + /// ### Valid + /// + /// ```css + /// @keyframes foo { 0% {} 100% {} } + /// ``` + /// + /// ```css + /// @keyframes foo { from {} to {} } + /// ``` + /// + pub NoDuplicateSelectorsKeyframeBlock { + version: "next", + name: "noDuplicateSelectorsKeyframeBlock", + recommended: true, + sources:&[RuleSource::Stylelint("keyframe-block-no-duplicate-selectors")], + } +} + +impl Rule for NoDuplicateSelectorsKeyframeBlock { + type Query = Ast; + type State = AnyCssKeyframesSelector; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Option { + let node = ctx.query(); + let mut selector_list = HashSet::new(); + for keyframe_item in node.items() { + match keyframe_item { + AnyCssKeyframesItem::CssKeyframesItem(item) => { + let keyframe_selector = item.selectors().into_iter().next()?.ok()?; + if !selector_list.insert(keyframe_selector.text().to_lowercase()) { + return Some(keyframe_selector); + } + } + _ => return None, + } + } + None + } + + fn diagnostic(_: &RuleContext, node: &Self::State) -> Option { + Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup! { + "Unexpected duplicate selector: "{node.text()} + }, + ) + .note(markup! { + "Consider using a different percentage value or keyword to avoid duplication" + }), + ) + } +} diff --git a/crates/biome_css_analyze/src/options.rs b/crates/biome_css_analyze/src/options.rs index 7a31a40e401e..8f7bc969c7c1 100644 --- a/crates/biome_css_analyze/src/options.rs +++ b/crates/biome_css_analyze/src/options.rs @@ -8,3 +8,4 @@ pub type NoCssEmptyBlock = ::Options; pub type NoDuplicateFontNames = ::Options; +pub type NoDuplicateSelectorsKeyframeBlock = < lint :: nursery :: no_duplicate_selectors_keyframe_block :: NoDuplicateSelectorsKeyframeBlock as biome_analyze :: Rule > :: Options ; diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css new file mode 100644 index 000000000000..e0129b4dae15 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css @@ -0,0 +1,13 @@ +@keyframes foo { from {} from {}} + +@keyframes foo { from {} FROM {}} + +@keyframes foo { 0% {} 0% {}} + +@keyframes foo { from {} to {} to {} } + +@keyframes foo { 0% {} 0% {} 100% {} } + +@-webkit-keyframes foo { 0% {} 0% {} 100% {} } + +@-moz-keyframes foo { 0% {} 0% {} 100% {} } \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css.snap new file mode 100644 index 000000000000..b3ea2083dd9e --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/invalid.css.snap @@ -0,0 +1,136 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: invalid.css +--- +# Input +```css +@keyframes foo { from {} from {}} + +@keyframes foo { from {} FROM {}} + +@keyframes foo { 0% {} 0% {}} + +@keyframes foo { from {} to {} to {} } + +@keyframes foo { 0% {} 0% {} 100% {} } + +@-webkit-keyframes foo { 0% {} 0% {} 100% {} } + +@-moz-keyframes foo { 0% {} 0% {} 100% {} } +``` + +# Diagnostics +``` +invalid.css:1:26 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: from + + > 1 │ @keyframes foo { from {} from {}} + │ ^^^^ + 2 │ + 3 │ @keyframes foo { from {} FROM {}} + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:3:26 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: FROM + + 1 │ @keyframes foo { from {} from {}} + 2 │ + > 3 │ @keyframes foo { from {} FROM {}} + │ ^^^^ + 4 │ + 5 │ @keyframes foo { 0% {} 0% {}} + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:5:24 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: 0% + + 3 │ @keyframes foo { from {} FROM {}} + 4 │ + > 5 │ @keyframes foo { 0% {} 0% {}} + │ ^^ + 6 │ + 7 │ @keyframes foo { from {} to {} to {} } + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:7:32 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: to + + 5 │ @keyframes foo { 0% {} 0% {}} + 6 │ + > 7 │ @keyframes foo { from {} to {} to {} } + │ ^^ + 8 │ + 9 │ @keyframes foo { 0% {} 0% {} 100% {} } + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:9:24 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: 0% + + 7 │ @keyframes foo { from {} to {} to {} } + 8 │ + > 9 │ @keyframes foo { 0% {} 0% {} 100% {} } + │ ^^ + 10 │ + 11 │ @-webkit-keyframes foo { 0% {} 0% {} 100% {} } + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:11:32 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: 0% + + 9 │ @keyframes foo { 0% {} 0% {} 100% {} } + 10 │ + > 11 │ @-webkit-keyframes foo { 0% {} 0% {} 100% {} } + │ ^^ + 12 │ + 13 │ @-moz-keyframes foo { 0% {} 0% {} 100% {} } + + i Consider using a different percentage value or keyword to avoid duplication + + +``` + +``` +invalid.css:13:29 lint/nursery/noDuplicateSelectorsKeyframeBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate selector: 0% + + 11 │ @-webkit-keyframes foo { 0% {} 0% {} 100% {} } + 12 │ + > 13 │ @-moz-keyframes foo { 0% {} 0% {} 100% {} } + │ ^^ + + i Consider using a different percentage value or keyword to avoid duplication + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css new file mode 100644 index 000000000000..2738fb8ee135 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css @@ -0,0 +1,3 @@ +@keyframes foo { from {} to {} } + +@keyframes foo { 0% {} 100% {} } \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css.snap new file mode 100644 index 000000000000..23106e800bbf --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateSelectorsKeyframeBlock/valid.css.snap @@ -0,0 +1,10 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: valid.css +--- +# Input +```css +@keyframes foo { from {} to {} } + +@keyframes foo { 0% {} 100% {} } +``` diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 6ec16f2dbfc3..ce22b36987a8 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -118,6 +118,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/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", "lint/nursery/noMisplacedAssertion": "https://biomejs.dev/linter/rules/no-misplaced-assertion", diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 9702355d60b5..00e25c38cb25 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -940,6 +940,10 @@ export interface Nursery { * Disallow two keys with the same name inside a JSON object. */ noDuplicateJsonKeys?: RuleConfiguration_for_Null; + /** + * Disallow duplicate selectors within keyframe blocks. + */ + noDuplicateSelectorsKeyframeBlock?: RuleConfiguration_for_Null; /** * Disallow variables from evolving into any type through reassignments. */ @@ -1948,6 +1952,7 @@ export type Category = | "lint/nursery/noDuplicateElseIf" | "lint/nursery/noDuplicateFontNames" | "lint/nursery/noDuplicateJsonKeys" + | "lint/nursery/noDuplicateSelectorsKeyframeBlock" | "lint/nursery/noEvolvingAny" | "lint/nursery/noFlatMapIdentity" | "lint/nursery/noMisplacedAssertion" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 3520a460b919..8179aed4c82a 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1489,6 +1489,13 @@ { "type": "null" } ] }, + "noDuplicateSelectorsKeyframeBlock": { + "description": "Disallow duplicate selectors within keyframe blocks.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "noEvolvingAny": { "description": "Disallow variables from evolving into any type through reassignments.", "anyOf": [