Skip to content

Commit

Permalink
add the ability to pass plugin rule types to the config
Browse files Browse the repository at this point in the history
  • Loading branch information
bradzacher committed Jul 20, 2023
1 parent 96b6e74 commit 68e4da9
Show file tree
Hide file tree
Showing 2 changed files with 332 additions and 4 deletions.
82 changes: 78 additions & 4 deletions packages/utils/src/ts-eslint/Config.ts
Expand Up @@ -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<TPlugins>;
type ExtractRulesFromPluginRuleTypes<TPlugins extends PluginRuleTypes> = {
[TPluginPrefix in keyof TPlugins]-?: TPluginPrefix extends string
? TPlugins[TPluginPrefix] extends undefined
? EmptyObject
: ExtractRulesFromPluginRuleType<
NonNullable<TPlugins[TPluginPrefix]>,
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]: <rule options>}
*/
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<TPlugins extends PluginRuleTypes = PluginRuleTypes> {
/**
* 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.
Expand Down Expand Up @@ -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<TPlugins>;
/**
* 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<ConfigArray>);
export type ConfigArray<TPlugins extends PluginRuleTypes = PluginRuleTypes> =
Config<TPlugins>[];
export type ConfigFile<TPlugins extends PluginRuleTypes = PluginRuleTypes> =
| ConfigArray<TPlugins>
| (() => Promise<ConfigArray<TPlugins>>);
}
254 changes: 254 additions & 0 deletions 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<Plugins>): 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<FlatConfig.Rules | undefined>;

// the higher-order composition types pass the types through as expected
const _configFile1: FlatConfig.ConfigFile<Plugins> = [
{
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<Plugins> = () =>
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<Plugins> = 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<Plugins> = 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<Plugins>;
// 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<Plugins> = [
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],
},
});

0 comments on commit 68e4da9

Please sign in to comment.