Skip to content

Commit 94ccca9

Browse files
feat(lint): add noReactNativeLiteralColors (#10012)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f42405f commit 94ccca9

23 files changed

Lines changed: 928 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noReactNativeLiteralColors`](https://biomejs.dev/linter/rules/no-react-native-literal-colors/), which disallows color literals inside React Native styles.
6+
7+
The rule belongs to the `reactNative` domain. It reports properties whose name contains `color` and whose value is a string literal when they appear inside a `StyleSheet.create(...)` call or inside a JSX attribute whose name contains `style`.
8+
9+
```jsx
10+
// Invalid
11+
const Hello = () => <Text style={{ backgroundColor: '#FFFFFF' }}>hi</Text>;
12+
13+
const styles = StyleSheet.create({
14+
text: { color: 'red' }
15+
});
16+
```
17+
18+
```jsx
19+
// Valid
20+
const red = '#f00';
21+
const styles = StyleSheet.create({
22+
text: { color: red }
23+
});
24+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 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/domain_selector.rs

Lines changed: 6 additions & 2 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.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use crate::frameworks::is_framework_api_reference;
2+
use crate::services::semantic::Semantic;
3+
use biome_analyze::{
4+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
5+
};
6+
use biome_console::markup;
7+
use biome_js_syntax::{
8+
AnyJsExpression, AnyJsLiteralExpression, JsCallExpression, JsPropertyObjectMember, JsxAttribute,
9+
};
10+
use biome_rowan::{AstNode, TextRange, declare_node_union};
11+
use biome_rule_options::no_react_native_literal_colors::NoReactNativeLiteralColorsOptions;
12+
use biome_string_case::StrLikeExtension;
13+
14+
declare_lint_rule! {
15+
/// Disallow color literals in React Native styles.
16+
///
17+
/// Hard-coding colors inside styles makes it harder to keep them consistent
18+
/// across components and to swap the palette when the design system evolves.
19+
/// Extracting colors into named constants or a shared theme module produces
20+
/// more maintainable code.
21+
///
22+
/// This rule reports properties whose name contains `color` (case-insensitive)
23+
/// and whose value is a string literal, when they appear inside a
24+
/// `StyleSheet.create` call or inside a JSX attribute whose name contains
25+
/// `style` (case-insensitive). A ternary expression is also reported when
26+
/// either branch is a string literal.
27+
///
28+
/// ## Examples
29+
///
30+
/// ### Invalid
31+
///
32+
/// ```jsx,expect_diagnostic
33+
/// const Hello = () => <Text style={{ backgroundColor: '#FFFFFF' }}>hi</Text>;
34+
/// ```
35+
///
36+
/// ```jsx,expect_diagnostic
37+
/// const styles = StyleSheet.create({
38+
/// text: { color: 'red' }
39+
/// });
40+
/// ```
41+
///
42+
/// ```jsx,expect_diagnostic
43+
/// const Hello = (flag) => (
44+
/// <Text style={{ backgroundColor: flag ? '#fff' : '#000' }}>hi</Text>
45+
/// );
46+
/// ```
47+
///
48+
/// ### Valid
49+
///
50+
/// ```jsx
51+
/// const red = '#f00';
52+
/// const styles = StyleSheet.create({
53+
/// text: { color: red }
54+
/// });
55+
/// ```
56+
///
57+
/// ```jsx
58+
/// const Hello = () => (
59+
/// <Text style={{ backgroundColor: theme.background }}>hi</Text>
60+
/// );
61+
/// ```
62+
///
63+
pub NoReactNativeLiteralColors {
64+
version: "next",
65+
name: "noReactNativeLiteralColors",
66+
language: "js",
67+
sources: &[RuleSource::EslintReactNative("no-color-literals").same()],
68+
domains: &[RuleDomain::ReactNative],
69+
recommended: false,
70+
}
71+
}
72+
73+
impl Rule for NoReactNativeLiteralColors {
74+
type Query = Semantic<AnyStyleSink>;
75+
type State = TextRange;
76+
type Signals = Vec<Self::State>;
77+
type Options = NoReactNativeLiteralColorsOptions;
78+
79+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
80+
let node = ctx.query();
81+
match node {
82+
AnyStyleSink::JsxAttribute(attribute) => {
83+
if !is_style_attribute(attribute) {
84+
return Vec::new();
85+
}
86+
node.collect_color_literal_properties()
87+
}
88+
AnyStyleSink::JsCallExpression(call) => {
89+
if !is_stylesheet_create(call, ctx.model()) {
90+
return Vec::new();
91+
}
92+
node.collect_color_literal_properties()
93+
}
94+
}
95+
}
96+
97+
fn diagnostic(_ctx: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
98+
Some(
99+
RuleDiagnostic::new(
100+
rule_category!(),
101+
range,
102+
markup! {
103+
"Color literals are not allowed inside styles."
104+
},
105+
)
106+
.note(markup! {
107+
"Inline colors are hard to keep consistent across screens and to adapt when the design palette changes."
108+
})
109+
.note(markup! {
110+
"Extract the color into a named constant or a shared theme module, and reference it from the style."
111+
}),
112+
)
113+
}
114+
}
115+
116+
declare_node_union! {
117+
/// The two places where React Native style objects can appear: a JSX
118+
/// attribute like `style={...}` or a call like `StyleSheet.create(...)`.
119+
pub AnyStyleSink = JsxAttribute | JsCallExpression
120+
}
121+
122+
impl AnyStyleSink {
123+
/// Walks all descendant `JsPropertyObjectMember` nodes and returns the text
124+
/// range of each one whose name contains `color` and whose value is a color
125+
/// literal (a string, or a ternary where at least one branch is a string).
126+
fn collect_color_literal_properties(&self) -> Vec<TextRange> {
127+
self.syntax()
128+
.descendants()
129+
.filter_map(JsPropertyObjectMember::cast)
130+
.filter(|property| {
131+
property
132+
.name()
133+
.ok()
134+
.and_then(|name| name.name())
135+
.is_some_and(|name| name.contains_ignore_ascii_case("color"))
136+
})
137+
.filter(|property| {
138+
property
139+
.value()
140+
.ok()
141+
.is_some_and(|value| has_color_literal_value(&value))
142+
})
143+
.map(|property| property.range())
144+
.collect()
145+
}
146+
}
147+
148+
fn has_color_literal_value(value: &AnyJsExpression) -> bool {
149+
match value {
150+
AnyJsExpression::AnyJsLiteralExpression(
151+
AnyJsLiteralExpression::JsStringLiteralExpression(_),
152+
) => true,
153+
AnyJsExpression::JsConditionalExpression(conditional) => {
154+
conditional
155+
.consequent()
156+
.ok()
157+
.is_some_and(|consequent| consequent.is_string_literal())
158+
|| conditional
159+
.alternate()
160+
.ok()
161+
.is_some_and(|alternate| alternate.is_string_literal())
162+
}
163+
_ => false,
164+
}
165+
}
166+
167+
fn is_style_attribute(attribute: &JsxAttribute) -> bool {
168+
attribute
169+
.name()
170+
.ok()
171+
.and_then(|name| name.name().ok())
172+
.is_some_and(|token| token.text_trimmed().contains_ignore_ascii_case("style"))
173+
}
174+
175+
/// Returns `true` when `call` is a call to `StyleSheet.create` where
176+
/// `StyleSheet` is either imported from `react-native`/`react-native-web` or
177+
/// is an unresolved global with that name. A `StyleSheet` identifier bound to
178+
/// a user declaration (local variable, import from another package, …) is
179+
/// rejected, so the rule only fires on the real React Native API.
180+
fn is_stylesheet_create(call: &JsCallExpression, model: &biome_js_semantic::SemanticModel) -> bool {
181+
let Ok(callee) = call.callee() else {
182+
return false;
183+
};
184+
is_framework_api_reference(
185+
&callee,
186+
model,
187+
"create",
188+
REACT_NATIVE_PACKAGE_NAMES,
189+
Some("StyleSheet"),
190+
)
191+
}
192+
193+
const REACT_NATIVE_PACKAGE_NAMES: &[&str] = &["react-native", "react-native-web"];
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* should generate diagnostics */
2+
3+
const Inline = () => <Text style={{ backgroundColor: '#FFFFFF', opacity: 0.5 }}>hello</Text>;
4+
5+
const stylesBasic = StyleSheet.create({
6+
text: { fontColor: '#000' },
7+
});
8+
9+
const MultipleInSheet = StyleSheet.create({
10+
primary: { color: 'red' },
11+
secondary: { borderBottomColor: 'blue' },
12+
});
13+
14+
const InArray = () => (
15+
<Text style={[styles.text, { backgroundColor: '#FFFFFF' }]}>hello</Text>
16+
);
17+
18+
const InLogical = ({ active }) => (
19+
<Text style={[styles.text, active && { backgroundColor: '#FFFFFF' }]}>hello</Text>
20+
);
21+
22+
const TernaryBothLiterals = ({ active }) => (
23+
<Text style={{ backgroundColor: active ? '#fff' : '#000' }}>hello</Text>
24+
);
25+
26+
const TernaryOneLiteral = ({ active }) => (
27+
<Text style={{ backgroundColor: active ? '#fff' : theme.background }}>hello</Text>
28+
);
29+
30+
const CustomStyleAttribute = () => (
31+
<Text contentContainerStyle={{ color: 'red' }}>hello</Text>
32+
);

0 commit comments

Comments
 (0)