Skip to content

Commit

Permalink
feat(linter): Implement useGenericFontNames (#2573)
Browse files Browse the repository at this point in the history
  • Loading branch information
togami2864 authored Apr 26, 2024
1 parent e96781a commit b3ed181
Show file tree
Hide file tree
Showing 14 changed files with 465 additions and 10 deletions.
29 changes: 25 additions & 4 deletions crates/biome_configuration/src/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_css_analyze/src/keywords.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub const BASIC_KEYWORDS: [&str; 5] = ["initial", "inherit", "revert", "revert-layer", "unset"];

pub const _SYSTEM_FONT_KEYWORDS: [&str; 6] = [
// https://drafts.csswg.org/css-fonts/#system-family-name-value
pub const SYSTEM_FAMILY_NAME_KEYWORDS: [&str; 6] = [
"caption",
"icon",
"menu",
Expand Down
5 changes: 2 additions & 3 deletions crates/biome_css_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,12 @@ mod tests {
String::from_utf8(buffer).unwrap()
}

const SOURCE: &str = r#".something {}
"#;
const SOURCE: &str = r#"@font-face { font-family: Gentium; }"#;

let parsed = parse_css(SOURCE, CssParserOptions::default());

let mut error_ranges: Vec<TextRange> = Vec::new();
let rule_filter = RuleFilter::Rule("nursery", "noDuplicateKeys");
let rule_filter = RuleFilter::Rule("nursery", "noMissingGenericFamilyKeyword");
let options = AnalyzerOptions::default();
analyze(
&parsed.tree(),
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_css_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod no_duplicate_font_names;
pub mod no_duplicate_selectors_keyframe_block;
pub mod no_important_in_keyframe;
pub mod no_unknown_unit;
pub mod use_generic_font_names;

declare_group! {
pub Nursery {
Expand All @@ -19,6 +20,7 @@ declare_group! {
self :: no_duplicate_selectors_keyframe_block :: NoDuplicateSelectorsKeyframeBlock ,
self :: no_important_in_keyframe :: NoImportantInKeyframe ,
self :: no_unknown_unit :: NoUnknownUnit ,
self :: use_generic_font_names :: UseGenericFontNames ,
]
}
}
170 changes: 170 additions & 0 deletions crates/biome_css_analyze/src/lint/nursery/use_generic_font_names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use biome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic, RuleSource};
use biome_console::markup;
use biome_css_syntax::{
AnyCssAtRule, AnyCssGenericComponentValue, AnyCssValue, CssAtRule,
CssGenericComponentValueList, CssGenericProperty, CssSyntaxKind,
};
use biome_rowan::{AstNode, SyntaxNodeCast, TextRange};

use crate::utils::{
find_font_family, is_css_variable, is_font_family_keyword, is_system_family_name_keyword,
};

declare_rule! {
/// Disallow a missing generic family keyword within font families.
///
/// The generic font family can be:
/// - placed anywhere in the font family list
/// - omitted if a keyword related to property inheritance or a system font is used
///
/// This rule checks the font and font-family properties.
/// The following special situations are ignored:
/// - Property with a keyword value such as `inherit`, `initial`.
/// - The last value being a CSS variable.
/// - `font-family` property in an `@font-face` rule.
///
/// ## Examples
///
/// ### Invalid
///
/// ```css,expect_diagnostic
/// a { font-family: Arial; }
/// ```
///
/// ```css,expect_diagnostic
/// a { font: normal 14px/32px -apple-system, BlinkMacSystemFont; }
/// ```
///
/// ### Valid
///
/// ```css
/// a { font-family: "Lucida Grande", "Arial", sans-serif; }
/// ```
///
/// ```css
/// a { font-family: inherit; }
/// ```
///
/// ```css
/// a { font-family: sans-serif; }
/// ```
///
/// ```css
/// a { font-family: var(--font); }
/// ```
///
/// ```css
/// @font-face { font-family: Gentium; }
/// ```
///
pub UseGenericFontNames {
version: "next",
name: "useGenericFontNames",
recommended: true,
sources: &[RuleSource::Stylelint("font-family-no-missing-generic-family-keyword")],
}
}

impl Rule for UseGenericFontNames {
type Query = Ast<CssGenericProperty>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
let node = ctx.query();
let property_name = node.name().ok()?.text().to_lowercase();

// Ignore `@font-face`. See more detail: https://drafts.csswg.org/css-fonts/#font-face-rule
if is_in_font_face_at_rule(node) {
return None;
}

let is_font_family = property_name == "font-family";
let is_font = property_name == "font";

if !is_font_family && !is_font {
return None;
}

// handle shorthand font property with special value
// e.g: { font: caption }, { font: inherit }
let properties = node.value();
if is_font && is_shorthand_font_property_with_keyword(&properties) {
return None;
}

let font_families = if is_font {
find_font_family(properties)
} else {
collect_font_family_properties(properties)
};

if font_families.is_empty() {
return None;
}

if has_generic_font_family_property(&font_families) {
return None;
}

// Ignore the last value if it's a CSS variable now.
let last_value = font_families.last()?;
if is_css_variable(&last_value.text()) {
return None;
}

Some(last_value.range())
}

fn diagnostic(_: &RuleContext<Self>, span: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
span,
markup! {
"Generic font family missing."
},
)
.note(markup! {
"Consider adding a generic font family as a fallback."
})
.footer_list(
markup! {
"For examples and more information, see" <Hyperlink href="https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family">" the MDN Web Docs"</Hyperlink>
},
&["serif", "sans-serif", "monospace", "etc."],
),
)
}
}

