diff --git a/.eslintrc.js b/.eslintrc.js index f68ccf5cc29b..ce1a16b8f3bd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { 'fbjs', 'plugin:react-hooks/recommended', 'plugin:lexical/all', + 'plugin:@lexical/all', 'prettier', ], @@ -79,8 +80,13 @@ 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', + // These are written in module JS for bootstrapping reasons, so we + // can use the plugin without compiling it first + 'packages/lexical-eslint-plugin/**/*.mjs', + ], parserOptions: { sourceType: 'module', }, @@ -119,6 +125,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 5bf364b35e94..a1e11d6edb19 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", @@ -97,7 +98,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": { @@ -4880,6 +4881,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 @@ -6402,9 +6407,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": "*" @@ -31450,6 +31455,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", @@ -35310,6 +35326,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": { @@ -36394,9 +36416,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 febdf2b41f5d..dbb6aca9301e 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,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..16019db5a1fb --- /dev/null +++ b/packages/lexical-eslint-plugin/README.md @@ -0,0 +1,5 @@ +# `@lexical/eslint-plugin` + +[![See API Documentation](https://lexical.dev/img/see-api-documentation.svg)](https://lexical.dev/docs/api/modules/lexical_eslint_plugin) + +Lexical specific linting rules for ESLint diff --git a/packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow b/packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow new file mode 100644 index 000000000000..3b23ff98ce68 --- /dev/null +++ b/packages/lexical-eslint-plugin/flow/LexicalEslintPlugin.js.flow @@ -0,0 +1,11 @@ +/** + * 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. + * + */ + +/** + * LexicalEslintPlugin + */ diff --git a/packages/lexical-eslint-plugin/package.json b/packages/lexical-eslint-plugin/package.json new file mode 100644 index 000000000000..16172ced4652 --- /dev/null +++ b/packages/lexical-eslint-plugin/package.json @@ -0,0 +1,49 @@ +{ + "name": "@lexical/eslint-plugin", + "description": "Lexical specific linting rules for ESLint", + "keywords": [ + "eslint", + "eslint-plugin", + "eslintplugin", + "lexical", + "editor" + ], + "version": "0.14.5", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/lexical.git", + "directory": "packages/lexical-eslint-plugin" + }, + "main": "LexicalEslintPlugin.js", + "types": "index.d.ts", + "bugs": { + "url": "https://github.com/facebook/lexical/issues" + }, + "homepage": "https://github.com/facebook/lexical#readme", + "sideEffects": false, + "peerDependencies": { + "eslint": ">=7.31.0 || ^8.0.0" + }, + "exports": { + ".": { + "import": { + "types": "./index.d.ts", + "development": "./LexicalEslintPlugin.dev.mjs", + "production": "./LexicalEslintPlugin.prod.mjs", + "node": "./LexicalEslintPlugin.node.mjs", + "default": "./LexicalEslintPlugin.mjs" + }, + "require": { + "types": "./index.d.ts", + "development": "./LexicalEslintPlugin.dev.js", + "production": "./LexicalEslintPlugin.prod.js", + "default": "./LexicalEslintPlugin.js" + } + } + }, + "devDependencies": { + "@types/eslint": "^8.56.9" + }, + "module": "LexicalEslintPlugin.mjs" +} diff --git a/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js b/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js new file mode 100644 index 000000000000..be9159e08480 --- /dev/null +++ b/packages/lexical-eslint-plugin/src/LexicalEslintPlugin.js @@ -0,0 +1,32 @@ +/** + * 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. + * + */ + +// @ts-check + +const {name, version} = require('../package.json'); +const rulesOfLexical = require('./rules/rules-of-lexical.js'); + +const all = { + plugins: ['@lexical'], + rules: { + '@lexical/rules-of-lexical': 'warn', + }, +}; + +const plugin = { + configs: { + all, + recommended: all, + }, + meta: {name, version}, + rules: { + 'rules-of-lexical': rulesOfLexical, + }, +}; + +module.exports = plugin; diff --git a/packages/lexical-eslint-plugin/src/__tests__/unit/rules-of-lexical.test.ts b/packages/lexical-eslint-plugin/src/__tests__/unit/rules-of-lexical.test.ts new file mode 100644 index 000000000000..fcd8dff16d1d --- /dev/null +++ b/packages/lexical-eslint-plugin/src/__tests__/unit/rules-of-lexical.test.ts @@ -0,0 +1,165 @@ +/** + * 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. + * + */ + +import {RuleTester} from 'eslint'; +import * as prettier from 'prettier'; + +import plugin from '../../LexicalEslintPlugin.js'; + +// The given string which may be prefixed or underscored later +const NAME = (name: string) => name; +// This name is always prefixed, never underscored, to create scope conflicts on naive rename +const NAME_PREFIXED = (name: string) => + name.replace(/^\$?/, '$').replace(/_$/, ''); +// Ensure an underscore if the given name is prefixed +const NAME_UNDERSCORE = (name: string) => + !/^\$/.test(name) || /_$/.test(name) ? name : name + '_'; +const REEXPORT = (name: string) => + /^\$/.test(name) + ? `\n/** @deprecated renamed to ${name} by @lexical/eslint-plugin rules-of-lexical */\nexport const ${name.replace( + /^\$/, + '', + )} = ${name};\n` + : ''; + +function fmt( + strings: TemplateStringsArray, + ...keys: ((name: string) => string)[] +) { + const rval = (name: string) => { + const result = [strings[0]]; + keys.forEach((key, i) => { + result.push(key(name), strings[i + 1]); + }); + return prettier.format(result.join(''), {parser: 'typescript'}); + }; + rval.keys = keys; + return rval; +} + +const ruleTester = new RuleTester({ + parserOptions: {ecmaVersion: 2018, sourceType: 'module'}, +}); + +describe('LexicalEslintPlugin', () => { + it('exports a plugin with meta and rules', () => { + expect(Object.keys(plugin).sort()).toEqual( + expect.arrayContaining(['meta', 'rules']), + ); + }); +}); +['rules-of-lexical'].forEach((ruleName) => { + const namedRules = [ + fmt`const ${NAME} = () => $getRoot();`, + fmt`const ${NAME} = () => { return $getRoot(); }`, + fmt`function ${NAME}() { return $getRoot(); }`, + fmt`export default function ${NAME}() { return $getRoot(); }`, + fmt` + function ${NAME}() { return $getRoot(); } + export default function caller(editor) { return editor.getState().read(() => ${NAME}()); }`, + fmt` + function render() { + const ${NAME} = () => { return $getRoot(); } + return editor.getState().read(() => ${NAME}()); + } + `, + fmt` + function render() { + const ${NAME} = useCallback(() => { return $getRoot(); }, []); + return editor.getState().read(() => ${NAME}()); + } + `, + fmt` + const ${NAME} = (node) => { + if ($isMarkNode(node)) { + $unwrapMarkNode(node); + return; + } + if ($isElementNode(node)) { + const children = node.getChildren(); + for (const child of children) { + ${NAME}(child); + } + } + }; + `, + fmt` + import {${NAME_PREFIXED}} from '../../nodes/KeywordNode'; + export default function KeywordsPlugin() { + const ${NAME_UNDERSCORE} = useCallback((textNode) => { + return ${NAME_PREFIXED}(textNode.getTextContent()); + }, []); + } + `, + fmt` + export function ${NAME}() { + $getRoot(); + }${REEXPORT} + `, + fmt` + export const ${NAME} = () => $getRoot();${REEXPORT} + `, + ]; + describe(ruleName, () => { + const rule = plugin.rules[ruleName]; + ruleTester.run(ruleName, rule, { + invalid: [ + ...namedRules.map((codegen, i): RuleTester.InvalidTestCase => { + const caller = `func${i}`; + const suggestName = + `$${caller}` + (codegen.keys.includes(NAME_UNDERSCORE) ? '_' : ''); + return { + code: codegen(caller), + errors: [ + { + messageId: 'rulesOfLexicalReport', + suggestions: [ + { + data: { + callee: '$getRoot', + caller, + suggestName, + }, + messageId: 'rulesOfLexicalSuggestion', + output: codegen(suggestName), + }, + ], + }, + ], + output: codegen(suggestName), + }; + }), + ], + valid: [ + // this is used in tests + fmt`async function testCase() { await update(() => { $getRoot() }) }`, + // accepted by .update + fmt`editor.update(() => $getRoot());`, + // Accepted by .read + fmt`editor.getEditorState().read(() => $getRoot());`, + // accepted by being in a class definition + fmt` + class Foo extends TextNode { + mutator() { + $getRoot(); + } + }`, + fmt` + export function $isSelectionCapturedInDecorator(node) { + return $isDecoratorNode($getNearestNodeFromDOMNode(node)); + } + `, + fmt`new Option({ onSelect: (_node) => $getRoot() })`, + fmt`new Option({ onSelect: function (_node) { $getRoot() } })`, + fmt`new Option({ onSelect: function onSelect(_node) { $getRoot() } })`, + fmt`new Option({ onSelect(_node) { $getRoot() } })`, + ...namedRules, + ].map((codegen, i) => codegen(`$func${i}`)), + }); + }); +}); diff --git a/packages/lexical-eslint-plugin/src/index.ts b/packages/lexical-eslint-plugin/src/index.ts new file mode 100644 index 000000000000..646570cc28cd --- /dev/null +++ b/packages/lexical-eslint-plugin/src/index.ts @@ -0,0 +1,16 @@ +/** + * 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. + * + */ + +/** + * For bootstrapping reasons, this module is written in CJS JavaScript so no + * compilation is necessary + */ + +import * as plugin from './LexicalEslintPlugin.js'; + +export default plugin; diff --git a/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js b/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js new file mode 100644 index 000000000000..4feaf29224e3 --- /dev/null +++ b/packages/lexical-eslint-plugin/src/rules/rules-of-lexical.js @@ -0,0 +1,365 @@ +/** + * 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. + * + */ +// @ts-check + +const getFunctionName = require('../util/getFunctionName.js'); +const getParentAssignmentName = require('../util/getParentAssignmentName.js'); + +/** + * @typedef {import('eslint').Rule.NodeParentExtension} NodeParentExtension + * @typedef {import('estree').CallExpression & NodeParentExtension} CallExpression + * @typedef {import('estree').Identifier & NodeParentExtension} Identifier + * @typedef {import('eslint').Rule.Fix} Fix + * @typedef {import('eslint').Rule.Node} Node + * @typedef {import('eslint').Rule.RuleModule} RuleModule + * @typedef {import('eslint').Rule.ReportFixer} ReportFixer + * @typedef {import('eslint').SourceCode} SourceCode + * @typedef {import('eslint').Scope.Variable} Variable + */ + +/** + * Find the variable associated with the given Identifier + * + * @param {SourceCode} sourceCode + * @param {Identifier} identifier + */ +function getIdentifierVariable(sourceCode, identifier) { + const scopeManager = sourceCode.scopeManager; + for ( + let node = /** @type {Node | null} */ (identifier); + node; + node = /** @type {Node | null}*/ (node.parent) + ) { + const variable = scopeManager + .getDeclaredVariables(node) + .find((v) => v.identifiers.includes(identifier)); + if (variable) { + return variable; + } + const scope = scopeManager.acquire(node); + if (scope) { + return ( + scope.set.get(identifier.name) || + (scope.upper ? scope.upper.set.get(identifier.name) : undefined) + ); + } + } + return undefined; +} + +/** + * Catch all identifiers that begin with "$" followed by an lowercase Latin + * character + * + * @param {string} name + */ +function isDollarFunctionName(name) { + // Can be in the format of $name or INTERNAL_$name + return /(^|_)\$[a-z]/.test(name); +} + +const ALLOWED_DOLLAR_FUNCTIONS = { + $createRootNode: true, +}; + +/** + * It's usually safe to call $isNode functions + * + * @param {string} name + * @returns + */ +function isAllowedDollarFunction(name) { + return ( + /^(INTERNAL_)?\$is[A-Z]/.test(name) || name in ALLOWED_DOLLAR_FUNCTIONS + ); +} + +// These are other function names that could have editor state context +const ALLOWED_IMPLICIT_EDITOR_STATE = { + after: true, + convert: true, + forChild: true, +}; + +// These are internal or exported so we probably have to keep them allowed for some time +const ALLOWED_DEPRECATED_IMPLICIT_EDITOR_STATE = { + beginUpdate: true, + commitPendingUpdates: true, + createChildrenArray: true, + flushRootMutations: true, + getNodeFromDOM: true, + getNodeFromDOMNode: true, + isSelectionCapturedInDecoratorInput: true, + parseEditorState: true, +}; + +/** + * Some function names should have implicit access to editor state, because they are likely + * part of a conversion or they were historically used and exported so will require deprecation + * + * @param {string} name + */ +function isImplicitDollarFunctionName(name) { + return ( + name in ALLOWED_IMPLICIT_EDITOR_STATE || + name in ALLOWED_DEPRECATED_IMPLICIT_EDITOR_STATE || + /NodeTransform$/.test(name) || + /^convert.*(Element|Node)/.test(name) || + /^(internal|INTERNAL_)/.test(name) + ); +} + +// read, update, registerCommand, and registerNodeTransform should definitely be here +const ALLOWED_CALL_IDENTIFIERS = { + read: true, + registerCommand: true, + registerNodeTransform: true, + update: true, +}; + +/** @param {Node | undefined} node */ +function isAllowedCallIdentifier(node) { + // These is the allow list of other functions that may call $functions + return ( + node && node.type === 'Identifier' && node.name in ALLOWED_CALL_IDENTIFIERS + ); +} + +/** @param {Node | undefined} node */ +function isHookFunctionIdentifier(node) { + return node && node.type === 'Identifier' && /^use[A-Z]/.test(node.name); +} + +/** + * @param {CallExpression} node + */ +function isCheckedDollarFunctionCall(node) { + const id = getFunctionNameIdentifier(/** @type {Node} */ (node.callee)); + return ( + id && isDollarFunctionName(id.name) && !isAllowedDollarFunction(id.name) + ); +} + +/** + * @param {Node | undefined} node + * @returns {Identifier | undefined} + */ +function getFunctionNameIdentifier(node) { + if (!node) { + return; + } else if (node.type === 'Identifier') { + return node; + } else if (node.type === 'MemberExpression' && !node.computed) { + return getFunctionNameIdentifier(/** @type {Node} */ (node.property)); + } +} + +/** @param {CallExpression} node */ +function isAllowedCall(node) { + // We allow read(), update(), (expr).read() and (expr).update() here + return isAllowedCallIdentifier( + getFunctionNameIdentifier(/** @type {Node} */ (node.callee)), + ); +} + +/** @param {Node} node */ +function getLexicalFunctionName(node) { + const name = getFunctionName(node); + if (name) { + return name; + } + const nodeParent = node.parent; + if ( + nodeParent.type === 'CallExpression' && + nodeParent.arguments[0] === node + ) { + const parentName = getFunctionNameIdentifier( + /** @type {Node} */ (nodeParent.callee), + ); + if (isHookFunctionIdentifier(parentName)) { + return getParentAssignmentName(nodeParent); + } + } +} + +/** + * @param {Identifier} nameIdentifier + * @param {Variable | undefined} variable + */ +function getSuggestName(nameIdentifier, variable) { + const suggestName = '$' + nameIdentifier.name; + // Add an underscore if this would shadow an existing name + if (variable) { + for (let scope = variable.scope.upper; scope; scope = scope.upper) { + if (scope.set.has(suggestName)) { + return suggestName + '_'; + } + } + } + return suggestName; +} + +/** + * @param {Identifier} nameIdentifier + * @param {Variable | undefined} variable + */ +function getExportDeclaration(nameIdentifier, variable) { + if (variable && variable.defs.length === 1) { + const [{node}] = variable.defs; + if (node.parent.type === 'ExportNamedDeclaration') { + // export function foo(); + return node.parent; + } else if ( + node.parent.type === 'VariableDeclaration' && + node.parent.parent.type === 'ExportNamedDeclaration' + ) { + // export const foo = () => {}; + return node.parent.parent; + } + } +} + +/** @param {Record<'caller'|'suggestName', string>} data */ +function renameExportText({caller, suggestName}) { + return `\n/** @deprecated renamed to ${suggestName} by @lexical/eslint-plugin rules-of-lexical */\nexport const ${caller} = ${suggestName};`; +} + +/** @type {RuleModule} */ +module.exports = { + create(context) { + // Deprecated in 8.x but we are still on 7.x + const sourceCode = context.getSourceCode(); + /** @type {Set} */ + const ignoreSet = new Set(); + /** @type {Set} */ + const reportedSet = new Set(); + /** @type {{ name?: Identifier, node: Node }[]} funStack */ + const funStack = []; + const shouldIgnore = () => { + if (ignoreSet.size > 0) { + return true; + } + // Ignore property assignments + const lastFunction = funStack[funStack.length - 1]; + return lastFunction && lastFunction.node.parent.type === 'Property'; + }; + const pushIgnoredNode = (/** @type {Node} */ node) => ignoreSet.add(node); + const popIgnoredNode = (/** @type {Node} */ node) => ignoreSet.delete(node); + const pushFunction = (/** @type {Node} */ node) => { + const name = getFunctionNameIdentifier(getLexicalFunctionName(node)); + funStack.push({name, node}); + if ( + name && + (isDollarFunctionName(name.name) || + isImplicitDollarFunctionName(name.name)) + ) { + pushIgnoredNode(node); + } + }; + const popFunction = (/** @type {Node} */ node) => { + funStack.pop(); + popIgnoredNode(node); + }; + const getParentLexicalFunctionNameIdentifier = ( + /** @type {Node} */ _node, + ) => { + const pair = funStack[funStack.length - 1]; + return pair ? pair.name : undefined; + }; + // Find all $function calls that are not inside a class or inside a $function + return { + ArrowFunctionExpression: pushFunction, + 'ArrowFunctionExpression:exit': popFunction, + CallExpression: (node) => { + if (isAllowedCall(node)) { + pushIgnoredNode(node); + } + if (shouldIgnore() || !isCheckedDollarFunctionCall(node)) { + return; + } + const nameIdentifier = getParentLexicalFunctionNameIdentifier(node); + if (!nameIdentifier || reportedSet.has(nameIdentifier)) { + return; + } + reportedSet.add(nameIdentifier); + const variable = getIdentifierVariable(sourceCode, nameIdentifier); + const suggestName = getSuggestName(nameIdentifier, variable); + const exportDeclaration = getExportDeclaration( + nameIdentifier, + variable, + ); + const data = { + callee: sourceCode.getText(node.callee), + caller: sourceCode.getText(nameIdentifier), + suggestName, + }; + /** @type {ReportFixer} */ + const fix = (fixer) => { + /** @type {Set} */ + const replaced = new Set(); + /** @type {Fix[]} */ + const fixes = []; + const renameIdentifier = (/** @type {Identifier} */ identifier) => { + if (!replaced.has(identifier)) { + replaced.add(identifier); + fixes.push(fixer.replaceText(identifier, suggestName)); + } + }; + renameIdentifier(nameIdentifier); + if (exportDeclaration) { + fixes.push( + fixer.insertTextAfter(exportDeclaration, renameExportText(data)), + ); + } + if (variable) { + for (const ref of variable.references) { + renameIdentifier(/** @type {Identifier} */ (ref.identifier)); + } + } + return fixes; + }; + context.report({ + data, + fix, + messageId: 'rulesOfLexicalReport', + node: nameIdentifier, + suggest: [ + { + data, + fix, + messageId: 'rulesOfLexicalSuggestion', + }, + ], + }); + }, + 'CallExpression:exit': popIgnoredNode, + ClassBody: pushIgnoredNode, + 'ClassBody:exit': popIgnoredNode, + FunctionDeclaration: pushFunction, + 'FunctionDeclaration:exit': popFunction, + FunctionExpression: pushFunction, + 'FunctionExpression:exit': popFunction, + }; + }, + meta: { + docs: { + description: 'enforces the Rules of Lexical', + recommended: true, + url: 'https://lexical.dev/docs/intro#reading-and-updating-editor-state', + }, + fixable: 'code', + hasSuggestions: true, + messages: { + rulesOfLexicalReport: + '{{ callee }} called from {{ caller }}, without $ prefix or read/update context', + rulesOfLexicalSuggestion: 'Rename {{ caller }} to {{ suggestName }}', + }, + schema: [{additionalProperties: false, properties: {}, type: 'object'}], + type: 'suggestion', + }, +}; diff --git a/packages/lexical-eslint-plugin/src/util/getFunctionName.js b/packages/lexical-eslint-plugin/src/util/getFunctionName.js new file mode 100644 index 000000000000..f61a0fa47299 --- /dev/null +++ b/packages/lexical-eslint-plugin/src/util/getFunctionName.js @@ -0,0 +1,43 @@ +/** + * 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. + * + */ +// @ts-check + +const getParentAssignmentName = require('./getParentAssignmentName'); + +/** + * Gets the static name of a function AST node. For function declarations it is + * easy. For anonymous function expressions it is much harder. If you search for + * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places + * where JS gives anonymous function expressions names. We roughly detect the + * same AST nodes with some exceptions to better fit our use case. + * + * @param {import('eslint').Rule.Node} node + */ +module.exports = function getFunctionName(node) { + if ( + node.type === 'FunctionDeclaration' || + (node.type === 'FunctionExpression' && node.id) + ) { + // function $function() {} + // const whatever = function $function() {}; + // + // Function declaration or function expression names win over any + // assignment statements or other renames. + return node.id; + } else if ( + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + // This checks for assignments such as + // const $function = function () {}; + // const $function = () => {}; + return getParentAssignmentName(node); + } else { + return undefined; + } +}; diff --git a/packages/lexical-eslint-plugin/src/util/getParentAssignmentName.js b/packages/lexical-eslint-plugin/src/util/getParentAssignmentName.js new file mode 100644 index 000000000000..f85d4e067431 --- /dev/null +++ b/packages/lexical-eslint-plugin/src/util/getParentAssignmentName.js @@ -0,0 +1,38 @@ +/** + * 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. + * + */ +// @ts-check +'use strict'; + +/** + * Gets the static name of an AST node, used to determine the name of an + * anonymous function declaration. This was extracted so it could also + * be used in the context of useCallback or useMemo, e.g. + * `const $fun = useCallback(() => {}, [])` where the name is not the direct + * parent of the anonymous function. + * + * @param {import('eslint').Rule.Node} node + */ +module.exports = function getParentAssignmentName(node) { + // Unlike React's rules of hooks, this does not check property assignment. + // The rules of lexical $function convention only applies to functions, + // not methods or properties. + const parentNode = node.parent; + if (parentNode.type === 'VariableDeclarator' && parentNode.init === node) { + // const $function = () => {}; + return parentNode.id; + } else if ( + parentNode.type === 'AssignmentExpression' && + parentNode.right === node && + parentNode.operator === '=' + ) { + // $function = () => {}; + return parentNode.left; + } else { + return undefined; + } +}; diff --git a/packages/lexical-website/docs/packages/lexical-eslint-plugin.md b/packages/lexical-website/docs/packages/lexical-eslint-plugin.md new file mode 100644 index 000000000000..b5e3088d4ab4 --- /dev/null +++ b/packages/lexical-website/docs/packages/lexical-eslint-plugin.md @@ -0,0 +1,6 @@ +--- +title: '' +sidebar_label: '@lexical/eslint-plugin' +--- + +{@import ../../../lexical-eslint-plugin/README.md} diff --git a/tsconfig.build.json b/tsconfig.build.json index dc17b674c796..6b35fbf19ed5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,9 @@ "./packages/lexical-devtools-core/src/index.ts" ], "@lexical/dragon": ["./packages/lexical-dragon/src/index.ts"], + "@lexical/eslint-plugin": [ + "./packages/lexical-eslint-plugin/src/index.ts" + ], "@lexical/file": ["./packages/lexical-file/src/index.ts"], "@lexical/hashtag": ["./packages/lexical-hashtag/src/index.ts"], "@lexical/headless": ["./packages/lexical-headless/src/index.ts"], diff --git a/tsconfig.json b/tsconfig.json index ea4625439029..56ca99ffaccb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ "./packages/lexical-devtools-core/src/index.ts" ], "@lexical/dragon": ["./packages/lexical-dragon/src/index.ts"], + "@lexical/eslint-plugin": [ + "./packages/lexical-eslint-plugin/src/index.ts" + ], "@lexical/file": ["./packages/lexical-file/src/index.ts"], "@lexical/hashtag": ["./packages/lexical-hashtag/src/index.ts"], "@lexical/headless": ["./packages/lexical-headless/src/index.ts"], @@ -179,6 +182,7 @@ "@lexical/code/src": ["./packages/lexical-code/src"], "@lexical/devtools-core/src": ["./packages/lexical-devtools-core/src"], "@lexical/dragon/src": ["./packages/lexical-dragon/src"], + "@lexical/eslint-plugin/src": ["./packages/lexical-eslint-plugin/src"], "@lexical/file/src": ["./packages/lexical-file/src"], "@lexical/hashtag/src": ["./packages/lexical-hashtag/src"], "@lexical/headless/src": ["./packages/lexical-headless/src"], @@ -213,6 +217,6 @@ "**/node_modules/**", "./packages/lexical-devtools/**" ], - "typedocOptions": {"logLevel": "Verbose"}, - "ts-node": {"require": ["tsconfig-paths/register"], "transpileOnly": true} + "typedocOptions": { "logLevel": "Verbose" }, + "ts-node": { "require": ["tsconfig-paths/register"], "transpileOnly": true } }