ts-codemod-lib 3.4.4 → 3.5.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 (49) hide show
  1. package/README.md +26 -7
  2. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  3. package/dist/functions/ast-transformers/append-as-const.mjs +6 -3
  4. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  5. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts.map +1 -1
  6. package/dist/functions/ast-transformers/convert-interface-to-type.mjs +2 -1
  7. package/dist/functions/ast-transformers/convert-interface-to-type.mjs.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +10 -0
  9. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  10. package/dist/functions/ast-transformers/convert-to-readonly.mjs +120 -16
  11. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  12. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts +7 -1
  13. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
  14. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +25 -2
  15. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs +3 -2
  18. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs.map +1 -1
  19. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts.map +1 -1
  20. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +6 -5
  21. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  22. package/dist/functions/functions/has-disable-next-line-comment.d.mts.map +1 -1
  23. package/dist/functions/functions/has-disable-next-line-comment.mjs +56 -4
  24. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  25. package/dist/functions/utils/index.d.mts +1 -0
  26. package/dist/functions/utils/index.d.mts.map +1 -1
  27. package/dist/functions/utils/index.mjs +1 -0
  28. package/dist/functions/utils/index.mjs.map +1 -1
  29. package/dist/functions/utils/replace-node-with-text.d.mts +21 -0
  30. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -0
  31. package/dist/functions/utils/replace-node-with-text.mjs +22 -0
  32. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -0
  33. package/dist/functions/utils/replace-with-debug.d.mts.map +1 -1
  34. package/dist/functions/utils/replace-with-debug.mjs +2 -1
  35. package/dist/functions/utils/replace-with-debug.mjs.map +1 -1
  36. package/package.json +25 -25
  37. package/src/functions/ast-transformers/append-as-const.mts +9 -3
  38. package/src/functions/ast-transformers/convert-interface-to-type.mts +2 -1
  39. package/src/functions/ast-transformers/convert-to-readonly.mts +187 -22
  40. package/src/functions/ast-transformers/convert-to-readonly.test.mts +430 -32
  41. package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +36 -5
  42. package/src/functions/ast-transformers/replace-any-with-unknown.mts +3 -2
  43. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +7 -5
  44. package/src/functions/functions/has-disable-next-line-comment.mts +79 -6
  45. package/src/functions/functions/has-disable-next-line-comment.test.mts +96 -0
  46. package/src/functions/utils/index.mts +1 -0
  47. package/src/functions/utils/replace-node-with-text.mts +37 -0
  48. package/src/functions/utils/replace-node-with-text.test.mts +89 -0
  49. package/src/functions/utils/replace-with-debug.mts +2 -1
@@ -1,3 +1,4 @@
1
+ import { Arr } from 'ts-data-forge';
1
2
  import * as tsm from 'ts-morph';
2
3
  import { IGNORE_LINE_COMMENT_PREFIXES } from '../constants/index.mjs';
3
4
 
