From fb1040cb084ddc8bc6b5d80bbea0ea4e90d26eb7 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Mon, 15 Apr 2024 00:38:20 +1200 Subject: [PATCH] feat(functional-parameters): allow overriding options based on where the function type is declared fix #575 --- README.md | 6 +- docs/rules/functional-parameters.md | 64 ++++- src/rules/functional-parameters.ts | 219 ++++++++++-------- src/utils/schemas.ts | 38 ++- src/utils/tree.ts | 21 +- .../functional-parameters/ts/index.test.ts | 17 ++ .../rules/functional-parameters/ts/invalid.ts | 106 +++++++++ tests/rules/functional-parameters/ts/valid.ts | 86 +++++++ 8 files changed, 453 insertions(+), 104 deletions(-) create mode 100644 tests/rules/functional-parameters/ts/index.test.ts create mode 100644 tests/rules/functional-parameters/ts/invalid.ts create mode 100644 tests/rules/functional-parameters/ts/valid.ts diff --git a/README.md b/README.md index bcb13bf2a..a10fe5a17 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ The [below section](#rules) gives details on which rules are enabled by each rul ### Currying -| Name | Description | πŸ’Ό | ⚠️ | 🚫 | πŸ”§ | πŸ’‘ | πŸ’­ | ❌ | -| :----------------------------------------------------------- | :----------------------------- | :--------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | -| [functional-parameters](docs/rules/functional-parameters.md) | Enforce functional parameters. | β˜‘οΈ βœ… πŸ”’ ![badge-currying][] | | | | | | | +| Name | Description | πŸ’Ό | ⚠️ | 🚫 | πŸ”§ | πŸ’‘ | πŸ’­ | ❌ | +| :----------------------------------------------------------- | :----------------------------- | :--------------------------- | :-- | :---------------------------- | :-- | :-- | :-- | :-- | +| [functional-parameters](docs/rules/functional-parameters.md) | Enforce functional parameters. | β˜‘οΈ βœ… πŸ”’ ![badge-currying][] | | ![badge-disableTypeChecked][] | | | πŸ’­ | | ### No Exceptions diff --git a/docs/rules/functional-parameters.md b/docs/rules/functional-parameters.md index ad91c8cb6..540b0099f 100644 --- a/docs/rules/functional-parameters.md +++ b/docs/rules/functional-parameters.md @@ -1,11 +1,15 @@ # Enforce functional parameters (`functional/functional-parameters`) -πŸ’Ό This rule is enabled in the following configs: `currying`, β˜‘οΈ `lite`, βœ… `recommended`, πŸ”’ `strict`. +πŸ’ΌπŸš« This rule is enabled in the following configs: `currying`, β˜‘οΈ `lite`, βœ… `recommended`, πŸ”’ `strict`. This rule is _disabled_ in the `disableTypeChecked` config. + +πŸ’­ 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,36 @@ type Options = { }; ignoreIdentifierPattern?: string[] | string; ignorePrefixSelector?: string[] | string; + overrides?: Array<{ + match: Array< + | { + from: "file"; + path?: string; + name?: string | string[]; + pattern?: RegExp | RegExp[]; + ignoreName?: string | string[]; + ignorePattern?: RegExp | RegExp[]; + } + | { + from: "lib"; + name?: string | string[]; + pattern?: RegExp | RegExp[]; + ignoreName?: string | string[]; + ignorePattern?: RegExp | RegExp[]; + } + | { + from: "package"; + package?: string; + name?: string | string[]; + pattern?: RegExp | RegExp[]; + ignoreName?: string | string[]; + ignorePattern?: RegExp | RegExp[]; + } + >; + options: Omit; + inherit?: boolean; + disable: boolean; + }>; }; ``` @@ -196,3 +230,31 @@ 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. + +In the case of reference types, both the type and its generics will be recursively checked. +If any of them match, the specifier will be considered a match. + +#### `overrides[n].options` + +The options to use when a specifiers matches. + +#### `overrides[n].inherit` + +Inherit the root options? Default is `true`. + +#### `overrides[n].disable` + +If true, when a specifier matches, this rule will not be applied to the matching node. diff --git a/src/rules/functional-parameters.ts b/src/rules/functional-parameters.ts index acf160950..5d82d36be 100644 --- a/src/rules/functional-parameters.ts +++ b/src/rules/functional-parameters.ts @@ -9,9 +9,13 @@ import { deepmerge } from "deepmerge-ts"; import { type IgnoreIdentifierPatternOption, type IgnorePrefixSelectorOption, + type OverridableOptions, + type RawOverridableOptions, + getCoreOptions, ignoreIdentifierPatternOptionSchema, ignorePrefixSelectorOptionSchema, shouldIgnorePattern, + upgradeRawOverridableOptions, } from "#eslint-plugin-functional/options"; import { ruleNameScope } from "#eslint-plugin-functional/utils/misc"; import { type ESFunction } from "#eslint-plugin-functional/utils/node-types"; @@ -20,7 +24,9 @@ import { type RuleResult, createRuleUsingFunction, } from "#eslint-plugin-functional/utils/rule"; +import { overridableOptionsSchema } from "#eslint-plugin-functional/utils/schemas"; import { + getEnclosingFunction, isArgument, isGetter, isIIFE, @@ -45,83 +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 - | { - count: ParameterCountOptions; - ignoreLambdaExpression: boolean; - ignoreIIFE: boolean; - ignoreGettersAndSetters: boolean; - }; - }, -]; +type RawOptions = [RawOverridableOptions]; +type Options = OverridableOptions; -/** - * The schema for the rule options. - */ -const schema: JSONSchema4[] = [ +const coreOptionsPropertiesSchema = deepmerge( + ignoreIdentifierPatternOptionSchema, + ignorePrefixSelectorOptionSchema, { - type: "object", - properties: deepmerge( - ignoreIdentifierPatternOptionSchema, - ignorePrefixSelectorOptionSchema, - { - allowRestParameter: { + allowRestParameter: { + type: "boolean", + }, + allowArgumentsKeyword: { + type: "boolean", + }, + enforceParameterCount: { + oneOf: [ + { type: "boolean", + enum: [false], }, - allowArgumentsKeyword: { - type: "boolean", + { + type: "string", + enum: ["atLeastOne", "exactlyOne"], }, - enforceParameterCount: { - oneOf: [ - { - type: "boolean", - enum: [false], - }, - { + { + type: "object", + properties: { + count: { 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, + ignoreGettersAndSetters: { + type: "boolean", }, - ], + ignoreLambdaExpression: { + type: "boolean", + }, + ignoreIIFE: { + type: "boolean", + }, + }, + additionalProperties: false, }, - } satisfies JSONSchema4ObjectSchema["properties"], - ), - additionalProperties: false, + ], + }, }, +) as NonNullable; + +/** + * The schema for the rule options. + */ +const schema: JSONSchema4[] = [ + overridableOptionsSchema(coreOptionsPropertiesSchema), ]; /** * The default options for the rule. */ -const defaultOptions: Options = [ +const defaultOptions: RawOptions = [ { allowRestParameter: false, allowArgumentsKeyword: false, @@ -149,25 +154,27 @@ const errorMessages = { /** * The meta data for this rule. */ -const meta: NamedCreateRuleCustomMeta = { - type: "suggestion", - docs: { - category: "Currying", - description: "Enforce functional parameters.", - recommended: "recommended", - recommendedSeverity: "error", - }, - messages: errorMessages, - schema, -}; +const meta: NamedCreateRuleCustomMeta = + { + type: "suggestion", + docs: { + category: "Currying", + description: "Enforce functional parameters.", + recommended: "recommended", + recommendedSeverity: "error", + requiresTypeChecking: true, + }, + messages: errorMessages, + schema, + }; /** * Get the rest parameter violations. */ function getRestParamViolations( - [{ allowRestParameter }]: Readonly, + { allowRestParameter }: Readonly, node: ESFunction, -): RuleResult["descriptors"] { +): RuleResult["descriptors"] { return !allowRestParameter && node.params.length > 0 && isRestElement(node.params.at(-1)) @@ -184,9 +191,9 @@ function getRestParamViolations( * Get the parameter count violations. */ function getParamCountViolations( - [{ enforceParameterCount }]: Readonly, + { enforceParameterCount }: Readonly, node: ESFunction, -): RuleResult["descriptors"] { +): RuleResult["descriptors"] { if ( enforceParameterCount === false || (node.params.length === 0 && @@ -232,11 +239,24 @@ function getParamCountViolations( */ function checkFunction( node: ESFunction, - context: Readonly>, - options: Readonly, -): RuleResult { - const [optionsObject] = options; - const { ignoreIdentifierPattern } = optionsObject; + context: Readonly>, + rawOptions: Readonly, +): RuleResult { + const options = upgradeRawOverridableOptions(rawOptions[0]); + const optionsToUse = getCoreOptions( + node, + context, + options, + ); + + if (optionsToUse === null) { + return { + context, + descriptors: [], + }; + } + + const { ignoreIdentifierPattern } = optionsToUse; if (shouldIgnorePattern(node, context, ignoreIdentifierPattern)) { return { @@ -248,8 +268,8 @@ function checkFunction( return { context, descriptors: [ - ...getRestParamViolations(options, node), - ...getParamCountViolations(options, node), + ...getRestParamViolations(optionsToUse, node), + ...getParamCountViolations(optionsToUse, node), ], }; } @@ -259,11 +279,31 @@ function checkFunction( */ function checkIdentifier( node: TSESTree.Identifier, - context: Readonly>, - options: Readonly, -): RuleResult { - const [optionsObject] = options; - const { ignoreIdentifierPattern } = optionsObject; + context: Readonly>, + rawOptions: Readonly, +): RuleResult { + if (node.name !== "arguments") { + return { + context, + descriptors: [], + }; + } + + const functionNode = getEnclosingFunction(node); + const options = upgradeRawOverridableOptions(rawOptions[0]); + const optionsToUse = + functionNode === null + ? options + : getCoreOptions(functionNode, context, options); + + if (optionsToUse === null) { + return { + context, + descriptors: [], + }; + } + + const { ignoreIdentifierPattern } = optionsToUse; if (shouldIgnorePattern(node, context, ignoreIdentifierPattern)) { return { @@ -272,15 +312,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, @@ -294,7 +331,7 @@ function checkIdentifier( // Create the rule. export const rule = createRuleUsingFunction< keyof typeof errorMessages, - Options + RawOptions >(name, meta, defaultOptions, (context, options) => { const [optionsObject] = options; const { ignorePrefixSelector } = optionsObject; diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts index f87fef83c..80788fc51 100644 --- a/src/utils/schemas.ts +++ b/src/utils/schemas.ts @@ -2,6 +2,7 @@ import { type JSONSchema4, type JSONSchema4ObjectSchema, } from "@typescript-eslint/utils/json-schema"; +import { deepmerge } from "deepmerge-ts"; const typeSpecifierPatternSchemaProperties: JSONSchema4ObjectSchema["properties"] = { @@ -63,9 +64,6 @@ const typeSpecifierSchema: JSONSchema4 = { ], }; -export const typeSpecifiersSchema: JSONSchema4 = - schemaInstanceOrInstanceArray(typeSpecifierSchema); - export function schemaInstanceOrInstanceArray( items: JSONSchema4, ): NonNullable[string] { @@ -79,3 +77,37 @@ export function schemaInstanceOrInstanceArray( ], }; } + +export function overridableOptionsSchema( + coreOptionsPropertiesSchema: NonNullable< + JSONSchema4ObjectSchema["properties"] + >, +): JSONSchema4 { + return { + type: "object", + properties: deepmerge(coreOptionsPropertiesSchema, { + overrides: { + type: "array", + items: { + type: "object", + properties: { + specifiers: schemaInstanceOrInstanceArray(typeSpecifierSchema), + options: { + type: "object", + properties: coreOptionsPropertiesSchema, + additionalProperties: false, + }, + inherit: { + type: "boolean", + }, + disable: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + }, + } satisfies JSONSchema4ObjectSchema["properties"]), + additionalProperties: false, + }; +} diff --git a/src/utils/tree.ts b/src/utils/tree.ts index eab28f17c..692f1f8f8 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/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;