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..002449b7c 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. @@ -65,6 +69,17 @@ type Options = { ignoreIIFE: boolean; ignoreGettersAndSetters: boolean; }; + overrides?: Array< + { + match: TypeDeclarationSpecifier | TypeDeclarationSpecifier[]; + options: { + allowRestParameter: boolean; + allowArgumentsKeyword: boolean; + enforceParameterCount: "see above"; + }; + disable: boolean; + } + >; ignoreIdentifierPattern?: string[] | string; ignorePrefixSelector?: string[] | string; }; @@ -170,6 +185,26 @@ If true, this option allows for the use of [IIFEs](https://developer.mozilla.org Getters should always take zero parameters, and setter one. If for some reason you want to treat these function like any other function, then you can set this option to `false`. +### `overrides` + +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: Using this option requires type infomation. +Note 2: 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. + ### `ignorePrefixSelector` This allows for ignore functions where one of the given selectors matches the parent node in the AST of the function node.\ diff --git a/package.json b/package.json index 15ce6268a..edfbcae9d 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "escape-string-regexp": "^4.0.0", "is-immutable-type": "^2.0.4", "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 a0ede5861..0cdaa07fd 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': @@ -7991,6 +7994,15 @@ packages: typescript: 5.4.3 dev: true + /ts-declaration-location@1.0.0(typescript@5.4.3): + resolution: {integrity: sha512-/C+R0e1CLF1KtwbE59IFhCg4jLwfU7Puob+uVIl4iUVQhepN/bHmWXy3Gt3mAgvdLQybEZ4yb4qhnJQbphnEgA==} + peerDependencies: + typescript: '>=4.0.0' + dependencies: + fast-glob: 3.3.2 + typescript: 5.4.3 + dev: false + /ts-node@10.9.2(@types/node@16.18.93)(typescript@5.4.3): resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true diff --git a/src/rules/functional-parameters.ts b/src/rules/functional-parameters.ts index 61304395b..a0cf52dc5 100644 --- a/src/rules/functional-parameters.ts +++ b/src/rules/functional-parameters.ts @@ -5,6 +5,9 @@ import { } from "@typescript-eslint/utils/json-schema"; import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; import { deepmerge } from "deepmerge-ts"; +import typeMatchesSpecifier, { + type TypeDeclarationSpecifier, +} from "ts-declaration-location"; import { ignoreIdentifierPatternOptionSchema, @@ -13,14 +16,17 @@ import { type IgnoreIdentifierPatternOption, type IgnorePrefixSelectorOption, } 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 { createRuleUsingFunction, + getTypeOfNode, type NamedCreateRuleCustomMeta, type RuleResult, } from "#eslint-plugin-functional/utils/rule"; import { + getEnclosingFunction, isArgument, isGetter, isIIFE, @@ -45,26 +51,83 @@ export const fullName = `${ruleNameScope}/${name}`; */ type ParameterCountOptions = "atLeastOne" | "exactlyOne"; +type CoreOptions = { + 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; - }; + IgnorePrefixSelectorOption & + CoreOptions & { + overrides?: Array< + { + specifiers: TypeDeclarationSpecifier | TypeDeclarationSpecifier[]; + } & ( + | { + options: CoreOptions; + disable?: false; + } + | { + disable: true; + } + ) + >; }, ]; +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 +137,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,16 +201,50 @@ const meta: NamedCreateRuleCustomMeta = { description: "Enforce functional parameters.", recommended: "recommended", recommendedSeverity: "error", + requiresTypeChecking: true, }, messages: errorMessages, schema, }; +/** + * Get the core options to use, taking into account overrides. + */ +function getCoreOptions( + 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; + } + return found.options; + } + + return optionsObject; +} + /** * Get the rest parameter violations. */ function getRestParamViolations( - [{ allowRestParameter }]: Readonly, + { allowRestParameter }: Readonly, node: ESFunction, ): RuleResult["descriptors"] { return !allowRestParameter && @@ -184,7 +263,7 @@ function getRestParamViolations( * Get the parameter count violations. */ function getParamCountViolations( - [{ enforceParameterCount }]: Readonly, + { enforceParameterCount }: Readonly, node: ESFunction, ): RuleResult["descriptors"] { if ( @@ -245,12 +324,17 @@ function checkFunction( }; } + const optionsToUse = getCoreOptions(node, context, options); + return { context, - descriptors: [ - ...getRestParamViolations(options, node), - ...getParamCountViolations(options, node), - ], + descriptors: + optionsToUse === null + ? [] + : [ + ...getRestParamViolations(optionsToUse, node), + ...getParamCountViolations(optionsToUse, node), + ], }; } @@ -262,6 +346,13 @@ function checkIdentifier( context: Readonly>, options: Readonly, ): RuleResult { + if (node.name !== "arguments") { + return { + context, + descriptors: [], + }; + } + const [optionsObject] = options; const { ignoreIdentifierPattern } = optionsObject; @@ -272,15 +363,24 @@ function checkIdentifier( }; } - const { allowArgumentsKeyword } = optionsObject; + const functionNode = getEnclosingFunction(node); + const optionsToUse = + functionNode === null + ? optionsObject + : getCoreOptions(functionNode, context, options); + if (optionsToUse === null) { + return { + context, + descriptors: [], + }; + } + + const { allowArgumentsKeyword } = optionsToUse; return { context, descriptors: - !allowArgumentsKeyword && - node.name === "arguments" && - !isPropertyName(node) && - !isPropertyAccess(node) + !allowArgumentsKeyword && !isPropertyName(node) && !isPropertyAccess(node) ? [ { node, diff --git a/src/utils/common-schemas.ts b/src/utils/common-schemas.ts new file mode 100644 index 000000000..553ccaa61 --- /dev/null +++ b/src/utils/common-schemas.ts @@ -0,0 +1,52 @@ +import { type JSONSchema4 } from "@typescript-eslint/utils/json-schema"; + +const typeSpecifierSchema: JSONSchema4 = { + oneOf: [ + { + type: "object", + properties: { + from: { + type: "string", + enum: ["file"], + }, + path: { + type: "string", + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + from: { + type: "string", + enum: ["lib"], + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + from: { + type: "string", + enum: ["package"], + }, + package: { + type: "string", + }, + }, + additionalProperties: false, + }, + ], +}; + +export const typeSpecifiersSchema: JSONSchema4 = { + oneOf: [ + { + type: "array", + items: typeSpecifierSchema, + }, + typeSpecifierSchema, + ], +}; diff --git a/src/utils/tree.ts b/src/utils/tree.ts index d80d9bc43..1b644f27c 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -52,7 +52,21 @@ export function isInFunctionBody( node: TSESTree.Node, async?: boolean, ): boolean { - const functionNode = getAncestorOfType( + const functionNode = getEnclosingFunction(node); + + return ( + functionNode !== null && + (async === undefined || functionNode.async === async) + ); +} + +/** + * Get the function the given node is in. + * + * Will return null if not in a function. + */ +export function getEnclosingFunction(node: TSESTree.Node) { + return getAncestorOfType( ( n, c, @@ -62,11 +76,6 @@ export function isInFunctionBody( | TSESTree.FunctionExpression => isFunctionLike(n) && n.body === c, node, ); - - return ( - functionNode !== null && - (async === undefined || functionNode.async === async) - ); } /** diff --git a/tests/rules/functional-parameters/es2015/valid.ts b/tests/rules/functional-parameters/es2015/valid.ts index d8b5adc88..5ea9e9b02 100644 --- a/tests/rules/functional-parameters/es2015/valid.ts +++ b/tests/rules/functional-parameters/es2015/valid.ts @@ -2,8 +2,8 @@ import dedent from "dedent"; import { type rule } from "#eslint-plugin-functional/rules/functional-parameters"; import { - type ValidTestCaseSet, type OptionsOf, + type ValidTestCaseSet, } from "#eslint-plugin-functional/tests/helpers/util"; const tests: Array>> = [ diff --git a/tests/rules/functional-parameters/ts/index.test.ts b/tests/rules/functional-parameters/ts/index.test.ts new file mode 100644 index 000000000..e06e353dc --- /dev/null +++ b/tests/rules/functional-parameters/ts/index.test.ts @@ -0,0 +1,17 @@ +import { + name, + rule, +} from "#eslint-plugin-functional/rules/functional-parameters"; +import { testRule } from "#eslint-plugin-functional/tests/helpers/testers"; + +import invalid from "./invalid"; +import valid from "./valid"; + +const tests = { + valid, + invalid, +}; + +const tester = testRule(name, rule); + +tester.typescript(tests); diff --git a/tests/rules/functional-parameters/ts/invalid.ts b/tests/rules/functional-parameters/ts/invalid.ts new file mode 100644 index 000000000..731c910a7 --- /dev/null +++ b/tests/rules/functional-parameters/ts/invalid.ts @@ -0,0 +1,106 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import dedent from "dedent"; + +import { type rule } from "#eslint-plugin-functional/rules/functional-parameters"; +import { + type InvalidTestCaseSet, + type MessagesOf, + type OptionsOf, +} from "#eslint-plugin-functional/tests/helpers/util"; + +const tests: Array< + InvalidTestCaseSet, OptionsOf> +> = [ + { + code: dedent` + function foo(...bar: string[]) { + console.log(bar); + } + `, + errors: [ + { + messageId: "restParam", + type: AST_NODE_TYPES.RestElement, + line: 1, + column: 14, + }, + ], + optionsSet: [ + [ + { + allowRestParameter: false, + overrides: [ + { + specifiers: { + from: "lib", + }, + disable: true, + }, + ], + }, + ], + [ + { + allowRestParameter: false, + overrides: [ + { + specifiers: { + from: "lib", + }, + options: { + allowRestParameter: true, + }, + }, + ], + }, + ], + ], + }, + { + code: dedent` + function foo(bar: string[]) { + console.log(arguments); + } + `, + errors: [ + { + messageId: "arguments", + type: AST_NODE_TYPES.Identifier, + line: 2, + column: 15, + }, + ], + optionsSet: [ + [ + { + allowArgumentsKeyword: false, + overrides: [ + { + specifiers: { + from: "lib", + }, + disable: true, + }, + ], + }, + ], + [ + { + allowArgumentsKeyword: false, + overrides: [ + { + specifiers: { + from: "lib", + }, + options: { + allowArgumentsKeyword: true, + }, + }, + ], + }, + ], + ], + }, +]; + +export default tests; diff --git a/tests/rules/functional-parameters/ts/valid.ts b/tests/rules/functional-parameters/ts/valid.ts new file mode 100644 index 000000000..a0ef62ae7 --- /dev/null +++ b/tests/rules/functional-parameters/ts/valid.ts @@ -0,0 +1,86 @@ +import dedent from "dedent"; + +import { type rule } from "#eslint-plugin-functional/rules/functional-parameters"; +import { + type OptionsOf, + type ValidTestCaseSet, +} from "#eslint-plugin-functional/tests/helpers/util"; + +const tests: Array>> = [ + { + code: dedent` + function foo(...bar: string[]) { + console.log(bar); + } + `, + optionsSet: [ + [ + { + allowRestParameter: false, + overrides: [ + { + specifiers: { + from: "file", + }, + disable: true, + }, + ], + }, + ], + [ + { + allowRestParameter: false, + overrides: [ + { + specifiers: { + from: "file", + }, + options: { + allowRestParameter: true, + }, + }, + ], + }, + ], + ], + }, + { + code: dedent` + function foo(bar: string[]) { + console.log(arguments); + } + `, + optionsSet: [ + [ + { + allowArgumentsKeyword: false, + overrides: [ + { + specifiers: { + from: "file", + }, + disable: true, + }, + ], + }, + ], + [ + { + allowArgumentsKeyword: false, + overrides: [ + { + specifiers: { + from: "file", + }, + options: { + allowArgumentsKeyword: true, + }, + }, + ], + }, + ], + ], + }, +]; + +export default tests;