diff --git a/Cargo.lock b/Cargo.lock index dca8fe6b2bc6..2b7a1d52c466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,11 +257,15 @@ dependencies = [ "biome_console", "biome_css_parser", "biome_css_syntax", + "biome_deserialize", + "biome_deserialize_macros", "biome_diagnostics", "biome_rowan", "biome_test_utils", "insta", "lazy_static", + "schemars", + "serde", "tests_macros", ] diff --git a/crates/biome_configuration/Cargo.toml b/crates/biome_configuration/Cargo.toml index ab271b16c4ee..ac3f90dd6b80 100644 --- a/crates/biome_configuration/Cargo.toml +++ b/crates/biome_configuration/Cargo.toml @@ -42,6 +42,7 @@ serde_json = { workspace = true, features = ["raw_value"] } schema = [ "dep:schemars", "biome_js_analyze/schema", + "biome_css_analyze/schema", "biome_formatter/serde", "biome_json_syntax/schema", "biome_css_syntax/schema", diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index ed3495c7e71b..315debd03366 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2656,6 +2656,9 @@ pub struct Nursery { #[doc = "Disallow the use of Math.min and Math.max to clamp a value where the result itself is constant."] #[serde(skip_serializing_if = "Option::is_none")] pub no_constant_math_min_max_clamp: Option>, + #[doc = "Disallow CSS empty blocks."] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_css_empty_block: Option>, #[doc = "Disallow using a callback in asynchronous tests and hooks."] #[serde(skip_serializing_if = "Option::is_none")] pub no_done_callback: Option>, @@ -2716,6 +2719,7 @@ impl Nursery { "noColorInvalidHex", "noConsole", "noConstantMathMinMaxClamp", + "noCssEmptyBlock", "noDoneCallback", "noDuplicateElseIf", "noDuplicateFontNames", @@ -2731,6 +2735,7 @@ impl Nursery { "useSortedClasses", ]; const RECOMMENDED_RULES: &'static [&'static str] = &[ + "noCssEmptyBlock", "noDoneCallback", "noDuplicateElseIf", "noDuplicateFontNames", @@ -2745,6 +2750,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6]), 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]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -2763,6 +2769,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13]), 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]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -2794,71 +2801,76 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); } } - if let Some(rule) = self.no_done_callback.as_ref() { + if let Some(rule) = self.no_css_empty_block.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); } } - if let Some(rule) = self.no_duplicate_else_if.as_ref() { + if let Some(rule) = self.no_done_callback.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.no_duplicate_font_names.as_ref() { + if let Some(rule) = self.no_duplicate_else_if.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.no_duplicate_json_keys.as_ref() { + if let Some(rule) = self.no_duplicate_font_names.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_json_keys.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - 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[8])); } } - 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[9])); } } - 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[10])); } } - 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[11])); } } - 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[12])); } } - 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[13])); } } - 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[14])); } } - 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[15])); } } + 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[16])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -2878,71 +2890,76 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); } } - if let Some(rule) = self.no_done_callback.as_ref() { + if let Some(rule) = self.no_css_empty_block.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3])); } } - if let Some(rule) = self.no_duplicate_else_if.as_ref() { + if let Some(rule) = self.no_done_callback.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.no_duplicate_font_names.as_ref() { + if let Some(rule) = self.no_duplicate_else_if.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.no_duplicate_json_keys.as_ref() { + if let Some(rule) = self.no_duplicate_font_names.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_json_keys.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - 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[8])); } } - 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[9])); } } - 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[10])); } } - 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[11])); } } - 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[12])); } } - 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[13])); } } - 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[14])); } } - 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[15])); } } + 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[16])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -2991,6 +3008,10 @@ impl Nursery { .no_constant_math_min_max_clamp .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noCssEmptyBlock" => self + .no_css_empty_block + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noDoneCallback" => self .no_done_callback .as_ref() diff --git a/crates/biome_css_analyze/Cargo.toml b/crates/biome_css_analyze/Cargo.toml index 669af7a91108..187489002128 100644 --- a/crates/biome_css_analyze/Cargo.toml +++ b/crates/biome_css_analyze/Cargo.toml @@ -13,12 +13,16 @@ version = "0.5.7" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -biome_analyze = { workspace = true } -biome_console = { workspace = true } -biome_css_syntax = { workspace = true } -biome_diagnostics = { workspace = true } -biome_rowan = { workspace = true } -lazy_static = { workspace = true } +biome_analyze = { workspace = true } +biome_console = { workspace = true } +biome_css_syntax = { workspace = true } +biome_deserialize = { workspace = true } +biome_deserialize_macros = { workspace = true } +biome_diagnostics = { workspace = true } +biome_rowan = { workspace = true } +lazy_static = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } [dev-dependencies] biome_css_parser = { path = "../biome_css_parser" } @@ -26,5 +30,8 @@ biome_test_utils = { path = "../biome_test_utils" } insta = { workspace = true, features = ["glob"] } tests_macros = { path = "../tests_macros" } +[features] +schema = ["schemars", "biome_deserialize/schema"] + [lints] workspace = true diff --git a/crates/biome_css_analyze/src/lint/nursery.rs b/crates/biome_css_analyze/src/lint/nursery.rs index 75e80277d7fb..9a64205ed6ca 100644 --- a/crates/biome_css_analyze/src/lint/nursery.rs +++ b/crates/biome_css_analyze/src/lint/nursery.rs @@ -3,6 +3,7 @@ use biome_analyze::declare_group; pub mod no_color_invalid_hex; +pub mod no_css_empty_block; pub mod no_duplicate_font_names; declare_group! { @@ -10,6 +11,7 @@ declare_group! { name : "nursery" , rules : [ self :: no_color_invalid_hex :: NoColorInvalidHex , + self :: no_css_empty_block :: NoCssEmptyBlock , self :: no_duplicate_font_names :: NoDuplicateFontNames , ] } diff --git a/crates/biome_css_analyze/src/lint/nursery/no_css_empty_block.rs b/crates/biome_css_analyze/src/lint/nursery/no_css_empty_block.rs new file mode 100644 index 000000000000..0e1fd2e958de --- /dev/null +++ b/crates/biome_css_analyze/src/lint/nursery/no_css_empty_block.rs @@ -0,0 +1,125 @@ +use biome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_css_syntax::stmt_ext::CssBlockLike; +use biome_deserialize_macros::Deserializable; +use biome_rowan::AstNode; +use serde::{Deserialize, Serialize}; + +declare_rule! { + /// Disallow CSS empty blocks. + /// + /// By default, it will allow empty blocks with comments inside. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```css,expect_diagnostic + /// p {} + /// ``` + /// + /// ```css,expect_diagnostic + /// .b {} + /// ``` + /// + /// ```css,expect_diagnostic + /// @media print { a {} } + /// ``` + /// + /// ### Valid + /// + /// ```css + /// p { + /// color: red; + /// } + /// ``` + /// + /// ```css + /// p { + /// /* foo */ + /// } + /// ``` + /// + /// ```css + /// @media print { a { color: pink; } } + /// ``` + /// + /// ## Options + /// + /// If false, exclude comments from being treated as content inside of a block. + /// + /// ```json + /// { + /// "noCssEmptyBlock": { + /// "options": { + /// "allowComments": false + /// } + /// } + /// } + /// ``` + /// + pub NoCssEmptyBlock { + version: "next", + name: "noCssEmptyBlock", + recommended: true, + sources: &[RuleSource::Stylelint("no-empty-block")], + } +} + +#[derive(Debug, Clone, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NoCssEmptyBlockOptions { + pub allow_comments: bool, +} + +impl Default for NoCssEmptyBlockOptions { + fn default() -> Self { + Self { + allow_comments: true, + } + } +} + +impl Rule for NoCssEmptyBlock { + type Query = Ast; + type State = CssBlockLike; + type Signals = Option; + type Options = NoCssEmptyBlockOptions; + + fn run(ctx: &RuleContext) -> Option { + let node = ctx.query(); + let options = ctx.options(); + let allow_comments_inside_empty_block = options.allow_comments; + if allow_comments_inside_empty_block { + let has_comments_inside_block = node.r_curly_token().ok()?.has_leading_comments() + || node.l_curly_token().ok()?.has_trailing_comments(); + + if !node.is_empty() || has_comments_inside_block { + return None; + } else { + return Some(node.clone()); + } + } else if node.is_empty() { + return Some(node.clone()); + } + + None + } + + fn diagnostic(_: &RuleContext, node: &Self::State) -> Option { + let span = node.range(); + Some( + RuleDiagnostic::new( + rule_category!(), + span, + markup! { + "Empty blocks aren't allowed." + }, + ) + .note(markup! { + "Consider removing the empty block or adding styles inside it." + }), + ) + } +} diff --git a/crates/biome_css_analyze/src/options.rs b/crates/biome_css_analyze/src/options.rs index e3f743001773..7a31a40e401e 100644 --- a/crates/biome_css_analyze/src/options.rs +++ b/crates/biome_css_analyze/src/options.rs @@ -4,5 +4,7 @@ use crate::lint; pub type NoColorInvalidHex = ::Options; +pub type NoCssEmptyBlock = + ::Options; pub type NoDuplicateFontNames = ::Options; diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css new file mode 100644 index 000000000000..5715183c5e0f --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css @@ -0,0 +1,19 @@ +a { /* foo */ } +a { + /* foo */ +} + +.b { /* foo */ } +.b { + /* foo */ +} + +@media print { /* foo */ } +@media print { + /* foo */ +} +@media print { + a { + /* foo */ + } +} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css.snap new file mode 100644 index 000000000000..b88b124b400d --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.css.snap @@ -0,0 +1,152 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: disallowComment.css +--- +# Input +```css +a { /* foo */ } +a { + /* foo */ +} + +.b { /* foo */ } +.b { + /* foo */ +} + +@media print { /* foo */ } +@media print { + /* foo */ +} +@media print { + a { + /* foo */ + } +} +``` + +# Diagnostics +``` +disallowComment.css:1:3 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + > 1 │ a { /* foo */ } + │ ^^^^^^^^^^^^^ + 2 │ a { + 3 │ /* foo */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:2:3 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 1 │ a { /* foo */ } + > 2 │ a { + │ ^ + > 3 │ /* foo */ + > 4 │ } + │ ^ + 5 │ + 6 │ .b { /* foo */ } + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:6:4 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 4 │ } + 5 │ + > 6 │ .b { /* foo */ } + │ ^^^^^^^^^^^^^ + 7 │ .b { + 8 │ /* foo */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:7:4 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 6 │ .b { /* foo */ } + > 7 │ .b { + │ ^ + > 8 │ /* foo */ + > 9 │ } + │ ^ + 10 │ + 11 │ @media print { /* foo */ } + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:11:14 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 9 │ } + 10 │ + > 11 │ @media print { /* foo */ } + │ ^^^^^^^^^^^^^ + 12 │ @media print { + 13 │ /* foo */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:12:14 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 11 │ @media print { /* foo */ } + > 12 │ @media print { + │ ^ + > 13 │ /* foo */ + > 14 │ } + │ ^ + 15 │ @media print { + 16 │ a { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +disallowComment.css:16:5 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 14 │ } + 15 │ @media print { + > 16 │ a { + │ ^ + > 17 │ /* foo */ + > 18 │ } + │ ^ + 19 │ } + + i Consider removing the empty block or adding styles inside it. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.options.json b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.options.json new file mode 100644 index 000000000000..8b7ebcfa0717 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/disallowComment.options.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "nursery": { + "noCssEmptyBlock": { + "level": "error", + "options": { + "allowComments": false + } + } + } + } + } +} diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css new file mode 100644 index 000000000000..3692b4ed5b41 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css @@ -0,0 +1,55 @@ +/* CssDeclarationOrRuleBlock */ +a {} +a { } +a { + +} + +.b {} +.b { } +.b { + +} + +/* CssRuleBlock */ +@media print {} +@media print { + +} +@media print { a {} } + +/* CssDeclarationBlock */ +@font-palette-values --ident {} +@font-face {} + +/* CssKeyframesBlock */ +@keyframes slidein {} +@keyframes slidein { + from { + } + + to { + transform: translateX(100%); + } + } + +/* CssFontFeatureValuesBlock */ +@font-feature-values Font One { + @styleset { + + } +} + +/* CssPageAtRuleBlock */ +@page {} +@page :right { +} + + +/* CssDeclarationOrAtRuleBlock */ +@page :left { @left-middle {} background: red; } +@page { + @top-right { + + } +} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css.snap new file mode 100644 index 000000000000..f65db7d66865 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/invalid.css.snap @@ -0,0 +1,378 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: invalid.css +--- +# Input +```css +/* CssDeclarationOrRuleBlock */ +a {} +a { } +a { + +} + +.b {} +.b { } +.b { + +} + +/* CssRuleBlock */ +@media print {} +@media print { + +} +@media print { a {} } + +/* CssDeclarationBlock */ +@font-palette-values --ident {} +@font-face {} + +/* CssKeyframesBlock */ +@keyframes slidein {} +@keyframes slidein { + from { + } + + to { + transform: translateX(100%); + } + } + +/* CssFontFeatureValuesBlock */ +@font-feature-values Font One { + @styleset { + + } +} + +/* CssPageAtRuleBlock */ +@page {} +@page :right { +} + + +/* CssDeclarationOrAtRuleBlock */ +@page :left { @left-middle {} background: red; } +@page { + @top-right { + + } +} +``` + +# Diagnostics +``` +invalid.css:2:3 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 1 │ /* CssDeclarationOrRuleBlock */ + > 2 │ a {} + │ ^^ + 3 │ a { } + 4 │ a { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:3:3 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 1 │ /* CssDeclarationOrRuleBlock */ + 2 │ a {} + > 3 │ a { } + │ ^^^ + 4 │ a { + 5 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:4:3 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 2 │ a {} + 3 │ a { } + > 4 │ a { + │ ^ + > 5 │ + > 6 │ } + │ ^ + 7 │ + 8 │ .b {} + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:8:4 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 6 │ } + 7 │ + > 8 │ .b {} + │ ^^ + 9 │ .b { } + 10 │ .b { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:9:4 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 8 │ .b {} + > 9 │ .b { } + │ ^^^ + 10 │ .b { + 11 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:10:4 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 8 │ .b {} + 9 │ .b { } + > 10 │ .b { + │ ^ + > 11 │ + > 12 │ } + │ ^ + 13 │ + 14 │ /* CssRuleBlock */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:15:14 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 14 │ /* CssRuleBlock */ + > 15 │ @media print {} + │ ^^ + 16 │ @media print { + 17 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:16:14 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 14 │ /* CssRuleBlock */ + 15 │ @media print {} + > 16 │ @media print { + │ ^ + > 17 │ + > 18 │ } + │ ^ + 19 │ @media print { a {} } + 20 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:19:18 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 18 │ } + > 19 │ @media print { a {} } + │ ^^ + 20 │ + 21 │ /* CssDeclarationBlock */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:22:30 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 21 │ /* CssDeclarationBlock */ + > 22 │ @font-palette-values --ident {} + │ ^^ + 23 │ @font-face {} + 24 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:23:12 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 21 │ /* CssDeclarationBlock */ + 22 │ @font-palette-values --ident {} + > 23 │ @font-face {} + │ ^^ + 24 │ + 25 │ /* CssKeyframesBlock */ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:26:20 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 25 │ /* CssKeyframesBlock */ + > 26 │ @keyframes slidein {} + │ ^^ + 27 │ @keyframes slidein { + 28 │ from { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:28:10 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 26 │ @keyframes slidein {} + 27 │ @keyframes slidein { + > 28 │ from { + │ ^ + > 29 │ } + │ ^ + 30 │ + 31 │ to { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:38:13 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 36 │ /* CssFontFeatureValuesBlock */ + 37 │ @font-feature-values Font One { + > 38 │ @styleset { + │ ^ + > 39 │ + > 40 │ } + │ ^ + 41 │ } + 42 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:44:7 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 43 │ /* CssPageAtRuleBlock */ + > 44 │ @page {} + │ ^^ + 45 │ @page :right { + 46 │ } + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:45:14 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 43 │ /* CssPageAtRuleBlock */ + 44 │ @page {} + > 45 │ @page :right { + │ ^ + > 46 │ } + │ ^ + 47 │ + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:50:28 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 49 │ /* CssDeclarationOrAtRuleBlock */ + > 50 │ @page :left { @left-middle {} background: red; } + │ ^^ + 51 │ @page { + 52 │ @top-right { + + i Consider removing the empty block or adding styles inside it. + + +``` + +``` +invalid.css:52:16 lint/nursery/noCssEmptyBlock ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Empty blocks aren't allowed. + + 50 │ @page :left { @left-middle {} background: red; } + 51 │ @page { + > 52 │ @top-right { + │ ^ + > 53 │ + > 54 │ } + │ ^ + 55 │ } + + i Consider removing the empty block or adding styles inside it. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css new file mode 100644 index 000000000000..9660bfd99182 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css @@ -0,0 +1,70 @@ +/* CssDeclarationOrRuleBlock */ +a { color: pink; } +a { /* foo */ } +a { + /* foo */ +} +a { + /* foo */ + /* bar */ +} +a { + /*\nfoo\nbar\n*/ +} + +/* CssRuleBlock */ +@media print { a { color: pink; } } +@media print { a { /* foo */ } } + +/* CssDeclarationBlock */ +@font-palette-values --identifier { + font-family: Bixa; +} + +@font-face { + font-family: "Trickster"; + src: + local("Trickster"), + url("trickster-COLRv1.otf") format("opentype") tech(color-COLRv1), + url("trickster-outline.otf") format("opentype"), + url("trickster-outline.woff") format("woff"); +} + +/* CssKeyframesBlock */ +@keyframes slidein { + from { + transform: translateX(0%); + } + + to { + transform: translateX(100%); + } +} + +/* CssFontFeatureValuesBlock */ +@font-feature-values Font One { + @styleset { + nice-style: 12; + } +} + +/* CssPageAtRuleBlock */ +@page { + size: 8.5in 9in; + margin-top: 4in; +} +@page :right { + size: 11in; + margin-top: 4in; +} + + +/* CssDeclarationOrAtRuleBlock */ +@page { + @top-right { + content: "Page " counter(pageNumber); + } +} + +@import "foo.css"; +@import url(x.css) \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css.snap new file mode 100644 index 000000000000..8ebfac1bab70 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noCssEmptyBlock/valid.css.snap @@ -0,0 +1,77 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: valid.css +--- +# Input +```css +/* CssDeclarationOrRuleBlock */ +a { color: pink; } +a { /* foo */ } +a { + /* foo */ +} +a { + /* foo */ + /* bar */ +} +a { + /*\nfoo\nbar\n*/ +} + +/* CssRuleBlock */ +@media print { a { color: pink; } } +@media print { a { /* foo */ } } + +/* CssDeclarationBlock */ +@font-palette-values --identifier { + font-family: Bixa; +} + +@font-face { + font-family: "Trickster"; + src: + local("Trickster"), + url("trickster-COLRv1.otf") format("opentype") tech(color-COLRv1), + url("trickster-outline.otf") format("opentype"), + url("trickster-outline.woff") format("woff"); +} + +/* CssKeyframesBlock */ +@keyframes slidein { + from { + transform: translateX(0%); + } + + to { + transform: translateX(100%); + } +} + +/* CssFontFeatureValuesBlock */ +@font-feature-values Font One { + @styleset { + nice-style: 12; + } +} + +/* CssPageAtRuleBlock */ +@page { + size: 8.5in 9in; + margin-top: 4in; +} +@page :right { + size: 11in; + margin-top: 4in; +} + + +/* CssDeclarationOrAtRuleBlock */ +@page { + @top-right { + content: "Page " counter(pageNumber); + } +} + +@import "foo.css"; +@import url(x.css) +``` diff --git a/crates/biome_css_syntax/src/lib.rs b/crates/biome_css_syntax/src/lib.rs index 27da932b6322..c604745cf239 100644 --- a/crates/biome_css_syntax/src/lib.rs +++ b/crates/biome_css_syntax/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] mod file_source; mod generated; +pub mod stmt_ext; mod syntax_node; pub use self::generated::*; diff --git a/crates/biome_css_syntax/src/stmt_ext.rs b/crates/biome_css_syntax/src/stmt_ext.rs new file mode 100644 index 000000000000..23ac09280501 --- /dev/null +++ b/crates/biome_css_syntax/src/stmt_ext.rs @@ -0,0 +1,48 @@ +use crate::generated::{ + CssDeclarationBlock, CssDeclarationOrAtRuleBlock, CssDeclarationOrRuleBlock, + CssFontFeatureValuesBlock, CssKeyframesBlock, CssPageAtRuleBlock, CssRuleBlock, +}; +use crate::CssSyntaxToken; +use biome_rowan::{declare_node_union, AstNodeList, SyntaxResult}; + +declare_node_union! { + pub CssBlockLike = CssKeyframesBlock | CssDeclarationOrAtRuleBlock | CssDeclarationBlock | CssRuleBlock | CssFontFeatureValuesBlock | CssPageAtRuleBlock | CssDeclarationOrRuleBlock +} + +impl CssBlockLike { + pub fn l_curly_token(&self) -> SyntaxResult { + match self { + CssBlockLike::CssKeyframesBlock(block) => block.l_curly_token(), + CssBlockLike::CssDeclarationOrAtRuleBlock(block) => block.l_curly_token(), + CssBlockLike::CssDeclarationBlock(block) => block.l_curly_token(), + CssBlockLike::CssRuleBlock(block) => block.l_curly_token(), + CssBlockLike::CssFontFeatureValuesBlock(block) => block.l_curly_token(), + CssBlockLike::CssPageAtRuleBlock(block) => block.l_curly_token(), + CssBlockLike::CssDeclarationOrRuleBlock(block) => block.l_curly_token(), + } + } + + pub fn r_curly_token(&self) -> SyntaxResult { + match self { + CssBlockLike::CssKeyframesBlock(block) => block.r_curly_token(), + CssBlockLike::CssDeclarationOrAtRuleBlock(block) => block.r_curly_token(), + CssBlockLike::CssDeclarationBlock(block) => block.r_curly_token(), + CssBlockLike::CssRuleBlock(block) => block.r_curly_token(), + CssBlockLike::CssFontFeatureValuesBlock(block) => block.r_curly_token(), + CssBlockLike::CssPageAtRuleBlock(block) => block.r_curly_token(), + CssBlockLike::CssDeclarationOrRuleBlock(block) => block.r_curly_token(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + CssBlockLike::CssKeyframesBlock(block) => block.items().is_empty(), + CssBlockLike::CssDeclarationOrAtRuleBlock(block) => block.items().is_empty(), + CssBlockLike::CssDeclarationBlock(block) => block.declarations().is_empty(), + CssBlockLike::CssRuleBlock(block) => block.rules().is_empty(), + CssBlockLike::CssFontFeatureValuesBlock(block) => block.items().is_empty(), + CssBlockLike::CssPageAtRuleBlock(block) => block.items().is_empty(), + CssBlockLike::CssDeclarationOrRuleBlock(block) => block.items().is_empty(), + } + } +} diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 603a8ff3cf30..6ec16f2dbfc3 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -113,6 +113,7 @@ define_categories! { "lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex", "lint/nursery/noConsole": "https://biomejs.dev/linter/rules/no-console", "lint/nursery/noConstantMathMinMaxClamp": "https://biomejs.dev/linter/rules/no-constant-math-min-max-clamp", + "lint/nursery/noCssEmptyBlock": "https://biomejs.dev/linter/rules/no-css-empty-block", "lint/nursery/noDoneCallback": "https://biomejs.dev/linter/rules/no-done-callback", "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", diff --git a/crates/biome_service/src/configuration.rs b/crates/biome_service/src/configuration.rs index e9d20f652a7f..7efa5a13e37b 100644 --- a/crates/biome_service/src/configuration.rs +++ b/crates/biome_service/src/configuration.rs @@ -7,11 +7,12 @@ use biome_configuration::{ PartialConfiguration, }; use biome_console::markup; +use biome_css_analyze::metadata as css_lint_metadata; use biome_deserialize::json::deserialize_from_json_str; use biome_deserialize::{Deserialized, Merge}; use biome_diagnostics::{DiagnosticExt, Error, Severity}; use biome_fs::{AutoSearchResult, ConfigName, FileSystem, OpenOptions}; -use biome_js_analyze::metadata; +use biome_js_analyze::metadata as js_lint_metadata; use biome_json_formatter::context::JsonFormatOptions; use biome_json_parser::{parse_json, JsonParserOptions}; use std::ffi::OsStr; @@ -321,7 +322,8 @@ pub fn to_analyzer_rules(settings: &WorkspaceSettings, path: &Path) -> AnalyzerR let overrides = &settings.override_settings; let mut analyzer_rules = AnalyzerRules::default(); if let Some(rules) = linter_settings.rules.as_ref() { - push_to_analyzer_rules(rules, metadata(), &mut analyzer_rules); + push_to_analyzer_rules(rules, js_lint_metadata(), &mut analyzer_rules); + push_to_analyzer_rules(rules, css_lint_metadata(), &mut analyzer_rules); } overrides.override_analyzer_rules(path, analyzer_rules) diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index bfe10770e117..9702355d60b5 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -920,6 +920,10 @@ export interface Nursery { * Disallow the use of Math.min and Math.max to clamp a value where the result itself is constant. */ noConstantMathMinMaxClamp?: RuleConfiguration_for_Null; + /** + * Disallow CSS empty blocks. + */ + noCssEmptyBlock?: RuleConfiguration_for_NoCssEmptyBlockOptions; /** * Disallow using a callback in asynchronous tests and hooks. */ @@ -1511,6 +1515,9 @@ export type RuleConfiguration_for_HooksOptions = export type RuleConfiguration_for_DeprecatedHooksOptions = | RulePlainConfiguration | RuleWithOptions_for_DeprecatedHooksOptions; +export type RuleConfiguration_for_NoCssEmptyBlockOptions = + | RulePlainConfiguration + | RuleWithOptions_for_NoCssEmptyBlockOptions; export type RuleConfiguration_for_RestrictedImportsOptions = | RulePlainConfiguration | RuleWithOptions_for_RestrictedImportsOptions; @@ -1550,6 +1557,10 @@ export interface RuleWithOptions_for_DeprecatedHooksOptions { level: RulePlainConfiguration; options: DeprecatedHooksOptions; } +export interface RuleWithOptions_for_NoCssEmptyBlockOptions { + level: RulePlainConfiguration; + options: NoCssEmptyBlockOptions; +} export interface RuleWithOptions_for_RestrictedImportsOptions { level: RulePlainConfiguration; options: RestrictedImportsOptions; @@ -1600,6 +1611,9 @@ export interface HooksOptions { * Options for the `useHookAtTopLevel` rule have been deprecated, since we now use the React hook naming convention to determine whether a function is a hook. */ export interface DeprecatedHooksOptions {} +export interface NoCssEmptyBlockOptions { + allowComments: boolean; +} /** * Options for the rule `noRestrictedImports`. */ @@ -1929,6 +1943,7 @@ export type Category = | "lint/nursery/noColorInvalidHex" | "lint/nursery/noConsole" | "lint/nursery/noConstantMathMinMaxClamp" + | "lint/nursery/noCssEmptyBlock" | "lint/nursery/noDoneCallback" | "lint/nursery/noDuplicateElseIf" | "lint/nursery/noDuplicateFontNames" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 92ffbdec9830..3520a460b919 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1413,6 +1413,18 @@ }, "additionalProperties": false }, + "NoCssEmptyBlockConfiguration": { + "anyOf": [ + { "$ref": "#/definitions/RulePlainConfiguration" }, + { "$ref": "#/definitions/RuleWithNoCssEmptyBlockOptions" } + ] + }, + "NoCssEmptyBlockOptions": { + "type": "object", + "required": ["allowComments"], + "properties": { "allowComments": { "type": "boolean" } }, + "additionalProperties": false + }, "Nursery": { "description": "A list of rules that belong to this group", "type": "object", @@ -1442,6 +1454,13 @@ { "type": "null" } ] }, + "noCssEmptyBlock": { + "description": "Disallow CSS empty blocks.", + "anyOf": [ + { "$ref": "#/definitions/NoCssEmptyBlockConfiguration" }, + { "type": "null" } + ] + }, "noDoneCallback": { "description": "Disallow using a callback in asynchronous tests and hooks.", "anyOf": [ @@ -1840,6 +1859,15 @@ }, "additionalProperties": false }, + "RuleWithNoCssEmptyBlockOptions": { + "type": "object", + "required": ["level", "options"], + "properties": { + "level": { "$ref": "#/definitions/RulePlainConfiguration" }, + "options": { "$ref": "#/definitions/NoCssEmptyBlockOptions" } + }, + "additionalProperties": false + }, "RuleWithNoOptions": { "type": "object", "required": ["level"],