From 295efb90b91bf66a03dec93a91a16623a5d93360 Mon Sep 17 00:00:00 2001 From: matsuura <79092292+tunamaguro@users.noreply.github.com> Date: Mon, 30 Sep 2024 21:22:14 +0900 Subject: [PATCH] feat(biome_css_analyze): implement `noDescendingSpecificity` (#4097) --- .../src/analyzer/linter/rules.rs | 164 ++++++++------ crates/biome_css_analyze/src/lint/nursery.rs | 2 + .../lint/nursery/no_descending_specificity.rs | 203 ++++++++++++++++++ crates/biome_css_analyze/src/options.rs | 1 + .../complex_selector.invalid.css | 7 + .../complex_selector.invalid.css.snap | 40 ++++ .../function_pseudo_selector.invalid.css | 15 ++ .../function_pseudo_selector.invalid.css.snap | 73 +++++++ .../nested.invalid.css | 9 + .../nested.invalid.css.snap | 42 ++++ .../simple_pseudo_selector.invalid.css | 7 + .../simple_pseudo_selector.invalid.css.snap | 40 ++++ .../nursery/noDescendingSpecificity/valid.css | 51 +++++ .../noDescendingSpecificity/valid.css.snap | 58 +++++ crates/biome_css_semantic/src/events.rs | 49 ++++- .../src/semantic_model/builder.rs | 20 +- .../src/semantic_model/mod.rs | 1 + .../src/semantic_model/model.rs | 46 +++- .../src/semantic_model/specificity.rs | 183 ++++++++++++++++ .../src/categories.rs | 1 + .../@biomejs/backend-jsonrpc/src/workspace.ts | 5 + .../@biomejs/biome/configuration_schema.json | 7 + 22 files changed, 939 insertions(+), 85 deletions(-) create mode 100644 crates/biome_css_analyze/src/lint/nursery/no_descending_specificity.rs create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css.snap create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css.snap create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css.snap create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css.snap create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css create mode 100644 crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css.snap create mode 100644 crates/biome_css_semantic/src/semantic_model/specificity.rs diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 7f9d335f686c..b7cac6a2ab55 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -3274,6 +3274,10 @@ pub struct Nursery { #[doc = "Disallow use of CommonJs module system in favor of ESM style imports."] #[serde(skip_serializing_if = "Option::is_none")] pub no_common_js: Option>, + #[doc = "Disallow a lower specificity selector from coming after a higher specificity selector."] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_descending_specificity: + Option>, #[doc = "Disallow duplicate custom properties within declaration blocks."] #[serde(skip_serializing_if = "Option::is_none")] pub no_duplicate_custom_properties: @@ -3417,6 +3421,7 @@ impl Nursery { const GROUP_NAME: &'static str = "nursery"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ "noCommonJs", + "noDescendingSpecificity", "noDuplicateCustomProperties", "noDuplicateElseIf", "noDuplicatedFields", @@ -3452,6 +3457,7 @@ impl Nursery { "useValidAutocomplete", ]; const RECOMMENDED_RULES: &'static [&'static str] = &[ + "noDescendingSpecificity", "noDuplicateCustomProperties", "noDuplicateElseIf", "noDuplicatedFields", @@ -3468,14 +3474,15 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -3512,6 +3519,7 @@ impl Nursery { 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]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -3533,171 +3541,176 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); } } - if let Some(rule) = self.no_duplicate_custom_properties.as_ref() { + if let Some(rule) = self.no_descending_specificity.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); } } - if let Some(rule) = self.no_duplicate_else_if.as_ref() { + if let Some(rule) = self.no_duplicate_custom_properties.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); } } - if let Some(rule) = self.no_duplicated_fields.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[3])); } } - if let Some(rule) = self.no_dynamic_namespace_import_access.as_ref() { + if let Some(rule) = self.no_duplicated_fields.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.no_enum.as_ref() { + if let Some(rule) = self.no_dynamic_namespace_import_access.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.no_exported_imports.as_ref() { + if let Some(rule) = self.no_enum.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.no_irregular_whitespace.as_ref() { + if let Some(rule) = self.no_exported_imports.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.no_missing_var_function.as_ref() { + if let Some(rule) = self.no_irregular_whitespace.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.no_nested_ternary.as_ref() { + if let Some(rule) = self.no_missing_var_function.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_octal_escape.as_ref() { + if let Some(rule) = self.no_nested_ternary.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_process_env.as_ref() { + if let Some(rule) = self.no_octal_escape.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_process_env.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_types.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.no_secrets.as_ref() { + if let Some(rule) = self.no_restricted_types.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } } - if let Some(rule) = self.no_static_element_interactions.as_ref() { + if let Some(rule) = self.no_secrets.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_substr.as_ref() { + if let Some(rule) = self.no_static_element_interactions.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_template_curly_in_string.as_ref() { + if let Some(rule) = self.no_substr.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_pseudo_class.as_ref() { + if let Some(rule) = self.no_template_curly_in_string.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_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_value_at_rule.as_ref() { + if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { + if let Some(rule) = self.no_value_at_rule.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { + if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_component_export_only_modules.as_ref() { + if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_consistent_curly_braces.as_ref() { + if let Some(rule) = self.use_component_export_only_modules.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_member_accessibility.as_ref() { + if let Some(rule) = self.use_consistent_curly_braces.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_deprecated_reason.as_ref() { + if let Some(rule) = self.use_consistent_member_accessibility.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_function_return_type.as_ref() { + if let Some(rule) = self.use_deprecated_reason.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_explicit_function_return_type.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - 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[30])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + 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[31])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -3707,171 +3720,176 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0])); } } - if let Some(rule) = self.no_duplicate_custom_properties.as_ref() { + if let Some(rule) = self.no_descending_specificity.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1])); } } - if let Some(rule) = self.no_duplicate_else_if.as_ref() { + if let Some(rule) = self.no_duplicate_custom_properties.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2])); } } - if let Some(rule) = self.no_duplicated_fields.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[3])); } } - if let Some(rule) = self.no_dynamic_namespace_import_access.as_ref() { + if let Some(rule) = self.no_duplicated_fields.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - if let Some(rule) = self.no_enum.as_ref() { + if let Some(rule) = self.no_dynamic_namespace_import_access.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5])); } } - if let Some(rule) = self.no_exported_imports.as_ref() { + if let Some(rule) = self.no_enum.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[6])); } } - if let Some(rule) = self.no_irregular_whitespace.as_ref() { + if let Some(rule) = self.no_exported_imports.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.no_missing_var_function.as_ref() { + if let Some(rule) = self.no_irregular_whitespace.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8])); } } - if let Some(rule) = self.no_nested_ternary.as_ref() { + if let Some(rule) = self.no_missing_var_function.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_octal_escape.as_ref() { + if let Some(rule) = self.no_nested_ternary.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_process_env.as_ref() { + if let Some(rule) = self.no_octal_escape.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_process_env.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_types.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.no_secrets.as_ref() { + if let Some(rule) = self.no_restricted_types.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } } - if let Some(rule) = self.no_static_element_interactions.as_ref() { + if let Some(rule) = self.no_secrets.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_substr.as_ref() { + if let Some(rule) = self.no_static_element_interactions.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } } - if let Some(rule) = self.no_template_curly_in_string.as_ref() { + if let Some(rule) = self.no_substr.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_pseudo_class.as_ref() { + if let Some(rule) = self.no_template_curly_in_string.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_pseudo_element.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_class.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } } - if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { + if let Some(rule) = self.no_unknown_pseudo_element.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.no_value_at_rule.as_ref() { + if let Some(rule) = self.no_useless_escape_in_regex.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } } - if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { + if let Some(rule) = self.no_value_at_rule.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { + if let Some(rule) = self.use_adjacent_overload_signatures.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } - if let Some(rule) = self.use_component_export_only_modules.as_ref() { + if let Some(rule) = self.use_aria_props_supported_by_role.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } } - if let Some(rule) = self.use_consistent_curly_braces.as_ref() { + if let Some(rule) = self.use_component_export_only_modules.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_member_accessibility.as_ref() { + if let Some(rule) = self.use_consistent_curly_braces.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } } - if let Some(rule) = self.use_deprecated_reason.as_ref() { + if let Some(rule) = self.use_consistent_member_accessibility.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_function_return_type.as_ref() { + if let Some(rule) = self.use_deprecated_reason.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.use_explicit_function_return_type.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } } - 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[30])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + 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[31])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[34])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3912,6 +3930,10 @@ impl Nursery { .no_common_js .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noDescendingSpecificity" => self + .no_descending_specificity + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noDuplicateCustomProperties" => self .no_duplicate_custom_properties .as_ref() diff --git a/crates/biome_css_analyze/src/lint/nursery.rs b/crates/biome_css_analyze/src/lint/nursery.rs index da0d81727c08..7910f2e76559 100644 --- a/crates/biome_css_analyze/src/lint/nursery.rs +++ b/crates/biome_css_analyze/src/lint/nursery.rs @@ -2,6 +2,7 @@ use biome_analyze::declare_lint_group; +pub mod no_descending_specificity; pub mod no_duplicate_custom_properties; pub mod no_irregular_whitespace; pub mod no_missing_var_function; @@ -13,6 +14,7 @@ declare_lint_group! { pub Nursery { name : "nursery" , rules : [ + self :: no_descending_specificity :: NoDescendingSpecificity , self :: no_duplicate_custom_properties :: NoDuplicateCustomProperties , self :: no_irregular_whitespace :: NoIrregularWhitespace , self :: no_missing_var_function :: NoMissingVarFunction , diff --git a/crates/biome_css_analyze/src/lint/nursery/no_descending_specificity.rs b/crates/biome_css_analyze/src/lint/nursery/no_descending_specificity.rs new file mode 100644 index 000000000000..94da4204046e --- /dev/null +++ b/crates/biome_css_analyze/src/lint/nursery/no_descending_specificity.rs @@ -0,0 +1,203 @@ +use rustc_hash::{FxHashMap, FxHashSet}; + +use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_css_semantic::model::{Rule as CssSemanticRule, RuleId, SemanticModel, Specificity}; +use biome_css_syntax::{AnyCssSelector, CssRoot}; +use biome_rowan::TextRange; + +use biome_rowan::AstNode; + +use crate::services::semantic::Semantic; + +declare_lint_rule! { + /// Disallow a lower specificity selector from coming after a higher specificity selector. + /// + /// This rule prohibits placing selectors with lower specificity after selectors with higher specificity. + /// By maintaining the order of the source and specificity as consistently as possible, it enhances readability. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```css,expect_diagnostic + /// b a { color: red; } + /// a { color: red; } + /// ``` + /// + /// ```css,expect_diagnostic + /// a { + /// & > b { color: red; } + /// } + /// b { color: red; } + /// ``` + /// + /// ```css,expect_diagnostic + /// :root input { + /// color: red; + /// } + /// html input { + /// color: red; + /// } + /// ``` + /// + /// + /// ### Valid + /// + /// ```css + /// a { color: red; } + /// b a { color: red; } + /// ``` + /// + /// ```css + /// b { color: red; } + /// a { + /// & > b { color: red; } + /// } + /// ``` + /// + /// ```css + /// a:hover { color: red; } + /// a { color: red; } + /// ``` + /// + /// ```css + /// a b { + /// color: red; + /// } + /// /* This selector is overwritten by the one above it, but this is not an error because the rule only evaluates it as a compound selector */ + /// :where(a) :is(b) { + /// color: blue; + /// } + /// ``` + /// + pub NoDescendingSpecificity { + version: "next", + name: "noDescendingSpecificity", + language: "css", + recommended: true, + sources: &[RuleSource::Stylelint("no-descending-specificity")], + } +} + +#[derive(Debug)] +pub struct DescendingSelector { + high: (TextRange, Specificity), + low: (TextRange, Specificity), +} +/// find tail selector +/// ```css +/// a b:hover { +/// ^^^^^^^ +/// } +/// ``` +fn find_tail_selector(selector: &AnyCssSelector) -> Option { + match selector { + AnyCssSelector::CssCompoundSelector(s) => { + let simple = s + .simple_selector() + .map_or(String::new(), |s| s.syntax().text_trimmed().to_string()); + let sub = s.sub_selectors().syntax().text_trimmed().to_string(); + + let last_selector = [simple, sub].join(""); + Some(last_selector) + } + AnyCssSelector::CssComplexSelector(s) => { + s.right().as_ref().ok().and_then(find_tail_selector) + } + _ => None, + } +} + +/// This function traverses the CSS rules starting from the given rule and checks for selectors that have the same tail selector. +/// For each selector, it compares its specificity with the previously encountered specificity of the same tail selector. +/// If a lower specificity selector is found after a higher specificity selector with the same tail selector, it records this as a descending selector. +fn find_descending_selector( + rule: &CssSemanticRule, + model: &SemanticModel, + visited_rules: &mut FxHashSet, + visited_selectors: &mut FxHashMap, + descending_selectors: &mut Vec, +) { + if visited_rules.contains(&rule.id) { + return; + } else { + visited_rules.insert(rule.id); + }; + + for selector in &rule.selectors { + let tail_selector = if let Some(s) = find_tail_selector(&selector.original) { + s + } else { + continue; + }; + + if let Some((last_textrange, last_specificity)) = visited_selectors.get(&tail_selector) { + if last_specificity > &selector.specificity { + descending_selectors.push(DescendingSelector { + high: (*last_textrange, last_specificity.clone()), + low: (selector.range, selector.specificity.clone()), + }); + } + } else { + visited_selectors.insert( + tail_selector, + (selector.range, selector.specificity.clone()), + ); + } + } + + for child_id in &rule.child_ids { + if let Some(child_rule) = model.get_rule_by_id(*child_id) { + find_descending_selector( + child_rule, + model, + visited_rules, + visited_selectors, + descending_selectors, + ); + } + } +} + +impl Rule for NoDescendingSpecificity { + type Query = Semantic; + type State = DescendingSelector; + type Signals = Vec; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let model = ctx.model(); + let mut visited_rules = FxHashSet::default(); + let mut visited_selectors = FxHashMap::default(); + let mut descending_selectors = Vec::new(); + for rule in model.rules() { + find_descending_selector( + rule, + model, + &mut visited_rules, + &mut visited_selectors, + &mut descending_selectors, + ); + } + + descending_selectors + } + + fn diagnostic(_: &RuleContext, node: &Self::State) -> Option { + Some( + RuleDiagnostic::new( + rule_category!(), + node.low.0, + markup! { + "Descending specificity selector found. This selector specificity is "{node.low.1.to_string()} + }, + ).detail(node.high.0, markup!( + "This selector specificity is "{node.high.1.to_string()} + )) + .note(markup! { + "Descending specificity selector may not applied. Consider rearranging the order of the selectors. See ""MDN web docs"" for more details." + }), + ) + } +} diff --git a/crates/biome_css_analyze/src/options.rs b/crates/biome_css_analyze/src/options.rs index 8f09248e25d6..c91337050f9b 100644 --- a/crates/biome_css_analyze/src/options.rs +++ b/crates/biome_css_analyze/src/options.rs @@ -2,6 +2,7 @@ use crate::lint; +pub type NoDescendingSpecificity = < lint :: nursery :: no_descending_specificity :: NoDescendingSpecificity as biome_analyze :: Rule > :: Options ; pub type NoDuplicateAtImportRules = < lint :: suspicious :: no_duplicate_at_import_rules :: NoDuplicateAtImportRules as biome_analyze :: Rule > :: Options ; pub type NoDuplicateCustomProperties = < lint :: nursery :: no_duplicate_custom_properties :: NoDuplicateCustomProperties as biome_analyze :: Rule > :: Options ; pub type NoDuplicateFontNames = < lint :: suspicious :: no_duplicate_font_names :: NoDuplicateFontNames as biome_analyze :: Rule > :: Options ; diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css new file mode 100644 index 000000000000..dabe1edd13c1 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css @@ -0,0 +1,7 @@ +b a { + color: red; +} + +a { + color: red; +} diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css.snap new file mode 100644 index 000000000000..e860dea23937 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/complex_selector.invalid.css.snap @@ -0,0 +1,40 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: complex_selector.invalid.css +--- +# Input +```css +b a { + color: red; +} + +a { + color: red; +} + +``` + +# Diagnostics +``` +complex_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Descending specificity selector found. This selector specificity is (0, 0, 1) + + 3 │ } + 4 │ + > 5 │ a { + │ ^ + 6 │ color: red; + 7 │ } + + i This selector specificity is (0, 0, 2) + + > 1 │ b a { + │ ^^^ + 2 │ color: red; + 3 │ } + + i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css new file mode 100644 index 000000000000..f4b9ff63a876 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css @@ -0,0 +1,15 @@ +:is(#a, a) f { + color: red; +} + +:is(a, b, c, d) f { + color: red; +} + +:is(#fake#fake#fake#fake#fake#fake, *) g { + color: red; +} + +:where(*) g { + color: red; +} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css.snap new file mode 100644 index 000000000000..03b6d35bb478 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/function_pseudo_selector.invalid.css.snap @@ -0,0 +1,73 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: function_pseudo_selector.invalid.css +--- +# Input +```css +:is(#a, a) f { + color: red; +} + +:is(a, b, c, d) f { + color: red; +} + +:is(#fake#fake#fake#fake#fake#fake, *) g { + color: red; +} + +:where(*) g { + color: red; +} +``` + +# Diagnostics +``` +function_pseudo_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━ + + ! Descending specificity selector found. This selector specificity is (0, 0, 2) + + 3 │ } + 4 │ + > 5 │ :is(a, b, c, d) f { + │ ^^^^^^^^^^^^^^^^^ + 6 │ color: red; + 7 │ } + + i This selector specificity is (1, 0, 1) + + > 1 │ :is(#a, a) f { + │ ^^^^^^^^^^^^ + 2 │ color: red; + 3 │ } + + i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details. + + +``` + +``` +function_pseudo_selector.invalid.css:13:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━ + + ! Descending specificity selector found. This selector specificity is (0, 0, 1) + + 11 │ } + 12 │ + > 13 │ :where(*) g { + │ ^^^^^^^^^^^ + 14 │ color: red; + 15 │ } + + i This selector specificity is (6, 0, 1) + + 7 │ } + 8 │ + > 9 │ :is(#fake#fake#fake#fake#fake#fake, *) g { + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 10 │ color: red; + 11 │ } + + i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css new file mode 100644 index 000000000000..f44130b1de57 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css @@ -0,0 +1,9 @@ +a { + & > b { + color: red; + } +} + +b { + color: red; +} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css.snap new file mode 100644 index 000000000000..8cadc52ec585 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/nested.invalid.css.snap @@ -0,0 +1,42 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: nested.invalid.css +--- +# Input +```css +a { + & > b { + color: red; + } +} + +b { + color: red; +} +``` + +# Diagnostics +``` +nested.invalid.css:7:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Descending specificity selector found. This selector specificity is (0, 0, 1) + + 5 │ } + 6 │ + > 7 │ b { + │ ^ + 8 │ color: red; + 9 │ } + + i This selector specificity is (0, 0, 2) + + 1 │ a { + > 2 │ & > b { + │ ^^^^^ + 3 │ color: red; + 4 │ } + + i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css new file mode 100644 index 000000000000..05a78fadd6eb --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css @@ -0,0 +1,7 @@ +a:hover #b { + color: red; +} + +a #b { + color: red; +} diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css.snap new file mode 100644 index 000000000000..d33be5459b12 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/simple_pseudo_selector.invalid.css.snap @@ -0,0 +1,40 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: simple_pseudo_selector.invalid.css +--- +# Input +```css +a:hover #b { + color: red; +} + +a #b { + color: red; +} + +``` + +# Diagnostics +``` +simple_pseudo_selector.invalid.css:5:1 lint/nursery/noDescendingSpecificity ━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Descending specificity selector found. This selector specificity is (1, 0, 1) + + 3 │ } + 4 │ + > 5 │ a #b { + │ ^^^^ + 6 │ color: red; + 7 │ } + + i This selector specificity is (1, 1, 1) + + > 1 │ a:hover #b { + │ ^^^^^^^^^^ + 2 │ color: red; + 3 │ } + + i Descending specificity selector may not applied. Consider rearranging the order of the selectors. See MDN web docs for more details. + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css new file mode 100644 index 000000000000..730a7a0b314e --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css @@ -0,0 +1,51 @@ +a { + color: red; +} + +b a { + color: red; +} + +d { + color: red; +} + +c { + &>d { + color: red; + } +} + +e:hover { + color: red; +} + +e { + color: red; +} + +:is(a, b, c, d) f { + color: red; +} + +:is(#a, a) f { + color: red; +} + +:where(#fake#fake#fake#fake#fake#fake, *) g { + color: red; +} + +:where(*) g { + color: red; +} + + +#h h { + color: red; +} + +/* This selector is overwritten by the one above it, but this is not an error because the rule only evaluates it as a compound selector */ +:where(#h) :is(h) { + color: red; +} \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css.snap new file mode 100644 index 000000000000..78ddffc7cafb --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDescendingSpecificity/valid.css.snap @@ -0,0 +1,58 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: valid.css +--- +# Input +```css +a { + color: red; +} + +b a { + color: red; +} + +d { + color: red; +} + +c { + &>d { + color: red; + } +} + +e:hover { + color: red; +} + +e { + color: red; +} + +:is(a, b, c, d) f { + color: red; +} + +:is(#a, a) f { + color: red; +} + +:where(#fake#fake#fake#fake#fake#fake, *) g { + color: red; +} + +:where(*) g { + color: red; +} + + +#h h { + color: red; +} + +/* This selector is overwritten by the one above it, but this is not an error because the rule only evaluates it as a compound selector */ +:where(#h) :is(h) { + color: red; +} +``` diff --git a/crates/biome_css_semantic/src/events.rs b/crates/biome_css_semantic/src/events.rs index 64b5dee7fedd..b2e6bb40c246 100644 --- a/crates/biome_css_semantic/src/events.rs +++ b/crates/biome_css_semantic/src/events.rs @@ -3,11 +3,12 @@ use std::{borrow::Cow, collections::VecDeque}; use biome_css_syntax::{ AnyCssSelector, CssDeclarationBlock, CssRelativeSelector, CssSyntaxKind::*, }; -use biome_rowan::{AstNode, SyntaxNodeCast, TextRange}; +use biome_rowan::{AstNode, SyntaxNodeCast, SyntaxNodeOptionExt, TextRange}; use crate::{ model::{CssProperty, CssValue}, semantic_model::model::Specificity, + specificity::{evaluate_complex_selector, evaluate_compound_selector}, }; const ROOT_SELECTOR: &str = ":root"; @@ -19,6 +20,7 @@ pub enum SemanticEvent { SelectorDeclaration { name: String, range: TextRange, + original: AnyCssSelector, specificity: Specificity, }, PropertyDeclaration { @@ -70,11 +72,23 @@ impl SemanticEventExtractor { self.current_rule_stack.push(range); } CSS_SELECTOR_LIST => { + if !matches!( + node.parent().kind(), + Some(CSS_QUALIFIED_RULE | CSS_NESTED_QUALIFIED_RULE) + ) { + return; + }; node.children() .filter_map(AnyCssSelector::cast) .for_each(|s| self.process_selector(s)); } CSS_RELATIVE_SELECTOR_LIST => { + if !matches!( + node.parent().kind(), + Some(CSS_QUALIFIED_RULE | CSS_NESTED_QUALIFIED_RULE) + ) { + return; + }; node.children() .filter_map(CssRelativeSelector::cast) .filter_map(|s| s.selector().ok()) @@ -108,20 +122,28 @@ impl SemanticEventExtractor { fn process_selector(&mut self, selector: AnyCssSelector) { match selector { AnyCssSelector::CssComplexSelector(s) => { - if let Ok(l) = s.left() { - self.add_selector_event(Cow::Borrowed(&l.text()), l.range()); - } - if let Ok(r) = s.right() { - self.add_selector_event(Cow::Borrowed(&r.text()), r.range()); - } + let specificity = evaluate_complex_selector(&s); + self.add_selector_event( + Cow::Borrowed(&s.text()), + s.range(), + AnyCssSelector::CssComplexSelector(s), + specificity, + ); } + AnyCssSelector::CssCompoundSelector(selector) => { let selector_text = selector.text(); if selector_text == ROOT_SELECTOR { self.stash.push_back(SemanticEvent::RootSelectorStart); self.is_in_root_selector = true; } - self.add_selector_event(Cow::Borrowed(&selector_text), selector.range()) + let specificity = evaluate_compound_selector(&selector); + self.add_selector_event( + Cow::Borrowed(&selector_text), + selector.range(), + AnyCssSelector::CssCompoundSelector(selector), + specificity, + ) } _ => {} } @@ -198,11 +220,18 @@ impl SemanticEventExtractor { }); } - fn add_selector_event(&mut self, name: Cow, range: TextRange) { + fn add_selector_event( + &mut self, + name: Cow, + range: TextRange, + original: AnyCssSelector, + specificity: Specificity, + ) { self.stash.push_back(SemanticEvent::SelectorDeclaration { name: name.into_owned(), range, - specificity: Specificity(0, 0, 0), // TODO: Implement this + original, + specificity, }); } diff --git a/crates/biome_css_semantic/src/semantic_model/builder.rs b/crates/biome_css_semantic/src/semantic_model/builder.rs index ec263240e54f..864c169707aa 100644 --- a/crates/biome_css_semantic/src/semantic_model/builder.rs +++ b/crates/biome_css_semantic/src/semantic_model/builder.rs @@ -6,7 +6,7 @@ use rustc_hash::FxHashMap; use super::model::{ CssDeclaration, CssGlobalCustomVariable, Rule, RuleId, Selector, SemanticModel, - SemanticModelData, + SemanticModelData, Specificity, }; use crate::events::SemanticEvent; @@ -66,6 +66,7 @@ impl SemanticModelBuilder { range, parent_id, child_ids: Vec::new(), + specificity: Specificity::default(), }; if let Some(&parent_id) = self.current_rule_stack.last() { @@ -95,15 +96,28 @@ impl SemanticModelBuilder { SemanticEvent::SelectorDeclaration { name, range, + original, specificity, } => { - if let Some(current_rule) = self.current_rule_stack.last_mut() { + let parent_specificity = self + .current_rule_stack + .last() + .and_then(|rule_id| self.rules_by_id.get(rule_id)) + .and_then(|rule| rule.parent_id) + .and_then(|parent_id| self.rules_by_id.get(&parent_id)) + .map(|parent| parent.specificity.clone()) + .unwrap_or_default(); + + if let Some(current_rule) = self.current_rule_stack.last() { let current_rule = self.rules_by_id.get_mut(current_rule).unwrap(); current_rule.selectors.push(Selector { name, range, - specificity, + original, + specificity: parent_specificity + specificity.clone(), }); + + current_rule.specificity += specificity; } } SemanticEvent::PropertyDeclaration { diff --git a/crates/biome_css_semantic/src/semantic_model/mod.rs b/crates/biome_css_semantic/src/semantic_model/mod.rs index 4372a8fdfae3..2038b0177447 100644 --- a/crates/biome_css_semantic/src/semantic_model/mod.rs +++ b/crates/biome_css_semantic/src/semantic_model/mod.rs @@ -1,5 +1,6 @@ pub mod builder; pub mod model; +pub mod specificity; use biome_css_syntax::CssRoot; use biome_rowan::AstNode; diff --git a/crates/biome_css_semantic/src/semantic_model/model.rs b/crates/biome_css_semantic/src/semantic_model/model.rs index 8dc2a2e3a562..f65e9e75f125 100644 --- a/crates/biome_css_semantic/src/semantic_model/model.rs +++ b/crates/biome_css_semantic/src/semantic_model/model.rs @@ -1,6 +1,6 @@ use std::{collections::BTreeMap, rc::Rc}; -use biome_css_syntax::CssRoot; +use biome_css_syntax::{AnyCssSelector, CssRoot}; use biome_rowan::{TextRange, TextSize}; use rustc_hash::FxHashMap; @@ -108,6 +108,9 @@ pub struct Rule { pub child_ids: Vec, /// The text range of this rule in the source document. pub range: TextRange, + /// Specificity context of this rule + /// See https://drafts.csswg.org/selectors-4/#specificity-rules + pub specificity: Specificity, } /// Represents a CSS selector. @@ -123,6 +126,7 @@ pub struct Selector { pub name: String, /// The text range of the selector in the source document. pub range: TextRange, + pub original: AnyCssSelector, /// The specificity of the selector. pub specificity: Specificity, } @@ -135,6 +139,46 @@ pub struct Selector { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Specificity(pub u32, pub u32, pub u32); +/// In CSS, when selectors are combined (e.g., in a compound selector), their specificities are summed. +/// This implementation mirrors that behavior by adding the ID, class, and type selector counts separately. +/// +/// Consider the following selector. +/// ```css +/// #id .class {} +/// ``` +/// +/// The specificity of each component is as follows: +/// - `#id` has a specificity of `Specificity(1, 0, 0)` +/// - `.class` has a specificity of `Specificity(0, 1, 0)` +/// +/// Therefore, the combined selector `#id .class` has a specificity of: +/// - `Specificity(1 + 0, 0 + 1, 0 + 0) = Specificity(1, 1, 0)` +/// +/// More details https://drafts.csswg.org/selectors/#example-d97bd125 +impl std::ops::Add for Specificity { + type Output = Specificity; + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0, self.1 + rhs.1, self.2 + rhs.2) + } +} + +impl std::ops::AddAssign for Specificity { + fn add_assign(&mut self, rhs: Self) { + self.0 = rhs.0; + self.1 = rhs.1; + self.2 = rhs.2; + } +} + +/// Formats the `Specificity` instance to match the notation used in the official CSS specification. +/// +/// More details https://www.w3.org/TR/selectors-4/#specificity-rules +impl std::fmt::Display for Specificity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {}, {})", self.0, self.1, self.2) + } +} + /// Represents a CSS declaration (property-value pair). /// ```css /// a { diff --git a/crates/biome_css_semantic/src/semantic_model/specificity.rs b/crates/biome_css_semantic/src/semantic_model/specificity.rs new file mode 100644 index 000000000000..041713d67e42 --- /dev/null +++ b/crates/biome_css_semantic/src/semantic_model/specificity.rs @@ -0,0 +1,183 @@ +use crate::semantic_model::model::Specificity; + +use biome_css_syntax::{ + AnyCssCompoundSelector, AnyCssPseudoClass, AnyCssRelativeSelector, AnyCssSelector, + AnyCssSimpleSelector, AnyCssSubSelector, CssComplexSelector, CssCompoundSelector, + CssPseudoClassSelector, +}; + +use biome_rowan::{AstNodeList, AstSeparatedList}; + +const ID_SPECIFICITY: Specificity = Specificity(1, 0, 0); +const CLASS_SPECIFICITY: Specificity = Specificity(0, 1, 0); +const TYPE_SPECIFICITY: Specificity = Specificity(0, 0, 1); +const ZERO_SPECIFICITY: Specificity = Specificity(0, 0, 0); + +fn evaluate_any_simple_selector(selector: &AnyCssSimpleSelector) -> Specificity { + match selector { + AnyCssSimpleSelector::CssTypeSelector(_) => TYPE_SPECIFICITY, + AnyCssSimpleSelector::CssUniversalSelector(_) => ZERO_SPECIFICITY, + } +} + +/// See https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity#the_is_not_has_and_css_nesting_exceptions +const fn evaluate_pseudo_function_selector(name: &str) -> Option { + match name.as_bytes() { + b"where" => None, + b"is" | b"not" | b"has" | b"matches" => Some(ZERO_SPECIFICITY), + _ => Some(CLASS_SPECIFICITY), + } +} + +fn evaluate_any_pseudo_class(class: &AnyCssPseudoClass) -> Specificity { + // https://www.w3.org/TR/selectors-4/#specificity-rules + match class { + AnyCssPseudoClass::CssBogusPseudoClass(_) => ZERO_SPECIFICITY, + AnyCssPseudoClass::CssPseudoClassFunctionCompoundSelector(selector) => { + CLASS_SPECIFICITY + + selector + .selector() + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_compound_selector(&s)) + } + AnyCssPseudoClass::CssPseudoClassFunctionCompoundSelectorList(selector_list) => { + let list_max = selector_list + .compound_selectors() + .iter() + .map(|s| s.map_or(ZERO_SPECIFICITY, |s| evaluate_any_compound_selector(&s))) + .reduce(|acc, e| acc.max(e)) + .unwrap_or(ZERO_SPECIFICITY); + + CLASS_SPECIFICITY + list_max + } + AnyCssPseudoClass::CssPseudoClassFunctionIdentifier(_) => CLASS_SPECIFICITY, + AnyCssPseudoClass::CssPseudoClassFunctionNth(_) => CLASS_SPECIFICITY, + AnyCssPseudoClass::CssPseudoClassFunctionRelativeSelectorList(selector_list) => { + if let Some(base) = selector_list + .name_token() + .ok() + .and_then(|name| evaluate_pseudo_function_selector(name.text())) + { + let list_max = selector_list + .relative_selectors() + .iter() + .map(|relative_selector| { + relative_selector + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_relative_selector(&s)) + }) + .reduce(|acc, e| acc.max(e)) + .unwrap_or(ZERO_SPECIFICITY); + base + list_max + } else { + ZERO_SPECIFICITY + } + } + AnyCssPseudoClass::CssPseudoClassFunctionSelector(s) => { + if let Some(base) = s + .name() + .ok() + .and_then(|name| evaluate_pseudo_function_selector(name.text())) + { + base + s.selector().map_or(ZERO_SPECIFICITY, |selector| { + evaluate_any_selector(&selector) + }) + } else { + ZERO_SPECIFICITY + } + } + AnyCssPseudoClass::CssPseudoClassFunctionSelectorList(selector_list) => { + if let Some(base) = selector_list + .name() + .ok() + .and_then(|name| evaluate_pseudo_function_selector(name.text())) + { + let list_max = selector_list + .selectors() + .iter() + .map(|selector| { + selector.map_or(ZERO_SPECIFICITY, |s| evaluate_any_selector(&s)) + }) + .reduce(|acc, e| acc.max(e)) + .unwrap_or(ZERO_SPECIFICITY); + base + list_max + } else { + ZERO_SPECIFICITY + } + } + AnyCssPseudoClass::CssPseudoClassFunctionValueList(_) => CLASS_SPECIFICITY, + AnyCssPseudoClass::CssPseudoClassIdentifier(_) => CLASS_SPECIFICITY, + } +} + +fn evaluate_pseudo_selector(selector: &CssPseudoClassSelector) -> Specificity { + match selector.class() { + Ok(any_pseudo_class) => evaluate_any_pseudo_class(&any_pseudo_class), + Err(_) => ZERO_SPECIFICITY, + } +} + +fn evaluate_any_subselector(selector: &AnyCssSubSelector) -> Specificity { + // https://www.w3.org/TR/selectors-4/#typedef-subclass-selector + match selector { + AnyCssSubSelector::CssIdSelector(_) => ID_SPECIFICITY, + AnyCssSubSelector::CssClassSelector(_) => CLASS_SPECIFICITY, + AnyCssSubSelector::CssAttributeSelector(_) => CLASS_SPECIFICITY, + AnyCssSubSelector::CssPseudoClassSelector(s) => evaluate_pseudo_selector(s), + AnyCssSubSelector::CssPseudoElementSelector(_) => TYPE_SPECIFICITY, + AnyCssSubSelector::CssBogusSubSelector(_) => ZERO_SPECIFICITY, + } +} + +pub fn evaluate_compound_selector(selector: &CssCompoundSelector) -> Specificity { + let nested_specificity = ZERO_SPECIFICITY; // TODO: Implement this + + let simple_specificity = selector + .simple_selector() + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_simple_selector(&s)); + let subselector_specificity = selector + .sub_selectors() + .iter() + .map(|s| evaluate_any_subselector(&s)) + .reduce(|acc, e| acc + e) + .unwrap_or(ZERO_SPECIFICITY); + + nested_specificity + simple_specificity + subselector_specificity +} + +fn evaluate_any_compound_selector(selector: &AnyCssCompoundSelector) -> Specificity { + match selector { + AnyCssCompoundSelector::CssBogusSelector(_) => ZERO_SPECIFICITY, + AnyCssCompoundSelector::CssCompoundSelector(s) => evaluate_compound_selector(s), + } +} + +pub fn evaluate_complex_selector(selector: &CssComplexSelector) -> Specificity { + let left_specificity = selector + .left() + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_selector(&s)); + let right_specificity = selector + .right() + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_selector(&s)); + + left_specificity + right_specificity +} + +pub fn evaluate_any_selector(selector: &AnyCssSelector) -> Specificity { + match selector { + AnyCssSelector::CssCompoundSelector(s) => evaluate_compound_selector(s), + AnyCssSelector::CssComplexSelector(s) => evaluate_complex_selector(s), + AnyCssSelector::CssBogusSelector(_) => ZERO_SPECIFICITY, + AnyCssSelector::CssMetavariable(_) => { + // TODO: Implement this + ZERO_SPECIFICITY + } + } +} + +fn evaluate_any_relative_selector(selector: &AnyCssRelativeSelector) -> Specificity { + match selector { + AnyCssRelativeSelector::CssBogusSelector(_) => ZERO_SPECIFICITY, + AnyCssRelativeSelector::CssRelativeSelector(s) => s + .selector() + .map_or(ZERO_SPECIFICITY, |s| evaluate_any_selector(&s)), + } +} diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index d39031092aa9..c8321d685ad8 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -136,6 +136,7 @@ define_categories! { "lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex", "lint/nursery/noCommonJs": "https://biomejs.dev/linter/rules/no-common-js", "lint/nursery/noConsole": "https://biomejs.dev/linter/rules/no-console", + "lint/nursery/noDescendingSpecificity": "https://biomejs.dev/linter/rules/no-descending-specificity", "lint/nursery/noDoneCallback": "https://biomejs.dev/linter/rules/no-done-callback", "lint/nursery/noDuplicateAtImportRules": "https://biomejs.dev/linter/rules/no-duplicate-at-import-rules", "lint/nursery/noDuplicateCustomProperties": "https://biomejs.dev/linter/rules/no-duplicate-custom-properties", diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 81fa5a018fc5..d10410948d90 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1222,6 +1222,10 @@ export interface Nursery { * Disallow use of CommonJs module system in favor of ESM style imports. */ noCommonJs?: RuleConfiguration_for_Null; + /** + * Disallow a lower specificity selector from coming after a higher specificity selector. + */ + noDescendingSpecificity?: RuleConfiguration_for_Null; /** * Disallow duplicate custom properties within declaration blocks. */ @@ -2833,6 +2837,7 @@ export type Category = | "lint/nursery/noColorInvalidHex" | "lint/nursery/noCommonJs" | "lint/nursery/noConsole" + | "lint/nursery/noDescendingSpecificity" | "lint/nursery/noDoneCallback" | "lint/nursery/noDuplicateAtImportRules" | "lint/nursery/noDuplicateCustomProperties" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index b45098e9db28..9f725c5c0a2e 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2083,6 +2083,13 @@ { "type": "null" } ] }, + "noDescendingSpecificity": { + "description": "Disallow a lower specificity selector from coming after a higher specificity selector.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "noDuplicateCustomProperties": { "description": "Disallow duplicate custom properties within declaration blocks.", "anyOf": [