ts-codemod-lib 3.5.1 → 4.0.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 (59) hide show
  1. package/README.md +7 -3
  2. package/dist/functions/ast-transformers/append-as-const.d.mts +20 -12
  3. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  4. package/dist/functions/ast-transformers/append-as-const.mjs +170 -88
  5. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  6. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +25 -6
  7. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.mjs +272 -255
  9. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
  11. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
  12. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  13. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
  15. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/transform-source-code.mjs +69 -69
  18. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  19. package/dist/functions/constants/index.d.mts +1 -0
  20. package/dist/functions/constants/index.d.mts.map +1 -1
  21. package/dist/functions/constants/index.mjs +1 -0
  22. package/dist/functions/constants/index.mjs.map +1 -1
  23. package/dist/functions/constants/mutable-prefixes.d.mts +23 -0
  24. package/dist/functions/constants/mutable-prefixes.d.mts.map +1 -0
  25. package/dist/functions/constants/mutable-prefixes.mjs +28 -0
  26. package/dist/functions/constants/mutable-prefixes.mjs.map +1 -0
  27. package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
  28. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  29. package/dist/functions/functions/is-readonly-node.d.mts +2 -2
  30. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  31. package/dist/functions/functions/is-readonly-node.mjs +13 -13
  32. package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
  33. package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
  34. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  35. package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
  36. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  37. package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
  38. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
  39. package/dist/functions/utils/replace-node-with-text.mjs +0 -1
  40. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
  41. package/package.json +6 -6
  42. package/src/functions/ast-transformers/append-as-const.mts +319 -174
  43. package/src/functions/ast-transformers/append-as-const.test.mts +118 -7
  44. package/src/functions/ast-transformers/convert-to-readonly.mts +568 -489
  45. package/src/functions/ast-transformers/convert-to-readonly.test.mts +381 -74
  46. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
  47. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
  48. package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
  49. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
  50. package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
  51. package/src/functions/ast-transformers/transform-source-code.mts +110 -110
  52. package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
  53. package/src/functions/constants/index.mts +1 -0
  54. package/src/functions/constants/mutable-prefixes.mts +28 -0
  55. package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
  56. package/src/functions/functions/is-readonly-node.mts +25 -25
  57. package/src/functions/functions/wrap-with-parentheses.mts +138 -52
  58. package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
  59. package/src/functions/utils/replace-node-with-text.mts +10 -11
@@ -30,6 +30,31 @@ export const isReadonlyArrayTypeNode = (
30
30
  node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
31
31
  node.getTypeNode().isKind(tsm.SyntaxKind.ArrayType);
32
32
 
33
+ export const isReadonlyTupleTypeNode = (
34
+ node: DeepReadonly<tsm.Node>,
35
+ ): node is ReadonlyTupleTypeNode =>
36
+ node.isKind(tsm.SyntaxKind.TypeOperator) &&
37
+ node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
38
+ node.getTypeNode().isKind(tsm.SyntaxKind.TupleType);
39
+
40
+ export const isReadonlyTypeReferenceNode = (
41
+ node: DeepReadonly<tsm.Node>,
42
+ ): node is ReadonlyTypeReferenceNode => {
43
+ if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
44
+ return false;
45
+ }
46
+
47
+ const typeName = node.getTypeName();
48
+
49
+ const typeArguments = node.getTypeArguments();
50
+
51
+ return (
52
+ typeName.isKind(tsm.SyntaxKind.Identifier) &&
53
+ typeName.getText() === 'Readonly' &&
54
+ Arr.isFixedLengthArray(1, typeArguments)
55
+ );
56
+ };
57
+
33
58
  // Helper to get a specific type node from source code
34
59
  const getTypeNodeFromSource = (
35
60
  sourceCode: string,
@@ -97,13 +122,6 @@ export type ReadonlyTupleTypeNode = tsm.TypeNode &
97
122
  getTypeNode: () => tsm.TupleTypeNode;
98
123
  }>;
99
124
 
