|
| 1 | +use biome_analyze::{ |
| 2 | + Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule, |
| 3 | +}; |
| 4 | +use biome_console::markup; |
| 5 | +use biome_diagnostics::Severity; |
| 6 | +use biome_js_syntax::{ |
| 7 | + AnyJsBindingPattern, AnyJsImportClause, AnyJsImportLike, AnyJsObjectBindingPatternMember, |
| 8 | + JsCallExpression, JsVariableDeclarator, |
| 9 | +}; |
| 10 | +use biome_rowan::{AstNode, TextRange, TokenText}; |
| 11 | +use biome_rule_options::use_react_native_platform_components::UseReactNativePlatformComponentsOptions; |
| 12 | + |
| 13 | +declare_lint_rule! { |
| 14 | + /// Ensure that platform-specific React Native components are only |
| 15 | + /// imported in files named for that platform. |
| 16 | + /// |
| 17 | + /// Some React Native components only work on one platform. For example, |
| 18 | + /// `ProgressBarAndroid` is Android-only and `ActivityIndicatorIOS` is |
| 19 | + /// iOS-only. These components should live in files with a matching |
| 20 | + /// platform suffix such as `.android.js` or `.ios.js`, so the React |
| 21 | + /// Native bundler can ship the right code to each platform. |
| 22 | + /// |
| 23 | + /// This rule reports an error when a platform-specific component is |
| 24 | + /// imported in a file that does not have the matching suffix, or when |
| 25 | + /// both Android and iOS components are imported in the same file. |
| 26 | + /// |
| 27 | + /// ## Examples |
| 28 | + /// |
| 29 | + /// ### Invalid |
| 30 | + /// |
| 31 | + /// Importing an Android component in a non-Android file: |
| 32 | + /// |
| 33 | + /// ```js,expect_diagnostic |
| 34 | + /// import { ProgressBarAndroid } from "react-native"; |
| 35 | + /// ``` |
| 36 | + /// |
| 37 | + /// Importing an iOS component in a non-iOS file: |
| 38 | + /// |
| 39 | + /// ```js,expect_diagnostic |
| 40 | + /// import { ActivityIndicatorIOS } from "react-native"; |
| 41 | + /// ``` |
| 42 | + /// |
| 43 | + /// ### Valid |
| 44 | + /// |
| 45 | + /// ```js |
| 46 | + /// import { View } from "react-native"; |
| 47 | + /// ``` |
| 48 | + /// |
| 49 | + /// ## Options |
| 50 | + /// |
| 51 | + /// ### `androidPathPatterns` |
| 52 | + /// |
| 53 | + /// A list of glob patterns to identify Android-specific files. |
| 54 | + /// |
| 55 | + /// Default: `["**/*.android.{js,jsx,ts,tsx}"]` |
| 56 | + /// |
| 57 | + /// In the following example, Android files use `.droid.jsx` as their suffix instead of the default `.android.js`: |
| 58 | + /// |
| 59 | + /// ```json,options |
| 60 | + /// { |
| 61 | + /// "options": { |
| 62 | + /// "androidPathPatterns": ["**/*.droid.jsx"] |
| 63 | + /// } |
| 64 | + /// } |
| 65 | + /// ``` |
| 66 | + /// |
| 67 | + /// ```jsx,use_options,file=Button.droid.jsx |
| 68 | + /// import { ProgressBarAndroid } from "react-native"; |
| 69 | + /// ``` |
| 70 | + /// |
| 71 | + /// ```jsx,expect_diagnostic,use_options,file=Button.android.jsx |
| 72 | + /// import { ProgressBarAndroid } from "react-native"; |
| 73 | + /// ``` |
| 74 | + /// |
| 75 | + /// ### `iosPathPatterns` |
| 76 | + /// |
| 77 | + /// A list of glob patterns to identify iOS-specific files. |
| 78 | + /// |
| 79 | + /// Default: `["**/*.ios.{js,jsx,ts,tsx}"]` |
| 80 | + /// |
| 81 | + /// In the following example, iOS files use `.apple.jsx` as their suffix instead of the default `.ios.js`: |
| 82 | + /// |
| 83 | + /// ```json,options |
| 84 | + /// { |
| 85 | + /// "options": { |
| 86 | + /// "iosPathPatterns": ["**/*.apple.jsx"] |
| 87 | + /// } |
| 88 | + /// } |
| 89 | + /// ``` |
| 90 | + /// |
| 91 | + /// ```jsx,use_options,file=Button.apple.jsx |
| 92 | + /// import { ActivityIndicatorIOS } from "react-native"; |
| 93 | + /// ``` |
| 94 | + /// |
| 95 | + /// ```jsx,expect_diagnostic,use_options,file=Button.ios.jsx |
| 96 | + /// import { ActivityIndicatorIOS } from "react-native"; |
| 97 | + /// ``` |
| 98 | + /// |
| 99 | + pub UseReactNativePlatformComponents { |
| 100 | + version: "next", |
| 101 | + name: "useReactNativePlatformComponents", |
| 102 | + language: "js", |
| 103 | + sources: &[RuleSource::EslintReactNative("split-platform-components").inspired()], |
| 104 | + domains: &[RuleDomain::ReactNative], |
| 105 | + recommended: true, |
| 106 | + severity: Severity::Error, |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +impl Rule for UseReactNativePlatformComponents { |
| 111 | + type Query = Ast<AnyJsImportLike>; |
| 112 | + type State = RuleState; |
| 113 | + type Signals = Vec<Self::State>; |
| 114 | + type Options = UseReactNativePlatformComponentsOptions; |
| 115 | + |
| 116 | + fn run(ctx: &RuleContext<Self>) -> Self::Signals { |
| 117 | + let node = ctx.query(); |
| 118 | + |
| 119 | + let module_name = node.inner_string_text(); |
| 120 | + if module_name.as_ref().map(|t| t.text()) != Some("react-native") { |
| 121 | + return vec![]; |
| 122 | + } |
| 123 | + |
| 124 | + let component_names = collect_imported_names(node); |
| 125 | + if component_names.is_empty() { |
| 126 | + return vec![]; |
| 127 | + } |
| 128 | + |
| 129 | + let file_path = ctx.file_path().as_str(); |
| 130 | + let options = ctx.options(); |
| 131 | + |
| 132 | + let is_android_file = options |
| 133 | + .android_path_patterns() |
| 134 | + .iter() |
| 135 | + .any(|glob| glob.is_match(file_path)); |
| 136 | + let is_ios_file = !is_android_file |
| 137 | + && options |
| 138 | + .ios_path_patterns() |
| 139 | + .iter() |
| 140 | + .any(|glob| glob.is_match(file_path)); |
| 141 | + |
| 142 | + let mut has_android = false; |
| 143 | + let mut has_ios = false; |
| 144 | + for (_, name_text) in &component_names { |
| 145 | + let text = name_text.text(); |
| 146 | + has_android |= text.contains("Android"); |
| 147 | + has_ios |= text.contains("IOS"); |
| 148 | + } |
| 149 | + let has_conflict = has_android && has_ios; |
| 150 | + |
| 151 | + let mut results: Vec<RuleState> = Vec::new(); |
| 152 | + for (range, name_text) in component_names { |
| 153 | + let text = name_text.text(); |
| 154 | + if text.contains("Android") && !is_android_file { |
| 155 | + results.push(RuleState { |
| 156 | + kind: PlatformKind::Android, |
| 157 | + range, |
| 158 | + name: name_text, |
| 159 | + has_conflict, |
| 160 | + }); |
| 161 | + } else if text.contains("IOS") && !is_ios_file { |
| 162 | + results.push(RuleState { |
| 163 | + kind: PlatformKind::Ios, |
| 164 | + range, |
| 165 | + name: name_text, |
| 166 | + has_conflict, |
| 167 | + }); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + results |
| 172 | + } |
| 173 | + |
| 174 | + fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> { |
| 175 | + let name = state.name.text(); |
| 176 | + let diagnostic = if state.has_conflict { |
| 177 | + RuleDiagnostic::new( |
| 178 | + rule_category!(), |
| 179 | + state.range, |
| 180 | + markup! { |
| 181 | + "iOS and Android components cannot be mixed in the same file." |
| 182 | + }, |
| 183 | + ) |
| 184 | + .note(markup! { |
| 185 | + <Emphasis>{name}</Emphasis>" is a platform-specific component." |
| 186 | + }) |
| 187 | + .note(markup! { |
| 188 | + "Split iOS and Android components into separate platform-specific files." |
| 189 | + }) |
| 190 | + } else { |
| 191 | + match state.kind { |
| 192 | + PlatformKind::Android => RuleDiagnostic::new( |
| 193 | + rule_category!(), |
| 194 | + state.range, |
| 195 | + markup! { |
| 196 | + "Android component "<Emphasis>{name}</Emphasis>" is used outside of an Android-specific file." |
| 197 | + }, |
| 198 | + ) |
| 199 | + .note(markup! { |
| 200 | + "Platform-specific components produce incorrect bundles when imported in shared files." |
| 201 | + }) |
| 202 | + .note(markup! { |
| 203 | + "Move this import to a file with an Android-specific suffix (e.g. "<Emphasis>".android.js"</Emphasis>")." |
| 204 | + }), |
| 205 | + PlatformKind::Ios => RuleDiagnostic::new( |
| 206 | + rule_category!(), |
| 207 | + state.range, |
| 208 | + markup! { |
| 209 | + "iOS component "<Emphasis>{name}</Emphasis>" is used outside of an iOS-specific file." |
| 210 | + }, |
| 211 | + ) |
| 212 | + .note(markup! { |
| 213 | + "Platform-specific components produce incorrect bundles when imported in shared files." |
| 214 | + }) |
| 215 | + .note(markup! { |
| 216 | + "Move this import to a file with an iOS-specific suffix (e.g. "<Emphasis>".ios.js"</Emphasis>")." |
| 217 | + }), |
| 218 | + } |
| 219 | + }; |
| 220 | + |
| 221 | + Some(diagnostic) |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +enum PlatformKind { |
| 226 | + Android, |
| 227 | + Ios, |
| 228 | +} |
| 229 | + |
| 230 | +pub struct RuleState { |
| 231 | + kind: PlatformKind, |
| 232 | + range: TextRange, |
| 233 | + name: TokenText, |
| 234 | + has_conflict: bool, |
| 235 | +} |
| 236 | + |
| 237 | +/// Collects the names of components imported from `react-native`. |
| 238 | +/// |
| 239 | +/// Handles both ES module imports (`import { X } from 'react-native'`) |
| 240 | +/// and CommonJS requires (`const { X } = require('react-native')`). |
| 241 | +fn collect_imported_names(node: &AnyJsImportLike) -> Vec<(TextRange, TokenText)> { |
| 242 | + match node { |
| 243 | + AnyJsImportLike::JsModuleSource(source) => { |
| 244 | + let clause = source.parent::<AnyJsImportClause>(); |
| 245 | + let Some(named_specifiers) = clause.and_then(|c| c.named_specifiers()) else { |
| 246 | + return Vec::new(); |
| 247 | + }; |
| 248 | + named_specifiers |
| 249 | + .specifiers() |
| 250 | + .into_iter() |
| 251 | + .flatten() |
| 252 | + .filter_map(|spec| { |
| 253 | + let token = spec.imported_name()?; |
| 254 | + Some((spec.range(), token.token_text_trimmed())) |
| 255 | + }) |
| 256 | + .collect() |
| 257 | + } |
| 258 | + AnyJsImportLike::JsCallExpression(call) => collect_require_destructured_names(call), |
| 259 | + AnyJsImportLike::JsImportCallExpression(_) => Vec::new(), |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +/// Extracts destructured property names from `const { X } = require('react-native')`. |
| 264 | +fn collect_require_destructured_names(call: &JsCallExpression) -> Vec<(TextRange, TokenText)> { |
| 265 | + let Some(declarator) = call |
| 266 | + .syntax() |
| 267 | + .grand_parent() |
| 268 | + .and_then(JsVariableDeclarator::cast) |
| 269 | + else { |
| 270 | + return Vec::new(); |
| 271 | + }; |
| 272 | + |
| 273 | + let Ok(AnyJsBindingPattern::JsObjectBindingPattern(pattern)) = declarator.id() else { |
| 274 | + return Vec::new(); |
| 275 | + }; |
| 276 | + |
| 277 | + // This pattern doesn't handle nested bindings on purpose, as it's unlikely to happen. |
| 278 | + // However, if that happens, this code needs to be updated to handle it. |
| 279 | + pattern |
| 280 | + .properties() |
| 281 | + .into_iter() |
| 282 | + .flatten() |
| 283 | + .filter_map(|member| match &member { |
| 284 | + AnyJsObjectBindingPatternMember::JsObjectBindingPatternShorthandProperty(prop) => { |
| 285 | + let binding = prop.identifier().ok()?; |
| 286 | + let ident = binding.as_js_identifier_binding()?; |
| 287 | + let token = ident.name_token().ok()?; |
| 288 | + Some((prop.range(), token.token_text_trimmed())) |
| 289 | + } |
| 290 | + AnyJsObjectBindingPatternMember::JsObjectBindingPatternProperty(prop) => { |
| 291 | + let member = prop.member().ok()?; |
| 292 | + let ident = member.as_js_literal_member_name()?; |
| 293 | + let token = ident.value().ok()?; |
| 294 | + Some((prop.range(), token.token_text_trimmed())) |
| 295 | + } |
| 296 | + _ => None, |
| 297 | + }) |
| 298 | + .collect() |
| 299 | +} |
0 commit comments