From ba5237d329a6dd1df2f5609b5493188093942db1 Mon Sep 17 00:00:00 2001 From: ESLint Jenkins Date: Fri, 11 Feb 2022 17:04:50 -0500 Subject: [PATCH] chore: tool to build keys out of AST definitions Also: 1. Removes `ExperimentalRestProperty`, `ExperimentalSpreadProperty` 2. Adds `JSXSpreadChild` --- lib/visitor-keys.js | 9 +- package.json | 7 + tests/lib/get-keys-from-ts.js | 32 +++ tools/build-keys-from-ts.js | 54 ++++ tools/get-keys-from-ts.js | 451 ++++++++++++++++++++++++++++++++++ 5 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 tests/lib/get-keys-from-ts.js create mode 100644 tools/build-keys-from-ts.js create mode 100644 tools/get-keys-from-ts.js diff --git a/lib/visitor-keys.js b/lib/visitor-keys.js index defd33b..582ddf0 100644 --- a/lib/visitor-keys.js +++ b/lib/visitor-keys.js @@ -75,12 +75,6 @@ const KEYS = { "test" ], EmptyStatement: [], - ExperimentalRestProperty: [ - "argument" - ], - ExperimentalSpreadProperty: [ - "argument" - ], ExportAllDeclaration: [ "exported", "source" @@ -188,6 +182,9 @@ const KEYS = { JSXSpreadAttribute: [ "argument" ], + JSXSpreadChild: [ + "expression" + ], JSXText: [], LabeledStatement: [ "label", diff --git a/package.json b/package.json index 5368ad5..eeb83a8 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "devDependencies": { + "@types/estree": "^0.0.51", + "@types/estree-jsx": "^0.0.1", + "@typescript-eslint/parser": "^5.11.0", "c8": "^7.7.3", + "chai": "^4.3.6", "eslint": "^7.29.0", "eslint-config-eslint": "^7.0.0", "eslint-plugin-jsdoc": "^35.4.0", "eslint-plugin-node": "^11.1.0", "eslint-release": "^3.2.0", + "esquery": "^1.4.0", + "json-diff": "^0.7.1", "mocha": "^9.0.1", "opener": "^1.5.2", "rollup": "^2.52.1", @@ -43,6 +49,7 @@ "lint": "eslint .", "tsc": "tsc", "tsd": "tsd", + "build-keys": "node tools/build-keys-from-ts", "test": "mocha tests/lib/**/*.cjs && c8 mocha tests/lib/**/*.js && npm run tsd", "coverage": "c8 report --reporter lcov && opener coverage/lcov-report/index.html", "generate-release": "eslint-generate-release", diff --git a/tests/lib/get-keys-from-ts.js b/tests/lib/get-keys-from-ts.js new file mode 100644 index 0000000..a381dd1 --- /dev/null +++ b/tests/lib/get-keys-from-ts.js @@ -0,0 +1,32 @@ +/** + * @fileoverview Tests for checking that our build tool can retrieve keys out of TypeScript AST. + * @author Brett Zamir + */ + +import { diffString } from "json-diff"; +import { expect } from "chai"; +import { alphabetizeKeyInterfaces, getKeysFromTsFile } from "../../tools/get-keys-from-ts.js"; +import { KEYS } from "../../lib/index.js"; + +describe("getKeysFromTsFile", () => { + it("gets keys", async () => { + const { keys, tsInterfaceDeclarations } = await getKeysFromTsFile( + "./node_modules/@types/estree/index.d.ts" + ); + const { keys: jsxKeys } = await getKeysFromTsFile( + "./node_modules/@types/estree-jsx/index.d.ts", + { + supplementaryDeclarations: tsInterfaceDeclarations + } + ); + + const actual = alphabetizeKeyInterfaces({ ...keys, ...jsxKeys }); + + const expected = KEYS; + + // eslint-disable-next-line no-console -- Mocha's may drop diffs so show with json-diff + console.log("JSON Diffs:", diffString(actual, expected) || "(none)"); + + expect(actual).to.deep.equal(expected); + }); +}); diff --git a/tools/build-keys-from-ts.js b/tools/build-keys-from-ts.js new file mode 100644 index 0000000..a65ff84 --- /dev/null +++ b/tools/build-keys-from-ts.js @@ -0,0 +1,54 @@ +/** + * @fileoverview Script to build our visitor keys based on TypeScript AST. + * + * Uses `get-keys-from-ts.js` to read the files and build the keys and then + * merges them in alphabetical order of Node type before writing to file. + * + * @author Brett Zamir + */ + +import fs from "fs"; +import { alphabetizeKeyInterfaces, getKeysFromTsFile } from "./get-keys-from-ts.js"; + +const { promises: { writeFile } } = fs; + +(async () => { + const { keys, tsInterfaceDeclarations } = await getKeysFromTsFile("./node_modules/@types/estree/index.d.ts"); + const { keys: jsxKeys } = await getKeysFromTsFile( + "./node_modules/@types/estree-jsx/index.d.ts", + { + supplementaryDeclarations: tsInterfaceDeclarations + } + ); + + const mergedKeys = alphabetizeKeyInterfaces({ ...keys, ...jsxKeys }); + + // eslint-disable-next-line no-console -- CLI + console.log("keys", mergedKeys); + + writeFile( + "./lib/visitor-keys.js", + // eslint-disable-next-line indent -- Readability +`/** + * @typedef {import('./index.js').VisitorKeys} VisitorKeys + */ + +/** + * @type {VisitorKeys} + */ +const KEYS = ${JSON.stringify(mergedKeys, null, 4).replace(/"(.*?)":/gu, "$1:")}; + +// Types. +const NODE_TYPES = Object.keys(KEYS); + +// Freeze the keys. +for (const type of NODE_TYPES) { + Object.freeze(KEYS[type]); +} +Object.freeze(KEYS); + +export default KEYS; +` + ); + +})(); diff --git a/tools/get-keys-from-ts.js b/tools/get-keys-from-ts.js new file mode 100644 index 0000000..d22c78c --- /dev/null +++ b/tools/get-keys-from-ts.js @@ -0,0 +1,451 @@ +/** + * @fileoverview Script to build our visitor keys based on TypeScript AST. + * + * Uses `get-keys-from-ts.js` to read the files and build the keys and then + * merges them in alphabetical order of Node type before writing to file. + * + * @author Brett Zamir + */ + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import { promises } from "fs"; +import { parseForESLint } from "@typescript-eslint/parser"; +import esquery from "esquery"; + +import { getKeys } from "../lib/index.js"; + +const { readFile } = promises; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const propertiesToIgnore = new Set([ + "loc", + "comments", + "innerComments", + "operator" +]); + +const exemptedTypes = new Set([ + "TSBooleanKeyword", + "TSNumberKeyword", + "TSStringKeyword", + "TSLiteralType", // E.g., `true` + + // Apparently used for primitives, so exempting + "TSTypeLiteral", // E.g., `{value: {cooked, raw}}` + + "TSUnionType", // I.e., `|` + "TSTypeReference" +]); + +// All items ending in `Statement` are also traversable +const traversableTypes = new Set([ + "Array", + "CatchClause", + "ChainElement", + "ClassBody", + "Declaration", + "Expression", + "FunctionExpression", + "Identifier", + "JSXClosingFragment", + "JSXIdentifier", + "JSXMemberExpression", + "JSXOpeningElement", + "JSXOpeningFragment", + "JSXClosingElement", + "Literal", + "Pattern", + "SourceLocation", + "TemplateLiteral", + "VariableDeclaration" +]); + +const notTraversableTypes = new Set([ + "RegExp", + "TSUndefinedKeyword", + "TSNullKeyword", + "TSBooleanKeyword", + "TSNumberKeyword", + "TSStringKeyword", + "TSBigIntKeyword", + "TSLiteralType" +]); + +/** + * Checks if a name is traverseable + * @param {string} name The name to check + * @returns {boolean} Whether it is traversable. + */ +function isTraversable(name) { + return name && (name.endsWith("Statement") || traversableTypes.has(name)); +} + +/** + * Determine whether the Node is traversable + * @param {Node} annotationType The annotation type Node + * @returns {boolean} Whether the node is traversable + */ +function checkTraversability(annotationType) { + if ( + notTraversableTypes.has(annotationType.type) + ) { + return false; + } + + if (annotationType.type === "TSTupleType") { + return annotationType.elementTypes.some(annType => checkTraversability(annType)); + } + + if (notTraversableTypes.has(annotationType.typeName.name)) { + return false; + } + + if (!isTraversable(annotationType.typeName.name)) { + + // Todo? + /* + const innerInterfaceName = tsAnnotation.typeName.name; + const innerTsDeclarationNode = findTsInterfaceDeclaration(innerInterfaceName); + + if (!innerTsDeclarationNode) { + + const innerTsTypeNode = findTsTypeDeclaration(innerInterfaceName); + + // We might iterate types here to see if children are iterable and + // fail if not + + unrecognizedTSTypeReferences.add(`${tsAnnotation.typeName.name}`); + break; + } + + // We might iterate interfaces here to see if children are iterable + // (see `addNodeForInterface` for a pattern of iteration) + */ + + throw new Error(`Type unknown as to traversability: ${annotationType.typeName.name}`); + } + + return true; +} + +/** + * Get the literal names out of AST + * @param {Node} excludedItem Excluded node + * @returns {string[]} The literal names + */ +function findOmitTypes(excludedItem) { + if (excludedItem.type === "TSUnionType") { + return excludedItem.types.map(typeNode => findOmitTypes(typeNode)); + } + if (excludedItem.type !== "TSLiteralType") { + throw new Error("Processing of non-literals in `Omit` not currently supported"); + } + return excludedItem.literal.value; +} + +/** + * Checks whether property should be excluded + * @param {string} property Property to check + * @param {string[]} excludedProperties Properties not to allow + * @returns {boolean} Whether or not to be excluded + */ +function isPropertyExcluded(property, excludedProperties) { + return propertiesToIgnore.has(property) || + (excludedProperties && excludedProperties.includes(property)); +} + +//------------------------------------------------------------------------------ +// Public APIs +//------------------------------------------------------------------------------ + +/** + * Returns alphabetized keys + * @param {KeysStrict} initialNodes Initial node list to sort + * @returns {KeysStrict} The keys + */ +function alphabetizeKeyInterfaces(initialNodes) { + + /** + * Alphabetize + * @param {string} typeA The first type to compare + * @param {string} typeB The second type to compare + * @returns {1|-1} The sorting index + */ + function alphabetize([typeA], [typeB]) { + return typeA < typeB ? -1 : 1; + } + const sortedNodeEntries = Object.entries(initialNodes).sort(alphabetize); + + /** + * Get the key sorter for a given type + * @param {string} type The type + * @returns {(string, string) => -1|1} The sorter + */ + function getKeySorter(type) { + const sequence = [ + + // The groups should generally be suggestive of order but may need + // to rearrange groups if occurring together + "id", "init", + "name", "attributes", + "key", "value", + ...( + type === "CallExpression" || type === "NewExpression" + ? ["callee", "arguments"] + : ["arguments", "callee"] + ), + "params", "param", "superClass", "left", "right", + ...(type === "DoWhileStatement" + ? [ + "body", "test" + ] : [ + "test", "consequent", "alternate", + "update", + "body" + ] + )]; + + /** + * Alphabetize + * @param {string} typeA The first type to compare + * @param {string} typeB The second type to compare + * @returns {1|-1} The sorting index + */ + return function sortKeys(typeA, typeB) { + return sequence.indexOf(typeA) < sequence.indexOf(typeB) ? -1 : 1; + }; + } + + for (const [type, keys] of sortedNodeEntries) { + keys.sort(getKeySorter(type)); + } + + return Object.fromEntries(sortedNodeEntries); +} + +/** + * Builds visitor keys based on TypeScript declaration. + * @param {string} code TypeScript declaration file as code to parse. + * @param {{supplementaryDeclarations: Node[]}} [options] The options + * @returns {Promise} The built visitor keys + */ +async function getKeysFromTs(code, { + + // Todo: Ideally we'd just get these from the import + supplementaryDeclarations = { + allTsInterfaceDeclarations: [], + exportedTsInterfaceDeclarations: [] + } +} = {}) { + const unrecognizedTSTypeReferences = new Set(); + const unrecognizedTSTypes = new Set(); + + const parsedTSDeclaration = parseForESLint(code); + + const allTsInterfaceDeclarations = [...esquery.query( + parsedTSDeclaration.ast, + "TSInterfaceDeclaration", + { + + // TypeScript keys here to find our *.d.ts nodes (not for the ESTree + // ones we want) + visitorKeys: parsedTSDeclaration.visitorKeys + } + ), ...supplementaryDeclarations.allTsInterfaceDeclarations]; + + const exportedTsInterfaceDeclarations = [...esquery.query( + parsedTSDeclaration.ast, + "ExportNamedDeclaration > TSInterfaceDeclaration", + { + + // TypeScript keys here to find our *.d.ts nodes (not for the ESTree + // ones we want) + visitorKeys: parsedTSDeclaration.visitorKeys + } + ), ...supplementaryDeclarations.exportedTsInterfaceDeclarations]; + + // const tsTypeDeclarations = esquery.query( + // parsedTSDeclaration.ast, + // "TSTypeAliasDeclaration", + // { + // + // // TypeScript keys here to find our *.d.ts nodes (not for the ESTree + // // ones we want) + // visitorKeys: parsedTSDeclaration.visitorKeys + // } + // ); + const initialNodes = {}; + + /** + * Finds a TypeScript interfaction declaration. + * @param {string} interfaceName The type name. + * @returns {Node} The interface declaration node + */ + function findTsInterfaceDeclaration(interfaceName) { + return allTsInterfaceDeclarations.find( + innerTsDeclaration => innerTsDeclaration.id.name === interfaceName + ); + } + + /** + * Adds a node for a given interface. + * @param {string} interfaceName Name of the interface + * @param {Node} tsDeclarationNode TypeScript declaration node + * @param {Node} node The Node on which to build + * @param {string[]} excludedProperties Excluded properties + * @returns {void} + */ + function addNodeForInterface(interfaceName, tsDeclarationNode, node, excludedProperties) { + const tsPropertySignatures = tsDeclarationNode.body.body; + + for (const tsPropertySignature of tsPropertySignatures) { + const property = tsPropertySignature.key.name; + + if (isPropertyExcluded(property, excludedProperties)) { + continue; + } + + const tsAnnotation = tsPropertySignature.typeAnnotation.typeAnnotation; + const tsPropertyType = tsAnnotation.type; + + // For sanity-checking + if (!exemptedTypes.has(tsPropertyType)) { + unrecognizedTSTypes.add(tsPropertyType); + continue; + } + + switch (tsPropertyType) { + case "TSUnionType": + if (tsAnnotation.types.some(checkTraversability)) { + break; + } + continue; + case "TSTypeReference": { + if (checkTraversability(tsAnnotation)) { + break; + } + + continue; + } default: + continue; + } + + node[property] = null; + } + + for (const extension of tsDeclarationNode.extends || []) { + const { typeParameters, expression } = extension; + const innerInterfaceName = expression.name; + + if (typeParameters) { + if (innerInterfaceName !== "Omit") { + throw new Error("Unknown extension type with parameters"); + } + + const [param, ...excludedAST] = typeParameters.params; + const paramInterfaceName = param.typeName.name; + const excluded = excludedAST.flatMap(findOmitTypes); + + const innerTsDeclarationNode = findTsInterfaceDeclaration(paramInterfaceName); + + if (!innerTsDeclarationNode) { + unrecognizedTSTypeReferences.add(`${paramInterfaceName}`); + return; + } + + addNodeForInterface(paramInterfaceName, innerTsDeclarationNode, node, excluded); + } else { + const innerTsDeclarationNode = findTsInterfaceDeclaration(innerInterfaceName); + + if (!innerTsDeclarationNode) { + unrecognizedTSTypeReferences.add(`${innerInterfaceName}`); + return; + } + + addNodeForInterface(innerInterfaceName, innerTsDeclarationNode, node); + } + } + } + + for (const tsDeclarationNode of exportedTsInterfaceDeclarations) { + const interfaceName = tsDeclarationNode.id.name; + + const typeName = tsDeclarationNode.body.body.find( + prop => prop.key.name === "type" + )?.typeAnnotation?.typeAnnotation?.literal?.value; + + if (!typeName) { + continue; + } + + const node = {}; + + addNodeForInterface(interfaceName, tsDeclarationNode, node); + + initialNodes[typeName] = [...new Set(getKeys(node), ...(initialNodes[typeName] || []))]; + } + + const nodes = alphabetizeKeyInterfaces(initialNodes); + + if (unrecognizedTSTypes.size) { + throw new Error( + "Unhandled TypeScript type; please update the code to " + + "handle the type or if not relevant, add it to " + + "`unrecognizedTSTypes`; see\n\n " + + `${[...unrecognizedTSTypes].join(", ")}\n` + ); + } + if (unrecognizedTSTypeReferences.size) { + throw new Error( + "Unhandled TypeScript type reference; please update the code to " + + "handle the type reference or if not relevant, add it to " + + "`unrecognizedTSTypeReferences`; see\n\n " + + `${[...unrecognizedTSTypeReferences].join(", ")}\n` + ); + } + + return { + keys: nodes, + tsInterfaceDeclarations: { + allTsInterfaceDeclarations, + exportedTsInterfaceDeclarations + } + }; +} + +/** + * @typedef {{tsInterfaceDeclarations: { + * allTsInterfaceDeclarations: { + * Node[], + * keys: KeysStrict + * }, + * exportedTsInterfaceDeclarations: + * Node[], + * keys: KeysStrict + * } + * }}} VisitorKeysExport + */ + +/** + * Builds visitor keys based on TypeScript declaration. + * @param {string} file TypeScript declaration file to parse. + * @param {{supplementaryDeclarations: Node[]}} options The options + * @returns {Promise} The built visitor keys + */ +async function getKeysFromTsFile(file, options) { + const code = await readFile(file); + + return await getKeysFromTs(code, options); +} + +//------------------------------------------------------------------------------ +// Public Interface +//------------------------------------------------------------------------------ + +export { alphabetizeKeyInterfaces, getKeysFromTs, getKeysFromTsFile };