diff --git a/packages/utils/src/ts-eslint/Config.ts b/packages/utils/src/ts-eslint/Config.ts index 61f943598d9a..736a683607a3 100644 --- a/packages/utils/src/ts-eslint/Config.ts +++ b/packages/utils/src/ts-eslint/Config.ts @@ -197,9 +197,80 @@ export namespace FlatConfig { sourceType?: SourceType; } + /* + eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/ban-types -- + this is a safe usage of the empty object type because it's only used in intersections */ + type EmptyObject = {}; + /** + * Some type wizardry that allows us to extract a list of rules and well-typed + * options from an object blob. + * + * @example + * ``` + * type Result = ExtractRulesFromPluginRuleTypes<{ + * '@typescript-eslint': { + * rules: { + * 'no-explicit-any': + * | [] + * | [{ fixToUnknown?: boolean; ignoreRestArgs?: boolean; }]; + * }; + * } + * }> + * + * typeof Result = { + * '@typescript-eslint/no-explicit-any': + * | RuleLevel + * | [RuleLevel] + * | [RuleLevel, { fixToUnknown?: boolean; ignoreRestArgs?: boolean; }] + * }; + * ``` + */ + type ExtractRulesFromPluginRuleTypesWithDefault< + TPlugins extends PluginRuleTypes = PluginRuleTypes, + > = + // no - this isn't the wrong way around! + // we don't want to check if TPlugins is assignable to PluginRuleTypes - + // because that is always true! + // instead we want to check if PluginRuleTypes is assignable to TPlugins - + // i.e. "is TPlugins the default type" + PluginRuleTypes | undefined extends TPlugins + ? Rules + : Rules & ExtractRulesFromPluginRuleTypes; + type ExtractRulesFromPluginRuleTypes = { + [TPluginPrefix in keyof TPlugins]-?: TPluginPrefix extends string + ? TPlugins[TPluginPrefix] extends undefined + ? EmptyObject + : ExtractRulesFromPluginRuleType< + NonNullable, + TPluginPrefix + > + : EmptyObject; + }[keyof TPlugins]; + type ExtractRulesFromPluginRuleType< + TPlugin extends PluginRuleType, + TPluginPrefix extends string, + > = keyof TPlugin['rules'] extends string + ? { + [k in keyof TPlugin['rules'] as `${TPluginPrefix}/${k}`]?: + | RuleLevel + | [RuleLevel, ...TPlugin['rules'][k]]; + } + : EmptyObject; + interface PluginRuleType { + /** + * A mapping from {[rule name]: } + */ + rules: { + [ruleName: string]: unknown[]; + }; + } + type PluginRuleTypes = { + [pluginPrefix in string]?: PluginRuleType; + }; + // it's not a json schema so it's nowhere near as nice to read and convert... // https://github.com/eslint/eslint/blob/v8.45.0/lib/config/flat-config-schema.js - export interface Config { + export interface Config { /** * An array of glob patterns indicating the files that the configuration object should apply to. * If not specified, the configuration object applies to all files matched by any other configuration object. @@ -233,12 +304,15 @@ export namespace FlatConfig { * An object containing the configured rules. * When `files` or `ignores` are specified, these rule configurations are only available to the matching files. */ - rules?: Rules; + rules?: ExtractRulesFromPluginRuleTypesWithDefault; /** * An object containing name-value pairs of information that should be available to all rules. */ settings?: Settings; } - export type ConfigArray = Config[]; - export type ConfigFile = ConfigArray | (() => Promise); + export type ConfigArray = + Config[]; + export type ConfigFile = + | ConfigArray + | (() => Promise>); } diff --git a/packages/utils/tests/ts-eslint/Config.type-test.ts b/packages/utils/tests/ts-eslint/Config.type-test.ts new file mode 100644 index 000000000000..1df5ea8a6757 --- /dev/null +++ b/packages/utils/tests/ts-eslint/Config.type-test.ts @@ -0,0 +1,254 @@ +import type { FlatConfig } from '../../src/ts-eslint/Config'; + +type NoExplicitAny = + | [] + | [ + { + /** Whether to enable auto-fixing in which the \`any\` type is converted to the \`unknown\` type. */ + fixToUnknown?: boolean; + /** Whether to ignore rest parameter arrays. */ + ignoreRestArgs?: boolean; + }, + ]; +type ClassLiteralPropertyStyle = [] | ['fields' | 'getters']; +type PaddingBetwenLineStatements = { + blankLine: 'always' | 'any' | 'never'; + next: string; + prev: string; +}[]; + +/* +eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- +this needs to be an object type so it has the implicit index sig +an interface that extends PluginRuleTypes doesn't work either because that breaks +the ExtractRulesFromPluginRuleTypesWithDefault condition. + +we expect most users would define this inline anyway - which avoids the issue: + FlatConfig.Config<{ ... }> +*/ +type Plugins = { + '@typescript-eslint'?: { + rules: { + 'no-explicit-any': NoExplicitAny; + 'class-literal-property-style': ClassLiteralPropertyStyle; + 'padding-line-between-statements': PaddingBetwenLineStatements; + }; + }; +}; + +declare function test(arg: FlatConfig.Config): void; + +// no-array style works +test({ + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/class-literal-property-style': 'error', + '@typescript-eslint/padding-line-between-statements': 'error', + }, +}); + +// array-with-no-options style works +test({ + rules: { + '@typescript-eslint/no-explicit-any': ['error'], + '@typescript-eslint/class-literal-property-style': ['error'], + '@typescript-eslint/padding-line-between-statements': ['error'], + }, +}); + +// not all rules need to be specified +test({ + rules: {}, +}); +test({ + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, +}); + +// unknown rules are allowed +test({ + rules: { + '@typescript-eslint/no-explicit-any': 'error', + 'unknown/hello': ['error', 'i live in a giant bucket'], + }, +}); + +// options are validated as defined +test({ + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + fixToUnknown: true, + // @ts-expect-error -- I errored like I expected because the type is wrong! + ignoreRestArgs: 'how did i get this wrong?', + }, + ], + '@typescript-eslint/class-literal-property-style': ['error', 'fields'], + '@typescript-eslint/padding-line-between-statements': [ + 'error', + { blankLine: 'always', next: 'Foo', prev: '*' }, + { + blankLine: 'never', + next: 'Bar', + // @ts-expect-error -- I errored like I expected because the type is wrong! + prev: 1, + }, + ], + }, +}); +test({ + rules: { + '@typescript-eslint/class-literal-property-style': [ + 'error', + 'getters', + // @ts-expect-error -- I errored because I was unexpected! + 'extra arg woopsie', + ], + }, +}); + +declare function testNoTypes(arg: FlatConfig.Config): void; +// unknown rules are allowed +testNoTypes({ + rules: { + '@typescript-eslint/no-explicit-any': 'error', + 'unknown/hello': ['error', 'wtf'], + }, +}); +declare const arg: FlatConfig.Config; +arg.rules satisfies Partial; + +// the higher-order composition types pass the types through as expected +const _configFile1: FlatConfig.ConfigFile = [ + { + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + fixToUnknown: true, + // @ts-expect-error -- I errored like I expected because the type is wrong! + ignoreRestArgs: 'how did i get this wrong?', + }, + ], + '@typescript-eslint/class-literal-property-style': ['error', 'fields'], + }, + }, +]; + +// this works - but sadly TS doesn't provide autocomplete or show the jsdoc comments +// I think it's due to the indirection of the `Promise.resolve`? +const _configFile2: FlatConfig.ConfigFile = () => + Promise.resolve([ + { + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + fixToUnknown: true, + }, + ], + '@typescript-eslint/class-literal-property-style': ['error', 'fields'], + }, + }, + ]); +// this works and provides autocomplete! +// eslint-disable-next-line @typescript-eslint/require-await +const _configFile3: FlatConfig.ConfigFile = async () => [ + { + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + fixToUnknown: true, + }, + ], + '@typescript-eslint/class-literal-property-style': ['error', 'fields'], + }, + }, +]; +// @ts-expect-error - sadly TS will kind of error here on the declaration for this case +// the only way to make this better is by using an explicit return type +// eslint-disable-next-line @typescript-eslint/require-await +const _configFile4: FlatConfig.ConfigFile = async () => [ + { + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + ignoreRestArgs: 'how did I get this wrong?', + }, + ], + }, + }, +]; + +// untyped config entries work with typed config entries +declare const configWithNoPlugin: FlatConfig.Config; +declare const configWithPlugin: FlatConfig.Config; +// eslint-disable-next-line @typescript-eslint/ban-types +declare const configWithOtherPlugin1: FlatConfig.Config<{}>; +declare const configWithOtherPlugin2: FlatConfig.Config<{ + other: { + rules: { + 'rule-name': []; + }; + }; +}>; +const _configFile5: FlatConfig.ConfigArray = [ + configWithNoPlugin, + configWithPlugin, + configWithOtherPlugin1, + // @ts-expect-error -- this is disallowed because the "other" plugin isn't declared in the parent generic + configWithOtherPlugin2, +]; + +// multiple plugins can be provided via an intersection +declare function combinedPlugins( + arg: FlatConfig.Config< + { + plugin1: { + rules: { + 'rule-name1': [1]; + 'rule-name2': [2]; + }; + }; + } & { + plugin2: { + rules: { + 'rule-name3': [3]; + 'rule-name4': [4]; + }; + }; + } + >, +): void; +combinedPlugins({ + rules: { + 'plugin1/rule-name1': ['error', 1], + 'plugin1/rule-name2': ['error', 2], + 'plugin2/rule-name3': ['error', 3], + // TODO - WTF????? NO ERROR? + 'plugin2/rule-name4': ['error', 99], + }, +}); +combinedPlugins({ + rules: { + 'plugin1/rule-name1': ['error', 1], + // TODO - WTF????? NO ERROR? + 'plugin1/rule-name2': ['error', 99], + 'plugin2/rule-name3': ['error', 3], + 'plugin2/rule-name4': ['error', 4], + }, +}); +combinedPlugins({ + rules: { + 'plugin1/rule-name1': ['error', 1], + // TODO - WTF????? NO ERROR? + 'plugin1/rule-name2': ['error', 99], + 'plugin2/rule-name3': ['error', 3], + // @ts-expect-error -- incorrect rule option + 'plugin2/rule-name4': ['error', 99], + }, +});