Skip to content

Commit c14c596

Browse files
committed
feat(lint): add nursery rule noArbitraryValue
1 parent f3e76ab commit c14c596

21 files changed

Lines changed: 821 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noArbitraryValue`](https://biomejs.dev/linter/rules/no-arbitrary-value/). Biome now reports Tailwind CSS arbitrary values such as `w-[400px]`, including in configured utility functions and tagged templates.

Cargo.lock

Lines changed: 2 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/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: 5 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_js_analyze/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ biome_rowan = { workspace = true }
4444
biome_rule_options = { workspace = true }
4545
biome_string_case = { workspace = true, features = ["biome_rowan"] }
4646
biome_suppression = { workspace = true }
47+
biome_tailwind_parser = { workspace = true }
48+
biome_tailwind_syntax = { workspace = true }
4749
biome_unicode_table = { workspace = true }
4850
bitvec = "1.0.1"
4951
camino = { workspace = true }
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
use crate::shared::any_class_string_like::AnyClassStringLike;
2+
use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule};
3+
use biome_console::markup;
4+
use biome_js_syntax::JsSyntaxKind;
5+
use biome_rowan::{AstNode, AstNodeList, TextRange, TextSize, TokenText};
6+
use biome_rule_options::no_arbitrary_value::NoArbitraryValueOptions;
7+
use biome_rule_options::use_sorted_classes::UseSortedClassesOptions;
8+
use biome_tailwind_parser::parse_tailwind;
9+
use biome_tailwind_syntax::{AnyTwCandidate, AnyTwFullCandidate, AnyTwModifier, AnyTwValue};
10+
11+
declare_lint_rule! {
12+
/// Disallow arbitrary values in Tailwind CSS utility classes.
13+
///
14+
/// Arbitrary values (e.g. `w-[400px]`, `text-[#555]`, `[color:red]`) bypass
15+
/// the design system. This rule reports them so teams can enforce a constraint-based approach.
16+
///
17+
/// ## Examples
18+
///
19+
/// ### Invalid
20+
///
21+
/// ```jsx,expect_diagnostic
22+
/// <div className="w-[400px]" />;
23+
/// ```
24+
///
25+
/// ```jsx,expect_diagnostic
26+
/// <div className="text-[#555] bg-white" />;
27+
/// ```
28+
///
29+
/// ```jsx,expect_diagnostic
30+
/// <div className="[color:red]" />;
31+
/// ```
32+
///
33+
/// ### Valid
34+
///
35+
/// ```jsx
36+
/// <div className="w-4 text-red-500 bg-white" />;
37+
/// ```
38+
///
39+
/// ```jsx
40+
/// <div className="[&:nth-child(3)]:px-2" />;
41+
/// ```
42+
///
43+
/// ## Options
44+
///
45+
/// ```json,options
46+
/// {
47+
/// "options": {
48+
/// "attributes": ["classList"],
49+
/// "functions": ["clsx", "cn", "classnames", "tw", "tw.*"]
50+
/// }
51+
/// }
52+
/// ```
53+
///
54+
/// ### attributes
55+
///
56+
/// Additional JSX attribute names to check (beyond the default `class` and `className`).
57+
///
58+
/// ### functions
59+
///
60+
/// Function or tagged template names whose classes will be checked for arbitrary values.
61+
///
62+
pub NoArbitraryValue {
63+
version: "next",
64+
name: "noArbitraryValue",
65+
language: "jsx",
66+
recommended: false,
67+
fix_kind: FixKind::None,
68+
}
69+
}
70+
71+
impl Rule for NoArbitraryValue {
72+
type Query = Ast<AnyClassStringLike>;
73+
type State = TextRange;
74+
type Signals = Vec<TextRange>;
75+
type Options = NoArbitraryValueOptions;
76+
77+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
78+
let node = ctx.query();
79+
let options = ctx.options();
80+
let visit_options = UseSortedClassesOptions {
81+
attributes: options.attributes.clone(),
82+
functions: options.functions.clone(),
83+
};
84+
85+
if node.should_visit(&visit_options).is_none() {
86+
return vec![];
87+
}
88+
89+
arbitrary_ranges_in_node(node)
90+
}
91+
92+
fn diagnostic(_ctx: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
93+
Some(
94+
RuleDiagnostic::new(
95+
rule_category!(),
96+
range,
97+
markup! { "Avoid arbitrary values in Tailwind CSS classes." },
98+
)
99+
.note(markup! {
100+
"Arbitrary values bypass the design system. Use a design token instead."
101+
}),
102+
)
103+
}
104+
}
105+
106+
struct ClassStringSource {
107+
text: TokenText,
108+
content_start: TextSize,
109+
}
110+
111+
fn class_string_source(node: &AnyClassStringLike) -> Option<ClassStringSource> {
112+
match node {
113+
AnyClassStringLike::JsxString(jsx_string) => {
114+
let token = jsx_string.value_token().ok()?;
115+
Some(ClassStringSource {
116+
text: jsx_string.inner_string_text().ok()?,
117+
content_start: token.text_trimmed_range().start() + TextSize::from(1),
118+
})
119+
}
120+
AnyClassStringLike::JsStringLiteralExpression(string_literal) => {
121+
let token = string_literal.value_token().ok()?;
122+
Some(ClassStringSource {
123+
text: string_literal.inner_string_text().ok()?,
124+
content_start: token.text_trimmed_range().start() + TextSize::from(1),
125+
})
126+
}
127+
AnyClassStringLike::JsTemplateChunkElement(chunk) => {
128+
let token = chunk.template_chunk_token().ok()?;
129+
Some(ClassStringSource {
130+
text: token.token_text(),
131+
content_start: token.text_trimmed_range().start(),
132+
})
133+
}
134+
AnyClassStringLike::JsLiteralMemberName(member_name) => {
135+
let token = member_name.value().ok()?;
136+
let quote_offset = if token.kind() == JsSyntaxKind::JS_STRING_LITERAL {
137+
TextSize::from(1)
138+
} else {
139+
TextSize::from(0)
140+
};
141+
142+
Some(ClassStringSource {
143+
text: member_name.name().ok()?,
144+
content_start: token.text_trimmed_range().start() + quote_offset,
145+
})
146+
}
147+
}
148+
}
149+
150+
fn class_ranges(text: &str) -> Vec<(usize, &str)> {
151+
let mut class_start = None;
152+
let mut classes = Vec::new();
153+
154+
for (index, ch) in text.char_indices() {
155+
if ch.is_ascii_whitespace() {
156+
if let Some(start) = class_start.take() {
157+
classes.push((start, &text[start..index]));
158+
}
159+
} else if class_start.is_none() {
160+
class_start = Some(index);
161+
}
162+
}
163+
164+
if let Some(start) = class_start {
165+
classes.push((start, &text[start..]));
166+
}
167+
168+
classes
169+
}
170+
171+
fn text_size(offset: usize) -> TextSize {
172+
TextSize::from(u32::try_from(offset).unwrap())
173+
}
174+
175+
fn push_arbitrary_value_range(
176+
results: &mut Vec<TextRange>,
177+
class_start: TextSize,
178+
value: Option<AnyTwValue>,
179+
) {
180+
if let Some(AnyTwValue::TwArbitraryValue(value)) = value {
181+
let range = value.syntax().text_trimmed_range();
182+
results.push(TextRange::new(
183+
class_start + range.start(),
184+
class_start + range.end(),
185+
));
186+
}
187+
}
188+
189+
fn push_modifier_range(
190+
results: &mut Vec<TextRange>,
191+
class_start: TextSize,
192+
modifier: Option<AnyTwModifier>,
193+
) {
194+
if let Some(AnyTwModifier::TwModifier(modifier)) = modifier {
195+
push_arbitrary_value_range(results, class_start, modifier.value().ok());
196+
}
197+
}
198+
199+
fn arbitrary_ranges_in_node(node: &AnyClassStringLike) -> Vec<TextRange> {
200+
let Some(source) = class_string_source(node) else {
201+
return vec![];
202+
};
203+
204+
let mut results = Vec::new();
205+
206+
for (class_offset, class_name) in class_ranges(source.text.text()) {
207+
let parse = parse_tailwind(class_name);
208+
let class_start = source.content_start + text_size(class_offset);
209+
210+
for candidate in parse.tree().candidates().iter() {
211+
let AnyTwFullCandidate::TwFullCandidate(candidate) = candidate else {
212+
continue;
213+
};
214+
215+
match candidate.candidate() {
216+
Ok(AnyTwCandidate::TwArbitraryCandidate(candidate)) => {
217+
let range = candidate.syntax().text_trimmed_range();
218+
results.push(TextRange::new(
219+
class_start + range.start(),
220+
class_start + range.end(),
221+
));
222+
push_modifier_range(&mut results, class_start, candidate.modifier());
223+
}
224+
Ok(AnyTwCandidate::TwFunctionalCandidate(candidate)) => {
225+
push_arbitrary_value_range(&mut results, class_start, candidate.value().ok());
226+
push_modifier_range(&mut results, class_start, candidate.modifier());
227+
}
228+
_ => {}
229+
}
230+
}
231+
}
232+
233+
results
234+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* should generate diagnostics */
2+
<div className="w-[400px]" />;
3+
<div className="text-[#555] bg-white" />;
4+
<div className="max-h-[calc(100dvh-40px)]" />;
5+
<div className="hover:w-[400px]" />;
6+
<div className="sm:hover:text-[1.5rem]" />;
7+
<div className="text-red-500/[0.31]" />;
8+
<div className="[color:red]" />;
9+
<div class="p-[10px]" />;
10+
<div className={`w-[400px] ${condition ? "p-4" : "m-2"}`} />;
11+
<div className={`${prefix} text-[#555]`} />;

0 commit comments

Comments
 (0)