ts-codemod-lib 4.0.0 → 4.1.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/README.md +2 -1
- package/dist/functions/ast-transformers/append-as-const.d.mts +9 -10
- package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
- package/dist/functions/ast-transformers/append-as-const.mjs +123 -114
- package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.d.mts +28 -7
- package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.mjs +452 -253
- package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
- package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
- package/dist/functions/ast-transformers/transform-source-code.mjs +69 -69
- package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
- package/dist/functions/constants/mutable-prefixes.d.mts +8 -8
- package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
- package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
- package/dist/functions/functions/is-readonly-node.d.mts +2 -2
- package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
- package/dist/functions/functions/is-readonly-node.mjs +13 -13
- package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
- package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
- package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
- package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
- package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
- package/dist/functions/utils/replace-node-with-text.mjs +0 -1
- package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
- package/package.json +9 -9
- package/src/functions/ast-transformers/append-as-const.mts +242 -231
- package/src/functions/ast-transformers/append-as-const.test.mts +39 -6
- package/src/functions/ast-transformers/convert-to-readonly-record-import.test.mts +458 -0
- package/src/functions/ast-transformers/convert-to-readonly.mts +1003 -694
- package/src/functions/ast-transformers/convert-to-readonly.test.mts +80 -33
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
- package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
- package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
- package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
- package/src/functions/ast-transformers/transform-source-code.mts +110 -110
- package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
- package/src/functions/constants/mutable-prefixes.mts +8 -8
- package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
- package/src/functions/functions/is-readonly-node.mts +25 -25
- package/src/functions/functions/wrap-with-parentheses.mts +138 -52
- package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
- package/src/functions/utils/replace-node-with-text.mts +10 -11
|
@@ -84,9 +84,9 @@ export const hasDisableNextLineComment = (
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// If we found *any* comment on the preceding line, but it wasn't
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// If we found *any* comment on the preceding line, but it wasn't the
|
|
88
|
+
// correct ignore comment, then the node is not ignored by a comment on
|
|
89
|
+
// the *immediately* preceding line. Stop checking further back.
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -99,38 +99,6 @@ export const hasDisableNextLineComment = (
|
|
|
99
99
|
return false;
|
|
100
100
|
};
|
|
101
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
102
|
const newlinePositionsOf = (text: string): readonly number[] => {
|
|
135
103
|
const mut_positions: number[] = [];
|
|
136
104
|
|
|
@@ -164,3 +132,35 @@ const countLessThan = (sorted: readonly number[], value: number): number => {
|
|
|
164
132
|
|
|
165
133
|
return mut_low;
|
|
166
134
|
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The 1-based line number of a position in `sourceFile`, counted the way
|
|
138
|
+
* ts-morph's `getStartLineNumber` and `getLineAndColumnAtPos` count it: one
|
|
139
|
+
* plus the number of `\n` before the position (a lone `\r` starts no line,
|
|
140
|
+
* unlike in the compiler's own line map).
|
|
141
|
+
*
|
|
142
|
+
* ts-morph scans the text from its start on every call, which made this
|
|
143
|
+
* check, asked of every node a transformer visits, quadratic in the size of the
|
|
144
|
+
* file. The newline positions are collected once per compiler source file
|
|
145
|
+
* instead. ts-morph replaces that object whenever the file is edited, so an
|
|
146
|
+
* entry never outlives the text it was collected from.
|
|
147
|
+
*/
|
|
148
|
+
const lineNumberLookup = (
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
150
|
+
sourceFile: tsm.ts.SourceFile,
|
|
151
|
+
): ((pos: number) => number) => {
|
|
152
|
+
const cached = newlinePositionsCache.get(sourceFile);
|
|
153
|
+
|
|
154
|
+
const newlinePositions = cached ?? newlinePositionsOf(sourceFile.text);
|
|
155
|
+
|
|
156
|
+
if (cached === undefined) {
|
|
157
|
+
newlinePositionsCache.set(sourceFile, newlinePositions);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (pos) => countLessThan(newlinePositions, pos) + 1;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const newlinePositionsCache = new WeakMap<
|
|
164
|
+
tsm.ts.SourceFile,
|
|
165
|
+
readonly number[]
|
|
166
|
+
>();
|
|
@@ -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
|
|
1
|
+
import * as tsm from 'ts-morph';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
mut_depth += parenDepthDelta(trimmed.at(mut_i));
|
|
139
|
+
let mut_state: ScanState = initialScanState;
|
|
45
140
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
};
|
|
152
|
+
mut_state = scanStep.get(mut_token)?.(mut_state, scanner) ?? mut_state;
|
|
54
153
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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,
|