ts-codemod-lib 3.4.3 → 3.5.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.
- package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
- package/dist/functions/ast-transformers/append-as-const.mjs +38 -6
- 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.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.mjs +2 -2
- package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +4 -0
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.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/functions/wrap-with-parentheses.d.mts +5 -5
- 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 +15 -15
- package/src/functions/ast-transformers/append-as-const.mts +57 -6
- package/src/functions/ast-transformers/append-as-const.test.mts +119 -0
- package/src/functions/ast-transformers/convert-interface-to-type.mts +2 -1
- package/src/functions/ast-transformers/convert-to-readonly.mts +5 -2
- package/src/functions/ast-transformers/convert-to-readonly.test.mts +9 -20
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +5 -1
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +36 -0
- 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/functions/wrap-with-parentheses.mts +5 -5
- 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
|
@@ -56,7 +56,7 @@ const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
|
|
|
56
56
|
* enableNoUncheckedIndexedAccessTransformer(),
|
|
57
57
|
* ]);
|
|
58
58
|
*
|
|
59
|
-
* assert.
|
|
59
|
+
* assert.strictEqual(
|
|
60
60
|
* after.trim(),
|
|
61
61
|
* dedent`
|
|
62
62
|
* declare const xs: readonly number[];
|
|
@@ -169,6 +169,10 @@ const collectAssertionPositions = (
|
|
|
169
169
|
noUncheckedIndexedAccess: true,
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// The type asked is the access's own, so an `as`, `satisfies` or `!` on
|
|
173
|
+
// its object is already in it. That is the type the option's error is
|
|
174
|
+
// reported against, and the `!` appended has no run-time effect for the
|
|
175
|
+
// unwrapped object to contradict.
|
|
172
176
|
const widened = candidates.filter(
|
|
173
177
|
(node) =>
|
|
174
178
|
typeIncludesUndefined(node.getType()) &&
|
|
@@ -481,6 +481,42 @@ describe(enableNoUncheckedIndexedAccessTransformer, () => {
|
|
|
481
481
|
])('$name', testFn);
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
+
describe('objects judged by the type the access reads, through type wrappers', () => {
|
|
485
|
+
test.each([
|
|
486
|
+
{
|
|
487
|
+
name: 'as to an element type without undefined needs the assertion',
|
|
488
|
+
source: 'console.log((sparse as readonly number[])[0].toFixed());',
|
|
489
|
+
expected: 'console.log((sparse as readonly number[])[0]!.toFixed());',
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: 'as to an element type with undefined needs none',
|
|
493
|
+
source:
|
|
494
|
+
'console.log((xs as readonly (number | undefined)[])[0]?.toFixed());',
|
|
495
|
+
expected:
|
|
496
|
+
'console.log((xs as readonly (number | undefined)[])[0]?.toFixed());',
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'angle-bracket assertion to an element type without undefined',
|
|
500
|
+
source: 'console.log((<readonly number[]>sparse)[0].toFixed());',
|
|
501
|
+
expected: 'console.log((<readonly number[]>sparse)[0]!.toFixed());',
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'satisfies keeps the type of the object it wraps',
|
|
505
|
+
source:
|
|
506
|
+
'console.log((xs satisfies readonly (number | undefined)[])[0].toFixed());',
|
|
507
|
+
expected:
|
|
508
|
+
'console.log((xs satisfies readonly (number | undefined)[])[0]!.toFixed());',
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: 'non-null assertion on the object',
|
|
512
|
+
source:
|
|
513
|
+
'declare const maybe: readonly number[] | undefined; console.log(maybe![0].toFixed());',
|
|
514
|
+
expected:
|
|
515
|
+
'declare const maybe: readonly number[] | undefined; console.log(maybe![0]!.toFixed());',
|
|
516
|
+
},
|
|
517
|
+
])('$name', testFn);
|
|
518
|
+
});
|
|
519
|
+
|
|
484
520
|
describe('applyLevel: "avoidWhereUndefinedIsAllowed"', () => {
|
|
485
521
|
test.each([
|
|
486
522
|
{
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isSpreadNamedTupleMemberNode,
|
|
5
5
|
isSpreadParameterNode,
|
|
6
6
|
} from '../functions/index.mjs';
|
|
7
|
+
import { replaceNodeWithText } from '../utils/index.mjs';
|
|
7
8
|
import type { TsMorphTransformer } from './types.mjs';
|
|
8
9
|
|
|
9
10
|
const TRANSFORMER_NAME = 'replace-any-with-unknown';
|
|
@@ -37,12 +38,12 @@ const transformNode = (node: tsm.Node): void => {
|
|
|
37
38
|
// `[name: E0, ...args: any)]` -> `[name: E0, ...args: unknown[]]`
|
|
38
39
|
isSpreadNamedTupleMemberNode(parent))
|
|
39
40
|
) {
|
|
40
|
-
anyKeywordNode
|
|
41
|
+
replaceNodeWithText(anyKeywordNode, 'readonly unknown[]');
|
|
41
42
|
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
anyKeywordNode
|
|
46
|
+
replaceNodeWithText(anyKeywordNode, 'unknown');
|
|
46
47
|
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Arr } from 'ts-data-forge';
|
|
3
3
|
import * as tsm from 'ts-morph';
|
|
4
4
|
import { hasDisableNextLineComment } from '../functions/index.mjs';
|
|
5
|
+
import { replaceNodeWithText } from '../utils/index.mjs';
|
|
5
6
|
import type { TsMorphTransformer } from './types.mjs';
|
|
6
7
|
|
|
7
8
|
const TRANSFORMER_NAME = 'replace-record-with-unknown-record';
|
|
@@ -88,7 +89,8 @@ const processInterfaceDeclaration = (
|
|
|
88
89
|
|
|
89
90
|
const interfaceName = interfaceDecl.getName();
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
replaceNodeWithText(
|
|
93
|
+
interfaceDecl,
|
|
92
94
|
`${exportPrefix}type ${interfaceName} = UnknownRecord;`,
|
|
93
95
|
);
|
|
94
96
|
|
|
@@ -128,7 +130,7 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
|
|
|
128
130
|
Arr.isFixedLengthArray(1, indexSigs) &&
|
|
129
131
|
isStringUnknownIndexSignature(indexSigs[0])
|
|
130
132
|
) {
|
|
131
|
-
node
|
|
133
|
+
replaceNodeWithText(node, 'UnknownRecord');
|
|
132
134
|
|
|
133
135
|
return true;
|
|
134
136
|
}
|
|
@@ -156,7 +158,7 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
|
|
|
156
158
|
Arr.isFixedLengthArray(1, indexSigs) &&
|
|
157
159
|
isStringUnknownIndexSignature(indexSigs[0])
|
|
158
160
|
) {
|
|
159
|
-
node
|
|
161
|
+
replaceNodeWithText(node, 'UnknownRecord');
|
|
160
162
|
|
|
161
163
|
return true;
|
|
162
164
|
}
|
|
@@ -229,7 +231,7 @@ const replaceIfRecordUnknown = (node: tsm.TypeReferenceNode): boolean => {
|
|
|
229
231
|
typeArgs[0].getText() === 'string' &&
|
|
230
232
|
typeArgs[1].getText() === 'unknown'
|
|
231
233
|
) {
|
|
232
|
-
node
|
|
234
|
+
replaceNodeWithText(node, 'UnknownRecord');
|
|
233
235
|
|
|
234
236
|
return true;
|
|
235
237
|
}
|
|
@@ -255,7 +257,7 @@ const replaceIfRecordUnknown = (node: tsm.TypeReferenceNode): boolean => {
|
|
|
255
257
|
innerTypeArgs[0].getText() === 'string' &&
|
|
256
258
|
innerTypeArgs[1].getText() === 'unknown'
|
|
257
259
|
) {
|
|
258
|
-
node
|
|
260
|
+
replaceNodeWithText(node, 'UnknownRecord');
|
|
259
261
|
|
|
260
262
|
return true;
|
|
261
263
|
}
|
|
@@ -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
|
+
});
|
|
@@ -60,17 +60,17 @@ const isWrappedWithParentheses = (str: string): boolean => {
|
|
|
60
60
|
* @example
|
|
61
61
|
*
|
|
62
62
|
* ```ts
|
|
63
|
-
* assert.
|
|
63
|
+
* assert.strictEqual(wrapWithParentheses('A'), '(A)');
|
|
64
64
|
*
|
|
65
65
|
* // already wrapped, so not '((A))'
|
|
66
|
-
* assert.
|
|
66
|
+
* assert.strictEqual(wrapWithParentheses('(A)'), '(A)');
|
|
67
67
|
*
|
|
68
|
-
* assert.
|
|
68
|
+
* assert.strictEqual(wrapWithParentheses('A | B'), '(A | B)');
|
|
69
69
|
*
|
|
70
|
-
* assert.
|
|
70
|
+
* assert.strictEqual(wrapWithParentheses('(A | B)'), '(A | B)');
|
|
71
71
|
*
|
|
72
72
|
* // the outer parentheses do not span everything, so a pair is added
|
|
73
|
-
* assert.
|
|
73
|
+
* assert.strictEqual(wrapWithParentheses('(A) | (B)'), '((A) | (B))');
|
|
74
74
|
* ```
|
|
75
75
|
*/
|
|
76
76
|
export const wrapWithParentheses = (nodeStr: string): string => {
|
|
@@ -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
|
};
|