diff --git a/.changeset/no-react-native-literal-colors.md b/.changeset/no-react-native-literal-colors.md new file mode 100644 index 000000000000..c689a5383e72 --- /dev/null +++ b/.changeset/no-react-native-literal-colors.md @@ -0,0 +1,24 @@ +--- +"@biomejs/biome": patch +--- + +Added the nursery rule [`noReactNativeLiteralColors`](https://biomejs.dev/linter/rules/no-react-native-literal-colors/), which disallows color literals inside React Native styles. + +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`. + +```jsx +// Invalid +const Hello = () => hi; + +const styles = StyleSheet.create({ + text: { color: 'red' } +}); +``` + +```jsx +// Valid +const red = '#f00'; +const styles = StyleSheet.create({ + text: { color: red } +}); +``` diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index 55d9b2465a1f..66f25e3fe628 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -3653,6 +3653,18 @@ pub(crate) fn migrate_eslint_any_rule( .get_or_insert(Default::default()); rule.set_level(rule.level().max(rule_severity.into())); } + "react-native/no-color-literals" => { + if !options.include_nursery { + results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery); + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .unwrap_group_as_mut() + .no_react_native_literal_colors + .get_or_insert(Default::default()); + rule.set_level(rule.level().max(rule_severity.into())); + } "react-native/no-raw-text" => { if !options.include_nursery { results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery); diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 253abd06030f..6cfb6f19ddf4 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -287,6 +287,7 @@ pub enum RuleName { NoQwikUseVisibleTask, NoReExportAll, NoReactForwardRef, + NoReactNativeLiteralColors, NoReactNativeRawText, NoReactPropAssignments, NoReactSpecificProps, @@ -780,6 +781,7 @@ impl RuleName { Self::NoQwikUseVisibleTask => "noQwikUseVisibleTask", Self::NoReExportAll => "noReExportAll", Self::NoReactForwardRef => "noReactForwardRef", + Self::NoReactNativeLiteralColors => "noReactNativeLiteralColors", Self::NoReactNativeRawText => "noReactNativeRawText", Self::NoReactPropAssignments => "noReactPropAssignments", Self::NoReactSpecificProps => "noReactSpecificProps", @@ -1269,6 +1271,7 @@ impl RuleName { Self::NoQwikUseVisibleTask => RuleGroup::Correctness, Self::NoReExportAll => RuleGroup::Performance, Self::NoReactForwardRef => RuleGroup::Suspicious, + Self::NoReactNativeLiteralColors => RuleGroup::Nursery, Self::NoReactNativeRawText => RuleGroup::Nursery, Self::NoReactPropAssignments => RuleGroup::Correctness, Self::NoReactSpecificProps => RuleGroup::Suspicious, @@ -1767,6 +1770,7 @@ impl std::str::FromStr for RuleName { "noQwikUseVisibleTask" => Ok(Self::NoQwikUseVisibleTask), "noReExportAll" => Ok(Self::NoReExportAll), "noReactForwardRef" => Ok(Self::NoReactForwardRef), + "noReactNativeLiteralColors" => Ok(Self::NoReactNativeLiteralColors), "noReactNativeRawText" => Ok(Self::NoReactNativeRawText), "noReactPropAssignments" => Ok(Self::NoReactPropAssignments), "noReactSpecificProps" => Ok(Self::NoReactSpecificProps), diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index fd800ddb3023..fb5d61845280 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -89,8 +89,12 @@ static REACT_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("suspicious", "noReactForwardRef"), ] }); -static REACTNATIVE_FILTERS: LazyLock>> = - LazyLock::new(|| vec![RuleFilter::Rule("nursery", "noReactNativeRawText")]); +static REACTNATIVE_FILTERS: LazyLock>> = LazyLock::new(|| { + vec![ + RuleFilter::Rule("nursery", "noReactNativeLiteralColors"), + RuleFilter::Rule("nursery", "noReactNativeRawText"), + ] +}); static SOLID_FILTERS: LazyLock>> = LazyLock::new(|| { vec![ RuleFilter::Rule("correctness", "noSolidDestructuredProps"), diff --git a/crates/biome_configuration/src/generated/linter_options_check.rs b/crates/biome_configuration/src/generated/linter_options_check.rs index dec79844d7aa..b4e14ef9bab4 100644 --- a/crates/biome_configuration/src/generated/linter_options_check.rs +++ b/crates/biome_configuration/src/generated/linter_options_check.rs @@ -938,6 +938,13 @@ pub fn config_side_rule_options_types() -> Vec<(&'static str, &'static str, Type "noReactForwardRef", TypeId::of::(), )); + result.push(( + "nursery", + "noReactNativeLiteralColors", + TypeId::of::< + biome_rule_options::no_react_native_literal_colors::NoReactNativeLiteralColorsOptions, + >(), + )); result.push(( "nursery", "noReactNativeRawText", diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index cc637027ee95..3e21e3f5a613 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -228,6 +228,7 @@ define_categories! { "lint/nursery/noPlaywrightWaitForSelector": "https://biomejs.dev/linter/rules/no-playwright-wait-for-selector", "lint/nursery/noPlaywrightWaitForTimeout": "https://biomejs.dev/linter/rules/no-playwright-wait-for-timeout", "lint/nursery/noProto": "https://biomejs.dev/linter/rules/no-proto", + "lint/nursery/noReactNativeLiteralColors": "https://biomejs.dev/linter/rules/no-react-native-literal-colors", "lint/nursery/noReactNativeRawText": "https://biomejs.dev/linter/rules/no-react-native-raw-text", "lint/nursery/noRedundantDefaultExport": "https://biomejs.dev/linter/rules/no-redundant-default-export", "lint/nursery/noReturnAssign": "https://biomejs.dev/linter/rules/no-return-assign", diff --git a/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs b/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs new file mode 100644 index 000000000000..501fdaed4915 --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/no_react_native_literal_colors.rs @@ -0,0 +1,193 @@ +use crate::frameworks::is_framework_api_reference; +use crate::services::semantic::Semantic; +use biome_analyze::{ + Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule, +}; +use biome_console::markup; +use biome_js_syntax::{ + AnyJsExpression, AnyJsLiteralExpression, JsCallExpression, JsPropertyObjectMember, JsxAttribute, +}; +use biome_rowan::{AstNode, TextRange, declare_node_union}; +use biome_rule_options::no_react_native_literal_colors::NoReactNativeLiteralColorsOptions; +use biome_string_case::StrLikeExtension; + +declare_lint_rule! { + /// Disallow color literals in React Native styles. + /// + /// Hard-coding colors inside styles makes it harder to keep them consistent + /// across components and to swap the palette when the design system evolves. + /// Extracting colors into named constants or a shared theme module produces + /// more maintainable code. + /// + /// This rule reports properties whose name contains `color` (case-insensitive) + /// and whose value is a string literal, when they appear inside a + /// `StyleSheet.create` call or inside a JSX attribute whose name contains + /// `style` (case-insensitive). A ternary expression is also reported when + /// either branch is a string literal. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + /// const Hello = () => hi; + /// ``` + /// + /// ```jsx,expect_diagnostic + /// const styles = StyleSheet.create({ + /// text: { color: 'red' } + /// }); + /// ``` + /// + /// ```jsx,expect_diagnostic + /// const Hello = (flag) => ( + /// hi + /// ); + /// ``` + /// + /// ### Valid + /// + /// ```jsx + /// const red = '#f00'; + /// const styles = StyleSheet.create({ + /// text: { color: red } + /// }); + /// ``` + /// + /// ```jsx + /// const Hello = () => ( + /// hi + /// ); + /// ``` + /// + pub NoReactNativeLiteralColors { + version: "next", + name: "noReactNativeLiteralColors", + language: "js", + sources: &[RuleSource::EslintReactNative("no-color-literals").same()], + domains: &[RuleDomain::ReactNative], + recommended: false, + } +} + +impl Rule for NoReactNativeLiteralColors { + type Query = Semantic; + type State = TextRange; + type Signals = Vec; + type Options = NoReactNativeLiteralColorsOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + match node { + AnyStyleSink::JsxAttribute(attribute) => { + if !is_style_attribute(attribute) { + return Vec::new(); + } + node.collect_color_literal_properties() + } + AnyStyleSink::JsCallExpression(call) => { + if !is_stylesheet_create(call, ctx.model()) { + return Vec::new(); + } + node.collect_color_literal_properties() + } + } + } + + fn diagnostic(_ctx: &RuleContext, range: &Self::State) -> Option { + Some( + RuleDiagnostic::new( + rule_category!(), + range, + markup! { + "Color literals are not allowed inside styles." + }, + ) + .note(markup! { + "Inline colors are hard to keep consistent across screens and to adapt when the design palette changes." + }) + .note(markup! { + "Extract the color into a named constant or a shared theme module, and reference it from the style." + }), + ) + } +} + +declare_node_union! { + /// The two places where React Native style objects can appear: a JSX + /// attribute like `style={...}` or a call like `StyleSheet.create(...)`. + pub AnyStyleSink = JsxAttribute | JsCallExpression +} + +impl AnyStyleSink { + /// Walks all descendant `JsPropertyObjectMember` nodes and returns the text + /// range of each one whose name contains `color` and whose value is a color + /// literal (a string, or a ternary where at least one branch is a string). + fn collect_color_literal_properties(&self) -> Vec { + self.syntax() + .descendants() + .filter_map(JsPropertyObjectMember::cast) + .filter(|property| { + property + .name() + .ok() + .and_then(|name| name.name()) + .is_some_and(|name| name.contains_ignore_ascii_case("color")) + }) + .filter(|property| { + property + .value() + .ok() + .is_some_and(|value| has_color_literal_value(&value)) + }) + .map(|property| property.range()) + .collect() + } +} + +fn has_color_literal_value(value: &AnyJsExpression) -> bool { + match value { + AnyJsExpression::AnyJsLiteralExpression( + AnyJsLiteralExpression::JsStringLiteralExpression(_), + ) => true, + AnyJsExpression::JsConditionalExpression(conditional) => { + conditional + .consequent() + .ok() + .is_some_and(|consequent| consequent.is_string_literal()) + || conditional + .alternate() + .ok() + .is_some_and(|alternate| alternate.is_string_literal()) + } + _ => false, + } +} + +fn is_style_attribute(attribute: &JsxAttribute) -> bool { + attribute + .name() + .ok() + .and_then(|name| name.name().ok()) + .is_some_and(|token| token.text_trimmed().contains_ignore_ascii_case("style")) +} + +/// Returns `true` when `call` is a call to `StyleSheet.create` where +/// `StyleSheet` is either imported from `react-native`/`react-native-web` or +/// is an unresolved global with that name. A `StyleSheet` identifier bound to +/// a user declaration (local variable, import from another package, …) is +/// rejected, so the rule only fires on the real React Native API. +fn is_stylesheet_create(call: &JsCallExpression, model: &biome_js_semantic::SemanticModel) -> bool { + let Ok(callee) = call.callee() else { + return false; + }; + is_framework_api_reference( + &callee, + model, + "create", + REACT_NATIVE_PACKAGE_NAMES, + Some("StyleSheet"), + ) +} + +const REACT_NATIVE_PACKAGE_NAMES: &[&str] = &["react-native", "react-native-web"]; diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx new file mode 100644 index 000000000000..2fe33f5a441a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx @@ -0,0 +1,32 @@ +/* should generate diagnostics */ + +const Inline = () => hello; + +const stylesBasic = StyleSheet.create({ + text: { fontColor: '#000' }, +}); + +const MultipleInSheet = StyleSheet.create({ + primary: { color: 'red' }, + secondary: { borderBottomColor: 'blue' }, +}); + +const InArray = () => ( + hello +); + +const InLogical = ({ active }) => ( + hello +); + +const TernaryBothLiterals = ({ active }) => ( + hello +); + +const TernaryOneLiteral = ({ active }) => ( + hello +); + +const CustomStyleAttribute = () => ( + hello +); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap new file mode 100644 index 000000000000..470f5a2f771e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalid.jsx.snap @@ -0,0 +1,223 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalid.jsx +--- +# Input +```jsx +/* should generate diagnostics */ + +const Inline = () => hello; + +const stylesBasic = StyleSheet.create({ + text: { fontColor: '#000' }, +}); + +const MultipleInSheet = StyleSheet.create({ + primary: { color: 'red' }, + secondary: { borderBottomColor: 'blue' }, +}); + +const InArray = () => ( + hello +); + +const InLogical = ({ active }) => ( + hello +); + +const TernaryBothLiterals = ({ active }) => ( + hello +); + +const TernaryOneLiteral = ({ active }) => ( + hello +); + +const CustomStyleAttribute = () => ( + hello +); + +``` + +# Diagnostics +``` +invalid.jsx:3:37 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 1 │ /* should generate diagnostics */ + 2 │ + > 3 │ const Inline = () => hello; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 4 │ + 5 │ const stylesBasic = StyleSheet.create({ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:6:10 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 5 │ const stylesBasic = StyleSheet.create({ + > 6 │ text: { fontColor: '#000' }, + │ ^^^^^^^^^^^^^^^^^ + 7 │ }); + 8 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:10:13 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 9 │ const MultipleInSheet = StyleSheet.create({ + > 10 │ primary: { color: 'red' }, + │ ^^^^^^^^^^^^ + 11 │ secondary: { borderBottomColor: 'blue' }, + 12 │ }); + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:11:15 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 9 │ const MultipleInSheet = StyleSheet.create({ + 10 │ primary: { color: 'red' }, + > 11 │ secondary: { borderBottomColor: 'blue' }, + │ ^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │ }); + 13 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:15:31 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 14 │ const InArray = () => ( + > 15 │ hello + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 16 │ ); + 17 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:19:41 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 18 │ const InLogical = ({ active }) => ( + > 19 │ hello + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 20 │ ); + 21 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:23:17 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 22 │ const TernaryBothLiterals = ({ active }) => ( + > 23 │ hello + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 24 │ ); + 25 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:27:17 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 26 │ const TernaryOneLiteral = ({ active }) => ( + > 27 │ hello + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 28 │ ); + 29 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` + +``` +invalid.jsx:31:33 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 30 │ const CustomStyleAttribute = () => ( + > 31 │ hello + │ ^^^^^^^^^^^^ + 32 │ ); + 33 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx new file mode 100644 index 000000000000..f62d3a2ef1b5 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx @@ -0,0 +1,7 @@ +/* should generate diagnostics */ + +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { color: 'red' }, +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap new file mode 100644 index 000000000000..a5cf05b8d07f --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/invalidReactNativeImport.jsx.snap @@ -0,0 +1,36 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalidReactNativeImport.jsx +--- +# Input +```jsx +/* should generate diagnostics */ + +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + text: { color: 'red' }, +}); + +``` + +# Diagnostics +``` +invalidReactNativeImport.jsx:6:10 lint/nursery/noReactNativeLiteralColors ━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i Color literals are not allowed inside styles. + + 5 │ const styles = StyleSheet.create({ + > 6 │ text: { color: 'red' }, + │ ^^^^^^^^^^^^ + 7 │ }); + 8 │ + + i Inline colors are hard to keep consistent across screens and to adapt when the design palette changes. + + i Extract the color into a named constant or a shared theme module, and reference it from the style. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx new file mode 100644 index 000000000000..e2aae5048864 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx @@ -0,0 +1,48 @@ +/* should not generate diagnostics */ + +const red = '#f00'; +const blue = '#00f'; + +const stylesFromVars = StyleSheet.create({ + title: { color: red }, + subtitle: { color: blue }, +}); + +const Themed = () => hello; + +const ConditionalVars = ({ isDanger }) => { + const trueColor = '#fff'; + const falseColor = '#000'; + return ( + + ); +}; + +const NonColorLiteral = StyleSheet.create({ + box: { fontFamily: 'Arial', padding: 10 }, +}); + +const ShorthandProperty = ({ color }) => ( + hello +); + +const OutsideStyleContext = { + backgroundColor: '#fff', +}; + +function paintBackground() { + return { backgroundColor: '#fff' }; +} + +const NonStyleSheetCreate = MyThing.create({ + box: { backgroundColor: '#fff' }, +}); + +const NonStyleAttribute = () => ( + hello +); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap new file mode 100644 index 000000000000..74bae1393e6b --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/valid.jsx.snap @@ -0,0 +1,56 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ + +const red = '#f00'; +const blue = '#00f'; + +const stylesFromVars = StyleSheet.create({ + title: { color: red }, + subtitle: { color: blue }, +}); + +const Themed = () => hello; + +const ConditionalVars = ({ isDanger }) => { + const trueColor = '#fff'; + const falseColor = '#000'; + return ( + + ); +}; + +const NonColorLiteral = StyleSheet.create({ + box: { fontFamily: 'Arial', padding: 10 }, +}); + +const ShorthandProperty = ({ color }) => ( + hello +); + +const OutsideStyleContext = { + backgroundColor: '#fff', +}; + +function paintBackground() { + return { backgroundColor: '#fff' }; +} + +const NonStyleSheetCreate = MyThing.create({ + box: { backgroundColor: '#fff' }, +}); + +const NonStyleAttribute = () => ( + hello +); + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx new file mode 100644 index 000000000000..5f2641fb028f --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx @@ -0,0 +1,7 @@ +/* should not generate diagnostics */ + +import { StyleSheet } from 'my-custom-lib'; + +const FromOtherPackage = StyleSheet.create({ + box: { backgroundColor: '#fff' }, +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap new file mode 100644 index 000000000000..a025c52b9d07 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validCustomStyleSheet.jsx.snap @@ -0,0 +1,15 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: validCustomStyleSheet.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ + +import { StyleSheet } from 'my-custom-lib'; + +const FromOtherPackage = StyleSheet.create({ + box: { backgroundColor: '#fff' }, +}); + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx new file mode 100644 index 000000000000..116124a084b7 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx @@ -0,0 +1,7 @@ +/* should not generate diagnostics */ + +const StyleSheet = { create: (value) => value }; + +const LocalSheet = StyleSheet.create({ + box: { backgroundColor: '#fff' }, +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap new file mode 100644 index 000000000000..fb1fdb5321e2 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noReactNativeLiteralColors/validLocalStyleSheet.jsx.snap @@ -0,0 +1,15 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: validLocalStyleSheet.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ + +const StyleSheet = { create: (value) => value }; + +const LocalSheet = StyleSheet.create({ + box: { backgroundColor: '#fff' }, +}); + +``` diff --git a/crates/biome_js_syntax/src/expr_ext.rs b/crates/biome_js_syntax/src/expr_ext.rs index 1984d9bf839d..ca93b86d270c 100644 --- a/crates/biome_js_syntax/src/expr_ext.rs +++ b/crates/biome_js_syntax/src/expr_ext.rs @@ -1367,6 +1367,13 @@ impl AnyJsExpression { .as_ref() .and_then(Self::inner_expression) } + + pub fn is_string_literal(&self) -> bool { + matches!( + self, + Self::AnyJsLiteralExpression(AnyJsLiteralExpression::JsStringLiteralExpression(_),) + ) + } } /// Returns `true` if this node is a transparent wrapper expression. diff --git a/crates/biome_rule_options/src/lib.rs b/crates/biome_rule_options/src/lib.rs index 763ef5c0cacb..d0a1eac88fe2 100644 --- a/crates/biome_rule_options/src/lib.rs +++ b/crates/biome_rule_options/src/lib.rs @@ -201,6 +201,7 @@ pub mod no_quickfix_biome; pub mod no_qwik_use_visible_task; pub mod no_re_export_all; pub mod no_react_forward_ref; +pub mod no_react_native_literal_colors; pub mod no_react_native_raw_text; pub mod no_react_prop_assignments; pub mod no_react_specific_props; diff --git a/crates/biome_rule_options/src/no_react_native_literal_colors.rs b/crates/biome_rule_options/src/no_react_native_literal_colors.rs new file mode 100644 index 000000000000..435a57871faa --- /dev/null +++ b/crates/biome_rule_options/src/no_react_native_literal_colors.rs @@ -0,0 +1,6 @@ +use biome_deserialize_macros::{Deserializable, Merge}; +use serde::{Deserialize, Serialize}; +#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct NoReactNativeLiteralColorsOptions {} diff --git a/crates/biome_string_case/src/lib.rs b/crates/biome_string_case/src/lib.rs index f9bb07de46cd..34bb1afbb961 100644 --- a/crates/biome_string_case/src/lib.rs +++ b/crates/biome_string_case/src/lib.rs @@ -659,6 +659,14 @@ pub trait StrLikeExtension: ToOwned { /// /// This orders Unicode code points based on their positions in the code charts. fn lexicographic_cmp(&self, other: &Self) -> Ordering; + + /// Returns `true` when `needle` appears anywhere inside `haystack`, treating + /// uppercase and lowercase ASCII letters as equal. For example, searching for + /// `"color"` inside `"backgroundColor"` returns `true`. + /// + /// Only ASCII letters are folded. Any other character (digits, punctuation, + /// or non-ASCII letters like `É`) must match byte-for-byte. + fn contains_ignore_ascii_case(&self, needle: &Self) -> bool; } pub trait StrOnlyExtension: ToOwned { @@ -688,6 +696,20 @@ impl StrLikeExtension for str { fn lexicographic_cmp(&self, other: &Self) -> Ordering { self.as_bytes().lexicographic_cmp(other.as_bytes()) } + + fn contains_ignore_ascii_case(&self, needle: &Self) -> bool { + let haystack = self.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() { + return true; + } + if haystack.len() < needle.len() { + return false; + } + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) + } } impl StrOnlyExtension for str { @@ -744,6 +766,11 @@ impl StrLikeExtension for std::ffi::OsStr { fn lexicographic_cmp(&self, other: &Self) -> Ordering { self.as_encoded_bytes().cmp(other.as_encoded_bytes()) } + + fn contains_ignore_ascii_case(&self, needle: &Self) -> bool { + self.as_encoded_bytes() + .contains_ignore_ascii_case(needle.as_encoded_bytes()) + } } impl StrLikeExtension for [u8] { @@ -763,6 +790,17 @@ impl StrLikeExtension for [u8] { fn lexicographic_cmp(&self, other: &Self) -> Ordering { self.iter().copied().cmp(other.iter().copied()) } + + fn contains_ignore_ascii_case(&self, needle: &Self) -> bool { + if needle.is_empty() { + return true; + } + if self.len() < needle.len() { + return false; + } + self.windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) + } } // TODO: Once trait-alias are stabilized it would be enough to `use` this trait instead of individual ones. @@ -1249,3 +1287,146 @@ mod tests { assert_eq!("0".ascii_nat_cmp("a"), Ordering::Less); } } + +#[cfg(test)] +mod contains_ignore_ascii_case_str { + use super::StrLikeExtension; + + #[test] + fn matches_exactly() { + assert!("color".contains_ignore_ascii_case("color")); + } + + #[test] + fn matches_at_start() { + assert!("colorScheme".contains_ignore_ascii_case("color")); + } + + #[test] + fn matches_at_end() { + assert!("backgroundcolor".contains_ignore_ascii_case("color")); + } + + #[test] + fn matches_in_the_middle() { + assert!("myColorValue".contains_ignore_ascii_case("color")); + } + + #[test] + fn ignores_ascii_case() { + assert!("backgroundColor".contains_ignore_ascii_case("color")); + assert!("BACKGROUNDCOLOR".contains_ignore_ascii_case("color")); + assert!("background".contains_ignore_ascii_case("GROUND")); + } + + #[test] + fn rejects_missing_needle() { + assert!(!"padding".contains_ignore_ascii_case("color")); + } + + #[test] + fn empty_needle_always_matches() { + assert!("anything".contains_ignore_ascii_case("")); + assert!("".contains_ignore_ascii_case("")); + } + + #[test] + fn needle_longer_than_haystack() { + assert!(!"hi".contains_ignore_ascii_case("hello")); + assert!(!"".contains_ignore_ascii_case("color")); + } + + #[test] + fn non_ascii_is_not_case_folded() { + assert!(!"café".contains_ignore_ascii_case("CAFÉ")); + assert!("café".contains_ignore_ascii_case("café")); + } + + #[test] + fn punctuation_and_digits_must_match_exactly() { + assert!("color-2".contains_ignore_ascii_case("COLOR-2")); + assert!(!"color-2".contains_ignore_ascii_case("color_2")); + } +} + +#[cfg(test)] +mod contains_ignore_ascii_case_bytes { + use super::StrLikeExtension; + + #[test] + fn matches_exactly() { + assert!(b"color".contains_ignore_ascii_case(b"color".as_slice())); + } + + #[test] + fn ignores_ascii_case() { + assert!(b"backgroundColor".contains_ignore_ascii_case(b"color".as_slice())); + assert!(b"BACKGROUNDCOLOR".contains_ignore_ascii_case(b"color".as_slice())); + assert!(b"background".contains_ignore_ascii_case(b"GROUND".as_slice())); + } + + #[test] + fn rejects_missing_needle() { + assert!(!b"padding".contains_ignore_ascii_case(b"color".as_slice())); + } + + #[test] + fn empty_needle_always_matches() { + assert!(b"anything".contains_ignore_ascii_case(b"".as_slice())); + assert!(b"".contains_ignore_ascii_case(b"".as_slice())); + } + + #[test] + fn needle_longer_than_haystack() { + assert!(!b"hi".contains_ignore_ascii_case(b"hello".as_slice())); + assert!(!b"".contains_ignore_ascii_case(b"color".as_slice())); + } + + #[test] + fn punctuation_and_digits_must_match_exactly() { + assert!(b"color-2".contains_ignore_ascii_case(b"COLOR-2".as_slice())); + assert!(!b"color-2".contains_ignore_ascii_case(b"color_2".as_slice())); + } +} + +#[cfg(test)] +mod contains_ignore_ascii_case_osstr { + use std::ffi::OsStr; + + use super::StrLikeExtension; + + #[test] + fn matches_exactly() { + assert!(OsStr::new("color").contains_ignore_ascii_case(OsStr::new("color"))); + } + + #[test] + fn ignores_ascii_case() { + assert!(OsStr::new("backgroundColor").contains_ignore_ascii_case(OsStr::new("color"))); + assert!(OsStr::new("BACKGROUNDCOLOR").contains_ignore_ascii_case(OsStr::new("color"))); + assert!(OsStr::new("background").contains_ignore_ascii_case(OsStr::new("GROUND"))); + } + + #[test] + fn rejects_missing_needle() { + assert!(!OsStr::new("padding").contains_ignore_ascii_case(OsStr::new("color"))); + } + + #[test] + fn empty_needle_always_matches() { + assert!(OsStr::new("anything").contains_ignore_ascii_case(OsStr::new(""))); + assert!(OsStr::new("").contains_ignore_ascii_case(OsStr::new(""))); + } + + #[test] + fn needle_longer_than_haystack() { + assert!(!OsStr::new("hi").contains_ignore_ascii_case(OsStr::new("hello"))); + assert!(!OsStr::new("").contains_ignore_ascii_case(OsStr::new("color"))); + } + + #[test] + fn punctuation_and_digits_must_match_exactly() { + assert!(OsStr::new("color-2").contains_ignore_ascii_case(OsStr::new("COLOR-2"))); + assert!(!OsStr::new("color-2").contains_ignore_ascii_case(OsStr::new("color_2"))); + } +} diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 0bb87abcd2ef..194666a7dbec 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -2298,6 +2298,11 @@ See https://biomejs.dev/linter/rules/no-proto */ noProto?: NoProtoConfiguration; /** + * Disallow color literals in React Native styles. +See https://biomejs.dev/linter/rules/no-react-native-literal-colors + */ + noReactNativeLiteralColors?: NoReactNativeLiteralColorsConfiguration; + /** * Disallow raw text outside \ components in React Native. See https://biomejs.dev/linter/rules/no-react-native-raw-text */ @@ -4355,6 +4360,9 @@ export type NoPlaywrightWaitForTimeoutConfiguration = export type NoProtoConfiguration = | RulePlainConfiguration | RuleWithNoProtoOptions; +export type NoReactNativeLiteralColorsConfiguration = + | RulePlainConfiguration + | RuleWithNoReactNativeLiteralColorsOptions; export type NoReactNativeRawTextConfiguration = | RulePlainConfiguration | RuleWithNoReactNativeRawTextOptions; @@ -6126,6 +6134,10 @@ export interface RuleWithNoProtoOptions { level: RulePlainConfiguration; options?: NoProtoOptions; } +export interface RuleWithNoReactNativeLiteralColorsOptions { + level: RulePlainConfiguration; + options?: NoReactNativeLiteralColorsOptions; +} export interface RuleWithNoReactNativeRawTextOptions { level: RulePlainConfiguration; options?: NoReactNativeRawTextOptions; @@ -7743,6 +7755,7 @@ export type NoPlaywrightWaitForNavigationOptions = {}; export type NoPlaywrightWaitForSelectorOptions = {}; export type NoPlaywrightWaitForTimeoutOptions = {}; export type NoProtoOptions = {}; +export type NoReactNativeLiteralColorsOptions = {}; export interface NoReactNativeRawTextOptions { /** * Names of additional components that are allowed to contain raw text. @@ -8919,6 +8932,7 @@ export type Category = | "lint/nursery/noPlaywrightWaitForSelector" | "lint/nursery/noPlaywrightWaitForTimeout" | "lint/nursery/noProto" + | "lint/nursery/noReactNativeLiteralColors" | "lint/nursery/noReactNativeRawText" | "lint/nursery/noRedundantDefaultExport" | "lint/nursery/noReturnAssign" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index c9f0bcb6a836..6eba06eb2f1f 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -4740,6 +4740,16 @@ "type": "object", "additionalProperties": false }, + "NoReactNativeLiteralColorsConfiguration": { + "oneOf": [ + { "$ref": "#/$defs/RulePlainConfiguration" }, + { "$ref": "#/$defs/RuleWithNoReactNativeLiteralColorsOptions" } + ] + }, + "NoReactNativeLiteralColorsOptions": { + "type": "object", + "additionalProperties": false + }, "NoReactNativeRawTextConfiguration": { "oneOf": [ { "$ref": "#/$defs/RulePlainConfiguration" }, @@ -6351,6 +6361,13 @@ { "type": "null" } ] }, + "noReactNativeLiteralColors": { + "description": "Disallow color literals in React Native styles.\nSee https://biomejs.dev/linter/rules/no-react-native-literal-colors", + "anyOf": [ + { "$ref": "#/$defs/NoReactNativeLiteralColorsConfiguration" }, + { "type": "null" } + ] + }, "noReactNativeRawText": { "description": "Disallow raw text outside \\ components in React Native.\nSee https://biomejs.dev/linter/rules/no-react-native-raw-text", "anyOf": [ @@ -9308,6 +9325,15 @@ "additionalProperties": false, "required": ["level"] }, + "RuleWithNoReactNativeLiteralColorsOptions": { + "type": "object", + "properties": { + "level": { "$ref": "#/$defs/RulePlainConfiguration" }, + "options": { "$ref": "#/$defs/NoReactNativeLiteralColorsOptions" } + }, + "additionalProperties": false, + "required": ["level"] + }, "RuleWithNoReactNativeRawTextOptions": { "type": "object", "properties": {