diff --git a/knip.jsonc b/knip.jsonc index 92335fa5a..1b26dc178 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/knip/schema-jsonc.json", "entry": ["src/index.ts!", "tests/**/*.test.ts"], "project": ["src/**/*.ts!", "tests/**/*.{js,ts}"], - "ignore": ["tests/fixture/file.ts"], + "ignore": ["tests/fixture/file.ts", "src/utils/schemas.ts"], "ignoreDependencies": [ // Unknown reason for issue. "@vitest/coverage-v8", diff --git a/package.json b/package.json index 9e32fa687..eab9735e2 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "deepmerge-ts": "^7.1.0", "escape-string-regexp": "^5.0.0", "is-immutable-type": "^5.0.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^1.3.0", + "ts-declaration-location": "^1.0.3" }, "devDependencies": { "@babel/eslint-parser": "7.25.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ad3e93d..a324e912a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: ts-api-utils: specifier: ^1.3.0 version: 1.3.0(typescript@5.5.4) + ts-declaration-location: + specifier: ^1.0.3 + version: 1.0.4(typescript@5.5.4) devDependencies: '@babel/eslint-parser': specifier: 7.25.1 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..8ac6e5c4a --- /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 "#/utils/rule"; +import { + type RawTypeSpecifier, + type TypeSpecifier, + typeMatchesPattern, +} from "#/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 m_stack = [type]; + // eslint-disable-next-line functional/no-loop-statements -- best to do this iteratively. + while (m_stack.length > 0) { + const t = m_stack.pop() ?? assert.fail(); + + if (typeMatchesSpecifier(program, specifier, t)) { + return true; + } + + if (t.aliasTypeArguments !== undefined) { + m_stack.push(...t.aliasTypeArguments); + } + } + + return false; +} diff --git a/src/utils/rule.ts b/src/utils/rule.ts index cbd4eecd2..f8678e054 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, @@ -23,6 +25,8 @@ import { getImmutabilityOverrides } from "#/settings"; import { __VERSION__ } from "#/utils/constants"; import { type ESFunction } from "#/utils/node-types"; +import { typeMatchesPattern } from "./type-specifier"; + type Docs = { /** * Used for creating category configs and splitting the README rules list into sub-lists. @@ -188,12 +192,46 @@ export function getTypeOfNode>( node: TSESTree.Node, context: Context, ): Type { + assert(typescript !== undefined); + const { esTreeNodeToTSNodeMap } = getParserServices(context); const tsNode = esTreeNodeToTSNodeMap.get(node); return getTypeOfTSNode(tsNode, context); } +/** + * Get the type of the the given node. + */ +export function getTypeNodeOfNode< + Context extends RuleContext, +>(node: TSESTree.Node, context: Context): TypeNode | null { + assert(typescript !== 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(typescript !== undefined); + + const { esTreeNodeToTSNodeMap } = getParserServices(context); + + const tsNode = esTreeNodeToTSNodeMap.get(node) as TSNode & { + type?: TypeNode; + }; + return [getTypeOfTSNode(tsNode, context), tsNode.type ?? null]; +} + /** * Get the type of the the given ts node. */ @@ -284,6 +322,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..0650636d7 --- /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 "#/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; +}