eslint-plugin-ts-type-forge 0.2.0 → 0.3.0

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
@@ -1,6 +1,7 @@
1
1
  import { type ESLintPlugin } from '../types.mjs';
2
- import { preferNonEmptyArray } from './prefer-non-empty-array.mjs';
2
+ import { preferCanonicalLengthConstrainedTuple } from './prefer-canonical-length-constrained-tuple.mjs';
3
3
 
4
4
  export const tsTypeForgeRules = {
5
- 'prefer-non-empty-array': preferNonEmptyArray,
5
+ 'prefer-canonical-length-constrained-tuple':
6
+ preferCanonicalLengthConstrainedTuple,
6
7
  } as const satisfies ESLintPlugin['rules'];
@@ -0,0 +1,124 @@
1
+ import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
+ import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
3
+ import {
4
+ buildTypeImportFix,
5
+ getImportedLocalName,
6
+ getTsTypeForgeImports,
7
+ hasConflictingDeclaration,
8
+ } from './import-utils.mjs';
9
+
10
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
11
+
12
+ /** `JSONSchema4`, reached through the public rule-module types. */
13
+ type JsonSchema = Exclude<
14
+ TSESLint.RuleModule<string, readonly unknown[]>['meta']['schema'],
15
+ readonly unknown[]
16
+ >;
17
+
18
+ export type ImportStyle = 'global' | 'named';
19
+
20
+ export const DEFAULT_IMPORT_STYLE: ImportStyle = 'global';
21
+
22
+ export const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {
23
+ type: 'string',
24
+ enum: ['global', 'named'],
25
+ description: [
26
+ 'How the ts-type-forge type is brought into scope.',
27
+ "'global' (default) assumes the ambient globals of",
28
+ '`ts-type-forge/global` and never touches imports;',
29
+ "'named' makes the autofix add the corresponding",
30
+ `\`import { type … } from '${TS_TYPE_FORGE_MODULE}';\``,
31
+ 'when the name is not imported yet.',
32
+ ].join(' '),
33
+ };
34
+
35
+ /**
36
+ * Resolves how a ts-type-forge type name should be written in this file, and
37
+ * whether the autofix has to add an import for it.
38
+ *
39
+ * Returns `undefined` when the name is already bound to something else (a local
40
+ * alias, or an import from another module) — rewriting to it would then silently
41
+ * resolve to the wrong declaration, which is exactly what happens inside
42
+ * ts-type-forge's own sources.
43
+ */
44
+ export const resolveTypeName = (
45
+ program: TSESTree.Program,
46
+ canonicalName: string,
47
+ importStyle: ImportStyle,
48
+ ): Readonly<{ localName: string; needsImport: boolean }> | undefined => {
49
+ const importedLocalName = getImportedLocalName(
50
+ getTsTypeForgeImports(program),
51
+ canonicalName,
52
+ );
53
+
54
+ if (importedLocalName !== undefined) {
55
+ return { localName: importedLocalName, needsImport: false };
56
+ }
57
+
58
+ return hasConflictingDeclaration(program, canonicalName)
59
+ ? undefined
60
+ : { localName: canonicalName, needsImport: importStyle === 'named' };
61
+ };
62
+
63
+ export type TupleRewrite = Readonly<{
64
+ node: TSESTree.Node;
65
+ canonicalName: string;
66
+ /** Text inside the `<...>` of the replacement, e.g. `'2, string'`. */
67
+ typeArgs: string;
68
+ }>;
69
+
70
+ /**
71
+ * Emits one report per rewrite, resolving each target name and attaching every
72
+ * required import to the *first* report.
73
+ *
74
+ * All import fixes must live on a single report: `insertTextBefore(program, …)`
75
+ * produces overlapping ranges, and ESLint merges each report's fixes into one
76
+ * span — so spreading them across reports makes the fixes conflict and only one
77
+ * survives per pass.
78
+ */
79
+ export const reportTupleRewrites = <MessageIds extends string>(
80
+ context: TSESLint.RuleContext<MessageIds, readonly unknown[]>,
81
+ rewrites: readonly TupleRewrite[],
82
+ messageId: MessageIds,
83
+ importStyle: ImportStyle,
84
+ ): void => {
85
+ const sourceCode = context.sourceCode;
86
+
87
+ const program = sourceCode.ast;
88
+
89
+ const resolved = rewrites.flatMap((rewrite) => {
90
+ const target = resolveTypeName(program, rewrite.canonicalName, importStyle);
91
+
92
+ // The name is already bound to something else in this file, so the rewrite
93
+ // would resolve to the wrong declaration.
94
+ return target === undefined ? [] : [{ rewrite, target }];
95
+ });
96
+
97
+ const importNames = Array.from(
98
+ new Set(
99
+ resolved
100
+ .filter(({ target }) => target.needsImport)
101
+ .map(({ rewrite }) => rewrite.canonicalName),
102
+ ),
103
+ );
104
+
105
+ for (const [index, { rewrite, target }] of resolved.entries()) {
106
+ const replacement = `${target.localName}<${rewrite.typeArgs}>`;
107
+
108
+ context.report({
109
+ node: rewrite.node,
110
+ messageId,
111
+ data: { original: sourceCode.getText(rewrite.node), replacement },
112
+ fix: (fixer) => [
113
+ ...(index === 0
114
+ ? importNames.flatMap((name) =>
115
+ buildTypeImportFix(fixer, program, name),
116
+ )
117
+ : []),
118
+ fixer.replaceText(rewrite.node, replacement),
119
+ ],
120
+ });
121
+ }
122
+ };
123
+
124
+ /* eslint-enable @typescript-eslint/prefer-readonly-parameter-types */
@@ -1,11 +0,0 @@
1
- import { type TSESLint } from '@typescript-eslint/utils';
2
- type ImportStyle = 'global' | 'named';
3
- type Options = readonly [
4
- Readonly<{
5
- importStyle?: ImportStyle;
6
- }>?
7
- ];
8
- type MessageIds = 'useNonEmptyArray';
9
- export declare const preferNonEmptyArray: TSESLint.RuleModule<MessageIds, Options>;
10
- export {};
11
- //# sourceMappingURL=prefer-non-empty-array.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"prefer-non-empty-array.d.mts","sourceRoot":"","sources":["../../src/rules/prefer-non-empty-array.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAiB,MAAM,0BAA0B,CAAC;AAaxE,KAAK,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtC,KAAK,OAAO,GAAG,SAAS;IACtB,QAAQ,CAAC;QACP,WAAW,CAAC,EAAE,WAAW,CAAC;KAC3B,CAAC,CAAC;CACJ,CAAC;AAEF,KAAK,UAAU,GAAG,kBAAkB,CAAC;AAIrC,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU,EAAE,OAAO,CAuG/D,CAAC"}
@@ -1,81 +0,0 @@
1
- import { getNonEmptyArrayElementText } from './ast-utils.mjs';
2
- import { NON_EMPTY_ARRAY_TYPE_NAME, TS_TYPE_FORGE_MODULE } from './constants.mjs';
3
- import { getImportedLocalName, getTsTypeForgeImports, hasConflictingDeclaration, buildTypeImportFix } from './import-utils.mjs';
4
-
5
- const DEFAULT_IMPORT_STYLE = "global";
6
- const preferNonEmptyArray = {
7
- meta: {
8
- type: "suggestion",
9
- docs: {
10
- description: "Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge."
11
- },
12
- fixable: "code",
13
- schema: [
14
- {
15
- type: "object",
16
- properties: {
17
- importStyle: {
18
- type: "string",
19
- enum: ["global", "named"],
20
- description: [
21
- "How `NonEmptyArray` is brought into scope.",
22
- "'global' (default) assumes the ambient globals of",
23
- "`ts-type-forge/global` and never touches imports;",
24
- "'named' makes the autofix add",
25
- `\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\``,
26
- "when the name is not imported yet."
27
- ].join(" ")
28
- }
29
- },
30
- additionalProperties: false
31
- }
32
- ],
33
- messages: {
34
- useNonEmptyArray: "Replace `{{original}}` with `{{replacement}}` from ts-type-forge."
35
- }
36
- },
37
- create: (context) => {
38
- const sourceCode = context.sourceCode;
39
- const program = sourceCode.ast;
40
- const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
41
- const importedLocalName = getImportedLocalName(
42
- getTsTypeForgeImports(program),
43
- NON_EMPTY_ARRAY_TYPE_NAME
44
- );
45
- const mut_nodesToFix = [];
46
- return {
47
- TSTypeOperator: (node) => {
48
- const elementText = getNonEmptyArrayElementText(node, sourceCode);
49
- if (elementText === void 0) return;
50
- mut_nodesToFix.push({ node, elementText });
51
- },
52
- "Program:exit": () => {
53
- if (mut_nodesToFix.length === 0) return;
54
- if (importedLocalName === void 0 && hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)) {
55
- return;
56
- }
57
- const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;
58
- const needsImport = importStyle === "named" && importedLocalName === void 0;
59
- for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {
60
- const replacement = `${localName}<${elementText}>`;
61
- context.report({
62
- node,
63
- messageId: "useNonEmptyArray",
64
- data: {
65
- original: sourceCode.getText(node),
66
- replacement
67
- },
68
- fix: (fixer) => [
69
- ...index === 0 && needsImport ? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME) : [],
70
- fixer.replaceText(node, replacement)
71
- ]
72
- });
73
- }
74
- }
75
- };
76
- },
77
- defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }]
78
- };
79
-
80
- export { preferNonEmptyArray };
81
- //# sourceMappingURL=prefer-non-empty-array.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"prefer-non-empty-array.mjs","sources":["../../src/rules/prefer-non-empty-array.mts"],"sourcesContent":["import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';\nimport { getNonEmptyArrayElementText } from './ast-utils.mjs';\nimport {\n NON_EMPTY_ARRAY_TYPE_NAME,\n TS_TYPE_FORGE_MODULE,\n} from './constants.mjs';\nimport {\n buildTypeImportFix,\n getImportedLocalName,\n getTsTypeForgeImports,\n hasConflictingDeclaration,\n} from './import-utils.mjs';\n\ntype ImportStyle = 'global' | 'named';\n\ntype Options = readonly [\n Readonly<{\n importStyle?: ImportStyle;\n }>?,\n];\n\ntype MessageIds = 'useNonEmptyArray';\n\nconst DEFAULT_IMPORT_STYLE: ImportStyle = 'global';\n\nexport const preferNonEmptyArray: TSESLint.RuleModule<MessageIds, Options> = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.',\n },\n fixable: 'code',\n schema: [\n {\n type: 'object',\n properties: {\n importStyle: {\n type: 'string',\n enum: ['global', 'named'],\n description: [\n 'How `NonEmptyArray` 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\",\n `\\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\\``,\n 'when the name is not imported yet.',\n ].join(' '),\n },\n },\n additionalProperties: false,\n },\n ],\n messages: {\n useNonEmptyArray:\n 'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',\n },\n },\n\n create: (context) => {\n const sourceCode = context.sourceCode;\n\n const program = sourceCode.ast;\n\n // `context.options` is what the user actually configured; `defaultOptions`\n // is only merged in by `RuleCreator`, which plain rule objects do not use.\n const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;\n\n const importedLocalName = getImportedLocalName(\n getTsTypeForgeImports(program),\n NON_EMPTY_ARRAY_TYPE_NAME,\n );\n\n const mut_nodesToFix: {\n node: TSESTree.TSTypeOperator;\n elementText: string;\n }[] = [];\n\n return {\n TSTypeOperator: (node) => {\n const elementText = getNonEmptyArrayElementText(node, sourceCode);\n\n if (elementText === undefined) return;\n\n mut_nodesToFix.push({ node, elementText });\n },\n 'Program:exit': () => {\n if (mut_nodesToFix.length === 0) return;\n\n // Rewriting to `NonEmptyArray<...>` is only sound when that name is\n // free (or already bound to the ts-type-forge export). Otherwise the\n // rewritten type would resolve to the wrong declaration — most\n // notably inside ts-type-forge's own sources, which define\n // `NonEmptyArray`.\n if (\n importedLocalName === undefined &&\n hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)\n ) {\n return;\n }\n\n const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;\n\n const needsImport =\n importStyle === 'named' && importedLocalName === undefined;\n\n for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {\n const replacement = `${localName}<${elementText}>`;\n\n context.report({\n node,\n messageId: 'useNonEmptyArray',\n data: {\n original: sourceCode.getText(node),\n replacement,\n },\n fix: (fixer) => [\n ...(index === 0 && needsImport\n ? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME)\n : []),\n fixer.replaceText(node, replacement),\n ],\n });\n }\n },\n };\n },\n defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],\n} as const;\n"],"names":[],"mappings":"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"}
@@ -1,129 +0,0 @@
1
- import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
- import { getNonEmptyArrayElementText } from './ast-utils.mjs';
3
- import {
4
- NON_EMPTY_ARRAY_TYPE_NAME,
5
- TS_TYPE_FORGE_MODULE,
6
- } from './constants.mjs';
7
- import {
8
- buildTypeImportFix,
9
- getImportedLocalName,
10
- getTsTypeForgeImports,
11
- hasConflictingDeclaration,
12
- } from './import-utils.mjs';
13
-
14
- type ImportStyle = 'global' | 'named';
15
-
16
- type Options = readonly [
17
- Readonly<{
18
- importStyle?: ImportStyle;
19
- }>?,
20
- ];
21
-
22
- type MessageIds = 'useNonEmptyArray';
23
-
24
- const DEFAULT_IMPORT_STYLE: ImportStyle = 'global';
25
-
26
- export const preferNonEmptyArray: TSESLint.RuleModule<MessageIds, Options> = {
27
- meta: {
28
- type: 'suggestion',
29
- docs: {
30
- description:
31
- 'Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.',
32
- },
33
- fixable: 'code',
34
- schema: [
35
- {
36
- type: 'object',
37
- properties: {
38
- importStyle: {
39
- type: 'string',
40
- enum: ['global', 'named'],
41
- description: [
42
- 'How `NonEmptyArray` is brought into scope.',
43
- "'global' (default) assumes the ambient globals of",
44
- '`ts-type-forge/global` and never touches imports;',
45
- "'named' makes the autofix add",
46
- `\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\``,
47
- 'when the name is not imported yet.',
48
- ].join(' '),
49
- },
50
- },
51
- additionalProperties: false,
52
- },
53
- ],
54
- messages: {
55
- useNonEmptyArray:
56
- 'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',
57
- },
58
- },
59
-
60
- create: (context) => {
61
- const sourceCode = context.sourceCode;
62
-
63
- const program = sourceCode.ast;
64
-
65
- // `context.options` is what the user actually configured; `defaultOptions`
66
- // is only merged in by `RuleCreator`, which plain rule objects do not use.
67
- const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
68
-
69
- const importedLocalName = getImportedLocalName(
70
- getTsTypeForgeImports(program),
71
- NON_EMPTY_ARRAY_TYPE_NAME,
72
- );
73
-
74
- const mut_nodesToFix: {
75
- node: TSESTree.TSTypeOperator;
76
- elementText: string;
77
- }[] = [];
78
-
79
- return {
80
- TSTypeOperator: (node) => {
81
- const elementText = getNonEmptyArrayElementText(node, sourceCode);
82
-
83
- if (elementText === undefined) return;
84
-
85
- mut_nodesToFix.push({ node, elementText });
86
- },
87
- 'Program:exit': () => {
88
- if (mut_nodesToFix.length === 0) return;
89
-
90
- // Rewriting to `NonEmptyArray<...>` is only sound when that name is
91
- // free (or already bound to the ts-type-forge export). Otherwise the
92
- // rewritten type would resolve to the wrong declaration — most
93
- // notably inside ts-type-forge's own sources, which define
94
- // `NonEmptyArray`.
95
- if (
96
- importedLocalName === undefined &&
97
- hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)
98
- ) {
99
- return;
100
- }
101
-
102
- const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;
103
-
104
- const needsImport =
105
- importStyle === 'named' && importedLocalName === undefined;
106
-
107
- for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {
108
- const replacement = `${localName}<${elementText}>`;
109
-
110
- context.report({
111
- node,
112
- messageId: 'useNonEmptyArray',
113
- data: {
114
- original: sourceCode.getText(node),
115
- replacement,
116
- },
117
- fix: (fixer) => [
118
- ...(index === 0 && needsImport
119
- ? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME)
120
- : []),
121
- fixer.replaceText(node, replacement),
122
- ],
123
- });
124
- }
125
- },
126
- };
127
- },
128
- defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],
129
- } as const;
@@ -1,192 +0,0 @@
1
- import parser from '@typescript-eslint/parser';
2
- import { RuleTester } from '@typescript-eslint/rule-tester';
3
- import dedent from 'dedent';
4
- import { preferNonEmptyArray } from './prefer-non-empty-array.mjs';
5
-
6
- const tester = new RuleTester({
7
- languageOptions: {
8
- parser,
9
- parserOptions: {
10
- ecmaVersion: 2020,
11
- sourceType: 'module',
12
- },
13
- },
14
- });
15
-
16
- describe('prefer-non-empty-array', () => {
17
- tester.run('prefer-non-empty-array', preferNonEmptyArray, {
18
- valid: [
19
- {
20
- name: 'ignores mutable tuples (that spelling is MutableNonEmptyArray)',
21
- code: 'type T = [number, ...number[]];',
22
- },
23
- {
24
- name: 'ignores plain readonly arrays',
25
- code: 'type T = readonly number[];',
26
- },
27
- {
28
- name: 'ignores tuples whose rest element type differs from the head',
29
- code: 'type T = readonly [string, ...number[]];',
30
- },
31
- {
32
- name: 'ignores min-length-2 tuples',
33
- code: 'type T = readonly [number, number, ...number[]];',
34
- },
35
- {
36
- name: 'ignores fixed-length tuples',
37
- code: 'type T = readonly [number];',
38
- },
39
- {
40
- name: 'ignores leading-rest tuples',
41
- code: 'type T = readonly [...number[], number];',
42
- },
43
- {
44
- name: 'ignores optional heads',
45
- code: 'type T = readonly [number?, ...number[]];',
46
- },
47
- {
48
- name: 'ignores labelled tuple members (the labels would be lost)',
49
- code: 'type T = readonly [head: number, ...tail: number[]];',
50
- },
51
- {
52
- name: 'ignores readonly operators applied to non-tuples',
53
- code: 'type T = readonly string[];',
54
- },
55
- {
56
- name: 'stays silent when NonEmptyArray is declared locally',
57
- code: dedent`
58
- type NonEmptyArray<A> = readonly [A, ...A[]];
59
- type T = readonly [number, ...number[]];
60
- `,
61
- },
62
- {
63
- name: 'stays silent when NonEmptyArray is exported locally',
64
- code: 'export type NonEmptyArray<A> = readonly [A, ...A[]];',
65
- },
66
- {
67
- name: 'stays silent when NonEmptyArray is imported from elsewhere',
68
- code: dedent`
69
- import { type NonEmptyArray } from './my-types.mjs';
70
-
71
- type T = readonly [number, ...number[]];
72
- type U = NonEmptyArray<string>;
73
- `,
74
- },
75
- ],
76
- invalid: [
77
- {
78
- name: 'replaces readonly [V, ...V[]] with NonEmptyArray<V>',
79
- code: 'type T = readonly [number, ...number[]];',
80
- output: 'type T = NonEmptyArray<number>;',
81
- errors: [{ messageId: 'useNonEmptyArray' }],
82
- },
83
- {
84
- name: 'replaces readonly [V, ...(readonly V[])] with NonEmptyArray<V>',
85
- code: 'type T = readonly [number, ...(readonly number[])];',
86
- output: 'type T = NonEmptyArray<number>;',
87
- errors: [{ messageId: 'useNonEmptyArray' }],
88
- },
89
- {
90
- name: 'keeps a composite element type intact',
91
- code: 'type T = readonly [string | number, ...(string | number)[]];',
92
- output: 'type T = NonEmptyArray<string | number>;',
93
- errors: [{ messageId: 'useNonEmptyArray' }],
94
- },
95
- {
96
- name: 'handles generic element types',
97
- code: 'type T<A> = readonly [A, ...A[]];',
98
- output: 'type T<A> = NonEmptyArray<A>;',
99
- errors: [{ messageId: 'useNonEmptyArray' }],
100
- },
101
- {
102
- name: 'rewrites occurrences nested inside another tuple',
103
- code: 'type T = readonly [readonly [number, ...number[]], ...string[]];',
104
- output: 'type T = readonly [NonEmptyArray<number>, ...string[]];',
105
- errors: [{ messageId: 'useNonEmptyArray' }],
106
- },
107
- {
108
- name: 'rewrites parameter and return type positions',
109
- code: dedent`
110
- declare const f: (xs: readonly [number, ...number[]]) => readonly [string, ...string[]];
111
- `,
112
- output: dedent`
113
- declare const f: (xs: NonEmptyArray<number>) => NonEmptyArray<string>;
114
- `,
115
- errors: [
116
- { messageId: 'useNonEmptyArray' },
117
- { messageId: 'useNonEmptyArray' },
118
- ],
119
- },
120
- {
121
- name: 'reuses the local alias when NonEmptyArray is imported aliased',
122
- code: dedent`
123
- import { type NonEmptyArray as NEA } from 'ts-type-forge';
124
-
125
- type T = readonly [number, ...number[]];
126
- `,
127
- output: dedent`
128
- import { type NonEmptyArray as NEA } from 'ts-type-forge';
129
-
130
- type T = NEA<number>;
131
- `,
132
- errors: [{ messageId: 'useNonEmptyArray' }],
133
- },
134
- {
135
- name: 'does not add an import with the default (global) import style',
136
- code: dedent`
137
- import { type StrictOmit } from 'ts-type-forge';
138
-
139
- type T = readonly [number, ...number[]];
140
- `,
141
- output: dedent`
142
- import { type StrictOmit } from 'ts-type-forge';
143
-
144
- type T = NonEmptyArray<number>;
145
- `,
146
- errors: [{ messageId: 'useNonEmptyArray' }],
147
- },
148
- {
149
- name: 'adds the named import when importStyle is "named"',
150
- options: [{ importStyle: 'named' }],
151
- code: 'type T = readonly [number, ...number[]];',
152
- output: dedent`
153
- import { type NonEmptyArray } from 'ts-type-forge';
154
- type T = NonEmptyArray<number>;
155
- `,
156
- errors: [{ messageId: 'useNonEmptyArray' }],
157
- },
158
- {
159
- name: 'adds the named import only once per file',
160
- options: [{ importStyle: 'named' }],
161
- code: dedent`
162
- type T = readonly [number, ...number[]];
163
- type U = readonly [string, ...string[]];
164
- `,
165
- output: dedent`
166
- import { type NonEmptyArray } from 'ts-type-forge';
167
- type T = NonEmptyArray<number>;
168
- type U = NonEmptyArray<string>;
169
- `,
170
- errors: [
171
- { messageId: 'useNonEmptyArray' },
172
- { messageId: 'useNonEmptyArray' },
173
- ],
174
- },
175
- {
176
- name: 'keeps an existing NonEmptyArray import untouched',
177
- options: [{ importStyle: 'named' }],
178
- code: dedent`
179
- import { type NonEmptyArray } from 'ts-type-forge';
180
-
181
- type T = readonly [number, ...number[]];
182
- `,
183
- output: dedent`
184
- import { type NonEmptyArray } from 'ts-type-forge';
185
-
186
- type T = NonEmptyArray<number>;
187
- `,
188
- errors: [{ messageId: 'useNonEmptyArray' }],
189
- },
190
- ],
191
- });
192
- });