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/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/rules/functional-parameters.ts b/src/rules/functional-parameters.ts index 61304395b..4a3f505ce 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,82 @@ 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 + CoreOptions & { + overrides?: Array< + { + specifiers: TypeDeclarationSpecifier | TypeDeclarationSpecifier[]; + } & ( | { - count: ParameterCountOptions; - ignoreLambdaExpression: boolean; - ignoreIIFE: boolean; - ignoreGettersAndSetters: boolean; - }; - }, + 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 +136,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 +200,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 +262,7 @@ function getRestParamViolations( * Get the parameter count violations. */ function getParamCountViolations( - [{ enforceParameterCount }]: Readonly, + { enforceParameterCount }: Readonly, node: ESFunction, ): RuleResult["descriptors"] { if ( @@ -235,8 +313,16 @@ 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 +334,8 @@ function checkFunction( return { context, descriptors: [ - ...getRestParamViolations(options, node), - ...getParamCountViolations(options, node), + ...getRestParamViolations(optionsToUse, node), + ...getParamCountViolations(optionsToUse, node), ], }; } @@ -262,8 +348,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 +377,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/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;