From 97d718f02c6f6f273ee31b7ef8863f732e41b15e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 14 Apr 2024 20:52:59 -0700 Subject: [PATCH] feat: @lexical/eslint-plugin - add a rules-of-lexical linter to help with $function rules --- .eslintrc.js | 34 +- .flowconfig | 1 + eslint-plugin/package.json | 2 +- package-lock.json | 36 +- package.json | 1 + packages/lexical-devtools/tsconfig.json | 1 + .../LexicalEslintPlugin.js | 14 + packages/lexical-eslint-plugin/README.md | 196 +++++++++ .../flow/LexicalEslintPlugin.js.flow | 11 + packages/lexical-eslint-plugin/package.json | 49 +++ .../src/LexicalEslintPlugin.js | 32 ++ .../src/__tests__/unit/buildMatcher.test.ts | 52 +++ .../__tests__/unit/rules-of-lexical.test.ts | 165 +++++++ packages/lexical-eslint-plugin/src/index.ts | 17 + .../src/rules/rules-of-lexical.js | 415 ++++++++++++++++++ .../src/util/buildMatcher.js | 73 +++ .../src/util/getFunctionName.js | 43 ++ .../src/util/getParentAssignmentName.js | 39 ++ tsconfig.build.json | 3 + tsconfig.json | 4 + 20 files changed, 1178 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 86307750e4be..2987119c071b 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,31 @@ module.exports = { }, plugins: ['react', '@typescript-eslint', 'header'], rules: { + '@lexical/rules-of-lexical': [ + WARN, + /** @type import('./packages/lexical-eslint-plugin/src').RulesOfLexicalOptions */ ({ + isDollarFunction: [ + '^INTERNAL_$[a-z_]', + '(NodeTransform$)', + '^convert.*(Element|Node)', + 'beginUpdate', + 'commitPendingUpdates', + 'createBinding', + 'createChildrenArray', + 'flushRootMutations', + 'getNodeFromDOM', + 'getNodeFromDOMNode', + 'getOrInitCollabNodeFromSharedType', + 'isSelectionCapturedInDecoratorInput', + 'mergePrevious', + 'parseEditorState', + 'removeNode', + 'syncLocalCursorPosition', + 'trimTextContentFromAnchor', + ], + isSafeDollarFunction: '$getRoot', + }), + ], '@typescript-eslint/ban-ts-comment': OFF, '@typescript-eslint/no-this-alias': OFF, '@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}], @@ -79,8 +106,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 +148,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 cb6c16f3d357..277424bdcd33 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 c50e3ba12a0f..89b92535ebfd 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 fc3dec158c94..d557a503a823 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 7d275ab31a03..d15d8cda27e1 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 350081311a66..8978b961e03a 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 000000000000..0195b476c585 --- /dev/null +++ b/packages/lexical-eslint-plugin/LexicalEslintPlugin.js @@ -0,0 +1,14 @@ +/** + * 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 + */ +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 000000000000..a8daa3ff691a --- /dev/null +++ b/packages/lexical-eslint-plugin/README.md @@ -0,0 +1,196 @@ +# `@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_]"], + "isLexicalProvider": ["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_\\$"`). + +#### `isLexicalProvider` + +*Base case*: `/^(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 `editor.update` or `editorState.read` + +```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({editor}) { + const [editor] = useLexicalComposerContext(); + const getRoot = useCallback(() => $getRoot(), []); + return (