Skip to content

Commit eaa06b4

Browse files
committed
feat(lint): add useTailwindShorthandClasses
1 parent 91ed677 commit eaa06b4

65 files changed

Lines changed: 3900 additions & 7 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ biome_ruledoc_utils = { path = "./crates/biome_ruledoc_utils" }
9292
biome_service = { path = "./crates/biome_service" }
9393
biome_string_case = { path = "./crates/biome_string_case", version = "0.5.7", features = ["biome_rowan"] }
9494
biome_suppression = { path = "./crates/biome_suppression", version = "0.5.7" }
95+
biome_tailwind_logic = { path = "./crates/biome_tailwind_logic", version = "0.1.0" }
9596
biome_tailwind_factory = { path = "./crates/biome_tailwind_factory", version = "0.0.1" }
9697
biome_tailwind_parser = { path = "./crates/biome_tailwind_parser", version = "0.0.1" }
9798
biome_tailwind_syntax = { path = "./crates/biome_tailwind_syntax", version = "0.0.1" }

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/linter_options_check.rs

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_html_analyze/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ biome_rowan = { workspace = true }
2828
biome_rule_options = { workspace = true }
2929
biome_string_case = { workspace = true }
3030
biome_suppression = { workspace = true }
31+
biome_tailwind_logic = { workspace = true }
32+
biome_tailwind_parser = { workspace = true }
3133
phf = { workspace = true }
3234
schemars = { workspace = true, optional = true }
3335
serde = { workspace = true, features = ["derive"] }

crates/biome_html_analyze/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod assist;
55
mod lint;
66
mod registry;
77
mod suppression_action;
8+
mod tailwind;
89
mod utils;
910

