From f309cc385c3ba190f3b284573958a18fdd87348b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 14 Apr 2024 20:52:59 -0700 Subject: [PATCH 1/2] feat: @lexical/eslint-plugin - add a rules-of-lexical linter to help with $function rules --- .eslintrc.js | 20 +- .flowconfig | 1 + eslint-plugin/package.json | 2 +- package-lock.json | 36 +- package.json | 1 + packages/lexical-devtools/tsconfig.json | 1 + .../LexicalEslintPlugin.js | 12 + packages/lexical-eslint-plugin/README.md | 216 +++++++++ .../flow/LexicalEslintPlugin.js.flow | 11 + packages/lexical-eslint-plugin/package.json | 49 ++ .../src/LexicalEslintPlugin.js | 32 ++ .../src/__tests__/unit/buildMatcher.test.ts | 54 +++ .../__tests__/unit/rules-of-lexical.test.ts | 250 +++++++++++ packages/lexical-eslint-plugin/src/index.ts | 17 + .../src/rules/rules-of-lexical.js | 418 ++++++++++++++++++ .../src/util/buildMatcher.js | 77 ++++ .../src/util/getFunctionName.js | 43 ++ .../src/util/getParentAssignmentName.js | 41 ++ tsconfig.build.json | 3 + tsconfig.json | 4 + 20 files changed, 1278 insertions(+), 10 deletions(-) create mode 100644 packages/lexical-eslint-plugin/LexicalEslintPlugin.js create mode 100644 packages/lexical-eslint-plugin/README.md create mode 100644 packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow create mode 100644 packages/lexical-eslint-plugin/package.json create mode 100644 packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js create mode 100644 packages/lexical-eslint-plugin/src/__tests__/unit/buildMatcher.test.ts create mode 100644 packages/lexical-eslint-plugin/src/__tests__/unit/rules-of-lexical.test.ts create mode 100644 packages/lexical-eslint-plugin/src/index.ts create mode 100644 packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js create mode 100644 packages/lexical-eslint-plugin/src/util/buildMatcher.js create mode 100644 packages/lexical-eslint-plugin/src/util/getFunctionName.js create mode 100644 packages/lexical-eslint-plugin/src/util/getParentAssignmentName.js diff --git a/.eslintrc.js b/.eslintrc.js index 86307750e4b..d67b1025297 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,7 @@ const restrictedGlobals = require('confusing-browser-globals'); const OFF = 0; +const WARN = 1; const ERROR = 2; module.exports = { @@ -64,6 +65,7 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', + 'plugin:@lexical/all', ], files: ['**/*.ts', '**/*.tsx'], parser: '@typescript-eslint/parser', @@ -72,6 +74,17 @@ module.exports = { }, plugins: ['react', '@typescript-eslint', 'header'], rules: { + '@lexical/rules-of-lexical': [ + WARN, + /** @type import('./packages/lexical-eslint-plugin/src').RulesOfLexicalOptions */ ({ + isDollarFunction: ['^INTERNAL_\\$'], + isIgnoredFunction: [ + // @lexical/yjs + 'createBinding', + ], + isSafeDollarFunction: '$createRootNode', + }), + ], '@typescript-eslint/ban-ts-comment': OFF, '@typescript-eslint/no-this-alias': OFF, '@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}], @@ -79,8 +92,10 @@ module.exports = { }, }, { - // These aren't compiled, but they're written in module JS - files: ['packages/lexical-playground/esm/*.mjs'], + files: [ + // These aren't compiled, but they're written in module JS + 'packages/lexical-playground/esm/*.mjs', + ], parserOptions: { sourceType: 'module', }, @@ -119,6 +134,7 @@ module.exports = { 'react', 'no-only-tests', 'lexical', + '@lexical', ], // Stop ESLint from looking for a configuration file in parent folders diff --git a/.flowconfig b/.flowconfig index cb6c16f3d35..277424bdcd3 100644 --- a/.flowconfig +++ b/.flowconfig @@ -24,6 +24,7 @@ module.name_mapper='^@lexical/clipboard$' -> '/packages/lexical-cl module.name_mapper='^@lexical/code$' -> '/packages/lexical-code/flow/LexicalCode.js.flow' module.name_mapper='^@lexical/devtools-core$' -> '/packages/lexical-devtools-core/flow/LexicalDevtoolsCore.js.flow' module.name_mapper='^@lexical/dragon$' -> '/packages/lexical-dragon/flow/LexicalDragon.js.flow' +module.name_mapper='^@lexical/eslint-plugin$' -> '/packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow' module.name_mapper='^@lexical/file$' -> '/packages/lexical-file/flow/LexicalFile.js.flow' module.name_mapper='^@lexical/hashtag$' -> '/packages/lexical-hashtag/flow/LexicalHashtag.js.flow' module.name_mapper='^@lexical/headless$' -> '/packages/lexical-headless/flow/LexicalHeadless.js.flow' diff --git a/eslint-plugin/package.json b/eslint-plugin/package.json index c50e3ba12a0..89b92535ebf 100644 --- a/eslint-plugin/package.json +++ b/eslint-plugin/package.json @@ -4,6 +4,6 @@ "description": "ESLint plugin for lexical", "main": "src/index.js", "peerDependencies": { - "eslint": ">=4.19.1" + "eslint": "^7.31.0 || ^8.0.0" } } diff --git a/package-lock.json b/package-lock.json index fc3dec158c9..d557a503a82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@babel/preset-flow": "^7.14.5", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.16.7", + "@lexical/eslint-plugin": "file:./packages/lexical-eslint-plugin", "@playwright/test": "^1.41.2", "@rollup/plugin-alias": "^3.1.4", "@rollup/plugin-babel": "^5.3.0", @@ -98,7 +99,7 @@ "version": "1.0.0", "dev": true, "peerDependencies": { - "eslint": ">=4.19.1" + "eslint": "^7.31.0 || ^8.0.0" } }, "node_modules/@aklinker1/rollup-plugin-visualizer": { @@ -6181,6 +6182,10 @@ "resolved": "packages/lexical-dragon", "link": true }, + "node_modules/@lexical/eslint-plugin": { + "resolved": "packages/lexical-eslint-plugin", + "link": true + }, "node_modules/@lexical/file": { "resolved": "packages/lexical-file", "link": true @@ -7721,9 +7726,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", - "integrity": "sha512-dhsC09y1gpJWnK+Ff4SGvCuSnk9DaU0BJZSzOwa6GVSg65XtTugLBITDAAzRU5duGBoXBHpdR/9jHGxJjNflJQ==", + "version": "8.56.9", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", + "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -33131,6 +33136,17 @@ "lexical": "0.14.5" } }, + "packages/lexical-eslint-plugin": { + "name": "@lexical/eslint-plugin", + "version": "0.14.5", + "license": "MIT", + "devDependencies": { + "@types/eslint": "^8.56.9" + }, + "peerDependencies": { + "eslint": ">=7.31.0 || ^8.0.0" + } + }, "packages/lexical-file": { "name": "@lexical/file", "version": "0.14.5", @@ -37984,6 +38000,12 @@ "lexical": "0.14.5" } }, + "@lexical/eslint-plugin": { + "version": "file:packages/lexical-eslint-plugin", + "requires": { + "@types/eslint": "^8.56.9" + } + }, "@lexical/file": { "version": "file:packages/lexical-file", "requires": { @@ -39082,9 +39104,9 @@ } }, "@types/eslint": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.5.tgz", - "integrity": "sha512-dhsC09y1gpJWnK+Ff4SGvCuSnk9DaU0BJZSzOwa6GVSg65XtTugLBITDAAzRU5duGBoXBHpdR/9jHGxJjNflJQ==", + "version": "8.56.9", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", + "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", "requires": { "@types/estree": "*", "@types/json-schema": "*" diff --git a/package.json b/package.json index 7d275ab31a0..d15d8cda27e 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@babel/preset-flow": "^7.14.5", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.16.7", + "@lexical/eslint-plugin": "file:./packages/lexical-eslint-plugin", "@playwright/test": "^1.41.2", "@rollup/plugin-alias": "^3.1.4", "@rollup/plugin-babel": "^5.3.0", diff --git a/packages/lexical-devtools/tsconfig.json b/packages/lexical-devtools/tsconfig.json index 350081311a6..8978b961e03 100644 --- a/packages/lexical-devtools/tsconfig.json +++ b/packages/lexical-devtools/tsconfig.json @@ -14,6 +14,7 @@ "@lexical/code": ["../lexical-code/src/index.ts"], "@lexical/devtools-core": ["../lexical-devtools-core/src/index.ts"], "@lexical/dragon": ["../lexical-dragon/src/index.ts"], + "@lexical/eslint-plugin": ["../lexical-eslint-plugin/src/index.ts"], "@lexical/file": ["../lexical-file/src/index.ts"], "@lexical/hashtag": ["../lexical-hashtag/src/index.ts"], "@lexical/headless": ["../lexical-headless/src/index.ts"], diff --git a/packages/lexical-eslint-plugin/LexicalEslintPlugin.js b/packages/lexical-eslint-plugin/LexicalEslintPlugin.js new file mode 100644 index 00000000000..9759cc2a16f --- /dev/null +++ b/packages/lexical-eslint-plugin/LexicalEslintPlugin.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; +// This file is here for bootstrapping reasons so we can use it without +// building anything and still comply with the monorepo conventions +module.exports = require('./src/LexicalEslintPlugin.js'); diff --git a/packages/lexical-eslint-plugin/README.md b/packages/lexical-eslint-plugin/README.md new file mode 100644 index 00000000000..7d895f0db3c --- /dev/null +++ b/packages/lexical-eslint-plugin/README.md @@ -0,0 +1,216 @@ +# `@lexical/eslint-plugin` + +This ESLint plugin enforces the [Lexical $function convention](https://lexical.dev/docs/intro#reading-and-updating-editor-state). + +## Installation + +Assuming you already have ESLint installed, run: + +```sh +npm install @lexical/eslint-plugin --save-dev +``` + +Then extend the recommended eslint config: + +```js +{ + "extends": [ + // ... + "plugin:@lexical/recommended" + ] +} +``` + +### Custom Configuration + +If you want more fine-grained configuration, you can instead add a snippet like this to your ESLint configuration file: + +```js +{ + "plugins": [ + // ... + "@lexical" + ], + "rules": { + // ... + "@lexical/rules-of-lexical": "error" + } +} +``` + +### Advanced configuration + +Most of the heuristics in `@lexical/rules-of-lexical` can be extended with +additional terms or patterns. + +The code example below is shown using the default implementations for each +option. When you configure these they are combined with the default +implementations using "OR", the default implementations can not be overridden. +These terms and patterns are only shown for reference and pasting this example +into your project is not useful. + +If the string begins with a `"^"` or `"("` then it is treated as a RegExp, +otherwise it will be an exact match. A string may also be used instead +of an array of strings. + +```js +{ + "plugins": [ + // ... + "@lexical" + ], + "rules": { + // ... + "@lexical/rules-of-lexical": [ + "error", + { + "isDollarFunction": ["^\\$[a-z_]"], + "isIgnoredFunction": [], + "isLexicalProvider": [ + "parseEditorState", + "read", + "registerCommand", + "registerNodeTransform", + "update" + ], + "isSafeDollarFunction": ["^\\$is"] + } + ] + } +} +``` + +#### `isDollarFunction` + +*Base case*: `/^\$[a-z_]/` + +This defines the \$function convention, which by default is any function that +starts with a dollar sign followed by a lowercase latin letter. You may have a +secondary convention in your codebase, such as non-latin letters, or an +internal prefix that you want to consider (e.g. `"^INTERNAL_\\$"`). + +#### `isIgnoredFunction` + +*Base case*: None + +Functions that match these patterns are ignored from analysis, they may call +Lexical \$functions but are not considered to be a dollar function themselves. + +#### `isLexicalProvider` + +*Base case*: `/^(parseEditorState|read|registerCommand|registerNodeTransform|update)$/` + +These are functions that allow their function argument to use Lexical +\$functions. + +#### `isSafeDollarFunction` + +*Base case*: `/^\$is/` + +These \$functions are considered safe to call from anywhere, generally +these functions are runtime type checks that do not depend on any other +state. + +## Valid and Invalid Examples + +### Valid Examples + +\$functions may be called by other \$functions + +```js +function $namedCorrectly() { + return $getRoot(); +} +``` + +\$functions may be called in functions defined when calling the following +methods (the heuristic only considers the method name): + +* `editor.update` +* `editorState.read` +* `editor.registerCommand` +* `editor.registerNodeTransform` + +```js +function validUsesEditorOrState(editor) { + editor.update(() => $getRoot()); + editor.getLatestState().read(() => $getRoot()); +} +``` + +\$functions may be called from class methods + +```js +class CustomNode extends ElementNode { + appendText(string) { + this.appendChild($createTextNode(string)); + } +} +``` + +### Invalid Examples + +#### Rename autofix + +```js +function invalidFunction() { + return $getRoot(); +} +function $callsInvalidFunction() { + return invalidFunction(); +} +``` + +*Autofix:* The function is renamed with a $ prefix. Any references to this +name in this module are also always renamed. + +```js +function $invalidFunction() { + return $getRoot(); +} +function $callsInvalidFunction() { + return $invalidFunction(); +} +``` + +#### Rename & deprecate autofix + +```js +export function exportedInvalidFunction() { + return $getRoot(); +} +``` + +*Autofix:* The exported function is renamed with a $ prefix. The previous name +is also exported and marked deprecated, because automatic renaming of +references to that name is limited to the module's scope. + +```js +export function $exportedInvalidFunction() { + return $getRoot(); +} +/** @deprecated renamed to $exportedInvalidFunction by @lexical/eslint-plugin rules-of-lexical */ +export const exportedInvalidFunction = $exportedInvalidFunction; +``` + +#### Rename scope conflict + +```js +import {$getRoot} from 'lexical'; +function InvalidComponent() { + const [editor] = useLexicalComposerContext(); + const getRoot = useCallback(() => $getRoot(), []); + return (