diff --git a/crates/biome_analyze/src/rule.rs b/crates/biome_analyze/src/rule.rs index 8079f2dfb68f..461a7cec2108 100644 --- a/crates/biome_analyze/src/rule.rs +++ b/crates/biome_analyze/src/rule.rs @@ -87,6 +87,8 @@ pub enum RuleSource { EslintMysticatea(&'static str), /// Rules from [Eslint Plugin Barrel Files](https://github.com/thepassle/eslint-plugin-barrel-files) EslintBarrelFiles(&'static str), + /// Rules from [Stylelint](https://github.com/stylelint/stylelint) + Stylelint(&'static str), } impl PartialEq for RuleSource { @@ -112,6 +114,7 @@ impl std::fmt::Display for RuleSource { RuleSource::EslintUnicorn(_) => write!(f, "eslint-plugin-unicorn"), RuleSource::EslintMysticatea(_) => write!(f, "eslint-plugin-mysticates"), RuleSource::EslintBarrelFiles(_) => write!(f, "eslint-plugin-barrel-files"), + RuleSource::Stylelint(_) => write!(f, "Stylelint"), } } } @@ -154,7 +157,8 @@ impl RuleSource { | Self::EslintStylistic(rule_name) | Self::EslintUnicorn(rule_name) | Self::EslintMysticatea(rule_name) - | Self::EslintBarrelFiles(rule_name) => rule_name, + | Self::EslintBarrelFiles(rule_name) + | Self::Stylelint(rule_name) => rule_name, } } @@ -173,6 +177,7 @@ impl RuleSource { Self::EslintUnicorn(rule_name) => format!("unicorn/{rule_name}"), Self::EslintMysticatea(rule_name) => format!("@mysticatea/{rule_name}"), Self::EslintBarrelFiles(rule_name) => format!("barrel-files/{rule_name}"), + Self::Stylelint(rule_name) => format!("stylelint/{rule_name}"), } } @@ -191,7 +196,8 @@ impl RuleSource { Self::EslintStylistic(rule_name) => format!("https://eslint.style/rules/default/{rule_name}"), Self::EslintUnicorn(rule_name) => format!("https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/{rule_name}.md"), Self::EslintMysticatea(rule_name) => format!("https://github.com/mysticatea/eslint-plugin/blob/master/docs/rules/{rule_name}.md"), - Self::EslintBarrelFiles(rule_name) => format!("https://github.com/thepassle/eslint-plugin-barrel-files/blob/main/docs/rules/{rule_name}.md") + Self::EslintBarrelFiles(rule_name) => format!("https://github.com/thepassle/eslint-plugin-barrel-files/blob/main/docs/rules/{rule_name}.md"), + Self::Stylelint(rule_name) => format!("https://github.com/stylelint/stylelint/blob/main/lib/rules/{rule_name}/README.md"), } } @@ -208,6 +214,10 @@ impl RuleSource { pub const fn is_eslint_plugin(&self) -> bool { !matches!(self, Self::Clippy(_) | Self::Eslint(_)) } + + pub const fn is_stylelint(&self) -> bool { + matches!(self, Self::Stylelint(_)) + } } #[derive(Debug, Default, Clone, Copy)] diff --git a/crates/biome_configuration/src/linter/rules.rs b/crates/biome_configuration/src/linter/rules.rs index 50740ae861bb..fbad2ad63e5c 100644 --- a/crates/biome_configuration/src/linter/rules.rs +++ b/crates/biome_configuration/src/linter/rules.rs @@ -2599,6 +2599,9 @@ pub struct Nursery { #[doc = "Disallow duplicate conditions in if-else-if chains"] #[serde(skip_serializing_if = "Option::is_none")] pub no_duplicate_else_if: Option>, + #[doc = "Disallow duplicate names within font families."] + #[serde(skip_serializing_if = "Option::is_none")] + pub no_duplicate_font_names: Option>, #[doc = "Disallow two keys with the same name inside a JSON object."] #[serde(skip_serializing_if = "Option::is_none")] pub no_duplicate_json_keys: Option>, @@ -2673,12 +2676,13 @@ impl DeserializableValidator for Nursery { } impl Nursery { const GROUP_NAME: &'static str = "nursery"; - pub(crate) const GROUP_RULES: [&'static str; 24] = [ + pub(crate) const GROUP_RULES: [&'static str; 25] = [ "noBarrelFile", "noColorInvalidHex", "noConsole", "noDoneCallback", "noDuplicateElseIf", + "noDuplicateFontNames", "noDuplicateJsonKeys", "noDuplicateTestHooks", "noEvolvingAny", @@ -2699,9 +2703,10 @@ impl Nursery { "useNodeAssertStrict", "useSortedClasses", ]; - const RECOMMENDED_RULES: [&'static str; 10] = [ + const RECOMMENDED_RULES: [&'static str; 11] = [ "noDoneCallback", "noDuplicateElseIf", + "noDuplicateFontNames", "noDuplicateJsonKeys", "noDuplicateTestHooks", "noEvolvingAny", @@ -2711,7 +2716,7 @@ impl Nursery { "noSuspiciousSemicolonInJsx", "noUselessTernary", ]; - const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 10] = [ + const RECOMMENDED_RULES_AS_FILTERS: [RuleFilter<'static>; 11] = [ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[3]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[5]), @@ -2720,10 +2725,11 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[8]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20]), ]; - const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 24] = [ + const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 25] = [ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), @@ -2748,6 +2754,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -2789,101 +2796,106 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - 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[5])); } } - if let Some(rule) = self.no_duplicate_test_hooks.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[6])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_test_hooks.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.no_excessive_nested_test_suites.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_exports_in_test.as_ref() { + if let Some(rule) = self.no_excessive_nested_test_suites.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_focused_tests.as_ref() { + if let Some(rule) = self.no_exports_in_test.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_misplaced_assertion.as_ref() { + if let Some(rule) = self.no_focused_tests.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.no_namespace_import.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[12])); } } - if let Some(rule) = self.no_nodejs_modules.as_ref() { + if let Some(rule) = self.no_namespace_import.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } - if let Some(rule) = self.no_re_export_all.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[14])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_re_export_all.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_skipped_tests.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[16])); } } - if let Some(rule) = self.no_suspicious_semicolon_in_jsx.as_ref() { + if let Some(rule) = self.no_skipped_tests.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_undeclared_dependencies.as_ref() { + if let Some(rule) = self.no_suspicious_semicolon_in_jsx.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_useless_ternary.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[19])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.no_useless_ternary.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.use_jsx_key_in_iterable.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[21])); } } - if let Some(rule) = self.use_node_assert_strict.as_ref() { + if let Some(rule) = self.use_jsx_key_in_iterable.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_node_assert_strict.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } + 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[24])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -2913,101 +2925,106 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[4])); } } - 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[5])); } } - if let Some(rule) = self.no_duplicate_test_hooks.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[6])); } } - if let Some(rule) = self.no_evolving_any.as_ref() { + if let Some(rule) = self.no_duplicate_test_hooks.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[7])); } } - if let Some(rule) = self.no_excessive_nested_test_suites.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_exports_in_test.as_ref() { + if let Some(rule) = self.no_excessive_nested_test_suites.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[9])); } } - if let Some(rule) = self.no_focused_tests.as_ref() { + if let Some(rule) = self.no_exports_in_test.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[10])); } } - if let Some(rule) = self.no_misplaced_assertion.as_ref() { + if let Some(rule) = self.no_focused_tests.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } } - if let Some(rule) = self.no_namespace_import.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[12])); } } - if let Some(rule) = self.no_nodejs_modules.as_ref() { + if let Some(rule) = self.no_namespace_import.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } } - if let Some(rule) = self.no_re_export_all.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[14])); } } - if let Some(rule) = self.no_restricted_imports.as_ref() { + if let Some(rule) = self.no_re_export_all.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } } - if let Some(rule) = self.no_skipped_tests.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[16])); } } - if let Some(rule) = self.no_suspicious_semicolon_in_jsx.as_ref() { + if let Some(rule) = self.no_skipped_tests.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } } - if let Some(rule) = self.no_undeclared_dependencies.as_ref() { + if let Some(rule) = self.no_suspicious_semicolon_in_jsx.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } } - if let Some(rule) = self.no_useless_ternary.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[19])); } } - if let Some(rule) = self.use_import_restrictions.as_ref() { + if let Some(rule) = self.no_useless_ternary.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } } - if let Some(rule) = self.use_jsx_key_in_iterable.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[21])); } } - if let Some(rule) = self.use_node_assert_strict.as_ref() { + if let Some(rule) = self.use_jsx_key_in_iterable.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_node_assert_strict.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } } + 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[24])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -3018,10 +3035,10 @@ impl Nursery { pub(crate) fn is_recommended_rule(rule_name: &str) -> bool { Self::RECOMMENDED_RULES.contains(&rule_name) } - pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 10] { + pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 11] { Self::RECOMMENDED_RULES_AS_FILTERS } - pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 24] { + pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 25] { Self::ALL_RULES_AS_FILTERS } #[doc = r" Select preset rules"] @@ -3064,6 +3081,10 @@ impl Nursery { .no_duplicate_else_if .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noDuplicateFontNames" => self + .no_duplicate_font_names + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noDuplicateJsonKeys" => self .no_duplicate_json_keys .as_ref() diff --git a/crates/biome_css_analyze/src/keywords.rs b/crates/biome_css_analyze/src/keywords.rs new file mode 100644 index 000000000000..7485b3687636 --- /dev/null +++ b/crates/biome_css_analyze/src/keywords.rs @@ -0,0 +1,83 @@ +pub const BASIC_KEYWORDS: [&str; 5] = ["initial", "inherit", "revert", "revert-layer", "unset"]; + +pub const _SYSTEM_FONT_KEYWORDS: [&str; 6] = [ + "caption", + "icon", + "menu", + "message-box", + "small-caption", + "status-bar", +]; + +pub const FONT_FAMILY_KEYWORDS: [&str; 10] = [ + "serif", + "sans-serif", + "cursive", + "fantasy", + "monospace", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", +]; + +pub const FONT_WEIGHT_ABSOLUTE_KEYWORDS: [&str; 2] = ["normal", "bold"]; +pub const FONT_WEIGHT_NUMERIC_KEYWORDS: [&str; 9] = [ + "100", "200", "300", "400", "500", "600", "700", "800", "900", +]; +pub const FONT_STYLE_KEYWORDS: [&str; 3] = ["normal", "italic", "oblique"]; +pub const FONT_VARIANTS_KEYWORDS: [&str; 35] = [ + "normal", + "none", + "historical-forms", + "none", + "common-ligatures", + "no-common-ligatures", + "discretionary-ligatures", + "no-discretionary-ligatures", + "historical-ligatures", + "no-historical-ligatures", + "contextual", + "no-contextual", + "small-caps", + "all-small-caps", + "petite-caps", + "all-petite-caps", + "unicase", + "titling-caps", + "lining-nums", + "oldstyle-nums", + "proportional-nums", + "tabular-nums", + "diagonal-fractions", + "stacked-fractions", + "ordinal", + "slashed-zero", + "jis78", + "jis83", + "jis90", + "jis04", + "simplified", + "traditional", + "full-width", + "proportional-width", + "ruby", +]; + +pub const FONT_STRETCH_KEYWORDS: [&str; 8] = [ + "semi-condensed", + "condensed", + "extra-condensed", + "ultra-condensed", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded", +]; + +pub const FONT_SIZE_KEYWORDS: [&str; 9] = [ + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "larger", "smaller", +]; + +pub const LINE_HEIGHT_KEYWORDS: [&str; 1] = ["normal"]; diff --git a/crates/biome_css_analyze/src/lib.rs b/crates/biome_css_analyze/src/lib.rs index a99a6caa1dd1..3f52f427a56b 100644 --- a/crates/biome_css_analyze/src/lib.rs +++ b/crates/biome_css_analyze/src/lib.rs @@ -1,6 +1,8 @@ +mod keywords; mod lint; pub mod options; mod registry; +mod utils; pub use crate::registry::visit_registry; use biome_analyze::{ diff --git a/crates/biome_css_analyze/src/lint/nursery.rs b/crates/biome_css_analyze/src/lint/nursery.rs index d102cb954659..75e80277d7fb 100644 --- a/crates/biome_css_analyze/src/lint/nursery.rs +++ b/crates/biome_css_analyze/src/lint/nursery.rs @@ -3,12 +3,14 @@ use biome_analyze::declare_group; pub mod no_color_invalid_hex; +pub mod no_duplicate_font_names; declare_group! { pub Nursery { name : "nursery" , rules : [ self :: no_color_invalid_hex :: NoColorInvalidHex , + self :: no_duplicate_font_names :: NoDuplicateFontNames , ] } } diff --git a/crates/biome_css_analyze/src/lint/nursery/no_duplicate_font_names.rs b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_font_names.rs new file mode 100644 index 000000000000..d6cedb15a93e --- /dev/null +++ b/crates/biome_css_analyze/src/lint/nursery/no_duplicate_font_names.rs @@ -0,0 +1,152 @@ +use std::collections::HashSet; + +use biome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_css_syntax::{AnyCssGenericComponentValue, AnyCssValue, CssGenericProperty}; +use biome_rowan::{AstNode, TextRange}; + +use crate::utils::{find_font_family, is_font_family_keyword}; + +declare_rule! { + /// Disallow duplicate names within font families. + /// + /// This rule checks the `font` and `font-family` properties for duplicate font names. + /// + /// This rule ignores var(--custom-property) variable syntaxes now. + /// + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```css,expect_diagnostic + /// a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } + /// ``` + /// + /// ```css,expect_diagnostic + /// a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } + /// ``` + /// + /// ```css,expect_diagnostic + /// a { FONT: italic 300 16px/30px Arial, " Arial", serif; } + /// ``` + /// + /// ### Valid + /// + /// ```css + /// a { font-family: "Lucida Grande", "Arial", sans-serif; } + /// ``` + /// + /// ```css + /// b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif; } + /// ``` + pub NoDuplicateFontNames { + version: "next", + name: "noDuplicateFontNames", + recommended: true, + sources: &[RuleSource::Stylelint("font-family-no-duplicate-names")], + } +} + +pub struct RuleState { + value: String, + span: TextRange, +} + +impl Rule for NoDuplicateFontNames { + type Query = Ast; + type State = RuleState; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Option { + let node = ctx.query(); + let property_name = node.name().ok()?.text().to_lowercase(); + + let is_font_family = property_name == "font-family"; + let is_font = property_name == "font"; + + if !is_font_family && !is_font { + return None; + } + + let mut unquoted_family_names: HashSet = HashSet::new(); + let mut family_names: HashSet = HashSet::new(); + let value_list = node.value(); + let font_families = if is_font { + find_font_family(value_list) + } else { + value_list + .into_iter() + .filter_map(|v| match v { + AnyCssGenericComponentValue::AnyCssValue(value) => Some(value), + _ => None, + }) + .collect() + }; + + for css_value in font_families { + match css_value { + // A generic family name like `sans-serif` or unquoted font name. + AnyCssValue::CssIdentifier(val) => { + let font_name = val.text(); + + // check the case: "Arial", Arial + // we ignore the case of the font name is a keyword(context: https://github.com/stylelint/stylelint/issues/1284) + // e.g "sans-serif", sans-serif + if family_names.contains(&font_name) && !is_font_family_keyword(&font_name) { + return Some(RuleState { + value: font_name, + span: val.range(), + }); + } + + // check the case: sans-self, sans-self + if unquoted_family_names.contains(&font_name) { + return Some(RuleState { + value: font_name, + span: val.range(), + }); + } + unquoted_family_names.insert(font_name); + } + // A font family name. e.g "Lucida Grande", "Arial". + AnyCssValue::CssString(val) => { + let normalized_font_name: String = val + .text() + .chars() + .filter(|&c| c != '\'' && c != '\"' && !c.is_whitespace()) + .collect(); + + if family_names.contains(&normalized_font_name) + || unquoted_family_names.contains(&normalized_font_name) + { + return Some(RuleState { + value: normalized_font_name, + span: val.range(), + }); + } + family_names.insert(normalized_font_name); + } + _ => continue, + } + } + None + } + + fn diagnostic(_: &RuleContext, state: &Self::State) -> Option { + let span = state.span; + Some( + RuleDiagnostic::new( + rule_category!(), + span, + markup! { + "Unexpected duplicate font name: "{ state.value } + }, + ) + .note(markup! { + "Remove duplicate font names within the property" + }), + ) + } +} diff --git a/crates/biome_css_analyze/src/options.rs b/crates/biome_css_analyze/src/options.rs index 2203533db392..e3f743001773 100644 --- a/crates/biome_css_analyze/src/options.rs +++ b/crates/biome_css_analyze/src/options.rs @@ -4,3 +4,5 @@ use crate::lint; pub type NoColorInvalidHex = ::Options; +pub type NoDuplicateFontNames = + ::Options; diff --git a/crates/biome_css_analyze/src/utils.rs b/crates/biome_css_analyze/src/utils.rs new file mode 100644 index 000000000000..a26f4c08ff13 --- /dev/null +++ b/crates/biome_css_analyze/src/utils.rs @@ -0,0 +1,94 @@ +use crate::keywords::{ + BASIC_KEYWORDS, FONT_FAMILY_KEYWORDS, FONT_SIZE_KEYWORDS, FONT_STRETCH_KEYWORDS, + FONT_STYLE_KEYWORDS, FONT_VARIANTS_KEYWORDS, FONT_WEIGHT_ABSOLUTE_KEYWORDS, + FONT_WEIGHT_NUMERIC_KEYWORDS, LINE_HEIGHT_KEYWORDS, +}; +use biome_css_syntax::{AnyCssGenericComponentValue, AnyCssValue, CssGenericComponentValueList}; +use biome_rowan::{AstNode, SyntaxNodeCast}; + +pub fn is_font_family_keyword(value: &str) -> bool { + BASIC_KEYWORDS.contains(&value) || FONT_FAMILY_KEYWORDS.contains(&value) +} + +// check if the value is a shorthand keyword used in `font` property +pub fn is_font_shorthand_keyword(value: &str) -> bool { + BASIC_KEYWORDS.contains(&value) + || FONT_STYLE_KEYWORDS.contains(&value) + || FONT_VARIANTS_KEYWORDS.contains(&value) + || FONT_WEIGHT_ABSOLUTE_KEYWORDS.contains(&value) + || FONT_WEIGHT_NUMERIC_KEYWORDS.contains(&value) + || FONT_STRETCH_KEYWORDS.contains(&value) + || FONT_SIZE_KEYWORDS.contains(&value) + || LINE_HEIGHT_KEYWORDS.contains(&value) + || FONT_FAMILY_KEYWORDS.contains(&value) +} + +pub fn is_css_variable(value: &str) -> bool { + value.to_lowercase().starts_with("var(") +} + +// Get the font-families within a `font` shorthand property value. +pub fn find_font_family(value: CssGenericComponentValueList) -> Vec { + let mut font_families: Vec = Vec::new(); + for v in value { + let lower_case_value = v.text().to_lowercase(); + + // Ignore CSS variables + if is_css_variable(&lower_case_value) { + continue; + } + + // Ignore keywords for other font parts + if is_font_shorthand_keyword(&lower_case_value) + && !is_font_family_keyword(&lower_case_value) + { + continue; + } + + // Ignore font-sizes + if matches!( + v, + AnyCssGenericComponentValue::AnyCssValue(AnyCssValue::AnyCssDimension(_)) + ) { + continue; + } + + // Ignore anything come after a /, because it's a line-height + if let Some(prev_node) = v.syntax().prev_sibling() { + if let Some(prev_prev_node) = prev_node.prev_sibling() { + if let Some(slash) = prev_node.cast::() { + if let Some(size) = prev_prev_node.cast::() { + if matches!( + size, + AnyCssGenericComponentValue::AnyCssValue(AnyCssValue::AnyCssDimension( + _ + )) + ) && matches!(slash, AnyCssGenericComponentValue::CssGenericDelimiter(_)) + { + continue; + } + } + }; + } + } + + // Ignore number values + if matches!( + v, + AnyCssGenericComponentValue::AnyCssValue(AnyCssValue::CssNumber(_)) + ) { + continue; + } + + match v { + AnyCssGenericComponentValue::CssGenericDelimiter(_) => continue, + AnyCssGenericComponentValue::AnyCssValue(css_value) => match css_value { + AnyCssValue::CssIdentifier(_) | AnyCssValue::CssString(_) => { + font_families.push(css_value) + } + _ => continue, + }, + } + } + font_families +} diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css new file mode 100644 index 000000000000..56db01dbb823 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css @@ -0,0 +1,6 @@ +a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } +a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } +a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } +a { font-family: 'Times', Times } +a { FONT: italic 300 16px/30px Arial, " Arial", serif; } +b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif, sans-serif; } \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css.snap new file mode 100644 index 000000000000..7a774e56b6b2 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/invalid.css.snap @@ -0,0 +1,110 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: invalid.css +--- +# Input +```css +a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } +a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } +a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } +a { font-family: 'Times', Times } +a { FONT: italic 300 16px/30px Arial, " Arial", serif; } +b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif, sans-serif; } +``` + +# Diagnostics +``` +invalid.css:1:56 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: sans-serif + + > 1 │ a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } + │ ^^^^^^^^^^ + 2 │ a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } + 3 │ a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } + + i Remove duplicate font names within the property + + +``` + +``` +invalid.css:2:44 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: Arial + + 1 │ a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } + > 2 │ a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } + │ ^^^^^ + 3 │ a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } + 4 │ a { font-family: 'Times', Times } + + i Remove duplicate font names within the property + + +``` + +``` +invalid.css:3:35 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: LucidaGrande + + 1 │ a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } + 2 │ a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } + > 3 │ a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } + │ ^^^^^^^^^^^^^^^^^^ + 4 │ a { font-family: 'Times', Times } + 5 │ a { FONT: italic 300 16px/30px Arial, " Arial", serif; } + + i Remove duplicate font names within the property + + +``` + +``` +invalid.css:4:27 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: Times + + 2 │ a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } + 3 │ a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } + > 4 │ a { font-family: 'Times', Times } + │ ^^^^^ + 5 │ a { FONT: italic 300 16px/30px Arial, " Arial", serif; } + 6 │ b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif, sans-serif; } + + i Remove duplicate font names within the property + + +``` + +``` +invalid.css:5:39 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: Arial + + 3 │ a { fOnT-fAmIlY: "Lucida Grande", ' Lucida Grande ', sans-serif; } + 4 │ a { font-family: 'Times', Times } + > 5 │ a { FONT: italic 300 16px/30px Arial, " Arial", serif; } + │ ^^^^^^^^ + 6 │ b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif, sans-serif; } + + i Remove duplicate font names within the property + + +``` + +``` +invalid.css:6:75 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Unexpected duplicate font name: sans-serif + + 4 │ a { font-family: 'Times', Times } + 5 │ a { FONT: italic 300 16px/30px Arial, " Arial", serif; } + > 6 │ b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif, sans-serif; } + │ ^^^^^^^^^^ + + i Remove duplicate font names within the property + + +``` diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css new file mode 100644 index 000000000000..0606eb41603c --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css @@ -0,0 +1,5 @@ +a { font-family: "Lucida Grande", "Arial", sans-serif; } +a { font: 1em "Lucida Grande", 'Arial', sans-serif; } +a { font: 1em "Lucida Grande", 'Arial', "sans-serif", sans-serif; } +a { font-family: Times, serif; } +b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif; } \ No newline at end of file diff --git a/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css.snap b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css.snap new file mode 100644 index 000000000000..e9c73be7ed38 --- /dev/null +++ b/crates/biome_css_analyze/tests/specs/nursery/noDuplicateFontNames/valid.css.snap @@ -0,0 +1,12 @@ +--- +source: crates/biome_css_analyze/tests/spec_tests.rs +expression: valid.css +--- +# Input +```css +a { font-family: "Lucida Grande", "Arial", sans-serif; } +a { font: 1em "Lucida Grande", 'Arial', sans-serif; } +a { font: 1em "Lucida Grande", 'Arial', "sans-serif", sans-serif; } +a { font-family: Times, serif; } +b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif; } +``` diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index 27b53f9b7d62..8021c4aab59b 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -119,6 +119,7 @@ define_categories! { "lint/nursery/noExcessiveNestedTestSuites": "https://biomejs.dev/linter/rules/no-excessive-nested-test-suites", "lint/nursery/noExportsInTest": "https://biomejs.dev/linter/rules/no-exports-in-test", "lint/nursery/noFocusedTests": "https://biomejs.dev/linter/rules/no-focused-tests", + "lint/nursery/noDuplicateFontNames": "https://biomejs.dev/linter/rules/no-font-family-duplicate-names", "lint/nursery/noMisplacedAssertion": "https://biomejs.dev/linter/rules/no-misplaced-assertion", "lint/nursery/noNamespaceImport": "https://biomejs.dev/linter/rules/no-namespace-import", "lint/nursery/noNodejsModules": "https://biomejs.dev/linter/rules/no-nodejs-modules", diff --git a/justfile b/justfile index 970efef32fd7..d72c4b5b8b73 100644 --- a/justfile +++ b/justfile @@ -113,8 +113,10 @@ test-doc: test-lintrule name: just _touch crates/biome_js_analyze/tests/spec_tests.rs just _touch crates/biome_json_analyze/tests/spec_tests.rs + just _touch crates/biome_css_analyze/tests/spec_tests.rs cargo test -p biome_js_analyze -- {{snakecase(name)}} --show-output cargo test -p biome_json_analyze -- {{snakecase(name)}} --show-output + cargo test -p biome_css_analyze -- {{snakecase(name)}} --show-output # Tests a lint rule. The name of the rule needs to be camel case test-transformation name: diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 19a3b7a0e3f9..06d53f2f9ac3 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -916,6 +916,10 @@ export interface Nursery { * Disallow duplicate conditions in if-else-if chains */ noDuplicateElseIf?: RuleConfiguration_for_Null; + /** + * Disallow duplicate names within font families. + */ + noDuplicateFontNames?: RuleConfiguration_for_Null; /** * Disallow two keys with the same name inside a JSON object. */ @@ -1919,6 +1923,7 @@ export type Category = | "lint/nursery/noExcessiveNestedTestSuites" | "lint/nursery/noExportsInTest" | "lint/nursery/noFocusedTests" + | "lint/nursery/noDuplicateFontNames" | "lint/nursery/noMisplacedAssertion" | "lint/nursery/noNamespaceImport" | "lint/nursery/noNodejsModules" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index 09e055e1be6a..3c9c718e6d71 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1435,6 +1435,13 @@ { "type": "null" } ] }, + "noDuplicateFontNames": { + "description": "Disallow duplicate names within font families.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] + }, "noDuplicateJsonKeys": { "description": "Disallow two keys with the same name inside a JSON object.", "anyOf": [ diff --git a/website/src/components/generated/NumberOfRules.astro b/website/src/components/generated/NumberOfRules.astro index f48db9a448f6..417b1a3a416a 100644 --- a/website/src/components/generated/NumberOfRules.astro +++ b/website/src/components/generated/NumberOfRules.astro @@ -1,2 +1,2 @@ -

Biome's linter has a total of 213 rules

\ No newline at end of file +

Biome's linter has a total of 214 rules

\ No newline at end of file diff --git a/website/src/content/docs/linter/rules/index.mdx b/website/src/content/docs/linter/rules/index.mdx index 691851433eb6..448267c37fe6 100644 --- a/website/src/content/docs/linter/rules/index.mdx +++ b/website/src/content/docs/linter/rules/index.mdx @@ -256,6 +256,7 @@ Rules that belong to this group are not subject to semantic versionconsole. | ⚠️ | | [noDoneCallback](/linter/rules/no-done-callback) | Disallow using a callback in asynchronous tests and hooks. | | | [noDuplicateElseIf](/linter/rules/no-duplicate-else-if) | Disallow duplicate conditions in if-else-if chains | | +| [noDuplicateFontNames](/linter/rules/no-duplicate-font-names) | Disallow duplicate names within font families. | | | [noDuplicateJsonKeys](/linter/rules/no-duplicate-json-keys) | Disallow two keys with the same name inside a JSON object. | | | [noDuplicateTestHooks](/linter/rules/no-duplicate-test-hooks) | A describe block should not contain duplicate hooks. | | | [noEvolvingAny](/linter/rules/no-evolving-any) | Disallow variables from evolving into any type through reassignments. | | diff --git a/website/src/content/docs/linter/rules/no-duplicate-font-names.md b/website/src/content/docs/linter/rules/no-duplicate-font-names.md new file mode 100644 index 000000000000..c3df6841967b --- /dev/null +++ b/website/src/content/docs/linter/rules/no-duplicate-font-names.md @@ -0,0 +1,88 @@ +--- +title: noDuplicateFontNames (not released) +--- + +**Diagnostic Category: `lint/nursery/noDuplicateFontNames`** + +:::danger +This rule hasn't been released yet. +::: + +:::caution +This rule is part of the [nursery](/linter/rules/#nursery) group. +::: + +Source: font-family-no-duplicate-names + +Disallow duplicate names within font families. + +This rule checks the `font` and `font-family` properties for duplicate font names. + +This rule ignores var(--custom-property) variable syntaxes now. + +## Examples + +### Invalid + +```css +a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; } +``` + +

nursery/noDuplicateFontNames.js:1:56 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Unexpected duplicate font name: sans-serif
+  
+  > 1 │ a { font-family: "Lucida Grande", 'Arial', sans-serif, sans-serif; }
+                                                          ^^^^^^^^^^
+    2 │ 
+  
+   Remove duplicate font names within the property
+  
+
+ +```css +a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; } +``` + +
nursery/noDuplicateFontNames.js:1:44 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Unexpected duplicate font name: Arial
+  
+  > 1 │ a { font-family: 'Arial', "Lucida Grande", Arial, sans-serif; }
+                                              ^^^^^
+    2 │ 
+  
+   Remove duplicate font names within the property
+  
+
+ +```css +a { FONT: italic 300 16px/30px Arial, " Arial", serif; } +``` + +
nursery/noDuplicateFontNames.js:1:39 lint/nursery/noDuplicateFontNames ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   Unexpected duplicate font name: Arial
+  
+  > 1 │ a { FONT: italic 300 16px/30px Arial, " Arial", serif; }
+                                         ^^^^^^^^
+    2 │ 
+  
+   Remove duplicate font names within the property
+  
+
+ +### Valid + +```css +a { font-family: "Lucida Grande", "Arial", sans-serif; } +``` + +```css +b { font: normal 14px/32px -apple-system, BlinkMacSystemFont, sans-serif; } +``` + +## Related links + +- [Disable a rule](/linter/#disable-a-lint-rule) +- [Rule options](/linter/#rule-options)