fn is_in_font_face_at_rule(node: &CssGenericProperty) -> bool {
node.syntax()
.ancestors()
.find(|n| n.kind() == CssSyntaxKind::CSS_AT_RULE)
.and_then(|n| n.cast::<CssAtRule>())
.and_then(|n| n.rule().ok())
.is_some_and(|n| matches!(n, AnyCssAtRule::CssFontFaceAtRule(_)))
}

fn is_shorthand_font_property_with_keyword(properties: &CssGenericComponentValueList) -> bool {
properties.into_iter().len() == 1
&& properties
.into_iter()
.any(|p| is_system_family_name_keyword(&p.text()))
}

fn has_generic_font_family_property(nodes: &[AnyCssValue]) -> bool {
nodes.iter().any(|n| is_font_family_keyword(&n.text()))
}

fn collect_font_family_properties(properties: CssGenericComponentValueList) -> Vec<AnyCssValue> {
properties
.into_iter()
.filter_map(|v| match v {
AnyCssGenericComponentValue::AnyCssValue(value) => Some(value),
_ => None,
})
.collect()
}
2 changes: 2 additions & 0 deletions crates/biome_css_analyze/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ pub type NoDuplicateSelectorsKeyframeBlock = < lint :: nursery :: no_duplicate_s
pub type NoImportantInKeyframe = < lint :: nursery :: no_important_in_keyframe :: NoImportantInKeyframe as biome_analyze :: Rule > :: Options ;
pub type NoUnknownUnit =
<lint::nursery::no_unknown_unit::NoUnknownUnit as biome_analyze::Rule>::Options;
pub type UseGenericFontNames =
<lint::nursery::use_generic_font_names::UseGenericFontNames as biome_analyze::Rule>::Options;
8 changes: 6 additions & 2 deletions crates/biome_css_analyze/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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,
FONT_WEIGHT_NUMERIC_KEYWORDS, LINE_HEIGHT_KEYWORDS, SYSTEM_FAMILY_NAME_KEYWORDS,
};
use biome_css_syntax::{AnyCssGenericComponentValue, AnyCssValue, CssGenericComponentValueList};
use biome_rowan::{AstNode, SyntaxNodeCast};
Expand All @@ -10,6 +10,10 @@ pub fn is_font_family_keyword(value: &str) -> bool {
BASIC_KEYWORDS.contains(&value) || FONT_FAMILY_KEYWORDS.contains(&value)
}

pub fn is_system_family_name_keyword(value: &str) -> bool {
BASIC_KEYWORDS.contains(&value) || SYSTEM_FAMILY_NAME_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)
Expand All @@ -27,7 +31,7 @@ pub fn is_css_variable(value: &str) -> bool {
value.to_lowercase().starts_with("var(")
}

// Get the font-families within a `font` shorthand property value.
/// Get the font-families within a `font` shorthand property value.
pub fn find_font_family(value: CssGenericComponentValueList) -> Vec<AnyCssValue> {
let mut font_families: Vec<AnyCssValue> = Vec::new();
for v in value {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
a { font-family: Arial; }
a { font-family: 'Arial', "Lucida Grande", Arial; }
a { fOnT-fAmIlY: ' Lucida Grande '; }
a { font-family: Times; }
a { FONT: italic 300 16px/30px Arial, " Arial"; }
a { font: normal 14px/32px -apple-system, BlinkMacSystemFont; }
a { font: 1em Lucida Grande, Arial, "sans-serif" }
Loading

0 comments on commit b3ed181

Please sign in to comment.