eslint-plugin-ts-type-forge 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -31
- package/dist/rule-types.d.mts +11 -6
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rules/ast-utils.d.mts +26 -11
- package/dist/rules/ast-utils.d.mts.map +1 -1
- package/dist/rules/ast-utils.mjs +30 -14
- package/dist/rules/ast-utils.mjs.map +1 -1
- package/dist/rules/constants.d.mts +12 -1
- package/dist/rules/constants.d.mts.map +1 -1
- package/dist/rules/constants.mjs +8 -2
- package/dist/rules/constants.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts +11 -0
- package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts.map +1 -0
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +81 -0
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -0
- package/dist/rules/rules.d.mts +3 -2
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +2 -2
- package/dist/rules/rules.mjs.map +1 -1
- package/dist/rules/tuple-rule-utils.d.mts +37 -0
- package/dist/rules/tuple-rule-utils.d.mts.map +1 -0
- package/dist/rules/tuple-rule-utils.mjs +56 -0
- package/dist/rules/tuple-rule-utils.mjs.map +1 -0
- package/package.json +22 -22
- package/src/rule-types.mts +13 -6
- package/src/rules/ast-utils.mts +72 -30
- package/src/rules/constants.mts +18 -1
- package/src/rules/prefer-canonical-length-constrained-tuple.mts +149 -0
- package/src/rules/prefer-canonical-length-constrained-tuple.test.mts +178 -0
- package/src/rules/rules.mts +3 -2
- package/src/rules/tuple-rule-utils.mts +124 -0
- package/dist/rules/prefer-non-empty-array.d.mts +0 -11
- package/dist/rules/prefer-non-empty-array.d.mts.map +0 -1
- package/dist/rules/prefer-non-empty-array.mjs +0 -81
- package/dist/rules/prefer-non-empty-array.mjs.map +0 -1
- package/src/rules/prefer-non-empty-array.mts +0 -129
- package/src/rules/prefer-non-empty-array.test.mts +0 -192
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
|
|
2
|
+
import { buildTypeImportFix, getImportedLocalName, getTsTypeForgeImports, hasConflictingDeclaration } from './import-utils.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_IMPORT_STYLE = "global";
|
|
5
|
+
const IMPORT_STYLE_SCHEMA_PROPERTY = {
|
|
6
|
+
type: "string",
|
|
7
|
+
enum: ["global", "named"],
|
|
8
|
+
description: [
|
|
9
|
+
"How the ts-type-forge type is brought into scope.",
|
|
10
|
+
"'global' (default) assumes the ambient globals of",
|
|
11
|
+
"`ts-type-forge/global` and never touches imports;",
|
|
12
|
+
"'named' makes the autofix add the corresponding",
|
|
13
|
+
`\`import { type \u2026 } from '${TS_TYPE_FORGE_MODULE}';\``,
|
|
14
|
+
"when the name is not imported yet."
|
|
15
|
+
].join(" ")
|
|
16
|
+
};
|
|
17
|
+
const resolveTypeName = (program, canonicalName, importStyle) => {
|
|
18
|
+
const importedLocalName = getImportedLocalName(
|
|
19
|
+
getTsTypeForgeImports(program),
|
|
20
|
+
canonicalName
|
|
21
|
+
);
|
|
22
|
+
if (importedLocalName !== void 0) {
|
|
23
|
+
return { localName: importedLocalName, needsImport: false };
|
|
24
|
+
}
|
|
25
|
+
return hasConflictingDeclaration(program, canonicalName) ? void 0 : { localName: canonicalName, needsImport: importStyle === "named" };
|
|
26
|
+
};
|
|
27
|
+
const reportTupleRewrites = (context, rewrites, messageId, importStyle) => {
|
|
28
|
+
const sourceCode = context.sourceCode;
|
|
29
|
+
const program = sourceCode.ast;
|
|
30
|
+
const resolved = rewrites.flatMap((rewrite) => {
|
|
31
|
+
const target = resolveTypeName(program, rewrite.canonicalName, importStyle);
|
|
32
|
+
return target === void 0 ? [] : [{ rewrite, target }];
|
|
33
|
+
});
|
|
34
|
+
const importNames = Array.from(
|
|
35
|
+
new Set(
|
|
36
|
+
resolved.filter(({ target }) => target.needsImport).map(({ rewrite }) => rewrite.canonicalName)
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
for (const [index, { rewrite, target }] of resolved.entries()) {
|
|
40
|
+
const replacement = `${target.localName}<${rewrite.typeArgs}>`;
|
|
41
|
+
context.report({
|
|
42
|
+
node: rewrite.node,
|
|
43
|
+
messageId,
|
|
44
|
+
data: { original: sourceCode.getText(rewrite.node), replacement },
|
|
45
|
+
fix: (fixer) => [
|
|
46
|
+
...index === 0 ? importNames.flatMap(
|
|
47
|
+
(name) => buildTypeImportFix(fixer, program, name)
|
|
48
|
+
) : [],
|
|
49
|
+
fixer.replaceText(rewrite.node, replacement)
|
|
50
|
+
]
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { DEFAULT_IMPORT_STYLE, IMPORT_STYLE_SCHEMA_PROPERTY, reportTupleRewrites, resolveTypeName };
|
|
56
|
+
//# sourceMappingURL=tuple-rule-utils.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tuple-rule-utils.mjs","sources":["../../src/rules/tuple-rule-utils.mts"],"sourcesContent":["import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';\nimport { TS_TYPE_FORGE_MODULE } from './constants.mjs';\nimport {\n buildTypeImportFix,\n getImportedLocalName,\n getTsTypeForgeImports,\n hasConflictingDeclaration,\n} from './import-utils.mjs';\n\n/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */\n\n/** `JSONSchema4`, reached through the public rule-module types. */\ntype JsonSchema = Exclude<\n TSESLint.RuleModule<string, readonly unknown[]>['meta']['schema'],\n readonly unknown[]\n>;\n\nexport type ImportStyle = 'global' | 'named';\n\nexport const DEFAULT_IMPORT_STYLE: ImportStyle = 'global';\n\nexport const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {\n type: 'string',\n enum: ['global', 'named'],\n description: [\n 'How the ts-type-forge type is brought into scope.',\n \"'global' (default) assumes the ambient globals of\",\n '`ts-type-forge/global` and never touches imports;',\n \"'named' makes the autofix add the corresponding\",\n `\\`import { type … } from '${TS_TYPE_FORGE_MODULE}';\\``,\n 'when the name is not imported yet.',\n ].join(' '),\n};\n\n/**\n * Resolves how a ts-type-forge type name should be written in this file, and\n * whether the autofix has to add an import for it.\n *\n * Returns `undefined` when the name is already bound to something else (a local\n * alias, or an import from another module) — rewriting to it would then silently\n * resolve to the wrong declaration, which is exactly what happens inside\n * ts-type-forge's own sources.\n */\nexport const resolveTypeName = (\n program: TSESTree.Program,\n canonicalName: string,\n importStyle: ImportStyle,\n): Readonly<{ localName: string; needsImport: boolean }> | undefined => {\n const importedLocalName = getImportedLocalName(\n getTsTypeForgeImports(program),\n canonicalName,\n );\n\n if (importedLocalName !== undefined) {\n return { localName: importedLocalName, needsImport: false };\n }\n\n return hasConflictingDeclaration(program, canonicalName)\n ? undefined\n : { localName: canonicalName, needsImport: importStyle === 'named' };\n};\n\nexport type TupleRewrite = Readonly<{\n node: TSESTree.Node;\n canonicalName: string;\n /** Text inside the `<...>` of the replacement, e.g. `'2, string'`. */\n typeArgs: string;\n}>;\n\n/**\n * Emits one report per rewrite, resolving each target name and attaching every\n * required import to the *first* report.\n *\n * All import fixes must live on a single report: `insertTextBefore(program, …)`\n * produces overlapping ranges, and ESLint merges each report's fixes into one\n * span — so spreading them across reports makes the fixes conflict and only one\n * survives per pass.\n */\nexport const reportTupleRewrites = <MessageIds extends string>(\n context: TSESLint.RuleContext<MessageIds, readonly unknown[]>,\n rewrites: readonly TupleRewrite[],\n messageId: MessageIds,\n importStyle: ImportStyle,\n): void => {\n const sourceCode = context.sourceCode;\n\n const program = sourceCode.ast;\n\n const resolved = rewrites.flatMap((rewrite) => {\n const target = resolveTypeName(program, rewrite.canonicalName, importStyle);\n\n // The name is already bound to something else in this file, so the rewrite\n // would resolve to the wrong declaration.\n return target === undefined ? [] : [{ rewrite, target }];\n });\n\n const importNames = Array.from(\n new Set(\n resolved\n .filter(({ target }) => target.needsImport)\n .map(({ rewrite }) => rewrite.canonicalName),\n ),\n );\n\n for (const [index, { rewrite, target }] of resolved.entries()) {\n const replacement = `${target.localName}<${rewrite.typeArgs}>`;\n\n context.report({\n node: rewrite.node,\n messageId,\n data: { original: sourceCode.getText(rewrite.node), replacement },\n fix: (fixer) => [\n ...(index === 0\n ? importNames.flatMap((name) =>\n buildTypeImportFix(fixer, program, name),\n )\n : []),\n fixer.replaceText(rewrite.node, replacement),\n ],\n });\n }\n};\n\n/* eslint-enable @typescript-eslint/prefer-readonly-parameter-types */\n"],"names":[],"mappings":";;;AAmBO,MAAM,oBAAA,GAAoC;AAE1C,MAAM,4BAAA,GAA2C;AAAA,EACtD,IAAA,EAAM,QAAA;AAAA,EACN,IAAA,EAAM,CAAC,QAAA,EAAU,OAAO,CAAA;AAAA,EACxB,WAAA,EAAa;AAAA,IACX,mDAAA;AAAA,IACA,mDAAA;AAAA,IACA,mDAAA;AAAA,IACA,iDAAA;AAAA,IACA,kCAA6B,oBAAoB,CAAA,IAAA,CAAA;AAAA,IACjD;AAAA,GACF,CAAE,KAAK,GAAG;AACZ;AAWO,MAAM,eAAA,GAAkB,CAC7B,OAAA,EACA,aAAA,EACA,WAAA,KACsE;AACtE,EAAA,MAAM,iBAAA,GAAoB,oBAAA;AAAA,IACxB,sBAAsB,OAAO,CAAA;AAAA,IAC7B;AAAA,GACF;AAEA,EAAA,IAAI,sBAAsB,MAAA,EAAW;AACnC,IAAA,OAAO,EAAE,SAAA,EAAW,iBAAA,EAAmB,WAAA,EAAa,KAAA,EAAM;AAAA,EAC5D;AAEA,EAAA,OAAO,yBAAA,CAA0B,OAAA,EAAS,aAAa,CAAA,GACnD,MAAA,GACA,EAAE,SAAA,EAAW,aAAA,EAAe,WAAA,EAAa,WAAA,KAAgB,OAAA,EAAQ;AACvE;AAkBO,MAAM,mBAAA,GAAsB,CACjC,OAAA,EACA,QAAA,EACA,WACA,WAAA,KACS;AACT,EAAA,MAAM,aAAa,OAAA,CAAQ,UAAA;AAE3B,EAAA,MAAM,UAAU,UAAA,CAAW,GAAA;AAE3B,EAAA,MAAM,QAAA,GAAW,QAAA,CAAS,OAAA,CAAQ,CAAC,OAAA,KAAY;AAC7C,IAAA,MAAM,MAAA,GAAS,eAAA,CAAgB,OAAA,EAAS,OAAA,CAAQ,eAAe,WAAW,CAAA;AAI1E,IAAA,OAAO,MAAA,KAAW,SAAY,EAAC,GAAI,CAAC,EAAE,OAAA,EAAS,QAAQ,CAAA;AAAA,EACzD,CAAC,CAAA;AAED,EAAA,MAAM,cAAc,KAAA,CAAM,IAAA;AAAA,IACxB,IAAI,GAAA;AAAA,MACF,QAAA,CACG,MAAA,CAAO,CAAC,EAAE,QAAO,KAAM,MAAA,CAAO,WAAW,CAAA,CACzC,IAAI,CAAC,EAAE,OAAA,EAAQ,KAAM,QAAQ,aAAa;AAAA;AAC/C,GACF;AAEA,EAAA,KAAA,MAAW,CAAC,OAAO,EAAE,OAAA,EAAS,QAAQ,CAAA,IAAK,QAAA,CAAS,OAAA,EAAQ,EAAG;AAC7D,IAAA,MAAM,cAAc,CAAA,EAAG,MAAA,CAAO,SAAS,CAAA,CAAA,EAAI,QAAQ,QAAQ,CAAA,CAAA,CAAA;AAE3D,IAAA,OAAA,CAAQ,MAAA,CAAO;AAAA,MACb,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAA;AAAA,MACA,IAAA,EAAM,EAAE,QAAA,EAAU,UAAA,CAAW,QAAQ,OAAA,CAAQ,IAAI,GAAG,WAAA,EAAY;AAAA,MAChE,GAAA,EAAK,CAAC,KAAA,KAAU;AAAA,QACd,GAAI,KAAA,KAAU,CAAA,GACV,WAAA,CAAY,OAAA;AAAA,UAAQ,CAAC,IAAA,KACnB,kBAAA,CAAmB,KAAA,EAAO,SAAS,IAAI;AAAA,YAEzC,EAAC;AAAA,QACL,KAAA,CAAM,WAAA,CAAY,OAAA,CAAQ,IAAA,EAAM,WAAW;AAAA;AAC7C,KACD,CAAA;AAAA,EACH;AACF;;;;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-ts-type-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "ESLint rules that steer type declarations toward ts-type-forge idioms.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,28 +34,9 @@
|
|
|
34
34
|
"README.md",
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsx ./scripts/cmd/build.mts",
|
|
39
|
-
"build:min": "tsx ./scripts/cmd/build.mts --skip-check",
|
|
40
|
-
"check:ext": "tsx ./scripts/cmd/check-ext.mts",
|
|
41
|
-
"fmt": "format-uncommitted --cwd .",
|
|
42
|
-
"fmt:diff": "format-diff-from origin/main --cwd .",
|
|
43
|
-
"fmt:full": "pnpm run z:prettier .",
|
|
44
|
-
"gen:rule-types": "tsx ./scripts/cmd/gen-rule-types.mts",
|
|
45
|
-
"lint": "eslint .",
|
|
46
|
-
"lint:fix": "eslint . --fix",
|
|
47
|
-
"test": "pnpm run z:vitest run",
|
|
48
|
-
"test:cov": "pnpm run z:vitest run --coverage",
|
|
49
|
-
"testw": "pnpm run z:vitest",
|
|
50
|
-
"tsc": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
51
|
-
"tscw": "node ../../node_modules/typescript-native/bin/tsc --noEmit --watch -p ./tsconfig.json",
|
|
52
|
-
"type-check": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
53
|
-
"z:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --ignore-path ../../.gitignore --ignore-unknown --no-error-on-unmatched-pattern --write",
|
|
54
|
-
"z:vitest": "vitest --config ./configs/vitest.config.mts"
|
|
55
|
-
},
|
|
56
37
|
"dependencies": {
|
|
57
38
|
"@typescript-eslint/utils": "8.65.0",
|
|
58
|
-
"ts-type-forge": "
|
|
39
|
+
"ts-type-forge": "8.0.0"
|
|
59
40
|
},
|
|
60
41
|
"devDependencies": {
|
|
61
42
|
"@typescript-eslint/parser": "8.65.0",
|
|
@@ -73,5 +54,24 @@
|
|
|
73
54
|
"publishConfig": {
|
|
74
55
|
"access": "public",
|
|
75
56
|
"provenance": true
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsx ./scripts/cmd/build.mts",
|
|
60
|
+
"build:min": "tsx ./scripts/cmd/build.mts --skip-check",
|
|
61
|
+
"check:ext": "tsx ./scripts/cmd/check-ext.mts",
|
|
62
|
+
"fmt": "format-uncommitted --cwd .",
|
|
63
|
+
"fmt:diff": "format-diff-from origin/main --cwd .",
|
|
64
|
+
"fmt:full": "pnpm run z:prettier .",
|
|
65
|
+
"gen:rule-types": "tsx ./scripts/cmd/gen-rule-types.mts",
|
|
66
|
+
"lint": "eslint .",
|
|
67
|
+
"lint:fix": "eslint . --fix",
|
|
68
|
+
"test": "pnpm run z:vitest run",
|
|
69
|
+
"test:cov": "pnpm run z:vitest run --coverage",
|
|
70
|
+
"testw": "pnpm run z:vitest",
|
|
71
|
+
"tsc": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
72
|
+
"tscw": "node ../../node_modules/typescript-native/bin/tsc --noEmit --watch -p ./tsconfig.json",
|
|
73
|
+
"type-check": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
74
|
+
"z:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --ignore-path ../../.gitignore --ignore-unknown --no-error-on-unmatched-pattern --write",
|
|
75
|
+
"z:vitest": "vitest --config ./configs/vitest.config.mts"
|
|
76
76
|
}
|
|
77
|
-
}
|
|
77
|
+
}
|
package/src/rule-types.mts
CHANGED
|
@@ -28,7 +28,7 @@ type RuleOptionsOf<Name extends keyof typeof tsTypeForgeRules> =
|
|
|
28
28
|
: never;
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* @description Replace
|
|
31
|
+
* @description Replace uniform tuple spellings such as `[V, ...V[]]` / `[V, V, ...V[]]` / `[V, V, V]` with the canonical ts-type-forge tuple type.
|
|
32
32
|
*
|
|
33
33
|
* ```md
|
|
34
34
|
* | key | value |
|
|
@@ -38,7 +38,7 @@ type RuleOptionsOf<Name extends keyof typeof tsTypeForgeRules> =
|
|
|
38
38
|
* | fixable | code |
|
|
39
39
|
* ```
|
|
40
40
|
*/
|
|
41
|
-
namespace
|
|
41
|
+
namespace PreferCanonicalLengthConstrainedTuple {
|
|
42
42
|
/**
|
|
43
43
|
* ### schema
|
|
44
44
|
*
|
|
@@ -53,7 +53,12 @@ namespace PreferNonEmptyArray {
|
|
|
53
53
|
* "global",
|
|
54
54
|
* "named"
|
|
55
55
|
* ],
|
|
56
|
-
* "description": "How
|
|
56
|
+
* "description": "How the ts-type-forge type is brought into scope. 'global' (default) assumes the ambient globals of `ts-type-forge/global` and never touches imports; 'named' makes the autofix add the corresponding `import { type … } from 'ts-type-forge';` when the name is not imported yet."
|
|
57
|
+
* },
|
|
58
|
+
* "maxLength": {
|
|
59
|
+
* "type": "integer",
|
|
60
|
+
* "minimum": 2,
|
|
61
|
+
* "description": "Longest tuple the rule rewrites (default 10). Longer tuples are usually spelled out on purpose, and the named form stops being more readable."
|
|
57
62
|
* }
|
|
58
63
|
* },
|
|
59
64
|
* "additionalProperties": false
|
|
@@ -61,7 +66,9 @@ namespace PreferNonEmptyArray {
|
|
|
61
66
|
* ]
|
|
62
67
|
* ```
|
|
63
68
|
*/
|
|
64
|
-
export type Options = NonNullable<
|
|
69
|
+
export type Options = NonNullable<
|
|
70
|
+
RuleOptionsOf<'prefer-canonical-length-constrained-tuple'>[0]
|
|
71
|
+
>;
|
|
65
72
|
|
|
66
73
|
export type RuleEntry =
|
|
67
74
|
| 'off'
|
|
@@ -70,11 +77,11 @@ namespace PreferNonEmptyArray {
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
export type EslintTsTypeForgeRules = Readonly<{
|
|
73
|
-
'ts-type-forge/prefer-
|
|
80
|
+
'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.RuleEntry;
|
|
74
81
|
}>;
|
|
75
82
|
|
|
76
83
|
export type EslintTsTypeForgeRulesOption = Readonly<{
|
|
77
|
-
'ts-type-forge/prefer-
|
|
84
|
+
'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.Options;
|
|
78
85
|
}>;
|
|
79
86
|
|
|
80
87
|
// If this assertion fails to type-check, this generated file has drifted from
|
package/src/rules/ast-utils.mts
CHANGED
|
@@ -6,54 +6,96 @@ import {
|
|
|
6
6
|
|
|
7
7
|
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
8
8
|
|
|
9
|
+
export type UniformTupleShape = Readonly<{
|
|
10
|
+
/** The node an autofix must replace: the tuple, or the `readonly` operator. */
|
|
11
|
+
node: TSESTree.Node;
|
|
12
|
+
isReadonly: boolean;
|
|
13
|
+
/** Source text of the (single, shared) element type. */
|
|
14
|
+
elementText: string;
|
|
15
|
+
/** Number of leading fixed-position elements. */
|
|
16
|
+
fixedCount: number;
|
|
17
|
+
/** Whether a trailing `...V[]` rest element is present. */
|
|
18
|
+
hasRest: boolean;
|
|
19
|
+
}>;
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
22
|
+
* Recognizes a tuple whose every position has the *same* element type — the
|
|
23
|
+
* hand-rolled spelling of ts-type-forge's length-constrained tuple family:
|
|
24
|
+
*
|
|
25
|
+
* | shape | meaning |
|
|
26
|
+
* | :------------------- | :------------------------- |
|
|
27
|
+
* | `[V, ..., V]` | `FixedLengthTuple<N, V>` |
|
|
28
|
+
* | `[V, ..., V, ...V[]]`| `MinLengthTuple<N, V>` |
|
|
13
29
|
*
|
|
14
|
-
* Deliberately narrow:
|
|
30
|
+
* Returns `undefined` for anything else. Deliberately narrow:
|
|
15
31
|
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
32
|
+
* - Element types are compared by normalized source text, so heterogeneous
|
|
33
|
+
* tuples are never matched.
|
|
34
|
+
* - Optional (`[V?]`) and labelled (`[head: V]`) members are skipped, since
|
|
35
|
+
* rewriting them would drop information.
|
|
36
|
+
* - A tuple that is only a rest element (`[...V[]]`) is not a length
|
|
37
|
+
* constraint at all.
|
|
22
38
|
*/
|
|
23
|
-
export const
|
|
24
|
-
|
|
39
|
+
export const analyzeUniformTuple = (
|
|
40
|
+
tuple: TSESTree.TSTupleType,
|
|
25
41
|
sourceCode: TSESLint.SourceCode,
|
|
26
|
-
):
|
|
27
|
-
|
|
42
|
+
): UniformTupleShape | undefined => {
|
|
43
|
+
const { parent } = tuple;
|
|
28
44
|
|
|
29
|
-
const
|
|
45
|
+
const isReadonly =
|
|
46
|
+
parent.type === AST_NODE_TYPES.TSTypeOperator &&
|
|
47
|
+
parent.operator === 'readonly';
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
const elements = tuple.elementTypes;
|
|
32
50
|
|
|
33
|
-
const
|
|
51
|
+
const last = elements.at(-1);
|
|
34
52
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
const hasRest = last?.type === AST_NODE_TYPES.TSRestType;
|
|
54
|
+
|
|
55
|
+
const leading = hasRest ? elements.slice(0, -1) : elements;
|
|
38
56
|
|
|
39
57
|
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
leading.length === 0 ||
|
|
59
|
+
leading.some(
|
|
60
|
+
(element) =>
|
|
61
|
+
element.type === AST_NODE_TYPES.TSRestType ||
|
|
62
|
+
element.type === AST_NODE_TYPES.TSOptionalType ||
|
|
63
|
+
element.type === AST_NODE_TYPES.TSNamedTupleMember,
|
|
64
|
+
)
|
|
44
65
|
) {
|
|
45
66
|
return undefined;
|
|
46
67
|
}
|
|
47
68
|
|
|
48
|
-
const restElementType =
|
|
69
|
+
const restElementType =
|
|
70
|
+
last !== undefined && hasRest
|
|
71
|
+
? getArrayElementType(last.typeAnnotation)
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
if (hasRest && restElementType === undefined) return undefined;
|
|
75
|
+
|
|
76
|
+
const [head] = leading;
|
|
77
|
+
|
|
78
|
+
if (head === undefined) return undefined;
|
|
79
|
+
|
|
80
|
+
const elementText = sourceCode.getText(head);
|
|
49
81
|
|
|
50
|
-
|
|
82
|
+
const expected = normalizeWhitespace(elementText);
|
|
51
83
|
|
|
52
|
-
const
|
|
84
|
+
const others = [
|
|
85
|
+
...leading.slice(1),
|
|
86
|
+
...(restElementType === undefined ? [] : [restElementType]),
|
|
87
|
+
];
|
|
53
88
|
|
|
54
|
-
return
|
|
55
|
-
normalizeWhitespace(sourceCode.getText(
|
|
56
|
-
|
|
89
|
+
return others.every(
|
|
90
|
+
(element) => normalizeWhitespace(sourceCode.getText(element)) === expected,
|
|
91
|
+
)
|
|
92
|
+
? {
|
|
93
|
+
node: isReadonly ? parent : tuple,
|
|
94
|
+
isReadonly,
|
|
95
|
+
elementText,
|
|
96
|
+
fixedCount: leading.length,
|
|
97
|
+
hasRest,
|
|
98
|
+
}
|
|
57
99
|
: undefined;
|
|
58
100
|
};
|
|
59
101
|
|
package/src/rules/constants.mts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
export const TS_TYPE_FORGE_MODULE = 'ts-type-forge';
|
|
2
2
|
|
|
3
|
-
export const
|
|
3
|
+
export const NON_EMPTY_TUPLE_TYPE_NAME = 'NonEmptyTuple';
|
|
4
|
+
|
|
5
|
+
export const MUTABLE_NON_EMPTY_TUPLE_TYPE_NAME = 'MutableNonEmptyTuple';
|
|
6
|
+
|
|
7
|
+
export const MIN_LENGTH_TUPLE_TYPE_NAME = 'MinLengthTuple';
|
|
8
|
+
|
|
9
|
+
export const MUTABLE_MIN_LENGTH_TUPLE_TYPE_NAME = 'MutableMinLengthTuple';
|
|
10
|
+
|
|
11
|
+
export const FIXED_LENGTH_TUPLE_TYPE_NAME = 'FixedLengthTuple';
|
|
12
|
+
|
|
13
|
+
export const MUTABLE_FIXED_LENGTH_TUPLE_TYPE_NAME = 'MutableFixedLengthTuple';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Upper bound for the `maxLength` option of `prefer-length-constrained-tuple`.
|
|
17
|
+
* Beyond this, spelling the tuple out is usually intentional (e.g. a matrix
|
|
18
|
+
* row), and the named form stops being more readable.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_MAX_TUPLE_LENGTH = 10;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
import { analyzeUniformTuple, type UniformTupleShape } from './ast-utils.mjs';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_TUPLE_LENGTH,
|
|
5
|
+
FIXED_LENGTH_TUPLE_TYPE_NAME,
|
|
6
|
+
MIN_LENGTH_TUPLE_TYPE_NAME,
|
|
7
|
+
MUTABLE_FIXED_LENGTH_TUPLE_TYPE_NAME,
|
|
8
|
+
MUTABLE_MIN_LENGTH_TUPLE_TYPE_NAME,
|
|
9
|
+
MUTABLE_NON_EMPTY_TUPLE_TYPE_NAME,
|
|
10
|
+
NON_EMPTY_TUPLE_TYPE_NAME,
|
|
11
|
+
} from './constants.mjs';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_IMPORT_STYLE,
|
|
14
|
+
IMPORT_STYLE_SCHEMA_PROPERTY,
|
|
15
|
+
reportTupleRewrites,
|
|
16
|
+
type TupleRewrite,
|
|
17
|
+
} from './tuple-rule-utils.mjs';
|
|
18
|
+
|
|
19
|
+
type Options = readonly [
|
|
20
|
+
Readonly<{
|
|
21
|
+
importStyle?: 'global' | 'named';
|
|
22
|
+
maxLength?: number;
|
|
23
|
+
}>?,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
type MessageIds = 'useCanonicalTuple';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A single fixed element without a rest (`[V]`) is left alone: it reads better
|
|
30
|
+
* than `FixedLengthTuple<1, V>`.
|
|
31
|
+
*/
|
|
32
|
+
const MIN_FIXED_LENGTH = 2;
|
|
33
|
+
|
|
34
|
+
export const preferCanonicalLengthConstrainedTuple: TSESLint.RuleModule<
|
|
35
|
+
MessageIds,
|
|
36
|
+
Options
|
|
37
|
+
> = {
|
|
38
|
+
meta: {
|
|
39
|
+
type: 'suggestion',
|
|
40
|
+
docs: {
|
|
41
|
+
description:
|
|
42
|
+
'Replace uniform tuple spellings such as `[V, ...V[]]` / `[V, V, ...V[]]` / `[V, V, V]` with the canonical ts-type-forge tuple type.',
|
|
43
|
+
},
|
|
44
|
+
fixable: 'code',
|
|
45
|
+
schema: [
|
|
46
|
+
{
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
importStyle: IMPORT_STYLE_SCHEMA_PROPERTY,
|
|
50
|
+
maxLength: {
|
|
51
|
+
type: 'integer',
|
|
52
|
+
minimum: MIN_FIXED_LENGTH,
|
|
53
|
+
description: [
|
|
54
|
+
'Longest tuple the rule rewrites (default',
|
|
55
|
+
`${DEFAULT_MAX_TUPLE_LENGTH}).`,
|
|
56
|
+
'Longer tuples are usually spelled out on purpose, and the named',
|
|
57
|
+
'form stops being more readable.',
|
|
58
|
+
].join(' '),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
messages: {
|
|
65
|
+
useCanonicalTuple:
|
|
66
|
+
'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
create: (context) => {
|
|
71
|
+
const sourceCode = context.sourceCode;
|
|
72
|
+
|
|
73
|
+
const options = context.options[0];
|
|
74
|
+
|
|
75
|
+
const importStyle = options?.importStyle ?? DEFAULT_IMPORT_STYLE;
|
|
76
|
+
|
|
77
|
+
const maxLength = options?.maxLength ?? DEFAULT_MAX_TUPLE_LENGTH;
|
|
78
|
+
|
|
79
|
+
const mut_rewrites: TupleRewrite[] = [];
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
TSTupleType: (node) => {
|
|
83
|
+
const shape = analyzeUniformTuple(node, sourceCode);
|
|
84
|
+
|
|
85
|
+
if (shape === undefined || shape.fixedCount > maxLength) return;
|
|
86
|
+
|
|
87
|
+
const canonicalName = canonicalNameFor(shape);
|
|
88
|
+
|
|
89
|
+
if (canonicalName === undefined) return;
|
|
90
|
+
|
|
91
|
+
mut_rewrites.push({
|
|
92
|
+
node: shape.node,
|
|
93
|
+
canonicalName,
|
|
94
|
+
typeArgs:
|
|
95
|
+
shape.fixedCount === 1
|
|
96
|
+
? shape.elementText
|
|
97
|
+
: `${shape.fixedCount}, ${shape.elementText}`,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
'Program:exit': () => {
|
|
101
|
+
reportTupleRewrites(
|
|
102
|
+
context,
|
|
103
|
+
mut_rewrites,
|
|
104
|
+
'useCanonicalTuple',
|
|
105
|
+
importStyle,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
defaultOptions: [
|
|
111
|
+
{ importStyle: DEFAULT_IMPORT_STYLE, maxLength: DEFAULT_MAX_TUPLE_LENGTH },
|
|
112
|
+
],
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ts-type-forge type a given tuple shape spells out, or `undefined` when the
|
|
117
|
+
* shape is deliberately left alone.
|
|
118
|
+
*
|
|
119
|
+
* | shape | readonly | mutable |
|
|
120
|
+
* | :-------------------- | :------------------ | :------------------------- |
|
|
121
|
+
* | `[V, ...V[]]` | `NonEmptyTuple` | `MutableNonEmptyTuple` |
|
|
122
|
+
* | `[V, …×N, ...V[]]` | `MinLengthTuple` | `MutableMinLengthTuple` |
|
|
123
|
+
* | `[V, …×N]` (N >= 2) | `FixedLengthTuple` | `MutableFixedLengthTuple` |
|
|
124
|
+
*/
|
|
125
|
+
const canonicalNameFor = (
|
|
126
|
+
// `UniformTupleShape` carries a `TSESTree.Node`, which is not deeply readonly.
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
128
|
+
shape: UniformTupleShape,
|
|
129
|
+
): string | undefined => {
|
|
130
|
+
const { isReadonly, hasRest, fixedCount } = shape;
|
|
131
|
+
|
|
132
|
+
if (hasRest) {
|
|
133
|
+
if (fixedCount === 1) {
|
|
134
|
+
return isReadonly
|
|
135
|
+
? NON_EMPTY_TUPLE_TYPE_NAME
|
|
136
|
+
: MUTABLE_NON_EMPTY_TUPLE_TYPE_NAME;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return isReadonly
|
|
140
|
+
? MIN_LENGTH_TUPLE_TYPE_NAME
|
|
141
|
+
: MUTABLE_MIN_LENGTH_TUPLE_TYPE_NAME;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (fixedCount < MIN_FIXED_LENGTH) return undefined;
|
|
145
|
+
|
|
146
|
+
return isReadonly
|
|
147
|
+
? FIXED_LENGTH_TUPLE_TYPE_NAME
|
|
148
|
+
: MUTABLE_FIXED_LENGTH_TUPLE_TYPE_NAME;
|
|
149
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import parser from '@typescript-eslint/parser';
|
|
2
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { preferCanonicalLengthConstrainedTuple } from './prefer-canonical-length-constrained-tuple.mjs';
|
|
5
|
+
|
|
6
|
+
const tester = new RuleTester({
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser,
|
|
9
|
+
parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('prefer-canonical-length-constrained-tuple', () => {
|
|
14
|
+
tester.run(
|
|
15
|
+
'prefer-canonical-length-constrained-tuple',
|
|
16
|
+
preferCanonicalLengthConstrainedTuple,
|
|
17
|
+
{
|
|
18
|
+
valid: [
|
|
19
|
+
{
|
|
20
|
+
name: 'ignores heterogeneous tuples',
|
|
21
|
+
code: 'type T = readonly [string, number];',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'ignores single-element tuples',
|
|
25
|
+
code: 'type T = readonly [string];',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'ignores tuples longer than maxLength',
|
|
29
|
+
options: [{ maxLength: 2 }],
|
|
30
|
+
code: 'type T = readonly [string, string, string];',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'ignores labelled members (the labels would be lost)',
|
|
34
|
+
code: 'type T = readonly [a: string, b: string];',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'ignores optional members',
|
|
38
|
+
code: 'type T = readonly [string, string?];',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'ignores rest-only tuples',
|
|
42
|
+
code: 'type T = readonly [...string[]];',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'ignores plain arrays',
|
|
46
|
+
code: 'type T = readonly string[];',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'stays silent when the target name is bound locally',
|
|
50
|
+
code: dedent`
|
|
51
|
+
type FixedLengthTuple<N, V> = readonly [N, V];
|
|
52
|
+
type T = readonly [string, string];
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
invalid: [
|
|
57
|
+
{
|
|
58
|
+
name: 'readonly non-empty tuple',
|
|
59
|
+
code: 'type T = readonly [string, ...string[]];',
|
|
60
|
+
output: 'type T = NonEmptyTuple<string>;',
|
|
61
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'mutable non-empty tuple',
|
|
65
|
+
code: 'type T = [string, ...string[]];',
|
|
66
|
+
output: 'type T = MutableNonEmptyTuple<string>;',
|
|
67
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'readonly non-empty tuple with a readonly rest spelling',
|
|
71
|
+
code: 'type T = readonly [string, ...(readonly string[])];',
|
|
72
|
+
output: 'type T = NonEmptyTuple<string>;',
|
|
73
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'readonly fixed-length tuple',
|
|
77
|
+
code: 'type T = readonly [string, string, string];',
|
|
78
|
+
output: 'type T = FixedLengthTuple<3, string>;',
|
|
79
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'mutable fixed-length tuple',
|
|
83
|
+
code: 'type T = [string, string, string];',
|
|
84
|
+
output: 'type T = MutableFixedLengthTuple<3, string>;',
|
|
85
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'readonly min-length tuple',
|
|
89
|
+
code: 'type T = readonly [number, number, ...number[]];',
|
|
90
|
+
output: 'type T = MinLengthTuple<2, number>;',
|
|
91
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'mutable min-length tuple',
|
|
95
|
+
code: 'type T = [number, number, ...number[]];',
|
|
96
|
+
output: 'type T = MutableMinLengthTuple<2, number>;',
|
|
97
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'readonly rest spelling inside a readonly tuple',
|
|
101
|
+
code: 'type T = readonly [number, number, ...(readonly number[])];',
|
|
102
|
+
output: 'type T = MinLengthTuple<2, number>;',
|
|
103
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'handles the default maximum length of 10',
|
|
107
|
+
code: 'type T = readonly [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];',
|
|
108
|
+
output: 'type T = FixedLengthTuple<10, 1>;',
|
|
109
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'keeps a composite element type intact',
|
|
113
|
+
code: 'type T = readonly [string | number, string | number];',
|
|
114
|
+
output: 'type T = FixedLengthTuple<2, string | number>;',
|
|
115
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'handles generic element types',
|
|
119
|
+
code: 'type T<V> = [V, V, ...V[]];',
|
|
120
|
+
output: 'type T<V> = MutableMinLengthTuple<2, V>;',
|
|
121
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'adds named imports once per target type',
|
|
125
|
+
options: [{ importStyle: 'named' }],
|
|
126
|
+
code: dedent`
|
|
127
|
+
type A = readonly [string, string];
|
|
128
|
+
type B = readonly [number, number, number];
|
|
129
|
+
type C = [number, number, ...number[]];
|
|
130
|
+
`,
|
|
131
|
+
output: dedent`
|
|
132
|
+
import { type FixedLengthTuple } from 'ts-type-forge';
|
|
133
|
+
import { type MutableMinLengthTuple } from 'ts-type-forge';
|
|
134
|
+
type A = FixedLengthTuple<2, string>;
|
|
135
|
+
type B = FixedLengthTuple<3, number>;
|
|
136
|
+
type C = MutableMinLengthTuple<2, number>;
|
|
137
|
+
`,
|
|
138
|
+
errors: [
|
|
139
|
+
{ messageId: 'useCanonicalTuple' },
|
|
140
|
+
{ messageId: 'useCanonicalTuple' },
|
|
141
|
+
{ messageId: 'useCanonicalTuple' },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'reuses an aliased import',
|
|
146
|
+
code: dedent`
|
|
147
|
+
import { type FixedLengthTuple as FLT } from 'ts-type-forge';
|
|
148
|
+
|
|
149
|
+
type T = readonly [string, string];
|
|
150
|
+
`,
|
|
151
|
+
output: dedent`
|
|
152
|
+
import { type FixedLengthTuple as FLT } from 'ts-type-forge';
|
|
153
|
+
|
|
154
|
+
type T = FLT<2, string>;
|
|
155
|
+
`,
|
|
156
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'rewrites parameter and return type positions',
|
|
160
|
+
code: 'declare const f: (xs: readonly [number, number]) => [string, string];',
|
|
161
|
+
output:
|
|
162
|
+
'declare const f: (xs: FixedLengthTuple<2, number>) => MutableFixedLengthTuple<2, string>;',
|
|
163
|
+
errors: [
|
|
164
|
+
{ messageId: 'useCanonicalTuple' },
|
|
165
|
+
{ messageId: 'useCanonicalTuple' },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'respects a raised maxLength',
|
|
170
|
+
options: [{ maxLength: 12 }],
|
|
171
|
+
code: 'type T = readonly [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];',
|
|
172
|
+
output: 'type T = FixedLengthTuple<12, 0>;',
|
|
173
|
+
errors: [{ messageId: 'useCanonicalTuple' }],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
});
|