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.
Files changed (37) hide show
  1. package/README.md +53 -31
  2. package/dist/rule-types.d.mts +11 -6
  3. package/dist/rule-types.d.mts.map +1 -1
  4. package/dist/rules/ast-utils.d.mts +26 -11
  5. package/dist/rules/ast-utils.d.mts.map +1 -1
  6. package/dist/rules/ast-utils.mjs +30 -14
  7. package/dist/rules/ast-utils.mjs.map +1 -1
  8. package/dist/rules/constants.d.mts +12 -1
  9. package/dist/rules/constants.d.mts.map +1 -1
  10. package/dist/rules/constants.mjs +8 -2
  11. package/dist/rules/constants.mjs.map +1 -1
  12. package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts +11 -0
  13. package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts.map +1 -0
  14. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +81 -0
  15. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -0
  16. package/dist/rules/rules.d.mts +3 -2
  17. package/dist/rules/rules.d.mts.map +1 -1
  18. package/dist/rules/rules.mjs +2 -2
  19. package/dist/rules/rules.mjs.map +1 -1
  20. package/dist/rules/tuple-rule-utils.d.mts +37 -0
  21. package/dist/rules/tuple-rule-utils.d.mts.map +1 -0
  22. package/dist/rules/tuple-rule-utils.mjs +56 -0
  23. package/dist/rules/tuple-rule-utils.mjs.map +1 -0
  24. package/package.json +22 -22
  25. package/src/rule-types.mts +13 -6
  26. package/src/rules/ast-utils.mts +72 -30
  27. package/src/rules/constants.mts +18 -1
  28. package/src/rules/prefer-canonical-length-constrained-tuple.mts +149 -0
  29. package/src/rules/prefer-canonical-length-constrained-tuple.test.mts +178 -0
  30. package/src/rules/rules.mts +3 -2
  31. package/src/rules/tuple-rule-utils.mts +124 -0
  32. package/dist/rules/prefer-non-empty-array.d.mts +0 -11
  33. package/dist/rules/prefer-non-empty-array.d.mts.map +0 -1
  34. package/dist/rules/prefer-non-empty-array.mjs +0 -81
  35. package/dist/rules/prefer-non-empty-array.mjs.map +0 -1
  36. package/src/rules/prefer-non-empty-array.mts +0 -129
  37. 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.2.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": "workspace:*"
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
+ }
@@ -28,7 +28,7 @@ type RuleOptionsOf<Name extends keyof typeof tsTypeForgeRules> =
28
28
  : never;
29
29
 
30
30
  /**
31
- * @description Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.
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 PreferNonEmptyArray {
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 `NonEmptyArray` is brought into scope. 'global' (default) assumes the ambient globals of `ts-type-forge/global` and never touches imports; 'named' makes the autofix add `import { type NonEmptyArray } from 'ts-type-forge';` when the name is not imported yet."
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<RuleOptionsOf<'prefer-non-empty-array'>[0]>;
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-non-empty-array': PreferNonEmptyArray.RuleEntry;
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-non-empty-array': PreferNonEmptyArray.Options;
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
@@ -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
- * Matches the "non-empty array" tuple spelling — `readonly [V, ...V[]]` (and
11
- * its equivalent `readonly [V, ...(readonly V[])]`) — and returns the source
12
- * text of `V`, or `undefined` when `node` is not that shape.
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
- * - Only the `readonly` operator is matched. A mutable `[V, ...V[]]` is a
17
- * different type (`MutableNonEmptyArray<V>`) and is left alone.
18
- * - Exactly two elements, the second being a rest element whose element type is
19
- * textually identical to the first.
20
- * - Labelled members (`readonly [head: V, ...tail: V[]]`) are skipped, since
21
- * rewriting them would silently drop the labels.
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 getNonEmptyArrayElementText = (
24
- node: TSESTree.TSTypeOperator,
39
+ export const analyzeUniformTuple = (
40
+ tuple: TSESTree.TSTupleType,
25
41
  sourceCode: TSESLint.SourceCode,
26
- ): string | undefined => {
27
- if (node.operator !== 'readonly') return undefined;
42
+ ): UniformTupleShape | undefined => {
43
+ const { parent } = tuple;
28
44
 
29
- const tuple = node.typeAnnotation;
45
+ const isReadonly =
46
+ parent.type === AST_NODE_TYPES.TSTypeOperator &&
47
+ parent.operator === 'readonly';
30
48
 
31
- if (tuple?.type !== AST_NODE_TYPES.TSTupleType) return undefined;
49
+ const elements = tuple.elementTypes;
32
50
 
33
- const [head, tail, ...rest] = tuple.elementTypes;
51
+ const last = elements.at(-1);
34
52
 
35
- if (head === undefined || tail === undefined || rest.length > 0) {
36
- return undefined;
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
- head.type === AST_NODE_TYPES.TSRestType ||
41
- head.type === AST_NODE_TYPES.TSOptionalType ||
42
- head.type === AST_NODE_TYPES.TSNamedTupleMember ||
43
- tail.type !== AST_NODE_TYPES.TSRestType
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 = getArrayElementType(tail.typeAnnotation);
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
- if (restElementType === undefined) return undefined;
82
+ const expected = normalizeWhitespace(elementText);
51
83
 
52
- const headText = sourceCode.getText(head);
84
+ const others = [
85
+ ...leading.slice(1),
86
+ ...(restElementType === undefined ? [] : [restElementType]),
87
+ ];
53
88
 
54
- return normalizeWhitespace(headText) ===
55
- normalizeWhitespace(sourceCode.getText(restElementType))
56
- ? headText
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
 
@@ -1,3 +1,20 @@
1
1
  export const TS_TYPE_FORGE_MODULE = 'ts-type-forge';
2
2
 
3
- export const NON_EMPTY_ARRAY_TYPE_NAME = 'NonEmptyArray';
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
+ });