@@ -19,16 +20,22 @@ export const hasDisableNextLineComment = (
19
20
  node: tsm.Node,
20
21
  transformerName?: string,
21
22
  ): boolean => {
22
- const nodeStartLine = node.getStartLineNumber();
23
+ // Most nodes have no leading comment, and then nothing below can match.
24
+ // Asking first spares them the line numbers, the costly part.
25
+ const leadingCommentRanges = node.getLeadingCommentRanges();
23
26
 
24
- // Cannot be ignored if it's on the first line
25
- if (nodeStartLine <= 1) {
27
+ if (Arr.isEmpty(leadingCommentRanges)) {
26
28
  return false;
27
29
  }
28
30
 
29
- const sourceFile = node.getSourceFile(); // Get the SourceFile
31
+ const lineNumberAt = lineNumberLookup(node.getSourceFile().compilerNode);
30
32
 
31
- const leadingCommentRanges = node.getLeadingCommentRanges();
33
+ const nodeStartLine = lineNumberAt(node.getStart());
34
+
35
+ // Cannot be ignored if it's on the first line
36
+ if (nodeStartLine <= 1) {
37
+ return false;
38
+ }
32
39
 
33
40
  // Iterate backwards through comments as the closest one is most relevant
34
41
  for (let mut_i = leadingCommentRanges.length - 1; mut_i >= 0; mut_i--) {
@@ -38,7 +45,7 @@ export const hasDisableNextLineComment = (
38
45
  // Get the end position and convert it to line number
39
46
  const commentEndPos = commentRange.getEnd();
40
47
 
41
- const commentEndLine = sourceFile.getLineAndColumnAtPos(commentEndPos).line;
48
+ const commentEndLine = lineNumberAt(commentEndPos);
42
49
 
43
50
  // Check if the comment is on the immediately preceding line
44
51
  if (nodeStartLine === commentEndLine + 1) {
@@ -91,3 +98,69 @@ export const hasDisableNextLineComment = (
91
98
 
92
99
  return false;
93
100
  };
101
+
102
+ /**
103
+ * The 1-based line number of a position in `sourceFile`, counted the way
104
+ * ts-morph's `getStartLineNumber` and `getLineAndColumnAtPos` count it: one
105
+ * plus the number of `\n` before the position (a lone `\r` starts no line,
106
+ * unlike in the compiler's own line map).
107
+ *
108
+ * ts-morph scans the text from its start on every call, which made this
109
+ * check, asked of every node a transformer visits, quadratic in the size of
110
+ * the file. The newline positions are collected once per compiler source file
111
+ * instead. ts-morph replaces that object whenever the file is edited, so an
112
+ * entry never outlives the text it was collected from.
113
+ */
114
+ const lineNumberLookup = (
115
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
116
+ sourceFile: tsm.ts.SourceFile,
117
+ ): ((pos: number) => number) => {
118
+ const cached = newlinePositionsCache.get(sourceFile);
119
+
120
+ const newlinePositions = cached ?? newlinePositionsOf(sourceFile.text);
121
+
122
+ if (cached === undefined) {
123
+ newlinePositionsCache.set(sourceFile, newlinePositions);
124
+ }
125
+
126
+ return (pos) => countLessThan(newlinePositions, pos) + 1;
127
+ };
128
+
129
+ const newlinePositionsCache = new WeakMap<
130
+ tsm.ts.SourceFile,
131
+ readonly number[]
132
+ >();
133
+
134
+ const newlinePositionsOf = (text: string): readonly number[] => {
135
+ const mut_positions: number[] = [];
136
+
137
+ for (
138
+ let mut_index = text.indexOf('\n');
139
+ mut_index !== -1;
140
+ mut_index = text.indexOf('\n', mut_index + 1)
141
+ ) {
142
+ mut_positions.push(mut_index);
143
+ }
144
+
145
+ return mut_positions;
146
+ };
147
+
148
+ /** The number of elements of the ascending `sorted` that are below `value`. */
149
+ const countLessThan = (sorted: readonly number[], value: number): number => {
150
+ let mut_low = 0;
151
+
152
+ let mut_high = sorted.length;
153
+
154
+ while (mut_low < mut_high) {
155
+ const mid = Math.floor((mut_low + mut_high) / 2);
156
+
157
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
158
+ if (sorted[mid]! < value) {
159
+ mut_low = mid + 1;
160
+ } else {
161
+ mut_high = mid;
162
+ }
163
+ }
164
+
165
+ return mut_low;
166
+ };
@@ -0,0 +1,96 @@
1
+ import * as tsm from 'ts-morph';
2
+ import { hasDisableNextLineComment } from './has-disable-next-line-comment.mjs';
3
+
4
+ /** The first type alias in `source`, the node every case asks about. */
5
+ const typeAliasOf = (source: string): tsm.TypeAliasDeclaration => {
6
+ const project = new tsm.Project({ useInMemoryFileSystem: true });
7
+
8
+ return project
9
+ .createSourceFile('source.ts', source)
10
+ .getFirstDescendantByKindOrThrow(tsm.SyntaxKind.TypeAliasDeclaration);
11
+ };
12
+
13
+ describe(hasDisableNextLineComment, () => {
14
+ test.each([
15
+ {
16
+ name: 'a comment on the line above (LF)',
17
+ source: '// transformer-ignore-next-line\ntype A = number[];\n',
18
+ expected: true,
19
+ },
20
+ {
21
+ name: 'a comment on the line above (CRLF)',
22
+ source: '// transformer-ignore-next-line\r\ntype A = number[];\r\n',
23
+ expected: true,
24
+ },
25
+ {
26
+ name: 'a comment two lines above',
27
+ source: '// transformer-ignore-next-line\n\ntype A = number[];\n',
28
+ expected: false,
29
+ },
30
+ {
31
+ name: 'a comment two lines above (CRLF)',
32
+ source: '// transformer-ignore-next-line\r\n\r\ntype A = number[];\r\n',
33
+ expected: false,
34
+ },
35
+ {
36
+ name: 'a later comment on the line above shadows an earlier ignore comment',
37
+ source:
38
+ '// transformer-ignore-next-line\n// something else\ntype A = number[];\n',
39
+ expected: false,
40
+ },
41
+ {
42
+ name: 'the node on the first line',
43
+ source: 'type A = number[];\n',
44
+ expected: false,
45
+ },
46
+ {
47
+ name: 'the node on the first line after a block comment',
48
+ source: '/* transformer-ignore-next-line */ type A = number[];\n',
49
+ expected: false,
50
+ },
51
+ {
52
+ name: 'a lone CR, which does not count as a line break',
53
+ source: '// transformer-ignore-next-line\rtype A = number[];\n',
54
+ expected: false,
55
+ },
56
+ {
57
+ name: 'a lone CR before the comment, deep in the file',
58
+ source:
59
+ 'const x = 1;\r\nconst y = 2;\r// transformer-ignore-next-line\ntype A = number[];\n',
60
+ expected: true,
61
+ },
62
+ {
63
+ name: 'a node far below many lines',
64
+ source: `${'const x = 1;\n'.repeat(500)}// transformer-ignore-next-line\ntype A = number[];\n`,
65
+ expected: true,
66
+ },
67
+ ])('$name', ({ source, expected }) => {
68
+ expect(hasDisableNextLineComment(typeAliasOf(source))).toBe(expected);
69
+ });
70
+
71
+ test('matches the transformer name list', () => {
72
+ const node = typeAliasOf(
73
+ '// transformer-ignore-next-line append-as-const, convert-to-readonly\ntype A = number[];\n',
74
+ );
75
+
76
+ assert.isTrue(hasDisableNextLineComment(node, 'convert-to-readonly'));
77
+
78
+ assert.isFalse(hasDisableNextLineComment(node, 'replace-any-with-unknown'));
79
+ });
80
+
81
+ test('reads the current text after the file has been edited', () => {
82
+ const node = typeAliasOf('type A = number[];\n');
83
+
84
+ assert.isFalse(hasDisableNextLineComment(node));
85
+
86
+ const sourceFile = node.getSourceFile();
87
+
88
+ sourceFile.insertText(0, '// transformer-ignore-next-line\n');
89
+
90
+ const edited = sourceFile.getFirstDescendantByKindOrThrow(
91
+ tsm.SyntaxKind.TypeAliasDeclaration,
92
+ );
93
+
94
+ assert.isTrue(hasDisableNextLineComment(edited));
95
+ });
96
+ });
@@ -1 +1,2 @@
1
+ export * from './replace-node-with-text.mjs';
1
2
  export * from './replace-with-debug.mjs';
@@ -0,0 +1,37 @@
1
+ import type * as tsm from 'ts-morph';
2
+
3
+ /**
4
+ * `node.replaceWithText(newNodeText)`, sparing a one-line text the indentation
5
+ * lookup.
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.
15
+ *
16
+ * 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.
20
+ */
21
+ export const replaceNodeWithText = (
22
+ node: tsm.Node,
23
+ newNodeText: string,
24
+ ): tsm.Node =>
25
+ newNodeText.includes('\n')
26
+ ? node.replaceWithText(newNodeText)
27
+ : (node.replaceWithText as ReplaceWithTextWithWriter).call(
28
+ node,
29
+ newNodeText,
30
+ node.getProject().createWriter(),
31
+ );
32
+
33
+ type ReplaceWithTextWithWriter = (
34
+ this: tsm.Node,
35
+ textOrWriterFunction: string,
36
+ writer: tsm.CodeBlockWriter,
37
+ ) => tsm.Node;
@@ -0,0 +1,89 @@
1
+ import dedent from 'dedent';
2
+ import * as tsm from 'ts-morph';
3
+ import { replaceNodeWithText } from './replace-node-with-text.mjs';
4
+
5
+ /**
6
+ * Replaces the first type reference named `target` in `source`, once with
7
+ * `replaceNodeWithText` and once with ts-morph's own `replaceWithText`.
8
+ */
9
+ const replaceBothWays = (
10
+ source: string,
11
+ target: string,
12
+ newNodeText: string,
13
+ ): Readonly<{ actual: string; expected: string; replacedKind: string }> => {
14
+ const replace = (
15
+ fn: (nodeToReplace: tsm.Node, text: string) => tsm.Node,
16
+ ): Readonly<{ text: string; replacedKind: string }> => {
17
+ const project = new tsm.Project({ useInMemoryFileSystem: true });
18
+
19
+ const sourceFile = project.createSourceFile('source.ts', source);
20
+
21
+ const node = sourceFile
22
+ .getDescendantsOfKind(tsm.SyntaxKind.TypeReference)
23
+ .find((n) => n.getText() === target);
24
+
25
+ assert.isDefined(node);
26
+
27
+ const replaced = fn(node, newNodeText);
28
+
29
+ return {
30
+ text: sourceFile.getFullText(),
31
+ replacedKind: replaced.getKindName(),
32
+ };
33
+ };
34
+
35
+ const actual = replace(replaceNodeWithText);
36
+
37
+ const expected = replace((node, text) => node.replaceWithText(text));
38
+
39
+ assert.strictEqual(actual.replacedKind, expected.replacedKind);
40
+
41
+ return {
42
+ actual: actual.text,
43
+ expected: expected.text,
44
+ replacedKind: actual.replacedKind,
45
+ };
46
+ };
47
+
48
+ const indentedSource = dedent`
49
+ namespace A {
50
+ namespace B {
51
+ export type C = {
52
+ readonly d: Foo;
53
+ };
54
+ }
55
+ }
56
+ `;
57
+
58
+ describe(replaceNodeWithText, () => {
59
+ test.each([
60
+ { name: 'a one-line text', newNodeText: 'readonly number[]' },
61
+ { name: 'an empty text', newNodeText: '' },
62
+ { name: 'a text with a lone CR', newNodeText: 'Bar\r' },
63
+ { name: 'a text with a trailing space', newNodeText: 'Bar ' },
64
+ { name: 'a text with a string literal', newNodeText: '\'a\' | "b"' },
65
+ { name: 'a text with a line comment', newNodeText: 'Bar /* c */' },
66
+ { name: 'a multi-line text', newNodeText: '{\n a: number;\n}' },
67
+ { name: 'a CRLF text', newNodeText: '{\r\n a: number;\r\n}' },
68
+ ])('writes the text replaceWithText writes: $name', ({ newNodeText }) => {
69
+ const { actual, expected } = replaceBothWays(
70
+ indentedSource,
71
+ 'Foo',
72
+ newNodeText,
73
+ );
74
+
75
+ expect(actual).toBe(expected);
76
+ });
77
+
78
+ test('returns the node that took the place of the replaced one', () => {
79
+ const { actual, replacedKind } = replaceBothWays(
80
+ indentedSource,
81
+ 'Foo',
82
+ 'Readonly<Bar>',
83
+ );
84
+
85
+ expect(replacedKind).toBe('TypeReference');
86
+
87
+ expect(actual).toContain('readonly d: Readonly<Bar>;');
88
+ });
89
+ });
@@ -1,4 +1,5 @@
1
1
  import type * as tsm from 'ts-morph';
2
+ import { replaceNodeWithText } from './replace-node-with-text.mjs';
2
3
 
3
4
  export const replaceNodeWithDebugPrint = (
4
5
  node: tsm.Node,
@@ -6,5 +7,5 @@ export const replaceNodeWithDebugPrint = (
6
7
  ): void => {
7
8
  console.debug(`${node.getText()} -> ${newNodeText}`);
8
9
 
9
- node.replaceWithText(newNodeText);
10
+ replaceNodeWithText(node, newNodeText);
10
11
  };