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.
- package/README.md +26 -7
- package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
- package/dist/functions/ast-transformers/append-as-const.mjs +6 -3
- package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
- package/dist/functions/ast-transformers/convert-interface-to-type.d.mts.map +1 -1
- package/dist/functions/ast-transformers/convert-interface-to-type.mjs +2 -1
- package/dist/functions/ast-transformers/convert-interface-to-type.mjs.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.d.mts +10 -0
- package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.mjs +120 -16
- package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
- package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts +7 -1
- package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
- package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +25 -2
- package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
- package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts.map +1 -1
- package/dist/functions/ast-transformers/replace-any-with-unknown.mjs +3 -2
- package/dist/functions/ast-transformers/replace-any-with-unknown.mjs.map +1 -1
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts.map +1 -1
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +6 -5
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
- package/dist/functions/functions/has-disable-next-line-comment.d.mts.map +1 -1
- package/dist/functions/functions/has-disable-next-line-comment.mjs +56 -4
- package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
- package/dist/functions/utils/index.d.mts +1 -0
- package/dist/functions/utils/index.d.mts.map +1 -1
- package/dist/functions/utils/index.mjs +1 -0
- package/dist/functions/utils/index.mjs.map +1 -1
- package/dist/functions/utils/replace-node-with-text.d.mts +21 -0
- package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -0
- package/dist/functions/utils/replace-node-with-text.mjs +22 -0
- package/dist/functions/utils/replace-node-with-text.mjs.map +1 -0
- package/dist/functions/utils/replace-with-debug.d.mts.map +1 -1
- package/dist/functions/utils/replace-with-debug.mjs +2 -1
- package/dist/functions/utils/replace-with-debug.mjs.map +1 -1
- package/package.json +25 -25
- package/src/functions/ast-transformers/append-as-const.mts +9 -3
- package/src/functions/ast-transformers/convert-interface-to-type.mts +2 -1
- package/src/functions/ast-transformers/convert-to-readonly.mts +187 -22
- package/src/functions/ast-transformers/convert-to-readonly.test.mts +430 -32
- package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +36 -5
- package/src/functions/ast-transformers/replace-any-with-unknown.mts +3 -2
- package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +7 -5
- package/src/functions/functions/has-disable-next-line-comment.mts +79 -6
- package/src/functions/functions/has-disable-next-line-comment.test.mts +96 -0
- package/src/functions/utils/index.mts +1 -0
- package/src/functions/utils/replace-node-with-text.mts +37 -0
- package/src/functions/utils/replace-node-with-text.test.mts +89 -0
- 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
|
-
|
|
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
|
-
|
|
25
|
-
if (nodeStartLine <= 1) {
|
|
27
|
+
if (Arr.isEmpty(leadingCommentRanges)) {
|
|
26
28
|
return false;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
const
|
|
31
|
+
const lineNumberAt = lineNumberLookup(node.getSourceFile().compilerNode);
|
|
30
32
|
|
|
31
|
-
const
|
|
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 =
|
|
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
|
+
});
|
|
@@ -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
|
|
10
|
+
replaceNodeWithText(node, newNodeText);
|
|
10
11
|
};
|