diff --git a/package.json b/package.json index cea3885e1..ceb60ba1d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "deepmerge-ts": "^5.1.0", "escape-string-regexp": "^4.0.0", "is-immutable-type": "^4.0.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^1.3.0", + "ts-declaration-location": "^1.0.1" }, "devDependencies": { "@babel/eslint-parser": "7.24.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7453355c..28760f2a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: ts-api-utils: specifier: ^1.3.0 version: 1.3.0(typescript@5.4.5) + ts-declaration-location: + specifier: ^1.0.1 + version: 1.0.1(typescript@5.4.5) devDependencies: '@babel/eslint-parser': specifier: 7.24.5 @@ -4460,8 +4463,8 @@ packages: peerDependencies: typescript: ^3.x || ^4.x || ^5.x - ts-declaration-location@1.0.0: - resolution: {integrity: sha512-/C+R0e1CLF1KtwbE59IFhCg4jLwfU7Puob+uVIl4iUVQhepN/bHmWXy3Gt3mAgvdLQybEZ4yb4qhnJQbphnEgA==} + ts-declaration-location@1.0.1: + resolution: {integrity: sha512-bCWJovNLBiS34rMD1o6AX+INI5n6ujtB9bPp/1a/opc9ExycJKpB/XcgIsVs66RH7SxAbsYcJBkrLrnLcDF0TA==} peerDependencies: typescript: '>=4.0.0' @@ -7920,7 +7923,7 @@ snapshots: '@typescript-eslint/type-utils': 7.8.0(eslint@9.2.0)(typescript@5.4.5) eslint: 9.2.0 ts-api-utils: 1.3.0(typescript@5.4.5) - ts-declaration-location: 1.0.0(typescript@5.4.5) + ts-declaration-location: 1.0.1(typescript@5.4.5) typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -9352,9 +9355,9 @@ snapshots: compatfactory: 3.0.0(typescript@5.4.5) typescript: 5.4.5 - ts-declaration-location@1.0.0(typescript@5.4.5): + ts-declaration-location@1.0.1(typescript@5.4.5): dependencies: - fast-glob: 3.3.2 + minimatch: 9.0.4 typescript: 5.4.5 ts-node@10.9.2(@types/node@20.12.8)(typescript@5.4.5): 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..7a5616d06 --- /dev/null +++ b/src/options/overrides.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; + +import { type TSESTree } from "@typescript-eslint/utils"; +import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; +import { deepmerge } from "deepmerge-ts"; +import typeMatchesSpecifier, { + type TypeDeclarationSpecifier, +} from "ts-declaration-location"; +import { type Program, type Type, type TypeNode } from "typescript"; + +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: Readonly>, +): 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); + return getCoreOptionsForType(type, typeNode, context, options); +} + +export function getCoreOptionsForType< + CoreOptions extends object, + Options extends Readonly>, +>( + type: Type, + typeNode: TypeNode | null, + context: Readonly>, + options: Readonly, +): CoreOptions | null { + const program = context.sourceCode.parserServices?.program ?? undefined; + if (program === undefined) { + return options; + } + + const found = options.overrides?.find((override) => + (Array.isArray(override.specifiers) + ? override.specifiers + : [override.specifiers] + ).some( + (specifier) => + typeMatchesSpecifierDeep(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; +} + +function typeMatchesSpecifierDeep( + program: Program, + specifier: TypeDeclarationSpecifier, + type: Type, +) { + const stack = [type]; + // eslint-disable-next-line functional/no-loop-statements -- best to do this iteratively. + while (stack.length > 0) { + const t = stack.pop() ?? assert.fail(); + + if (typeMatchesSpecifier(program, specifier, t)) { + return true; + } + + if (t.aliasTypeArguments !== undefined) { + stack.push(...t.aliasTypeArguments); + } + } + + return false; +} diff --git a/src/utils/rule.ts b/src/utils/rule.ts index 79e4d45e2..934e5d298 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,45 @@ 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) as TSNode & { + type?: TypeNode; + }; + return tsNode.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) as TSNode & { + type?: TypeNode; + }; + return [getTypeOfTSNode(tsNode, context), tsNode.type ?? null]; } /** @@ -274,6 +313,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; +}