From 6ece4bd4086965bdaf92d95b6a03d8d122468b4e Mon Sep 17 00:00:00 2001 From: Brett Zamir Date: Tue, 29 Mar 2022 08:39:07 +0800 Subject: [PATCH] feat: add `JSXSpreadChild` and tool to build keys out of AST definitions (#36) * refactor: Alphabetize for easier comparisons * chore: tool to build keys out of AST definitions Also: 1. Removes `ExperimentalRestProperty`, `ExperimentalSpreadProperty` 2. Adds `JSXSpreadChild` * refactor: sort alphabetically after known keys * refactor: restore backward compatible experimental keys * refactor: put backward compatible keys into own file * refactor: file rename * refactor: drop `propertiesToIgnore` in favor of `getKeys` blacklist * refactor: allow for more primitiveish types; fix error Also: - test: improve coverage * refactor: drop optional chaining fix for Node 12 * refactor: update testing/build devDeps. * refactor: exclude keys if not leading to an object with a non-comment type * refactor: no need for async on function * refactor: Remove commented out properties * refactor: Avoid unnecessary await Co-authored-by: Milos Djermanovic Co-authored-by: Milos Djermanovic --- lib/visitor-keys.js | 73 +-- package.json | 15 +- .../fixtures/bad-extends-type-reference.d.ts | 3 + tests/lib/fixtures/bad-type-parameters.d.ts | 5 + tests/lib/fixtures/bad-type-reference.d.ts | 3 + tests/lib/fixtures/bad-type-value.d.ts | 8 + tests/lib/fixtures/bad-type.d.ts | 4 + tests/lib/fixtures/new-keys-bad.d.ts | 4 + .../new-keys-on-old-order-switched.d.ts | 20 + .../fixtures/new-keys-on-old-other-order.d.ts | 20 + tests/lib/fixtures/new-keys-on-old.d.ts | 35 ++ tests/lib/fixtures/new-keys.d.ts | 19 + tests/lib/fixtures/union-omit.d.ts | 11 + tests/lib/get-keys-from-ts.js | 201 +++++++ tools/backward-compatible-keys.js | 10 + tools/build-keys-from-ts.js | 55 ++ tools/get-keys-from-ts.js | 546 ++++++++++++++++++ 17 files changed, 993 insertions(+), 39 deletions(-) create mode 100644 tests/lib/fixtures/bad-extends-type-reference.d.ts create mode 100644 tests/lib/fixtures/bad-type-parameters.d.ts create mode 100644 tests/lib/fixtures/bad-type-reference.d.ts create mode 100644 tests/lib/fixtures/bad-type-value.d.ts create mode 100644 tests/lib/fixtures/bad-type.d.ts create mode 100644 tests/lib/fixtures/new-keys-bad.d.ts create mode 100644 tests/lib/fixtures/new-keys-on-old-order-switched.d.ts create mode 100644 tests/lib/fixtures/new-keys-on-old-other-order.d.ts create mode 100644 tests/lib/fixtures/new-keys-on-old.d.ts create mode 100644 tests/lib/fixtures/new-keys.d.ts create mode 100644 tests/lib/fixtures/union-omit.d.ts create mode 100644 tests/lib/get-keys-from-ts.js create mode 100644 tools/backward-compatible-keys.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 d456d64..7e52686 100644 --- a/lib/visitor-keys.js +++ b/lib/visitor-keys.js @@ -6,14 +6,6 @@ * @type {VisitorKeys} */ const KEYS = { - AssignmentExpression: [ - "left", - "right" - ], - AssignmentPattern: [ - "left", - "right" - ], ArrayExpression: [ "elements" ], @@ -24,16 +16,24 @@ const KEYS = { "params", "body" ], + AssignmentExpression: [ + "left", + "right" + ], + AssignmentPattern: [ + "left", + "right" + ], AwaitExpression: [ "argument" ], - BlockStatement: [ - "body" - ], BinaryExpression: [ "left", "right" ], + BlockStatement: [ + "body" + ], BreakStatement: [ "label" ], @@ -75,6 +75,12 @@ const KEYS = { "test" ], EmptyStatement: [], + ExperimentalRestProperty: [ + "argument" + ], + ExperimentalSpreadProperty: [ + "argument" + ], ExportAllDeclaration: [ "exported", "source" @@ -94,18 +100,6 @@ const KEYS = { ExpressionStatement: [ "expression" ], - ExperimentalRestProperty: [ - "argument" - ], - ExperimentalSpreadProperty: [ - "argument" - ], - ForStatement: [ - "init", - "test", - "update", - "body" - ], ForInStatement: [ "left", "right", @@ -116,6 +110,12 @@ const KEYS = { "right", "body" ], + ForStatement: [ + "init", + "test", + "update", + "body" + ], FunctionDeclaration: [ "id", "params", @@ -156,6 +156,7 @@ const KEYS = { JSXClosingElement: [ "name" ], + JSXClosingFragment: [], JSXElement: [ "openingElement", "children", @@ -165,6 +166,11 @@ const KEYS = { JSXExpressionContainer: [ "expression" ], + JSXFragment: [ + "openingFragment", + "children", + "closingFragment" + ], JSXIdentifier: [], JSXMemberExpression: [ "object", @@ -178,22 +184,19 @@ const KEYS = { "name", "attributes" ], + JSXOpeningFragment: [], JSXSpreadAttribute: [ "argument" ], - JSXText: [], - JSXFragment: [ - "openingFragment", - "children", - "closingFragment" + JSXSpreadChild: [ + "expression" ], - JSXClosingFragment: [], - JSXOpeningFragment: [], - Literal: [], + JSXText: [], LabeledStatement: [ "label", "body" ], + Literal: [], LogicalExpression: [ "left", "right" @@ -248,14 +251,14 @@ const KEYS = { "body" ], Super: [], - SwitchStatement: [ - "discriminant", - "cases" - ], SwitchCase: [ "test", "consequent" ], + SwitchStatement: [ + "discriminant", + "cases" + ], TaggedTemplateExpression: [ "tag", "quasi" diff --git a/package.json b/package.json index 5368ad5..d0f7c77 100644 --- a/package.json +++ b/package.json @@ -25,17 +25,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "devDependencies": { - "c8": "^7.7.3", + "@types/estree": "^0.0.51", + "@types/estree-jsx": "^0.0.1", + "@typescript-eslint/parser": "^5.14.0", + "c8": "^7.11.0", + "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", - "mocha": "^9.0.1", + "esquery": "^1.4.0", + "json-diff": "^0.7.3", + "mocha": "^9.2.1", "opener": "^1.5.2", - "rollup": "^2.52.1", + "rollup": "^2.70.0", "tsd": "^0.19.1", - "typescript": "^4.5.5" + "typescript": "^4.6.2" }, "scripts": { "prepare": "npm run build", @@ -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/fixtures/bad-extends-type-reference.d.ts b/tests/lib/fixtures/bad-extends-type-reference.d.ts new file mode 100644 index 0000000..1457d67 --- /dev/null +++ b/tests/lib/fixtures/bad-extends-type-reference.d.ts @@ -0,0 +1,3 @@ +export interface Something extends BadSomething { + type: "Something"; +} diff --git a/tests/lib/fixtures/bad-type-parameters.d.ts b/tests/lib/fixtures/bad-type-parameters.d.ts new file mode 100644 index 0000000..0095c9c --- /dev/null +++ b/tests/lib/fixtures/bad-type-parameters.d.ts @@ -0,0 +1,5 @@ +export interface Statement {} + +export interface StaticBlock extends BadTypeParam { + type: "StaticBlock"; +} diff --git a/tests/lib/fixtures/bad-type-reference.d.ts b/tests/lib/fixtures/bad-type-reference.d.ts new file mode 100644 index 0000000..9c226f1 --- /dev/null +++ b/tests/lib/fixtures/bad-type-reference.d.ts @@ -0,0 +1,3 @@ +export interface StaticBlock extends Omit { + type: "StaticBlock"; +} diff --git a/tests/lib/fixtures/bad-type-value.d.ts b/tests/lib/fixtures/bad-type-value.d.ts new file mode 100644 index 0000000..973aa2b --- /dev/null +++ b/tests/lib/fixtures/bad-type-value.d.ts @@ -0,0 +1,8 @@ +interface BadExpression { + type: undefined; +} + +export interface NewFangledExpression { + type: "NewFangledExpression"; + right: BadExpression; +} diff --git a/tests/lib/fixtures/bad-type.d.ts b/tests/lib/fixtures/bad-type.d.ts new file mode 100644 index 0000000..ce4b983 --- /dev/null +++ b/tests/lib/fixtures/bad-type.d.ts @@ -0,0 +1,4 @@ +export interface SomeExpression { + type: "SomeExpression"; + someProperty: any; +} diff --git a/tests/lib/fixtures/new-keys-bad.d.ts b/tests/lib/fixtures/new-keys-bad.d.ts new file mode 100644 index 0000000..99389ea --- /dev/null +++ b/tests/lib/fixtures/new-keys-bad.d.ts @@ -0,0 +1,4 @@ +export interface NewFangledExpression { + type: "NewFangledExpression"; + right: BadExpression; +} diff --git a/tests/lib/fixtures/new-keys-on-old-order-switched.d.ts b/tests/lib/fixtures/new-keys-on-old-order-switched.d.ts new file mode 100644 index 0000000..dc0141d --- /dev/null +++ b/tests/lib/fixtures/new-keys-on-old-order-switched.d.ts @@ -0,0 +1,20 @@ +export type AssignmentOperator = "="; +interface Pattern { + type: "Pattern" +}; +interface MemberExpression { + type: "MemberExpression" +}; +interface Expression { + type: "Expression" +}; + +export interface AssignmentExpression { + type: "AssignmentExpression"; + operator: AssignmentOperator; + down: Expression; + up: Expression; + left: Pattern | MemberExpression; + right: Expression; + nontraversable: RegExp; +} diff --git a/tests/lib/fixtures/new-keys-on-old-other-order.d.ts b/tests/lib/fixtures/new-keys-on-old-other-order.d.ts new file mode 100644 index 0000000..3f9d5d3 --- /dev/null +++ b/tests/lib/fixtures/new-keys-on-old-other-order.d.ts @@ -0,0 +1,20 @@ +export type AssignmentOperator = "="; +interface Pattern { + type: "Pattern" +}; +interface MemberExpression { + type: "MemberExpression" +}; +interface Expression { + type: "Expression" +}; + +export interface AssignmentExpression { + type: "AssignmentExpression"; + operator: AssignmentOperator; + up: Expression; + left: Pattern | MemberExpression; + down: Expression; + right: Expression; + nontraversable: RegExp; +} diff --git a/tests/lib/fixtures/new-keys-on-old.d.ts b/tests/lib/fixtures/new-keys-on-old.d.ts new file mode 100644 index 0000000..1ac742c --- /dev/null +++ b/tests/lib/fixtures/new-keys-on-old.d.ts @@ -0,0 +1,35 @@ +export type AssignmentOperator = "="; + +interface IgnoreBase { + type: "Line"; +} + +type AnotherIgnore = IgnoreBase; + +interface BasePattern { + type: "Pattern" +}; +interface IgnoreChild extends Omit { +}; + +interface Pattern { + type: "Pattern" +}; +interface MemberExpression { + type: "MemberExpression" +}; +interface Expression { + type: "Expression" +}; + +export interface AssignmentExpression { + type: "AssignmentExpression"; + ignore: IgnoreChild; + anotherIgnore: AnotherIgnore; + operator: AssignmentOperator; + up: Expression; + down: Expression; + left: Pattern | MemberExpression; + right: Expression; + nontraversable: RegExp; +} diff --git a/tests/lib/fixtures/new-keys.d.ts b/tests/lib/fixtures/new-keys.d.ts new file mode 100644 index 0000000..6ff0aa8 --- /dev/null +++ b/tests/lib/fixtures/new-keys.d.ts @@ -0,0 +1,19 @@ +export type AssignmentOperator = "="; +interface Pattern { + type: "Pattern" +}; +interface MemberExpression { + type: "MemberExpression" +}; +interface Expression { + type: "Expression" +}; + +export interface NewFangledExpression { + type: "NewFangledExpression"; + operator: AssignmentOperator; + up: Expression; + down: Expression; + left: Pattern | MemberExpression; + right: Expression; +} diff --git a/tests/lib/fixtures/union-omit.d.ts b/tests/lib/fixtures/union-omit.d.ts new file mode 100644 index 0000000..d6088df --- /dev/null +++ b/tests/lib/fixtures/union-omit.d.ts @@ -0,0 +1,11 @@ +export interface IgnoredStatement { + type: "IgnoredStatement" +} +export interface AnotherStatement { + type: "AnotherStatement"; + anotherToIgnore: IgnoredStatement; +} + +export interface StaticBlock extends Omit { + type: "StaticBlock"; +} diff --git a/tests/lib/get-keys-from-ts.js b/tests/lib/get-keys-from-ts.js new file mode 100644 index 0000000..3a9e2a7 --- /dev/null +++ b/tests/lib/get-keys-from-ts.js @@ -0,0 +1,201 @@ +/** + * @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"; +import backwardCompatibleKeys from "../../tools/backward-compatible-keys.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, ...backwardCompatibleKeys }); + + 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); + }); + + it("gets keys minus explicitly omitted ones", async () => { + const { keys: actual } = await getKeysFromTsFile( + "./tests/lib/fixtures/union-omit.d.ts" + ); + + const expected = { + AnotherStatement: [ + "anotherToIgnore" + ], + IgnoredStatement: [], + StaticBlock: [] + }; + + expect(actual).to.deep.equal(expected); + }); + + it("sorts keys alphabetically if new", async () => { + const { keys: actual } = await getKeysFromTsFile( + "./tests/lib/fixtures/new-keys.d.ts" + ); + + const expected = { + NewFangledExpression: [ + "down", + "left", + "right", + "up" + ] + }; + + expect(actual).to.deep.equal(expected); + }); + + it("sorts extra keys at end alphabetically", async () => { + const { keys: actual } = await getKeysFromTsFile( + "./tests/lib/fixtures/new-keys-on-old.d.ts" + ); + + const expected = { + AssignmentExpression: [ + "left", + "right", + "down", + "up" + ] + }; + + expect(actual).to.deep.equal(expected); + }); + + it("sorts extra keys at end alphabetically (other order)", async () => { + const { keys: actual } = await getKeysFromTsFile( + "./tests/lib/fixtures/new-keys-on-old-other-order.d.ts" + ); + + const expected = { + AssignmentExpression: [ + "left", + "right", + "down", + "up" + ] + }; + + expect(actual).to.deep.equal(expected); + }); + + it("sorts extra keys at end alphabetically (switched order)", async () => { + const { keys: actual } = await getKeysFromTsFile( + "./tests/lib/fixtures/new-keys-on-old-order-switched.d.ts" + ); + + const expected = { + AssignmentExpression: [ + "left", + "right", + "down", + "up" + ] + }; + + expect(actual).to.deep.equal(expected); + }); + + it("throws with unhandled TS type reference", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/bad-type-reference.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.contain("Unhandled TypeScript type reference"); + }); + + it("throws with unhandled extends TS type reference", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/bad-extends-type-reference.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.contain("Unhandled TypeScript type reference"); + }); + + it("throws with unhandled TS type", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/bad-type.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.contain("Unhandled TypeScript type;"); + }); + + it("throws with unhandled TS typeParameters", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/bad-type-parameters.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.contain("Unknown type parameter"); + }); + + it("throws with bad key", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/new-keys-bad.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.equal("Type unknown as to traversability: BadExpression"); + }); + + it("throws with bad type value", async () => { + let error; + + try { + await getKeysFromTsFile( + "./tests/lib/fixtures/bad-type-value.d.ts" + ); + } catch (err) { + error = err; + } + + expect(error.message).to.equal("Unexpected `type` value property type TSUndefinedKeyword"); + }); +}); diff --git a/tools/backward-compatible-keys.js b/tools/backward-compatible-keys.js new file mode 100644 index 0000000..ead9e70 --- /dev/null +++ b/tools/backward-compatible-keys.js @@ -0,0 +1,10 @@ +const backwardCompatibleKeys = { + ExperimentalRestProperty: [ + "argument" + ], + ExperimentalSpreadProperty: [ + "argument" + ] +}; + +export default backwardCompatibleKeys; diff --git a/tools/build-keys-from-ts.js b/tools/build-keys-from-ts.js new file mode 100644 index 0000000..a47b210 --- /dev/null +++ b/tools/build-keys-from-ts.js @@ -0,0 +1,55 @@ +/** + * @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"; +import backwardCompatibleKeys from "./backward-compatible-keys.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, ...backwardCompatibleKeys }); + + // 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..514fe89 --- /dev/null +++ b/tools/get-keys-from-ts.js @@ -0,0 +1,546 @@ +/** + * @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, KEYS } from "../lib/index.js"; + +const { readFile } = promises; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const knownTypes = new Set([ + "TSUndefinedKeyword", + "TSNullKeyword", + "TSUnknownKeyword", + "TSBooleanKeyword", + "TSNumberKeyword", + "TSStringKeyword", + "TSLiteralType", // E.g., `true` + + // Apparently used for primitives, so exempting + "TSTypeLiteral", // E.g., `{value: {cooked, raw}}` + + "TSUnionType", // I.e., `|` + "TSTypeReference" +]); + +const notTraversableTypes = new Set([ + "RegExp" +]); + +const notTraversableTSTypes = new Set([ + "TSUndefinedKeyword", + "TSNullKeyword", + "TSBooleanKeyword", + "TSNumberKeyword", + "TSStringKeyword", + "TSBigIntKeyword", + "TSLiteralType" +]); + +const commentTypes = new Set([ + "Line", + "Block" +]); + +/** + * 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)); + } + 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 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 = KEYS[type]; + + /** + * 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) { + if (!sequence) { + return typeA < typeB ? -1 : 1; + } + + const idxA = sequence.indexOf(typeA); + const idxB = sequence.indexOf(typeB); + + if (idxA === -1 && idxB === -1) { + return typeA < typeB ? -1 : 1; + } + if (idxA === -1) { + return 1; + } + if (idxB === -1) { + return -1; + } + + return idxA < idxB ? -1 : 1; + }; + } + + for (const [type, keys] of sortedNodeEntries) { + keys.sort(getKeySorter(type)); + } + + return Object.fromEntries(sortedNodeEntries); +} + +/** + * Traverse interface `extends` + * @param {Node} declNode The TS declaration node + * @param {Function} handler The callback + * @returns {any[]} Return value of handler + */ +function traverseExtends(declNode, handler) { + const ret = []; + + for (const extension of declNode.extends || []) { + const { typeParameters, expression } = extension; + const innerInterfaceName = expression.name; + + let res; + + if (typeParameters) { + if (innerInterfaceName !== "Omit") { + throw new Error("Unknown type parameter"); + } + + const [param, ...excludedAST] = typeParameters.params; + const paramInterfaceName = param.typeName.name; + const excluded = excludedAST.flatMap(findOmitTypes); + + res = handler({ iName: paramInterfaceName, excluded }); + } else { + res = handler({ iName: innerInterfaceName }); + } + + ret.push(res); + } + + return ret; +} + +/** + * Traverse the properties of a declaration node. + * @param {Node} tsDeclarationNode The declaration node + * @param {(string) => void} handler Passed the property + * @returns {any[]} The return values of the callback + */ +function traverseProperties(tsDeclarationNode, handler) { + const tsPropertySignatures = tsDeclarationNode.body.body; + + const ret = []; + + for (const tsPropertySignature of tsPropertySignatures) { + const property = tsPropertySignature.key.name; + + const tsAnnotation = tsPropertySignature.typeAnnotation.typeAnnotation; + + const res = handler({ property, tsAnnotation }); + + ret.push(res); + } + + return ret; +} + +/** + * Builds visitor keys based on TypeScript declaration. + * @param {string} code TypeScript declaration file as code to parse. + * @param {{supplementaryDeclarations: Node[]}} [options] The options + * @returns {VisitorKeysExport} The built visitor keys + */ +function getKeysFromTs(code, { + + // Todo: Ideally we'd just get these from the import + supplementaryDeclarations = { + allTsInterfaceDeclarations: [], + exportedTsInterfaceDeclarations: [], + tsTypeDeclarations: [] + } +} = {}) { + 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 + } + ), ...supplementaryDeclarations.tsTypeDeclarations]; + + 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 + ); + } + + /** + * Finds a TypeScript type declaration. + * @param {string} typeName A type name + * @returns {Node} The type declaration node + */ + function findTsTypeDeclaration(typeName) { + return tsTypeDeclarations.find(typeDecl => typeDecl.id.name === typeName); + } + + /** + * Whether has a valid (non-comment) type + * @param {object} cfg Config object + * @param {string} cfg.property The property name + * @param {Node} cfg.tsAnnotation The annotation node + * @returns {boolean} Whether has a traverseable type + */ + function hasValidType({ property, tsAnnotation }) { + const tsPropertyType = tsAnnotation.type; + + if (property !== "type") { + return false; + } + + switch (tsPropertyType) { + case "TSLiteralType": + return typeof tsAnnotation.literal.value === "string" && + !commentTypes.has(tsAnnotation.literal.value); + case "TSStringKeyword": + + // Ok, but not sufficient + return false; + case "TSUnionType": + // eslint-disable-next-line no-use-before-define -- Circular + return tsAnnotation.types.some(annType => hasValidType({ + property: "type", + tsAnnotation: annType + })); + default: + throw new Error(`Unexpected \`type\` value property type ${tsPropertyType}`); + } + } + + /** + * Whether the interface has a valid type ancestor + * @param {string} interfaceName The interface to check + * @returns {void} + */ + function hasValidTypeAncestor(interfaceName) { + let decl = findTsInterfaceDeclaration(interfaceName); + + if (decl) { + if (traverseProperties(decl, hasValidType).some(hasValid => hasValid)) { + return true; + } + } + + if (!decl) { + decl = findTsTypeDeclaration(interfaceName); + if (decl) { + if (!decl.typeAnnotation.types) { + return notTraversableTSTypes.has(decl.typeAnnotation.type) + ? false + : hasValidTypeAncestor(decl.typeAnnotation.typeName.name); + } + + return decl.typeAnnotation.types.some(type => { + if (!type.typeName) { + + // Literal + return false; + } + + return hasValidTypeAncestor(type.typeName.name); + }); + } + } + + if (!decl) { + throw new Error(`Type unknown as to traversability: ${interfaceName}`); + } + + if (traverseExtends(decl, ({ iName, excluded }) => { + + // We don't want to look at this ancestor's `type` if being excluded + if (excluded && excluded.includes("type")) { + return false; + } + + return hasValidTypeAncestor(iName); + }).some(hasValid => hasValid)) { + return true; + } + + return false; + } + + /** + * Determine whether the Node is traversable + * @param {Node} annotationType The annotation type Node + * @param {string} property The property name + * @returns {boolean} Whether the node is traversable + */ + function checkTraversability(annotationType, property) { + if ( + notTraversableTSTypes.has(annotationType.type) + ) { + return false; + } + + if (annotationType.type === "TSTupleType") { + return annotationType.elementTypes.some(annType => checkTraversability(annType, property)); + } + + if (annotationType.type === "TSUnionType") { + return annotationType.types.some(annType => checkTraversability(annType, property)); + } + + if (annotationType.typeName.name === "Array") { + return annotationType.typeParameters.params.some(annType => checkTraversability(annType, property)); + } + + if ( + notTraversableTypes.has(annotationType.typeName.name) + ) { + return false; + } + + if (hasValidTypeAncestor(annotationType.typeName.name)) { + return true; + } + + return false; + } + + /** + * Adds a property to a node based on a type declaration node's contents. + * @param {Node} tsDeclarationNode TypeScript declaration node + * @param {Node} node The Node on which to build + * @param {string[]} excludedProperties Excluded properties + * @returns {void} + */ + function addPropertyToNodeForDeclaration(tsDeclarationNode, node, excludedProperties) { + + traverseProperties(tsDeclarationNode, ({ property, tsAnnotation }) => { + if (isPropertyExcluded(property, excludedProperties)) { + return; + } + + const tsPropertyType = tsAnnotation.type; + + if (property === "type" && tsPropertyType === "TSLiteralType") { + + // console.log('tsAnnotation', tsAnnotation); + // node[property] = tsAnnotation.literal.value; + // return; + } + + // For sanity-checking + if (!knownTypes.has(tsPropertyType)) { + unrecognizedTSTypes.add(tsPropertyType); + return; + } + + switch (tsPropertyType) { + case "TSUnionType": + if (tsAnnotation.types.some(annType => checkTraversability(annType, property))) { + break; + } + return; + case "TSTypeReference": { + if (checkTraversability(tsAnnotation, property)) { + break; + } + + return; + } default: + return; + } + + node[property] = null; + }); + + traverseExtends(tsDeclarationNode, ({ iName, excluded }) => { + const innerTsDeclarationNode = findTsInterfaceDeclaration(iName); + + if (!innerTsDeclarationNode) { + unrecognizedTSTypeReferences.add(iName); + return; + } + + addPropertyToNodeForDeclaration(innerTsDeclarationNode, node, excluded); + }); + } + + for (const tsDeclarationNode of exportedTsInterfaceDeclarations) { + const bodyType = tsDeclarationNode.body.body.find( + prop => prop.key.name === "type" + ); + + const typeName = bodyType && bodyType.typeAnnotation && + bodyType.typeAnnotation.typeAnnotation && + bodyType.typeAnnotation.typeAnnotation.literal && + bodyType.typeAnnotation.typeAnnotation.literal.value; + + if (!typeName) { + continue; + } + + const node = {}; + + addPropertyToNodeForDeclaration(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, + tsTypeDeclarations + } + }; +} + +/** + * @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: Object}} options The options + * @returns {Promise} The built visitor keys + */ +async function getKeysFromTsFile(file, options) { + const code = await readFile(file); + + return getKeysFromTs(code, options); +} + +//------------------------------------------------------------------------------ +// Public Interface +//------------------------------------------------------------------------------ + +export { alphabetizeKeyInterfaces, getKeysFromTs, getKeysFromTsFile };