1011
pub use crate::registry::visit_registry;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
use crate::HtmlRuleAction;
2+
use crate::tailwind::{apply_fixed_class_string, class_string, host_range};
3+
use biome_analyze::{
4+
Ast, FixKind, Rule, RuleAction, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext,
5+
declare_lint_rule,
6+
};
7+
use biome_console::markup;
8+
use biome_html_syntax::HtmlAttribute;
9+
use biome_rowan::{AstNode, BatchMutationExt};
10+
use biome_rule_options::use_tailwind_shorthand_classes::UseTailwindShorthandClassesOptions;
11+
use biome_tailwind_logic::use_tailwind_shorthand_classes::{
12+
TailwindShorthandViolation, analyze_tailwind_shorthand, auto_fix,
13+
};
14+
use biome_tailwind_parser::parse_tailwind;
15+
16+
declare_lint_rule! {
17+
/// Enforce using fewer Tailwind utilities instead of multiple utilities that are functionally the same.
18+
///
19+
/// This rule detects sequences of Tailwind CSS utility classes that can be replaced by a single
20+
/// shorter utility. Using shorthands reduces duplication, keeps class lists readable, and helps
21+
/// prevent drift where one side gets updated but the matching side does not.
22+
///
23+
/// ## Examples
24+
///
25+
/// ### Invalid
26+
///
27+
/// ```html,expect_diagnostic
28+
/// <div class="w-4 h-4"></div>
29+
/// ```
30+
///
31+
/// ### Valid
32+
///
33+
/// ```html
34+
/// <div class="size-4"></div>
35+
/// ```
36+
///
37+
/// ## Options
38+
///
39+
/// ### `attributes`
40+
///
41+
/// ```json,options
42+
/// {
43+
/// "options": {
44+
/// "attributes": ["data-classes"]
45+
/// }
46+
/// }
47+
/// ```
48+
///
49+
/// Default: `[]`
50+
///
51+
/// The `class` attribute is always checked.
52+
/// Use this option to add more HTML attribute names that should be treated as Tailwind class lists.
53+
///
54+
/// #### Invalid
55+
///
56+
/// ```html,expect_diagnostic,use_options
57+
/// <div data-classes="w-4 h-4"></div>
58+
/// ```
59+
///
60+
/// #### Valid
61+
///
62+
/// ```html,use_options
63+
/// <div data-classes="size-4"></div>
64+
/// ```
65+
///
66+
pub UseTailwindShorthandClasses {
67+
version: "next",
68+
name: "useTailwindShorthandClasses",
69+
language: "html",
70+
domains: &[RuleDomain::Tailwind],
71+
sources: &[RuleSource::EslintBetterTailwindcss("enforce-shorthand-classes").inspired()],
72+
recommended: false,
73+
fix_kind: FixKind::Unsafe,
74+
}
75+
}
76+
77+
impl Rule for UseTailwindShorthandClasses {
78+
type Query = Ast<HtmlAttribute>;
79+
type State = TailwindShorthandViolation;
80+
type Signals = Box<[Self::State]>;
81+
type Options = UseTailwindShorthandClassesOptions;
82+
83+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
84+
let Some(class_list) = class_string(ctx.query(), ctx.options()) else {
85+
return Vec::new().into_boxed_slice();
86+
};
87+
88+
let parse = parse_tailwind(class_list.text());
89+
if parse.has_errors() {
90+
return Vec::new().into_boxed_slice();
91+
}
92+
93+
analyze_tailwind_shorthand(&parse.tree().candidates()).into_boxed_slice()
94+
}
95+
96+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
97+
let first_range = host_range(ctx.query(), state.uncompressed_nodes.first()?.range())?;
98+
99+
let mut diagnostic = RuleDiagnostic::new(
100+
rule_category!(),
101+
first_range,
102+
markup! {
103+
"These Tailwind classes can be replaced with a shorthand class."
104+
},
105+
);
106+
107+
for candidate in state.uncompressed_nodes.iter().skip(1) {
108+
if let Some(range) = host_range(ctx.query(), candidate.range()) {
109+
diagnostic = diagnostic.detail(
110+
range,
111+
markup! {
112+
"Compressible utility used here."
113+
},
114+
);
115+
}
116+
}
117+
118+
Some(diagnostic.note(markup! {
119+
"Using fewer classes reduces duplication and improves readability."
120+
}))
121+
}
122+
123+
fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<HtmlRuleAction> {
124+
let class_list = class_string(ctx.query(), ctx.options())?;
125+
126+
let parse = parse_tailwind(class_list.text());
127+
if parse.has_errors() {
128+
return None;
129+
}
130+
131+
let violations = analyze_tailwind_shorthand(&parse.tree().candidates());
132+
let fresh_state = find_matching_violation(state, &violations)?;
133+
let fixed = auto_fix(&parse.tree(), fresh_state)?.commit().to_string();
134+
135+
let mut mutation = ctx.root().begin();
136+
apply_fixed_class_string(&mut mutation, ctx.query(), &fixed)?;
137+
138+
Some(RuleAction::new(
139+
ctx.metadata().action_category(ctx.category(), ctx.group()),
140+
ctx.metadata().applicability(),
141+
markup! {
142+
"Use the Tailwind shorthand classes."
143+
}
144+
.to_owned(),
145+
mutation,
146+
))
147+
}
148+
}
149+
150+
fn find_matching_violation<'a>(
151+
state: &TailwindShorthandViolation,
152+
violations: &'a [TailwindShorthandViolation],
153+
) -> Option<&'a TailwindShorthandViolation> {
154+
violations.iter().find(|violation| {
155+
violation.replace_whole_node == state.replace_whole_node
156+
&& violation.replacement_bases == state.replacement_bases
157+
&& violation.uncompressed_nodes.len() == state.uncompressed_nodes.len()
158+
&& violation
159+
.uncompressed_nodes
160+
.iter()
161+
.zip(&state.uncompressed_nodes)
162+
.all(|(left, right)| {
163+
left.range() == right.range()
164+
&& left.syntax().text_trimmed() == right.syntax().text_trimmed()
165+
})
166+
})
167+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use biome_html_factory::make;
2+
use biome_html_syntax::{HtmlAttribute, HtmlLanguage, HtmlString, HtmlSyntaxKind, HtmlSyntaxToken};
3+
use biome_rowan::{BatchMutation, TextRange, TextSize, TokenText};
4+
use biome_rule_options::use_tailwind_shorthand_classes::UseTailwindShorthandClassesOptions;
5+
6+
pub(crate) trait TailwindClassStringOptions {
7+
fn has_attribute(&self, name: &str) -> bool;
8+
}
9+
10+
impl TailwindClassStringOptions for UseTailwindShorthandClassesOptions {
11+
fn has_attribute(&self, name: &str) -> bool {
12+
self.has_attribute(name)
13+
}
14+
}
15+
16+
pub(crate) fn class_string(
17+
attribute: &HtmlAttribute,
18+
options: &impl TailwindClassStringOptions,
19+
) -> Option<TokenText> {
20+
if !options.has_attribute(attribute_name(attribute)?.text_trimmed()) {
21+
return None;
22+
}
23+
24+
html_string(attribute)?.inner_string_text().ok()
25+
}
26+
27+
pub(crate) fn html_string(attribute: &HtmlAttribute) -> Option<HtmlString> {
28+
attribute
29+
.initializer()?
30+
.value()
31+
.ok()?
32+
.as_html_string()
33+
.cloned()
34+
}
35+
36+
pub(crate) fn host_range(attribute: &HtmlAttribute, range: TextRange) -> Option<TextRange> {
37+
let start = html_string(attribute)?
38+
.value_token()
39+
.ok()?
40+
.text_trimmed_range()
41+
.start()
42+
+ TextSize::from(1);
43+
Some(TextRange::new(start + range.start(), start + range.end()))
44+
}
45+
46+
pub(crate) fn apply_fixed_class_string(
47+
mutation: &mut BatchMutation<HtmlLanguage>,
48+
attribute: &HtmlAttribute,
49+
fixed: &str,
50+
) -> Option<()> {
51+
let html_string = html_string(attribute)?;
52+
let value_token = html_string.value_token().ok()?;
53+
let new_token = if value_token.text_trimmed().starts_with('\'') {
54+
html_string_literal_single_quotes(fixed)
55+
} else {
56+
make::html_string_literal(fixed)
57+
};
58+
mutation.replace_node(html_string, make::html_string(new_token));
59+
Some(())
60+
}
61+
62+
fn attribute_name(attribute: &HtmlAttribute) -> Option<biome_html_syntax::HtmlSyntaxToken> {
63+
attribute.name().ok()?.value_token().ok()
64+
}
65+
66+
fn html_string_literal_single_quotes(text: &str) -> HtmlSyntaxToken {
67+
HtmlSyntaxToken::new_detached(
68+
HtmlSyntaxKind::HTML_STRING_LITERAL,
69+
&format!("'{text}'"),
70+
[],
71+
[],
72+
)
73+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div data-classes="px-2 py-2"></div>

0 commit comments

Comments
 (0)