From 3a6327a5d9ebfd0de56c37009ab7de1d0e6bdf85 Mon Sep 17 00:00:00 2001 From: Ramanpreet Nara Date: Tue, 22 Sep 2020 11:30:04 -0700 Subject: [PATCH] Open source react-native-modules ESLint rule Summary: Open source this ESLint rule so that we can lint our open source NativeModule specs. Changelog: [Internal] Reviewed By: shergin, cpojer Differential Revision: D23791748 fbshipit-source-id: e44444bc87eaa9dc9b7f2b3ed03151798a35e8a5 --- .eslintrc | 21 +- Libraries/Alert/NativeAlertManager.js | 1 + Libraries/Animated/NativeAnimatedModule.js | 2 +- .../Animated/NativeAnimatedTurboModule.js | 2 +- .../NativeStatusBarManagerAndroid.js | 1 + .../StatusBar/NativeStatusBarManagerIOS.js | 1 + Libraries/Core/NativeExceptionsManager.js | 1 + Libraries/Image/NativeImageEditor.js | 1 + .../specs/NativeDialogManagerAndroid.js | 1 + .../NativePermissionsAndroid.js | 1 + .../NativePushNotificationManagerIOS.js | 3 +- Libraries/Share/NativeShareModule.js | 1 + .../samples/NativeSampleTurboModule.js | 1 + packages/eslint-plugin-codegen/BUCK | 23 + .../__tests__/eslint-tester.js | 22 + .../__tests__/react-native-modules-test.js | 397 +++++++++++++++ packages/eslint-plugin-codegen/index.js | 19 + packages/eslint-plugin-codegen/package.json | 11 + .../react-native-modules.js | 467 ++++++++++++++++++ 19 files changed, 965 insertions(+), 11 deletions(-) create mode 100644 packages/eslint-plugin-codegen/BUCK create mode 100644 packages/eslint-plugin-codegen/__tests__/eslint-tester.js create mode 100644 packages/eslint-plugin-codegen/__tests__/react-native-modules-test.js create mode 100644 packages/eslint-plugin-codegen/index.js create mode 100644 packages/eslint-plugin-codegen/package.json create mode 100644 packages/eslint-plugin-codegen/react-native-modules.js diff --git a/.eslintrc b/.eslintrc index 47460cc9582..2c5a51053d8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,15 +5,20 @@ "./packages/eslint-config-react-native-community/index.js" ], + "plugins": [ + "@react-native/eslint-plugin-codegen" + ], + "overrides": [ { "files": [ "Libraries/**/*.js", ], - rules: { - '@react-native-community/no-haste-imports': 2, - '@react-native-community/error-subclass-name': 2, - '@react-native-community/platform-colors': 2, + "rules": { + "@react-native-community/no-haste-imports": 2, + "@react-native-community/error-subclass-name": 2, + "@react-native-community/platform-colors": 2, + "@react-native/codegen/react-native-modules": 2 } }, { @@ -40,8 +45,8 @@ ], "env": { "jasmine": true, - "jest": true, - }, - }, - ], + "jest": true + } + } + ] } diff --git a/Libraries/Alert/NativeAlertManager.js b/Libraries/Alert/NativeAlertManager.js index 055a742b203..e3bfb8211bc 100644 --- a/Libraries/Alert/NativeAlertManager.js +++ b/Libraries/Alert/NativeAlertManager.js @@ -26,6 +26,7 @@ export type Args = {| export interface Spec extends TurboModule { +alertWithArgs: ( + // eslint-disable-next-line @react-native/codegen/react-native-modules args: Args, callback: (id: number, value: string) => void, ) => void; diff --git a/Libraries/Animated/NativeAnimatedModule.js b/Libraries/Animated/NativeAnimatedModule.js index a42e7ba5b12..c16597018d1 100644 --- a/Libraries/Animated/NativeAnimatedModule.js +++ b/Libraries/Animated/NativeAnimatedModule.js @@ -30,7 +30,7 @@ export type AnimatingNodeConfig = Object; export interface Spec extends TurboModule { +startOperationBatch: () => void; +finishOperationBatch: () => void; - + // eslint-disable-next-line @react-native/codegen/react-native-modules +createAnimatedNode: (tag: number, config: AnimatedNodeConfig) => void; +getValue: (tag: number, saveValueCallback: SaveValueCallback) => void; +startListeningToAnimatedNodeValue: (tag: number) => void; diff --git a/Libraries/Animated/NativeAnimatedTurboModule.js b/Libraries/Animated/NativeAnimatedTurboModule.js index ac6c50b4506..e5d5bcbeebd 100644 --- a/Libraries/Animated/NativeAnimatedTurboModule.js +++ b/Libraries/Animated/NativeAnimatedTurboModule.js @@ -30,7 +30,7 @@ export type AnimatingNodeConfig = Object; export interface Spec extends TurboModule { +startOperationBatch: () => void; +finishOperationBatch: () => void; - + // eslint-disable-next-line @react-native/codegen/react-native-modules +createAnimatedNode: (tag: number, config: AnimatedNodeConfig) => void; +getValue: (tag: number, saveValueCallback: SaveValueCallback) => void; +startListeningToAnimatedNodeValue: (tag: number) => void; diff --git a/Libraries/Components/StatusBar/NativeStatusBarManagerAndroid.js b/Libraries/Components/StatusBar/NativeStatusBarManagerAndroid.js index 5b00b2a4c21..723ac602abb 100644 --- a/Libraries/Components/StatusBar/NativeStatusBarManagerAndroid.js +++ b/Libraries/Components/StatusBar/NativeStatusBarManagerAndroid.js @@ -26,6 +26,7 @@ export interface Spec extends TurboModule { * - 'default' * - 'dark-content' */ + // eslint-disable-next-line @react-native/codegen/react-native-modules +setStyle: (statusBarStyle?: ?string) => void; +setHidden: (hidden: boolean) => void; } diff --git a/Libraries/Components/StatusBar/NativeStatusBarManagerIOS.js b/Libraries/Components/StatusBar/NativeStatusBarManagerIOS.js index b5da2851bf2..86251253640 100644 --- a/Libraries/Components/StatusBar/NativeStatusBarManagerIOS.js +++ b/Libraries/Components/StatusBar/NativeStatusBarManagerIOS.js @@ -31,6 +31,7 @@ export interface Spec extends TurboModule { * - 'dark-content' * - 'light-content' */ + // eslint-disable-next-line @react-native/codegen/react-native-modules +setStyle: (statusBarStyle?: ?string, animated: boolean) => void; /** * - withAnimation can be: 'none' | 'fade' | 'slide' diff --git a/Libraries/Core/NativeExceptionsManager.js b/Libraries/Core/NativeExceptionsManager.js index 10b7b77e08e..28192d72780 100644 --- a/Libraries/Core/NativeExceptionsManager.js +++ b/Libraries/Core/NativeExceptionsManager.js @@ -47,6 +47,7 @@ export interface Spec extends TurboModule { stack: Array, exceptionId: number, ) => void; + // eslint-disable-next-line @react-native/codegen/react-native-modules +reportException?: (data: ExceptionData) => void; +updateExceptionMessage: ( message: string, diff --git a/Libraries/Image/NativeImageEditor.js b/Libraries/Image/NativeImageEditor.js index c6fdc63f7d4..738483b1584 100644 --- a/Libraries/Image/NativeImageEditor.js +++ b/Libraries/Image/NativeImageEditor.js @@ -42,6 +42,7 @@ export interface Spec extends TurboModule { +getConstants: () => {||}; +cropImage: ( uri: string, + // eslint-disable-next-line @react-native/codegen/react-native-modules cropData: Options, successCallback: (uri: string) => void, errorCallback: (error: string) => void, diff --git a/Libraries/NativeModules/specs/NativeDialogManagerAndroid.js b/Libraries/NativeModules/specs/NativeDialogManagerAndroid.js index fcc476b3419..809f488f0a1 100644 --- a/Libraries/NativeModules/specs/NativeDialogManagerAndroid.js +++ b/Libraries/NativeModules/specs/NativeDialogManagerAndroid.js @@ -40,6 +40,7 @@ export interface Spec extends TurboModule { +buttonNeutral: DialogButtonKey, |}; +showAlert: ( + // eslint-disable-next-line @react-native/codegen/react-native-modules config: DialogOptions, onError: (error: string) => void, onAction: (action: DialogAction, buttonKey?: DialogButtonKey) => void, diff --git a/Libraries/PermissionsAndroid/NativePermissionsAndroid.js b/Libraries/PermissionsAndroid/NativePermissionsAndroid.js index 2e834fc725c..2b53284da78 100644 --- a/Libraries/PermissionsAndroid/NativePermissionsAndroid.js +++ b/Libraries/PermissionsAndroid/NativePermissionsAndroid.js @@ -47,6 +47,7 @@ export type PermissionType = */ export interface Spec extends TurboModule { + // eslint-disable-next-line @react-native/codegen/react-native-modules +checkPermission: (permission: PermissionType) => Promise; +requestPermission: (permission: PermissionType) => Promise; +shouldShowRequestPermissionRationale: ( diff --git a/Libraries/PushNotificationIOS/NativePushNotificationManagerIOS.js b/Libraries/PushNotificationIOS/NativePushNotificationManagerIOS.js index 2e2044262ee..941ba0a3c77 100644 --- a/Libraries/PushNotificationIOS/NativePushNotificationManagerIOS.js +++ b/Libraries/PushNotificationIOS/NativePushNotificationManagerIOS.js @@ -32,7 +32,7 @@ type Notification = {| |}; export interface Spec extends TurboModule { - +getConstants: () => {...}; + +getConstants: () => {||}; +onFinishRemoteNotification: ( notificationId: string, /** @@ -52,6 +52,7 @@ export interface Spec extends TurboModule { |}) => Promise; +abandonPermissions: () => void; +checkPermissions: (callback: (permissions: Permissions) => void) => void; + // eslint-disable-next-line @react-native/codegen/react-native-modules +presentLocalNotification: (notification: Notification) => void; +scheduleLocalNotification: (notification: Notification) => void; +cancelAllLocalNotifications: () => void; diff --git a/Libraries/Share/NativeShareModule.js b/Libraries/Share/NativeShareModule.js index 08d313d76d7..e6d8800725b 100644 --- a/Libraries/Share/NativeShareModule.js +++ b/Libraries/Share/NativeShareModule.js @@ -17,6 +17,7 @@ export interface Spec extends TurboModule { +getConstants: () => {||}; +share: ( content: {|title?: string, message?: string|}, + // eslint-disable-next-line @react-native/codegen/react-native-modules dialogTitle?: string, ) => Promise<{|action: string|}>; } diff --git a/Libraries/TurboModule/samples/NativeSampleTurboModule.js b/Libraries/TurboModule/samples/NativeSampleTurboModule.js index f9c4c6ef53e..5af0c1f77c6 100644 --- a/Libraries/TurboModule/samples/NativeSampleTurboModule.js +++ b/Libraries/TurboModule/samples/NativeSampleTurboModule.js @@ -26,6 +26,7 @@ export interface Spec extends TurboModule { +getString: (arg: string) => string; +getArray: (arg: Array) => Array; +getObject: (arg: Object) => Object; + // eslint-disable-next-line @react-native/codegen/react-native-modules +getRootTag: (arg: RootTag) => RootTag; +getValue: (x: number, y: string, z: Object) => Object; +getValueWithCallback: (callback: (value: string) => void) => void; diff --git a/packages/eslint-plugin-codegen/BUCK b/packages/eslint-plugin-codegen/BUCK new file mode 100644 index 00000000000..8fd38289431 --- /dev/null +++ b/packages/eslint-plugin-codegen/BUCK @@ -0,0 +1,23 @@ +load("@fbsource//tools/build_defs/third_party:yarn_defs.bzl", "yarn_workspace") + +yarn_workspace( + name = "yarn-workspace", + srcs = glob( + ["**/*.js"], + exclude = [ + "**/__fixtures__/**", + "**/__flowtests__/**", + "**/__mocks__/**", + "**/__server_snapshot_tests__/**", + "**/__tests__/**", + "**/node_modules/**", + "**/node_modules/.bin/**", + "**/.*", + "**/.*/**", + "**/.*/.*", + "**/*.xcodeproj/**", + "**/*.xcworkspace/**", + ], + ), + visibility = ["PUBLIC"], +) diff --git a/packages/eslint-plugin-codegen/__tests__/eslint-tester.js b/packages/eslint-plugin-codegen/__tests__/eslint-tester.js new file mode 100644 index 00000000000..ea767a705b7 --- /dev/null +++ b/packages/eslint-plugin-codegen/__tests__/eslint-tester.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const ESLintTester = require('eslint').RuleTester; + +ESLintTester.setDefaultConfig({ + parser: require.resolve('babel-eslint'), + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +module.exports = ESLintTester; diff --git a/packages/eslint-plugin-codegen/__tests__/react-native-modules-test.js b/packages/eslint-plugin-codegen/__tests__/react-native-modules-test.js new file mode 100644 index 00000000000..8d38ab0e1af --- /dev/null +++ b/packages/eslint-plugin-codegen/__tests__/react-native-modules-test.js @@ -0,0 +1,397 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react_native + * @format + */ + +'use strict'; + +const ESLintTester = require('./eslint-tester.js'); + +const rule = require('../react-native-modules'); + +const NATIVE_MODULES_DIR = __dirname; + +const eslintTester = new ESLintTester(); + +const VALID_SPECS = [ + // Standard specification will all supported param types. + { + code: ` +'use strict'; + +import {TurboModuleRegistry, type TurboModule} from 'react-native'; + +export interface Spec extends TurboModule { + func1(): void, + func2(a: number, b: string, c: boolean): void, + func3(a: Object, b: Array, c: () => void): void, + func4(a: ?string): void, + func5(a: ?Object, b: ?Array, c: ?() => void): void, + func6(a: string[], b: ?number[]): void, + func7(): string, + func8(): Object, + func9(): Promise, + func10(): number, + func11(): boolean, + func12(a: {|x: string|}): void, + func13(a: $ReadOnlyArray): void, + func14(): {| + x: number, + y: string, + |}, + a: number, + b: string, + c: {a: number, b: string}, +} + +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + }, + + // With convenience API wrapper + { + code: ` +'use strict'; + +import {TurboModuleRegistry, type TurboModule} from 'react-native'; + +export interface Spec extends TurboModule { + func1(a: Object): void, +} + +const NativeModule = TurboModuleRegistry.get('XYZ'); +const NativeXYZ = { + func1(a?: number): void { + NativeModule.func1(a || {}); + }, +}; +export default NativeXYZ; +`, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + }, + + // Non-spec JS file. + { + code: ` +'use strict'; + +import {Platform} from 'react-native'; + +export default Platform.OS; +`, + }, +]; + +const INVALID_SPECS = [ + // Haste module name doesn't start with "Native" + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/XYZ.js`, + errors: [ + { + message: rule.errors.invalidHasteName('XYZ'), + }, + ], + }, + + // Invalid Spec interface name + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Foo extends TurboModule { + func1(): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.invalidNativeModuleInterfaceName('Foo'), + }, + { + message: rule.errors.specNotDeclaredInFile(), + }, + ], + output: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + }, + + // Missing method in Spec + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.missingSpecInterfaceMethod(), + }, + ], + }, + + // Invalid Spec method return type + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(): XYZ, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedMethodReturnType('XYZ'), + }, + ], + }, + + // Unsupported Spec property + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + id: Map, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('Map'), + }, + ], + }, + + // Unsupported nested Spec property + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + a: { + b: number, + c: () => number, + }, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('Function'), + }, + ], + }, + + // Unsupported Spec method arg type + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +type SomeType = {}; +export interface Spec extends TurboModule { + func1(a: SomeType): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('SomeType'), + }, + ], + }, + + // Unsupported Spec method arg type: optional + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a?: string): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('optional string'), + }, + ], + }, + + // Unsupported Spec method arg type: unsupported nullable + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +type Foo = {}; +export interface Spec extends TurboModule { + func1(a: ?Foo): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('nullable Foo'), + }, + ], + }, + + // Unsupported Spec method arg type: generic Function + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a: Function): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('Function'), + }, + ], + }, + + // Unsupported Spec method arg type: nullable generic Function + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a: ?Function): void, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.unsupportedType('nullable Function'), + }, + ], + }, + + // Spec method object return type must be exact + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a: string): {}, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.inexactObjectReturnType(), + }, + ], + }, + + // Untyped NativeModule require + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.untypedModuleRequire('get'), + }, + ], + }, + + // Incorrectly typed NativeModule require: 0 types + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export default TurboModuleRegistry.get<>('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.incorrectlyTypedModuleRequire('get'), + }, + ], + }, + + // Incorrectly typed NativeModule require: 1 type, but wrong + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a: string): {||}, +} + +// According to Flow, this also conforms to TurboModule +type Spec1 = {| + getConstants: () => {...} +|}; + +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.incorrectlyTypedModuleRequire('get'), + }, + ], + }, + + // Incorrectly typed NativeModule require: > 1 type + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +export interface Spec extends TurboModule { + func1(a: string): {||}, +} +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.incorrectlyTypedModuleRequire('get'), + }, + ], + }, + + // NativeModule spec not declared in file + { + code: ` +import {TurboModuleRegistry, type TurboModule} from 'react-native'; +import type {Spec} from 'NativeFoo'; +export default TurboModuleRegistry.get('XYZ'); + `, + filename: `${NATIVE_MODULES_DIR}/NativeXYZ.js`, + errors: [ + { + message: rule.errors.specNotDeclaredInFile(), + }, + ], + }, +]; + +eslintTester.run('../react-native-modules', rule, { + valid: VALID_SPECS, + invalid: INVALID_SPECS, +}); diff --git a/packages/eslint-plugin-codegen/index.js b/packages/eslint-plugin-codegen/index.js new file mode 100644 index 00000000000..60a5f864f7a --- /dev/null +++ b/packages/eslint-plugin-codegen/index.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react_native + * @format + */ + +'use strict'; + +const reactNativeModules = require('./react-native-modules'); + +module.exports = { + rules: { + 'react-native-modules': reactNativeModules, + }, +}; diff --git a/packages/eslint-plugin-codegen/package.json b/packages/eslint-plugin-codegen/package.json new file mode 100644 index 00000000000..9d4b2328a91 --- /dev/null +++ b/packages/eslint-plugin-codegen/package.json @@ -0,0 +1,11 @@ +{ + "name": "@react-native/eslint-plugin-codegen", + "version": "0.0.1", + "description": "ESLint rules to validate NativeModule and Component Specs", + "main": "index.js", + "repository": { + "type": "git", + "url": "git@github.com:facebook/react-native.git" + }, + "license": "MIT" +} diff --git a/packages/eslint-plugin-codegen/react-native-modules.js b/packages/eslint-plugin-codegen/react-native-modules.js new file mode 100644 index 00000000000..fd55aca4db6 --- /dev/null +++ b/packages/eslint-plugin-codegen/react-native-modules.js @@ -0,0 +1,467 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react_native + * @format + */ + +'use strict'; + +const path = require('path'); + +const supportedTypes = [ + 'ArrayTypeAnnotation', + 'BooleanTypeAnnotation', + 'NumberTypeAnnotation', + 'StringTypeAnnotation', +]; + +const supportedTypeAliases = { + BooleanTypeAnnotation: 'boolean', + FunctionTypeAnnotation: 'Function', + NumberTypeAnnotation: 'number', + StringTypeAnnotation: 'string', + ObjectTypeAnnotation: 'object', +}; + +const supportedGenericTypes = [ + 'Array', + 'Object', + 'RootTag', + '$ReadOnly', + '$ReadOnlyArray', +]; + +const supportedNullableTypes = [ + 'ArrayTypeAnnotation', + 'BooleanTypeAnnotation', + 'FunctionTypeAnnotation', + 'NumberTypeAnnotation', + 'ObjectTypeAnnotation', + 'StringTypeAnnotation', +]; + +const supportedMethodReturnTypes = [ + 'BooleanTypeAnnotation', + 'NumberTypeAnnotation', + 'ObjectTypeAnnotation', + 'StringTypeAnnotation', + 'VoidTypeAnnotation', +]; + +const errors = { + invalidNativeModuleInterfaceName(interfaceName) { + return ( + "NativeModule interfaces must be named 'Spec', " + + `got '${interfaceName}'.` + ); + }, + inexactObjectReturnType() { + return 'Spec interface method object return type must be exact.'; + }, + invalidHasteName(hasteName) { + return ( + 'Module name for a NativeModule JS wrapper must start with ' + + `'Native', got '${hasteName}' instead.` + ); + }, + missingSpecInterfaceMethod() { + return 'NativeModule Spec interface must define at least one method'; + }, + unsupportedMethodReturnType(typeName) { + return ( + `Spec interface method has unsupported return type '${typeName}'. ` + + 'See https://fburl.com/rn-nativemodules for more details.' + ); + }, + unsupportedType(typeName) { + return ( + `Unsupported type '${typeName}' for Spec interface. ` + + 'See https://fburl.com/rn-nativemodules for more details.' + ); + }, + untypedModuleRequire(requireMethodName) { + return ( + 'NativeModule require not type-safe. Please require with the NativeModule interface ' + + `'Spec': TurboModuleRegistry.${requireMethodName}` + ); + }, + incorrectlyTypedModuleRequire(requireMethodName) { + return ( + 'NativeModule require incorrectly typed. Please require with the NativeModule interface identifier ' + + `'Spec', and nothing else: TurboModuleRegistry.${requireMethodName}` + ); + }, + specNotDeclaredInFile() { + return "The NativeModule interface 'Spec' wasn't declared in this NativeModule spec file."; + }, +}; + +function interfaceExtendsFrom(node, superInterfaceName) { + return ( + node.type === 'InterfaceDeclaration' && + node.extends[0] && + node.extends[0].id.name === superInterfaceName + ); +} + +function isSupportedFunctionParam(node) { + if (node.type !== 'FunctionTypeParam') { + return false; + } + + if (node.optional) { + return false; + } + + return findUnsupportedType(node.typeAnnotation, true) == null; +} + +function findUnsupportedType(typeAnnotation, supportCallbacks) { + if (supportedTypes.includes(typeAnnotation.type)) { + return null; + } + + if (typeAnnotation.type === 'NullableTypeAnnotation') { + if (supportedNullableTypes.includes(typeAnnotation.typeAnnotation.type)) { + return null; + } + typeAnnotation = typeAnnotation.typeAnnotation; + } + + if (typeAnnotation.type === 'FunctionTypeAnnotation' && supportCallbacks) { + return null; + } + + if (typeAnnotation.type === 'GenericTypeAnnotation') { + if (!supportedGenericTypes.includes(typeAnnotation.id.name)) { + return typeAnnotation; + } + if ( + !isGenericArrayTypeAnnotation(typeAnnotation) && + typeAnnotation.typeParameters + ) { + for (const param of typeAnnotation.typeParameters.params) { + const unsupported = findUnsupportedType(param, supportCallbacks); + if (unsupported != null) { + return unsupported; + } + } + } + return null; + } + + if (typeAnnotation.type === 'ObjectTypeAnnotation') { + for (const prop of typeAnnotation.properties) { + const unsupported = findUnsupportedType(prop.value, supportCallbacks); + if (unsupported != null) { + return unsupported; + } + } + return null; + } + + return typeAnnotation; +} + +function functionParamTypeName(node) { + if (node.type !== 'FunctionTypeParam') { + return null; + } + + const parts = []; + if (node.optional) { + parts.push('optional'); + } + parts.push(functionParamTypeAnnotationName(node.typeAnnotation)); + return parts.join(' '); +} + +function functionParamTypeAnnotationName(typeAnnotation) { + const {id, type} = typeAnnotation; + if (type === 'GenericTypeAnnotation') { + return id.name; + } + + const parts = []; + if (type === 'NullableTypeAnnotation') { + parts.push('nullable'); + parts.push(functionParamTypeAnnotationName(typeAnnotation.typeAnnotation)); + } else { + parts.push(supportedTypeAliases[type] || type); + } + return parts.join(' '); +} + +function getTypeName(typeAnnotation) { + const {id, type} = typeAnnotation; + if (type === 'GenericTypeAnnotation') { + return id.name; + } + return supportedTypeAliases[type]; +} + +function checkSupportedSpecProperty(context, node) { + if (node.type !== 'FunctionTypeAnnotation') { + const unsupportedNode = findUnsupportedType(node, false); + if (unsupportedNode != null) { + context.report({ + node: unsupportedNode, + message: errors.unsupportedType( + functionParamTypeAnnotationName(unsupportedNode), + ), + }); + return false; + } + return true; + } + + if (!isSupportedMethodReturnTypeAnnotation(node.returnType)) { + context.report({ + node: node.returnType, + message: errors.unsupportedMethodReturnType(getTypeName(node.returnType)), + }); + return false; + } + + // Check for exact object return type. + if ( + node.returnType.type === 'ObjectTypeAnnotation' && + !node.returnType.exact + ) { + context.report({ + node: node.returnType, + message: errors.inexactObjectReturnType(), + }); + } + + for (const param of node.params) { + if (!isSupportedFunctionParam(param)) { + context.report({ + node: param.typeAnnotation, + message: errors.unsupportedType(functionParamTypeName(param)), + }); + return false; + } + } + + return true; +} + +function isPromiseTypeAnnotation(typeAnnotation) { + return ( + typeAnnotation.type === 'GenericTypeAnnotation' && + typeAnnotation.id && + typeAnnotation.id.name === 'Promise' + ); +} + +function isGenericArrayTypeAnnotation(typeAnnotation) { + return ( + typeAnnotation.type === 'GenericTypeAnnotation' && + typeAnnotation.id && + typeAnnotation.id.name === 'Array' + ); +} + +function isGenericObjectTypeAnnotation(typeAnnotation) { + return ( + typeAnnotation.type === 'GenericTypeAnnotation' && + typeAnnotation.id && + typeAnnotation.id.name === 'Object' + ); +} + +function isSupportedMethodReturnTypeAnnotation(typeAnnotation) { + const resolvedType = + typeAnnotation.type === 'NullableTypeAnnotation' + ? typeAnnotation.typeAnnotation + : typeAnnotation; + return ( + supportedMethodReturnTypes.includes(resolvedType.type) || + isGenericArrayTypeAnnotation(resolvedType) || + isGenericObjectTypeAnnotation(resolvedType) || + isPromiseTypeAnnotation(resolvedType) + ); +} + +const VALID_SPEC_NAMES = /^Native\S+$/; + +function isModuleRequire(node) { + if (node.type !== 'CallExpression') { + return false; + } + + const callExpression = node; + + if (callExpression.callee.type !== 'MemberExpression') { + return false; + } + + const memberExpression = callExpression.callee; + if ( + !( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + ) + ) { + return false; + } + + if ( + !( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + ) + ) { + return false; + } + return true; +} + +function isGeneratedFile(context) { + return ( + context + .getSourceCode() + .getText() + .indexOf('@' + 'generated SignedSource<<') !== -1 + ); +} + +/** + * A lint rule to guide best practices in writing type safe React NativeModules. + */ +function rule(context) { + const filename = context.getFilename(); + + if (isGeneratedFile(context)) { + return {}; + } + + const sourceCode = context.getSourceCode().getText(); + if (!sourceCode.includes('TurboModuleRegistry')) { + return {}; + } + + const specIdentifierUsages = []; + const declaredModuleInterfaces = []; + + return { + 'Program:exit': function() { + if ( + specIdentifierUsages.length > 0 && + declaredModuleInterfaces.length === 0 + ) { + specIdentifierUsages.forEach(specNode => { + context.report({ + node: specNode, + message: errors.specNotDeclaredInFile(), + }); + }); + } + }, + CallExpression(node) { + if (!isModuleRequire(node)) { + return; + } + + /** + * Validate that NativeModule requires are typed + */ + + const {typeArguments} = node; + + if (typeArguments == null) { + const methodName = node.callee.property.name; + context.report({ + node, + message: errors.untypedModuleRequire(methodName), + }); + return; + } + + if (typeArguments.type !== 'TypeParameterInstantiation') { + return; + } + + const [param] = typeArguments.params; + + /** + * Validate that NativeModule requires are correctly typed + */ + + if ( + typeArguments.params.length !== 1 || + param.type !== 'GenericTypeAnnotation' || + param.id.name !== 'Spec' + ) { + const methodName = node.callee.property.name; + context.report({ + node, + message: errors.incorrectlyTypedModuleRequire(methodName), + }); + return; + } + + specIdentifierUsages.push(param); + return true; + }, + InterfaceDeclaration(node) { + if ( + !interfaceExtendsFrom(node, 'DEPRECATED_RCTExport') && + !interfaceExtendsFrom(node, 'TurboModule') + ) { + return; + } + + const basename = path.basename(filename, '.js'); + if ( + basename && + basename !== 'RCTExport' && + !VALID_SPEC_NAMES.test(basename) + ) { + context.report({ + loc: {start: {line: 0, column: 0}}, + message: errors.invalidHasteName(basename), + }); + } + + if (node.id.name !== 'Spec') { + context.report({ + node, + message: errors.invalidNativeModuleInterfaceName(node.id.name), + fix: fixer => fixer.replaceText(node.id, 'Spec'), + }); + return; + } + + declaredModuleInterfaces.push(node); + + if (!node.body.properties.length) { + context.report({ + node: node.body, + message: errors.missingSpecInterfaceMethod(), + }); + return; + } + + let hasUnsupportedProp = false; + node.body.properties.forEach(prop => { + if (hasUnsupportedProp) { + return; + } + if (!checkSupportedSpecProperty(context, prop.value)) { + hasUnsupportedProp = true; + } + }); + }, + }; +} + +rule.errors = errors; + +module.exports = rule;