100
- export const isReadonlyTupleTypeNode = (
101
- node: DeepReadonly<tsm.Node>,
102
- ): node is ReadonlyTupleTypeNode =>
103
- node.isKind(tsm.SyntaxKind.TypeOperator) &&
104
- node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
105
- node.getTypeNode().isKind(tsm.SyntaxKind.TupleType);
106
-
107
125
  if (import.meta.vitest !== undefined) {
108
126
  describe('isReadonlyTupleTypeNode', () => {
109
127
  test('should return true for readonly tuple type', () => {
@@ -162,24 +180,6 @@ export type ReadonlyTypeReferenceNode = tsm.TypeNode &
162
180
  getTypeArguments: () => readonly [tsm.TypeNode];
163
181
  }>;
164
182
 
165
- export const isReadonlyTypeReferenceNode = (
166
- node: DeepReadonly<tsm.Node>,
167
- ): node is ReadonlyTypeReferenceNode => {
168
- if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
169
- return false;
170
- }
171
-
172
- const typeName = node.getTypeName();
173
-
174
- const typeArguments = node.getTypeArguments();
175
-
176
- return (
177
- typeName.isKind(tsm.SyntaxKind.Identifier) &&
178
- typeName.getText() === 'Readonly' &&
179
- Arr.isFixedLengthArray(1, typeArguments)
180
- );
181
- };
182
-
183
183
  if (import.meta.vitest !== undefined) {
184
184
  describe('isReadonlyTypeReferenceNode', () => {
185
185
  test('should return true for Readonly<T>', () => {
@@ -1,21 +1,107 @@
1
- import { asUint32, range } from 'ts-data-forge';
1
+ import * as tsm from 'ts-morph';
2
2
 
3
- const parenDepthDelta = (char: string | undefined): number => {
4
- switch (char) {
5
- case '(':
6
- return 1;
7
-
8
- case ')':
9
- return -1;
3
+ /**
4
+ * Wraps a string with parentheses if not already wrapped.
5
+ * Avoids adding redundant parentheses when the expression is already fully
6
+ * wrapped with balanced parentheses.
7
+ *
8
+ * @example
9
+ *
10
+ * ```ts
11
+ * assert.strictEqual(wrapWithParentheses('A'), '(A)');
12
+ *
13
+ * // already wrapped, so not '((A))'
14
+ * assert.strictEqual(wrapWithParentheses('(A)'), '(A)');
15
+ *
16
+ * assert.strictEqual(wrapWithParentheses('A | B'), '(A | B)');
17
+ *
18
+ * assert.strictEqual(wrapWithParentheses('(A | B)'), '(A | B)');
19
+ *
20
+ * // the outer parentheses do not span everything, so a pair is added
21
+ * assert.strictEqual(wrapWithParentheses('(A) | (B)'), '((A) | (B))');
22
+ * ```
23
+ */
24
+ export const wrapWithParentheses = (nodeStr: string): string => {
25
+ const trimmed = nodeStr.trim();
10
26
 
11
- // `at` returns `undefined` past the end of the string. The loop below
12
- // never asks for such an index, but the type says it can.
13
- case undefined:
14
- default:
15
- return 0;
16
- }
27
+ return isWrappedWithParentheses(trimmed)
28
+ ? trimmed
29
+ : (`(${trimmed})` as const);
17
30
  };
18
31
 
32
+ /**
33
+ * What {@link isWrappedWithParentheses} tracks while scanning. `parenDepth` is
34
+ * the only verdict; `openBraces` tells the `}` that closes a template
35
+ * substitution from one that closes a type literal.
36
+ */
37
+ type ScanState = Readonly<{
38
+ parenDepth: number;
39
+ /**
40
+ * One character per brace still open, innermost last: `{` for a type
41
+ * literal's, `$` for a template substitution's (`${`).
42
+ */
43
+ openBraces: string;
44
+ }>;
45
+
46
+ type ScanStep = (state: ScanState, scanner: tsm.ts.Scanner) => ScanState;
47
+
48
+ const initialScanState: ScanState = {
49
+ parenDepth: 0,
50
+ openBraces: '',
51
+ } as const;
52
+
53
+ /**
54
+ * Tokens after which the scan cannot be trusted. The scanner reads without a
55
+ * parser, so it cannot tell a regular expression literal from a division: a
56
+ * quote or parenthesis inside `/"/` or `/\(/` would be read as a string or a
57
+ * parenthesis, and could balance by accident. No type contains one of these.
58
+ */
59
+ const untrustedTokens: ReadonlySet<tsm.ts.SyntaxKind> =
60
+ new Set<tsm.ts.SyntaxKind>([
61
+ tsm.ts.SyntaxKind.SlashToken,
62
+ tsm.ts.SyntaxKind.SlashEqualsToken,
63
+ tsm.ts.SyntaxKind.Unknown,
64
+ ]);
65
+
66
+ /**
67
+ * How each token that matters moves the state; every other token leaves it as
68
+ * is. A map rather than a `switch`, which would have to name every
69
+ * `SyntaxKind`.
70
+ */
71
+ const scanStep: ReadonlyMap<tsm.ts.SyntaxKind, ScanStep> = new Map<
72
+ tsm.ts.SyntaxKind,
73
+ ScanStep
74
+ >([
75
+ [
76
+ tsm.ts.SyntaxKind.OpenParenToken,
77
+ (state) => ({ ...state, parenDepth: state.parenDepth + 1 }),
78
+ ],
79
+ [
80
+ tsm.ts.SyntaxKind.CloseParenToken,
81
+ (state) => ({ ...state, parenDepth: state.parenDepth - 1 }),
82
+ ],
83
+ [
84
+ tsm.ts.SyntaxKind.OpenBraceToken,
85
+ (state) => ({ ...state, openBraces: `${state.openBraces}{` }),
86
+ ],
87
+ [
88
+ tsm.ts.SyntaxKind.TemplateHead,
89
+ (state) => ({ ...state, openBraces: `${state.openBraces}$` }),
90
+ ],
91
+ [
92
+ tsm.ts.SyntaxKind.CloseBraceToken,
93
+ (state, scanner) =>
94
+ // A `}` that closes a substitution resumes the template's text, which the
95
+ // scanner reads only when asked to re-scan; the substitution stays open
96
+ // when that text ends in another `${` (a `TemplateMiddle`).
97
+ state.openBraces.endsWith('$') &&
98
+ scanner.reScanTemplateToken(/* isTaggedTemplate */ false) !==
99
+ tsm.ts.SyntaxKind.TemplateTail
100
+ ? state
101
+ : { ...state, openBraces: state.openBraces.slice(0, -1) },
102
+ ],
103
+ ]);
104
+
19
105
  /**
20
106
  * Checks if a string is already wrapped with a single pair of balanced
21
107
  * parentheses that encompasses the entire expression.
@@ -24,10 +110,17 @@ const parenDepthDelta = (char: string | undefined): number => {
24
110
  * everything — while `'A'` is not, and neither is `'(A) | (B)'`, whose first
25
111
  * parenthesis closes before the end.
26
112
  *
113
+ * Only parenthesis tokens count: the text is read with TypeScript's scanner, so
114
+ * a parenthesis inside a string literal, a template literal's text or a comment
115
+ * (`(A | '(') | (B)`) does not. Text the scanner cannot read with certainty — a
116
+ * `/` that may start a regular expression (so `(a / b)` too), an unterminated
117
+ * string or template literal, unbalanced parentheses — is reported as not
118
+ * wrapped, so that it gets a pair.
119
+ *
27
120
  * Module-local, so `samples/` — which imports the package the way a consumer
28
121
  * does — cannot reach it, and it therefore carries no `@example`. See
29
- * {@link wrapWithParentheses}, the exported form, whose example covers the
30
- * same cases.
122
+ * {@link wrapWithParentheses}, the exported form, whose example covers the same
123
+ * cases.
31
124
  */
32
125
  const isWrappedWithParentheses = (str: string): boolean => {
33
126
  const trimmed = str.trim();
@@ -36,47 +129,40 @@ const isWrappedWithParentheses = (str: string): boolean => {
36
129
  return false;
37
130
  }
38
131
 
39
- // Check if the opening and closing parentheses are balanced
40
- // and the opening parenthesis corresponds to the closing one
41
- let mut_depth = 0;
132
+ const scanner = tsm.ts.createScanner(
133
+ tsm.ts.ScriptTarget.Latest,
134
+ /* skipTrivia */ true,
135
+ tsm.ts.LanguageVariant.Standard,
136
+ trimmed,
137
+ );
42
138
 
43
- for (const mut_i of range(0, asUint32(trimmed.length))) {
44
- mut_depth += parenDepthDelta(trimmed.at(mut_i));
139
+ let mut_state: ScanState = initialScanState;
45
140
 
46
- // If we reach depth 0 before the end, the outer parentheses don't wrap everything
47
- if (mut_depth === 0 && mut_i < trimmed.length - 1) {
141
+ for (
142
+ let mut_token = scanner.scan();
143
+ mut_token !== tsm.ts.SyntaxKind.EndOfFileToken;
144
+ mut_token = scanner.scan()
145
+ ) {
146
+ // Wrapping is the safe answer: a redundant pair costs nothing, a missing
147
+ // one changes the meaning.
148
+ if (untrustedTokens.has(mut_token)) {
48
149
  return false;
49
150
  }
50
- }
51
151
 
52
- return true;
53
- };
152
+ mut_state = scanStep.get(mut_token)?.(mut_state, scanner) ?? mut_state;
54
153
 
55
- /**
56
- * Wraps a string with parentheses if not already wrapped.
57
- * Avoids adding redundant parentheses when the expression is already
58
- * fully wrapped with balanced parentheses.
59
- *
60
- * @example
61
- *
62
- * ```ts
63
- * assert.strictEqual(wrapWithParentheses('A'), '(A)');
64
- *
65
- * // already wrapped, so not '((A))'
66
- * assert.strictEqual(wrapWithParentheses('(A)'), '(A)');
67
- *
68
- * assert.strictEqual(wrapWithParentheses('A | B'), '(A | B)');
69
- *
70
- * assert.strictEqual(wrapWithParentheses('(A | B)'), '(A | B)');
71
- *
72
- * // the outer parentheses do not span everything, so a pair is added
73
- * assert.strictEqual(wrapWithParentheses('(A) | (B)'), '((A) | (B))');
74
- * ```
75
- */
76
- export const wrapWithParentheses = (nodeStr: string): string => {
77
- const trimmed = nodeStr.trim();
154
+ // Read after the step, which may have re-scanned a template's text.
155
+ if (scanner.isUnterminated()) {
156
+ return false;
157
+ }
78
158
 
79
- return isWrappedWithParentheses(trimmed)
80
- ? trimmed
81
- : (`(${trimmed})` as const);
159
+ // If we reach depth 0 before the end, the outer parentheses don't wrap
160
+ // everything
161
+ if (mut_state.parenDepth === 0 && scanner.getTokenEnd() < trimmed.length) {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ // Unbalanced text is wrapped too.
167
+ return mut_state.parenDepth === 0;
82
168
  };
@@ -0,0 +1,115 @@
1
+ // `dedent` keeps these fixtures template literals: as plain strings, a `${` in
2
+ // them reads as a forgotten substitution (no-template-curly-in-string).
3
+ import dedent from 'dedent';
4
+ import { wrapWithParentheses } from './wrap-with-parentheses.mjs';
5
+
6
+ describe(wrapWithParentheses, () => {
7
+ test.each([
8
+ { name: 'a bare name', source: 'A', expected: '(A)' },
9
+ { name: 'a wrapped name', source: '(A)', expected: '(A)' },
10
+ { name: 'a doubly wrapped name', source: '((A))', expected: '((A))' },
11
+ { name: 'a bare union', source: 'A | B', expected: '(A | B)' },
12
+ {
13
+ name: 'outer parentheses that close before the end',
14
+ source: '(A) | (B)',
15
+ expected: '((A) | (B))',
16
+ },
17
+ {
18
+ name: 'surrounding whitespace',
19
+ source: ' (A | B)\n',
20
+ expected: '(A | B)',
21
+ },
22
+ {
23
+ name: 'an opening parenthesis inside a string literal type',
24
+ source: "(A | '(') | (B)",
25
+ expected: "((A | '(') | (B))",
26
+ },
27
+ {
28
+ name: 'a closing parenthesis inside a string literal type',
29
+ source: "(A | ')' | B)",
30
+ expected: "(A | ')' | B)",
31
+ },
32
+ {
33
+ name: 'an escaped quote before a parenthesis in a string literal type',
34
+ source: String.raw`(A | '\'(') | (B)`,
35
+ expected: String.raw`((A | '\'(') | (B))`,
36
+ },
37
+ {
38
+ name: 'a parenthesis inside a block comment',
39
+ source: '(A /* ( */) | (B)',
40
+ expected: '((A /* ( */) | (B))',
41
+ },
42
+ {
43
+ name: 'a parenthesis inside a line comment',
44
+ source: '(A // (\n) | (B)',
45
+ expected: '((A // (\n) | (B))',
46
+ },
47
+ {
48
+ name: 'a parenthesis inside a template literal type without substitutions',
49
+ source: '(`(`) | (B)',
50
+ expected: '((`(`) | (B))',
51
+ },
52
+ {
53
+ name: 'parentheses inside the text spans of a template literal type',
54
+ source: dedent`(\`(\${A}(\${B})\`) | (C)`,
55
+ expected: dedent`((\`(\${A}(\${B})\`) | (C))`,
56
+ },
57
+ {
58
+ name: 'a type literal with braces inside a template literal substitution',
59
+ source: dedent`(\`\${{ a: \`(\` }['a']}\`) | (B)`,
60
+ expected: dedent`((\`\${{ a: \`(\` }['a']}\`) | (B))`,
61
+ },
62
+ {
63
+ name: 'parentheses inside a template literal substitution still count',
64
+ source: dedent`(\`\${(A)}\`)`,
65
+ expected: dedent`(\`\${(A)}\`)`,
66
+ },
67
+ {
68
+ name: 'a quote left open by a regular expression literal',
69
+ source: "(/'/.test(a)) || (b)",
70
+ expected: "((/'/.test(a)) || (b))",
71
+ },
72
+ {
73
+ name: 'a parenthesis in a regular expression literal',
74
+ source: '(/[(]/.test(a)) || (b)',
75
+ expected: '((/[(]/.test(a)) || (b))',
76
+ },
77
+ {
78
+ name: 'quotes that close inside a later regular expression literal',
79
+ source: '(/"/.test(a)) || (/"/.test(b))',
80
+ expected: '((/"/.test(a)) || (/"/.test(b)))',
81
+ },
82
+ {
83
+ name: 'escaped parentheses in regular expression literals that balance',
84
+ source: String.raw`(/\(/.test(a)) || (/\)/.test(b))`,
85
+ expected: String.raw`((/\(/.test(a)) || (/\)/.test(b)))`,
86
+ },
87
+ {
88
+ name: 'a division inside wrapped parentheses',
89
+ source: '(a / b)',
90
+ expected: '((a / b))',
91
+ },
92
+ {
93
+ name: 'an unterminated string literal that runs to the end',
94
+ source: "(A | ') | (B)",
95
+ expected: "((A | ') | (B))",
96
+ },
97
+ {
98
+ name: 'an unterminated string literal that a line break ends',
99
+ source: "(A | '(\n)",
100
+ expected: "((A | '(\n))",
101
+ },
102
+ {
103
+ name: 'an unterminated template literal after a substitution',
104
+ source: dedent`(\`\${A}) | (B)`,
105
+ expected: dedent`((\`\${A}) | (B))`,
106
+ },
107
+ {
108
+ name: 'an unterminated block comment',
109
+ source: '(A /* ) | (B)',
110
+ expected: '((A /* ) | (B))',
111
+ },
112
+ ] as const)('$name', ({ source, expected }) => {
113
+ expect(wrapWithParentheses(source)).toBe(expected);
114
+ });
115
+ });
@@ -4,19 +4,18 @@ import type * as tsm from 'ts-morph';
4
4
  * `node.replaceWithText(newNodeText)`, sparing a one-line text the indentation
5
5
  * lookup.
6
6
  *
7
- * `replaceWithText` writes the text through a writer whose indentation it
8
- * looks up first, for the lines after the first one, and the lookup reparses
9
- * the whole file in the language service: on top of the reparse every
10
- * replacement makes anyway, once per replacement, so it is quadratic in the
11
- * size of the file. A text with no `\n` has no line after the first, the
12
- * indentation is never applied, and a plain writer from the same project
13
- * writes the same text — so the lookup is skipped. A multi-line text takes
14
- * the original path.
7
+ * `replaceWithText` writes the text through a writer whose indentation it looks
8
+ * up first, for the lines after the first one, and the lookup reparses the
9
+ * whole file in the language service: on top of the reparse every replacement
10
+ * makes anyway, once per replacement, so it is quadratic in the size of the
11
+ * file. A text with no `\n` has no line after the first, the indentation is
12
+ * never applied, and a plain writer from the same project writes the same text
13
+ * — so the lookup is skipped. A multi-line text takes the original path.
15
14
  *
16
15
  * The writer is passed as `replaceWithText`'s second argument, which its
17
- * declaration leaves out and ts-morph passes itself when it sets an
18
- * expression. Were it ever dropped, the method would look the indentation up
19
- * as before: slower, never different.
16
+ * declaration leaves out and ts-morph passes itself when it sets an expression.
17
+ * Were it ever dropped, the method would look the indentation up as before:
18
+ * slower, never different.
20
19
  */
21
20
  export const replaceNodeWithText = (
22
21
  node: tsm.Node,