diff --git a/README.md b/README.md index 1d0c32861..20bb6f564 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The [below section](#rules) gives details on which rules are enabled by each rul | Name | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | 💭 | ❌ | | :----------------------------------------------------------- | :----------------------------- | :-------------------------- | :- | :- | :- | :- | :- | :- | -| [functional-parameters](docs/rules/functional-parameters.md) | Enforce functional parameters. | ☑️ ✅ 🔒 ![badge-currying][] | | | | | | | +| [functional-parameters](docs/rules/functional-parameters.md) | Enforce functional parameters. | ☑️ ✅ 🔒 ![badge-currying][] | | | | | 💭 | | ### No Exceptions diff --git a/docs/rules/functional-parameters.md b/docs/rules/functional-parameters.md index 544b61f48..2c866927b 100644 --- a/docs/rules/functional-parameters.md +++ b/docs/rules/functional-parameters.md @@ -2,10 +2,14 @@ 💼 This rule is enabled in the following configs: `currying`, ☑️ `lite`, ✅ `recommended`, 🔒 `strict`. +💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting). + Disallow use of rest parameters, the `arguments` keyword and enforces that functions take at least 1 parameter. +Note: type information is only required when using the [overrides](#overrides) option. + ## Rule Details In functions, `arguments` is a special variable that is implicitly available. @@ -67,6 +71,23 @@ type Options = { }; ignoreIdentifierPattern?: string[] | string; ignorePrefixSelector?: string[] | string; + overrides?: Array<{ + match: + | { + from: "file"; + path?: string; + } + | { + from: "lib"; + } + | { + from: "package"; + package?: string; + } + | TypeDeclarationSpecifier[]; + options: Omit; + disable: boolean; + }>; }; ``` @@ -196,3 +217,24 @@ const sum = [1, 2, 3].reduce((carry, current) => current, 0); This option takes a RegExp string or an array of RegExp strings. It allows for the ability to ignore violations based on a function's name. + +### `overrides` + +_Using this option requires type infomation._ + +Allows for applying overrides to the options based on where the function's type is defined. +This can be used to override the settings for types coming from 3rd party libraries. + +Note: Only the first matching override will be used. + +#### `overrides[n].specifiers` + +A specifier, or an array of specifiers to match the function type against. + +#### `overrides[n].options` + +The options to use when a specifiers matches. + +#### `overrides[n].disable` + +If true, when a specifier matches, this rule will not be applied to the matching node. diff --git a/docs/rules/prefer-immutable-types.md b/docs/rules/prefer-immutable-types.md index 202946f70..09b6f4d0b 100644 --- a/docs/rules/prefer-immutable-types.md +++ b/docs/rules/prefer-immutable-types.md @@ -244,6 +244,24 @@ type Options = { ReadonlyDeep?: Array>; Immutable?: Array>; }; + + overrides?: Array<{ + match: + | { + from: "file"; + path?: string; + } + | { + from: "lib"; + } + | { + from: "package"; + package?: string; + } + | TypeDeclarationSpecifier[]; + options: Omit; + disable: boolean; + }>; }; ``` @@ -475,3 +493,22 @@ It allows for the ability to ignore violations based on the identifier (name) of This option takes a `RegExp` string or an array of `RegExp` strings. It allows for the ability to ignore violations based on the type (as written, with whitespace removed) of the node in question. + +### `overrides` + +Allows for applying overrides to the options based on where the type is defined. +This can be used to override the settings for types coming from 3rd party libraries. + +Note: Only the first matching override will be used. + +#### `overrides[n].specifiers` + +A specifier, or an array of specifiers to match the function type against. + +#### `overrides[n].options` + +The options to use when a specifiers matches. + +#### `overrides[n].disable` + +If true, when a specifier matches, this rule will not be applied to the matching node. diff --git a/package.json b/package.json index 594fe402c..06c658885 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "escape-string-regexp": "^4.0.0", "is-immutable-type": "^3.1.0", "semver": "^7.6.0", - "ts-api-utils": "^1.3.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 e1a4d0eeb..59c7e891c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: ts-api-utils: specifier: ^1.3.0 version: 1.3.0(typescript@5.4.3) + ts-declaration-location: + specifier: 1.0.0 + version: 1.0.0(typescript@5.4.3) devDependencies: '@babel/eslint-parser': 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..735ca2256 --- /dev/null +++ b/src/options/overrides.ts @@ -0,0 +1,68 @@ +import { type TSESTree } from "@typescript-eslint/utils"; +import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; +import typeMatchesSpecifier, { + type TypeDeclarationSpecifier, +} from "ts-declaration-location"; + +import { getTypeOfNode } from "../utils/rule"; + +/** + * Options that can be overridden. + */ +export type OverridableOptions = CoreOptions & { + overrides?: Array< + { + specifiers: TypeDeclarationSpecifier | TypeDeclarationSpecifier[]; + } & ( + | { + options: CoreOptions; + disable?: false; + } + | { + disable: true; + } + ) + >; +}; + +/** + * Get the core options to use, taking into account overrides. + * + * @throws when there is a configuration error. + */ +export function getCoreOptions< + CoreOptions extends object, + Options extends readonly [Readonly>], +>( + node: TSESTree.Node, + context: Readonly>, + options: Readonly, +): CoreOptions | null { + const [optionsObject] = options; + + const program = context.sourceCode.parserServices?.program ?? undefined; + if (program === undefined) { + return optionsObject; + } + + const type = getTypeOfNode(node, context); + const found = optionsObject.overrides?.find((override) => + (Array.isArray(override.specifiers) + ? override.specifiers + : [override.specifiers] + ).some((specifier) => typeMatchesSpecifier(program, specifier, type)), + ); + + if (found !== undefined) { + if (found.disable === true) { + return null; + } + if (found.options === undefined) { + // eslint-disable-next-line functional/no-throw-statements + throw new Error("Configuration error: No options found for override."); + } + return found.options; + } + + return optionsObject; +} diff --git a/src/rules/functional-parameters.ts b/src/rules/functional-parameters.ts index 61304395b..6b4fba80b 100644 --- a/src/rules/functional-parameters.ts +++ b/src/rules/functional-parameters.ts @@ -7,12 +7,15 @@ import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; import { deepmerge } from "deepmerge-ts"; import { + getCoreOptions, ignoreIdentifierPatternOptionSchema, ignorePrefixSelectorOptionSchema, shouldIgnorePattern, type IgnoreIdentifierPatternOption, type IgnorePrefixSelectorOption, + type OverridableOptions, } from "#eslint-plugin-functional/options"; +import { typeSpecifiersSchema } from "#eslint-plugin-functional/utils/common-schemas"; import { ruleNameScope } from "#eslint-plugin-functional/utils/misc"; import { type ESFunction } from "#eslint-plugin-functional/utils/node-types"; import { @@ -21,6 +24,7 @@ import { type RuleResult, } from "#eslint-plugin-functional/utils/rule"; import { + getEnclosingFunction, isArgument, isGetter, isIIFE, @@ -45,25 +49,65 @@ export const fullName = `${ruleNameScope}/${name}`; */ type ParameterCountOptions = "atLeastOne" | "exactlyOne"; +type CoreOptions = IgnoreIdentifierPatternOption & + IgnorePrefixSelectorOption & { + allowRestParameter: boolean; + allowArgumentsKeyword: boolean; + enforceParameterCount: + | ParameterCountOptions + | false + | { + count: ParameterCountOptions; + ignoreLambdaExpression: boolean; + ignoreIIFE: boolean; + ignoreGettersAndSetters: boolean; + }; + }; + /** * The options this rule can take. */ -type Options = [ - IgnoreIdentifierPatternOption & - IgnorePrefixSelectorOption & { - allowRestParameter: boolean; - allowArgumentsKeyword: boolean; - enforceParameterCount: - | ParameterCountOptions - | false - | { - count: ParameterCountOptions; - ignoreLambdaExpression: boolean; - ignoreIIFE: boolean; - ignoreGettersAndSetters: boolean; - }; - }, -]; +type Options = [OverridableOptions]; + +const coreOptionsPropertiesSchema: JSONSchema4ObjectSchema["properties"] = { + allowRestParameter: { + type: "boolean", + }, + allowArgumentsKeyword: { + type: "boolean", + }, + enforceParameterCount: { + oneOf: [ + { + type: "boolean", + enum: [false], + }, + { + type: "string", + enum: ["atLeastOne", "exactlyOne"], + }, + { + type: "object", + properties: { + count: { + type: "string", + enum: ["atLeastOne", "exactlyOne"], + }, + ignoreGettersAndSetters: { + type: "boolean", + }, + ignoreLambdaExpression: { + type: "boolean", + }, + ignoreIIFE: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + ], + }, +}; /** * The schema for the rule options. @@ -74,43 +118,25 @@ const schema: JSONSchema4[] = [ properties: deepmerge( ignoreIdentifierPatternOptionSchema, ignorePrefixSelectorOptionSchema, + coreOptionsPropertiesSchema, { - allowRestParameter: { - type: "boolean", - }, - allowArgumentsKeyword: { - type: "boolean", - }, - enforceParameterCount: { - oneOf: [ - { - type: "boolean", - enum: [false], - }, - { - type: "string", - enum: ["atLeastOne", "exactlyOne"], - }, - { - type: "object", - properties: { - count: { - type: "string", - enum: ["atLeastOne", "exactlyOne"], - }, - ignoreGettersAndSetters: { - type: "boolean", - }, - ignoreLambdaExpression: { - type: "boolean", - }, - ignoreIIFE: { - type: "boolean", - }, + overrides: { + type: "array", + items: { + type: "object", + properties: { + specifiers: typeSpecifiersSchema, + options: { + type: "object", + properties: coreOptionsPropertiesSchema, + additionalProperties: false, + }, + disable: { + type: "boolean", }, - additionalProperties: false, }, - ], + additionalProperties: false, + }, }, } satisfies JSONSchema4ObjectSchema["properties"], ), @@ -156,6 +182,7 @@ const meta: NamedCreateRuleCustomMeta = { description: "Enforce functional parameters.", recommended: "recommended", recommendedSeverity: "error", + requiresTypeChecking: true, }, messages: errorMessages, schema, @@ -165,7 +192,7 @@ const meta: NamedCreateRuleCustomMeta = { * Get the rest parameter violations. */ function getRestParamViolations( - [{ allowRestParameter }]: Readonly, + { allowRestParameter }: Readonly, node: ESFunction, ): RuleResult["descriptors"] { return !allowRestParameter && @@ -184,7 +211,7 @@ function getRestParamViolations( * Get the parameter count violations. */ function getParamCountViolations( - [{ enforceParameterCount }]: Readonly, + { enforceParameterCount }: Readonly, node: ESFunction, ): RuleResult["descriptors"] { if ( @@ -235,8 +262,20 @@ function checkFunction( context: Readonly>, options: Readonly, ): RuleResult { - const [optionsObject] = options; - const { ignoreIdentifierPattern } = optionsObject; + const optionsToUse = getCoreOptions( + node, + context, + options, + ); + + if (optionsToUse === null) { + return { + context, + descriptors: [], + }; + } + + const { ignoreIdentifierPattern } = optionsToUse; if (shouldIgnorePattern(node, context, ignoreIdentifierPattern)) { return { @@ -248,8 +287,8 @@ function checkFunction( return { context, descriptors: [ - ...getRestParamViolations(options, node), - ...getParamCountViolations(options, node), + ...getRestParamViolations(optionsToUse, node), + ...getParamCountViolations(optionsToUse, node), ], }; } @@ -262,8 +301,27 @@ function checkIdentifier( context: Readonly>, options: Readonly, ): RuleResult { - const [optionsObject] = options; - const { ignoreIdentifierPattern } = optionsObject; + if (node.name !== "arguments") { + return { + context, + descriptors: [], + }; + } + + const functionNode = getEnclosingFunction(node); + const optionsToUse = + functionNode === null + ? options[0] + : getCoreOptions(functionNode, context, options); + + if (optionsToUse === null) { + return { + context, + descriptors: [], + }; + } + + const { ignoreIdentifierPattern } = optionsToUse; if (shouldIgnorePattern(node, context, ignoreIdentifierPattern)) { return { @@ -272,15 +330,12 @@ function checkIdentifier( }; } - const { allowArgumentsKeyword } = optionsObject; + const { allowArgumentsKeyword } = optionsToUse; return { context, descriptors: - !allowArgumentsKeyword && - node.name === "arguments" && - !isPropertyName(node) && - !isPropertyAccess(node) + !allowArgumentsKeyword && !isPropertyName(node) && !isPropertyAccess(node) ? [ { node, diff --git a/src/rules/prefer-immutable-types.ts b/src/rules/prefer-immutable-types.ts index ab31d5b62..181a17254 100644 --- a/src/rules/prefer-immutable-types.ts +++ b/src/rules/prefer-immutable-types.ts @@ -11,12 +11,15 @@ import { deepmerge } from "deepmerge-ts"; import { Immutability } from "is-immutable-type"; import { + getCoreOptions, ignoreClassesOptionSchema, shouldIgnoreClasses, shouldIgnoreInFunction, shouldIgnorePattern, type IgnoreClassesOption, + type OverridableOptions, } from "#eslint-plugin-functional/options"; +import { typeSpecifiersSchema } from "#eslint-plugin-functional/utils/common-schemas"; import { ruleNameScope } from "#eslint-plugin-functional/utils/misc"; import { type ESFunctionType } from "#eslint-plugin-functional/utils/node-types"; import { @@ -55,7 +58,8 @@ export const fullName = `${ruleNameScope}/${name}`; type RawEnforcement = | Exclude | "None" - | false; + | false + | undefined; type Option = IgnoreClassesOption & { enforcement: RawEnforcement; @@ -64,6 +68,20 @@ type Option = IgnoreClassesOption & { ignoreTypePattern?: string[] | string; }; +type CoreOptions = Option & { + parameters?: Partial