From 9674e31a09eace84f6da64001f82844636d97204 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Tue, 23 Apr 2024 00:20:30 +1200 Subject: [PATCH] part: set up overridable options --- package.json | 5 +- pnpm-lock.yaml | 13 +-- src/options/index.ts | 1 + src/options/overrides.ts | 141 +++++++++++++++++++++++++++++++ src/utils/rule.ts | 46 ++++++++++- src/utils/schemas.ts | 81 ++++++++++++++++++ src/utils/type-specifier.ts | 161 ++++++++++++++++++++++++++++++++++++ 7 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 src/options/overrides.ts create mode 100644 src/utils/schemas.ts create mode 100644 src/utils/type-specifier.ts diff --git a/package.json b/package.json index 1db54c0a5..0ac43bcff 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,9 @@ "@typescript-eslint/utils": "^7.6.0", "deepmerge-ts": "^5.1.0", "escape-string-regexp": "^4.0.0", - "is-immutable-type": "^3.1.0", - "ts-api-utils": "^1.3.0" + "is-immutable-type": "^4.0.0", + "ts-api-utils": "^1.3.0", + "ts-declaration-location": "^1.0.0" }, "devDependencies": { "@babel/eslint-parser": "7.24.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbecd0e71..9e200671d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,11 +18,14 @@ importers: specifier: ^4.0.0 version: 4.0.0 is-immutable-type: - specifier: ^3.1.0 - version: 3.1.0(eslint@9.0.0)(typescript@5.4.5) + specifier: ^4.0.0 + version: 4.0.0(eslint@9.0.0)(typescript@5.4.5) ts-api-utils: specifier: ^1.3.0 version: 1.3.0(typescript@5.4.5) + ts-declaration-location: + specifier: ^1.0.0 + version: 1.0.0(typescript@5.4.5) devDependencies: '@babel/eslint-parser': specifier: 7.24.1 @@ -3101,8 +3104,8 @@ packages: is-hexadecimal@1.0.4: resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} - is-immutable-type@3.1.0: - resolution: {integrity: sha512-EIAsgCk/4tEohdqKa5iGf1+IwoRYV/81Fe1awSspgobMxOmmxTZslvkH/PAtSKtR2NDFXGVkZZNqiLQA37GKBQ==} + is-immutable-type@4.0.0: + resolution: {integrity: sha512-gyFBCXv+NikTs8/PGZhgjbMmFZQ5jvHGZIsVu6+/9Bk4K7imlWBIDN7hTr9fNioGzFg71I4YM3z8f0aKXarTAw==} peerDependencies: eslint: '*' typescript: '>=4.7.4' @@ -8391,7 +8394,7 @@ snapshots: is-hexadecimal@1.0.4: {} - is-immutable-type@3.1.0(eslint@9.0.0)(typescript@5.4.5): + is-immutable-type@4.0.0(eslint@9.0.0)(typescript@5.4.5): dependencies: '@typescript-eslint/type-utils': 7.7.0(eslint@9.0.0)(typescript@5.4.5) eslint: 9.0.0 diff --git a/src/options/index.ts b/src/options/index.ts index a2ca8f7c3..e5b888321 100644 --- a/src/options/index.ts +++ b/src/options/index.ts @@ -1 +1,2 @@ export * from "./ignore"; +export * from "./overrides"; diff --git a/src/options/overrides.ts b/src/options/overrides.ts new file mode 100644 index 000000000..f9c7f4fed --- /dev/null +++ b/src/options/overrides.ts @@ -0,0 +1,141 @@ +import { type TSESTree } from "@typescript-eslint/utils"; +import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; +import { deepmerge } from "deepmerge-ts"; +import typeMatchesSpecifier from "ts-declaration-location"; + +import { getTypeDataOfNode } from "#eslint-plugin-functional/utils/rule"; +import { + type RawTypeSpecifier, + type TypeSpecifier, + typeMatchesPattern, +} from "#eslint-plugin-functional/utils/type-specifier"; + +/** + * Options that can be overridden. + */ +export type OverridableOptions = CoreOptions & { + overrides?: Array< + { + specifiers: TypeSpecifier | TypeSpecifier[]; + } & ( + | { + options: CoreOptions; + inherit?: boolean; + disable?: false; + } + | { + disable: true; + } + ) + >; +}; + +export type RawOverridableOptions = CoreOptions & { + overrides?: Array<{ + specifiers?: RawTypeSpecifier | RawTypeSpecifier[]; + options?: CoreOptions; + inherit?: boolean; + disable?: boolean; + }>; +}; + +export function upgradeRawOverridableOptions( + raw: RawOverridableOptions, +): OverridableOptions { + return { + ...raw, + overrides: + raw.overrides?.map((override) => ({ + ...override, + specifiers: + override.specifiers === undefined + ? [] + : Array.isArray(override.specifiers) + ? override.specifiers.map(upgradeRawTypeSpecifier) + : [upgradeRawTypeSpecifier(override.specifiers)], + })) ?? [], + } as OverridableOptions; +} + +function upgradeRawTypeSpecifier(raw: RawTypeSpecifier): TypeSpecifier { + const { ignoreName, ignorePattern, name, pattern, ...rest } = raw; + + const names = name === undefined ? [] : Array.isArray(name) ? name : [name]; + + const patterns = ( + pattern === undefined ? [] : Array.isArray(pattern) ? pattern : [pattern] + ).map((p) => new RegExp(p, "u")); + + const ignoreNames = + ignoreName === undefined + ? [] + : Array.isArray(ignoreName) + ? ignoreName + : [ignoreName]; + + const ignorePatterns = ( + ignorePattern === undefined + ? [] + : Array.isArray(ignorePattern) + ? ignorePattern + : [ignorePattern] + ).map((p) => new RegExp(p, "u")); + + const include = [...names, ...patterns]; + const exclude = [...ignoreNames, ...ignorePatterns]; + + return { + ...rest, + include, + exclude, + }; +} + +/** + * Get the core options to use, taking into account overrides. + */ +export function getCoreOptions< + CoreOptions extends object, + Options extends Readonly>, +>( + node: TSESTree.Node, + context: Readonly>, + options: Readonly, +): CoreOptions | null { + const program = context.sourceCode.parserServices?.program ?? undefined; + if (program === undefined) { + return options; + } + + const [type, typeNode] = getTypeDataOfNode(node, context); + const found = options.overrides?.find((override) => + (Array.isArray(override.specifiers) + ? override.specifiers + : [override.specifiers] + ).some( + (specifier) => + typeMatchesSpecifier(program, specifier, type) && + (specifier.include === undefined || + specifier.include.length === 0 || + typeMatchesPattern( + program, + type, + typeNode, + specifier.include, + specifier.exclude, + )), + ), + ); + + if (found !== undefined) { + if (found.disable === true) { + return null; + } + if (found.inherit !== false) { + return deepmerge(options, found.options) as CoreOptions; + } + return found.options; + } + + return options; +} diff --git a/src/utils/rule.ts b/src/utils/rule.ts index 80973cd32..d3d2dbd77 100644 --- a/src/utils/rule.ts +++ b/src/utils/rule.ts @@ -1,3 +1,5 @@ +import assert from "node:assert/strict"; + import { type TSESTree } from "@typescript-eslint/utils"; import { type NamedCreateRuleMeta, @@ -21,6 +23,8 @@ import { getImmutabilityOverrides } from "#eslint-plugin-functional/settings"; import { __VERSION__ } from "#eslint-plugin-functional/utils/constants"; import { type ESFunction } from "#eslint-plugin-functional/utils/node-types"; +import { typeMatchesPattern } from "./type-specifier"; + /** * Any custom rule meta properties. */ @@ -187,10 +191,49 @@ export function getTypeOfNode>( node: TSESTree.Node, context: Context, ): Type { + assert(ts !== undefined); + const { esTreeNodeToTSNodeMap } = getParserServices(context); const tsNode = esTreeNodeToTSNodeMap.get(node); - return getTypeOfTSNode(tsNode, context); + const typedNode = ts.isIdentifier(tsNode) ? tsNode.parent : tsNode; + return getTypeOfTSNode(typedNode, context); +} + +/** + * Get the type of the the given node. + */ +export function getTypeNodeOfNode< + Context extends RuleContext, +>(node: TSESTree.Node, context: Context): TypeNode | null { + assert(ts !== undefined); + + const { esTreeNodeToTSNodeMap } = getParserServices(context); + + const tsNode = esTreeNodeToTSNodeMap.get(node); + const typedNode = ( + ts.isIdentifier(tsNode) ? tsNode.parent : tsNode + ) as TSNode & { type?: TypeNode }; + return typedNode.type ?? null; +} + +/** + * Get the type of the the given node. + */ +export function getTypeDataOfNode< + Context extends RuleContext, +>(node: TSESTree.Node, context: Context): [Type, TypeNode | null] { + assert(ts !== undefined); + + const { esTreeNodeToTSNodeMap } = getParserServices(context); + + const tsNode = esTreeNodeToTSNodeMap.get(node); + const typedNode = ( + ts.isIdentifier(tsNode) ? tsNode.parent : tsNode + ) as TSNode & { + type?: TypeNode; + }; + return [getTypeOfTSNode(typedNode, context), typedNode.type ?? null]; } /** @@ -275,6 +318,7 @@ export function getTypeImmutabilityOfNode< // Don't use the global cache in testing environments as it may cause errors when switching between different config options. process.env["NODE_ENV"] !== "test", maxImmutability, + typeMatchesPattern, ); } diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts new file mode 100644 index 000000000..f87fef83c --- /dev/null +++ b/src/utils/schemas.ts @@ -0,0 +1,81 @@ +import { + type JSONSchema4, + type JSONSchema4ObjectSchema, +} from "@typescript-eslint/utils/json-schema"; + +const typeSpecifierPatternSchemaProperties: JSONSchema4ObjectSchema["properties"] = + { + name: schemaInstanceOrInstanceArray({ + type: "string", + }), + pattern: schemaInstanceOrInstanceArray({ + type: "string", + }), + ignoreName: schemaInstanceOrInstanceArray({ + type: "string", + }), + ignorePattern: schemaInstanceOrInstanceArray({ + type: "string", + }), + }; + +const typeSpecifierSchema: JSONSchema4 = { + oneOf: [ + { + type: "object", + properties: { + ...typeSpecifierPatternSchemaProperties, + from: { + type: "string", + enum: ["file"], + }, + path: { + type: "string", + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + ...typeSpecifierPatternSchemaProperties, + from: { + type: "string", + enum: ["lib"], + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + ...typeSpecifierPatternSchemaProperties, + from: { + type: "string", + enum: ["package"], + }, + package: { + type: "string", + }, + }, + additionalProperties: false, + }, + ], +}; + +export const typeSpecifiersSchema: JSONSchema4 = + schemaInstanceOrInstanceArray(typeSpecifierSchema); + +export function schemaInstanceOrInstanceArray( + items: JSONSchema4, +): NonNullable[string] { + return { + oneOf: [ + items, + { + type: "array", + items, + }, + ], + }; +} diff --git a/src/utils/type-specifier.ts b/src/utils/type-specifier.ts new file mode 100644 index 000000000..3d08e6699 --- /dev/null +++ b/src/utils/type-specifier.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; + +import { type TypeDeclarationSpecifier } from "ts-declaration-location"; +import { type Program, type Type, type TypeNode } from "typescript"; + +import ts from "#eslint-plugin-functional/conditional-imports/typescript"; + +export type TypePattern = string | RegExp; + +type TypeSpecifierPattern = { + include?: TypePattern[]; + exclude?: TypePattern[]; +}; + +/** + * How a type can be specified. + */ +export type TypeSpecifier = TypeSpecifierPattern & TypeDeclarationSpecifier; + +export type RawTypeSpecifier = { + name?: string | string[]; + pattern?: string | string[]; + ignoreName?: string | string[]; + ignorePattern?: string | string[]; +} & TypeDeclarationSpecifier; + +export function typeMatchesPattern( + program: Program, + type: Type, + typeNode: TypeNode | null, + include: ReadonlyArray, + exclude: ReadonlyArray = [], +) { + assert(ts !== undefined); + + if (include.length === 0) { + return false; + } + + let m_shouldInclude = false; + + const typeNameAlias = getTypeAliasName(type, typeNode); + if (typeNameAlias !== null) { + const testTypeNameAlias = (pattern: TypePattern) => + typeof pattern === "string" + ? pattern === typeNameAlias + : pattern.test(typeNameAlias); + + if (exclude.some(testTypeNameAlias)) { + return false; + } + m_shouldInclude ||= include.some(testTypeNameAlias); + } + + const typeValue = getTypeAsString(program, type, typeNode); + const testTypeValue = (pattern: TypePattern) => + typeof pattern === "string" + ? pattern === typeValue + : pattern.test(typeValue); + + if (exclude.some(testTypeValue)) { + return false; + } + m_shouldInclude ||= include.some(testTypeValue); + + const typeNameName = extractTypeName(typeValue); + if (typeNameName !== null) { + const testTypeNameName = (pattern: TypePattern) => + typeof pattern === "string" + ? pattern === typeNameName + : pattern.test(typeNameName); + + if (exclude.some(testTypeNameName)) { + return false; + } + m_shouldInclude ||= include.some(testTypeNameName); + } + + // Special handling for arrays not written in generic syntax. + if (program.getTypeChecker().isArrayType(type) && typeNode !== null) { + if ( + (ts.isTypeOperatorNode(typeNode) && + typeNode.operator === ts.SyntaxKind.ReadonlyKeyword) || + (ts.isTypeOperatorNode(typeNode.parent) && + typeNode.parent.operator === ts.SyntaxKind.ReadonlyKeyword) + ) { + const testIsReadonlyArray = (pattern: TypePattern) => + typeof pattern === "string" && pattern === "ReadonlyArray"; + + if (exclude.some(testIsReadonlyArray)) { + return false; + } + m_shouldInclude ||= include.some(testIsReadonlyArray); + } else { + const testIsArray = (pattern: TypePattern) => + typeof pattern === "string" && pattern === "Array"; + + if (exclude.some(testIsArray)) { + return false; + } + m_shouldInclude ||= include.some(testIsArray); + } + } + + return m_shouldInclude; +} + +/** + * Get the type alias name from the given type data. + * + * Null will be returned if the type is not a type alias. + */ +function getTypeAliasName(type: Type, typeNode: TypeNode | null) { + assert(ts !== undefined); + + if (typeNode === null) { + const t = "target" in type ? (type.target as Type) : type; + return t.aliasSymbol?.getName() ?? null; + } + + return ts.isTypeAliasDeclaration(typeNode.parent) + ? typeNode.parent.name.getText() + : null; +} + +/** + * Get the type as a string. + */ +function getTypeAsString( + program: Program, + type: Type, + typeNode: TypeNode | null, +) { + assert(ts !== undefined); + + return typeNode === null + ? program + .getTypeChecker() + .typeToString( + type, + undefined, + ts.TypeFormatFlags.AddUndefined | + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.OmitParameterModifiers | + ts.TypeFormatFlags.UseFullyQualifiedType | + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.WriteArrowStyleSignature | + ts.TypeFormatFlags.WriteTypeArgumentsOfSignature, + ) + : typeNode.getText(); +} + +/** + * Get the type name extracted from the the type's string. + * + * This only work if the type is a type reference. + */ +function extractTypeName(typeValue: string) { + const match = /^([^<]+)<.+>$/u.exec(typeValue); + return match?.[1] ?? null; +}