ts-codemod-lib 3.3.1 → 3.3.3
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 +38 -16
- package/dist/functions/ast-transformers/append-as-const.mjs +11 -10
- package/dist/functions/ast-transformers/append-as-const.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 +16 -12
- package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +32 -9
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts.map +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +23 -0
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -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 +8 -2
- package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +10 -19
- 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 +101 -49
- package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
- package/dist/functions/functions/has-call-or-construct-signature.d.mts +18 -0
- package/dist/functions/functions/has-call-or-construct-signature.d.mts.map +1 -0
- package/dist/functions/functions/has-call-or-construct-signature.mjs +87 -0
- package/dist/functions/functions/has-call-or-construct-signature.mjs.map +1 -0
- package/dist/functions/functions/index.d.mts +1 -0
- package/dist/functions/functions/index.d.mts.map +1 -1
- package/dist/functions/functions/index.mjs +1 -0
- package/dist/functions/functions/index.mjs.map +1 -1
- package/dist/functions/functions/should-avoid-parentheses-for-readonly.d.mts.map +1 -1
- package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs +1 -4
- package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.d.mts +14 -5
- package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.mjs +25 -14
- package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
- package/package.json +43 -41
- package/src/functions/ast-transformers/append-as-const.mts +22 -20
- package/src/functions/ast-transformers/convert-to-readonly.mts +19 -13
- package/src/functions/ast-transformers/convert-to-readonly.test.mts +121 -10
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +32 -9
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +1 -1
- package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +8 -1
- package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +13 -22
- package/src/functions/ast-transformers/transform-source-code.mts +158 -57
- package/src/functions/ast-transformers/transform-source-code.test.mts +123 -0
- package/src/functions/functions/has-call-or-construct-signature.mts +120 -0
- package/src/functions/functions/index.mts +1 -0
- package/src/functions/functions/should-avoid-parentheses-for-readonly.mts +1 -5
- package/src/functions/functions/wrap-with-parentheses.mts +30 -20
|
@@ -2,80 +2,179 @@ import { Arr } from 'ts-data-forge';
|
|
|
2
2
|
import * as tsm from 'ts-morph';
|
|
3
3
|
import { type TsMorphTransformer } from './types.mjs';
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
5
|
+
const fileIgnorePrefixes = [
|
|
6
|
+
'transformer-ignore',
|
|
7
|
+
'ts-codemod-ignore',
|
|
8
|
+
'codemod-ignore',
|
|
9
|
+
'transform-ignore',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Finds the file-level ignore comment `/* <prefix> name, name *\/` and returns
|
|
14
|
+
* the transformer names it lists (empty for all transformers), or `undefined`
|
|
15
|
+
* when the file has none. Whitespace, line breaks included, may surround the
|
|
16
|
+
* prefix and the names, but the names themselves must stay on one line. The
|
|
17
|
+
* prefixes are tried in order, each against the whole file.
|
|
18
|
+
*
|
|
19
|
+
* The scan runs in time linear in the length of `code`, whatever it contains.
|
|
20
|
+
*/
|
|
21
|
+
const findFileIgnoreTransformers = (
|
|
22
|
+
code: string,
|
|
23
|
+
): readonly string[] | undefined => {
|
|
24
|
+
for (const prefix of fileIgnorePrefixes) {
|
|
25
|
+
const names = findFileIgnoreComment(code, prefix);
|
|
26
|
+
|
|
27
|
+
if (names !== undefined) {
|
|
28
|
+
return names;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
return undefined;
|
|
33
|
+
};
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const findFileIgnoreComment = (
|
|
36
|
+
code: string,
|
|
37
|
+
prefix: string,
|
|
38
|
+
): readonly string[] | undefined => {
|
|
39
|
+
// Queries into these searches only move forward, so each part of `code` is
|
|
40
|
+
// scanned a bounded number of times.
|
|
41
|
+
const findCommentEnd = createForwardSearch((from) =>
|
|
42
|
+
code.indexOf('*/', from),
|
|
43
|
+
);
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
const findLineTerminator = createForwardSearch((from) =>
|
|
46
|
+
indexOfLineTerminator(code, from),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const trimEndBefore = createLastValueCache((commentEnd: number) =>
|
|
50
|
+
skipWhitespaceBackward(code, commentEnd),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
for (
|
|
54
|
+
let mut_open = code.indexOf('/*');
|
|
55
|
+
mut_open !== -1;
|
|
56
|
+
mut_open = code.indexOf('/*', mut_open + 2)
|
|
57
|
+
) {
|
|
58
|
+
const prefixStart = skipWhitespace(code, mut_open + 2);
|
|
59
|
+
|
|
60
|
+
if (!code.startsWith(prefix, prefixStart)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const namesStart = skipWhitespace(code, prefixStart + prefix.length);
|
|
65
|
+
|
|
66
|
+
const commentEnd = findCommentEnd(namesStart);
|
|
67
|
+
|
|
68
|
+
// Nothing after this point is closed, so no later comment can match.
|
|
69
|
+
if (commentEnd === -1) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const namesEnd = Math.max(namesStart, trimEndBefore(commentEnd));
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
75
|
+
const lineTerminator = findLineTerminator(namesStart);
|
|
76
|
+
|
|
77
|
+
if (lineTerminator === -1 || lineTerminator >= namesEnd) {
|
|
78
|
+
const names = code.slice(namesStart, namesEnd);
|
|
79
|
+
|
|
80
|
+
// Empty means ignore all transformers; otherwise parse comma-separated
|
|
81
|
+
// transformer names
|
|
82
|
+
return names === '' ? [] : names.split(',').map((name) => name.trim());
|
|
39
83
|
}
|
|
40
84
|
}
|
|
41
85
|
|
|
42
|
-
return
|
|
86
|
+
return undefined;
|
|
43
87
|
};
|
|
44
88
|
|
|
45
89
|
const shouldSkipFile = (
|
|
46
|
-
|
|
47
|
-
transformerName: string
|
|
48
|
-
): boolean =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
ignoredTransformers: readonly string[] | undefined,
|
|
91
|
+
transformerName: string,
|
|
92
|
+
): boolean =>
|
|
93
|
+
ignoredTransformers !== undefined &&
|
|
94
|
+
// Empty array means ignore all transformers (file-level ignore without
|
|
95
|
+
// specific transformers); otherwise check if the transformer is in the
|
|
96
|
+
// ignore list
|
|
97
|
+
(Arr.isEmpty(ignoredTransformers) ||
|
|
98
|
+
ignoredTransformers.includes(transformerName));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wraps a search returning the first index at or after `from` (or -1), reusing
|
|
102
|
+
* the previous answer while it still holds for a later `from`.
|
|
103
|
+
*/
|
|
104
|
+
const createForwardSearch = (
|
|
105
|
+
search: (from: number) => number,
|
|
106
|
+
): ((from: number) => number) => {
|
|
107
|
+
let mut_from = Number.POSITIVE_INFINITY;
|
|
108
|
+
|
|
109
|
+
let mut_found = -1;
|
|
110
|
+
|
|
111
|
+
return (from) => {
|
|
112
|
+
if (from < mut_from || (mut_found !== -1 && from > mut_found)) {
|
|
113
|
+
mut_from = from;
|
|
114
|
+
|
|
115
|
+
mut_found = search(from);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return mut_found;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const createLastValueCache = (
|
|
123
|
+
fn: (arg: number) => number,
|
|
124
|
+
): ((arg: number) => number) => {
|
|
125
|
+
let mut_arg: number | undefined = undefined;
|
|
126
|
+
|
|
127
|
+
let mut_value = 0;
|
|
128
|
+
|
|
129
|
+
return (arg) => {
|
|
130
|
+
if (arg !== mut_arg) {
|
|
131
|
+
mut_arg = arg;
|
|
132
|
+
|
|
133
|
+
mut_value = fn(arg);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return mut_value;
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const skipWhitespace = (code: string, from: number): number => {
|
|
141
|
+
let mut_index = from;
|
|
142
|
+
|
|
143
|
+
while (mut_index < code.length && isWhitespace(code[mut_index])) {
|
|
144
|
+
mut_index += 1;
|
|
63
145
|
}
|
|
64
146
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
147
|
+
return mut_index;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const skipWhitespaceBackward = (code: string, end: number): number => {
|
|
151
|
+
let mut_index = end;
|
|
152
|
+
|
|
153
|
+
while (mut_index > 0 && isWhitespace(code[mut_index - 1])) {
|
|
154
|
+
mut_index -= 1;
|
|
68
155
|
}
|
|
69
156
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
157
|
+
return mut_index;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const indexOfLineTerminator = (code: string, from: number): number => {
|
|
161
|
+
let mut_index = from;
|
|
162
|
+
|
|
163
|
+
while (mut_index < code.length && !isLineTerminator(code[mut_index])) {
|
|
164
|
+
mut_index += 1;
|
|
73
165
|
}
|
|
74
166
|
|
|
75
|
-
|
|
76
|
-
return ignoredTransformers.includes(transformerName);
|
|
167
|
+
return mut_index < code.length ? mut_index : -1;
|
|
77
168
|
};
|
|
78
169
|
|
|
170
|
+
// Same set as `\s` and `String.prototype.trim`
|
|
171
|
+
const isWhitespace = (char: string | undefined): boolean =>
|
|
172
|
+
char !== undefined && /^\s$/u.test(char);
|
|
173
|
+
|
|
174
|
+
// The characters `.` does not match
|
|
175
|
+
const isLineTerminator = (char: string | undefined): boolean =>
|
|
176
|
+
char !== undefined && /^[\n\r\u{2028}\u{2029}]$/u.test(char);
|
|
177
|
+
|
|
79
178
|
export const transformSourceCode = (
|
|
80
179
|
code: string,
|
|
81
180
|
isTsx: boolean,
|
|
@@ -96,10 +195,12 @@ export const transformSourceCode = (
|
|
|
96
195
|
code,
|
|
97
196
|
);
|
|
98
197
|
|
|
198
|
+
const ignoredTransformers = findFileIgnoreTransformers(code);
|
|
199
|
+
|
|
99
200
|
for (const transformer of transformers) {
|
|
100
201
|
const transformerName = transformer.name;
|
|
101
202
|
|
|
102
|
-
if (shouldSkipFile(
|
|
203
|
+
if (shouldSkipFile(ignoredTransformers, transformerName)) {
|
|
103
204
|
if (debug) {
|
|
104
205
|
console.debug(
|
|
105
206
|
`skipped by ignore-file comment for transformer: ${transformerName}`,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { replaceAnyWithUnknownTransformer } from './replace-any-with-unknown.mjs';
|
|
2
|
+
import { transformSourceCode } from './transform-source-code.mjs';
|
|
3
|
+
|
|
4
|
+
const body = 'export const x: any = 1;\n';
|
|
5
|
+
|
|
6
|
+
const transformedBody = 'export const x: unknown = 1;\n';
|
|
7
|
+
|
|
8
|
+
const transform = (code: string): string =>
|
|
9
|
+
transformSourceCode(code, false, [replaceAnyWithUnknownTransformer()]);
|
|
10
|
+
|
|
11
|
+
describe('transformSourceCode file-level ignore comments', () => {
|
|
12
|
+
test.each([
|
|
13
|
+
{
|
|
14
|
+
name: 'no ignore comment',
|
|
15
|
+
header: '',
|
|
16
|
+
skipped: false,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'ignore all transformers',
|
|
20
|
+
header: '/* transformer-ignore */\n',
|
|
21
|
+
skipped: true,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'ignore all transformers without spaces',
|
|
25
|
+
header: '/*transformer-ignore*/\n',
|
|
26
|
+
skipped: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'ignore the running transformer',
|
|
30
|
+
header: '/* transformer-ignore replace-any-with-unknown */\n',
|
|
31
|
+
skipped: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'ignore the running transformer among others',
|
|
35
|
+
header:
|
|
36
|
+
'/* transformer-ignore append-as-const, replace-any-with-unknown */\n',
|
|
37
|
+
skipped: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'ignore another transformer only',
|
|
41
|
+
header: '/* transformer-ignore append-as-const */\n',
|
|
42
|
+
skipped: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'ts-codemod-ignore prefix',
|
|
46
|
+
header: '/* ts-codemod-ignore */\n',
|
|
47
|
+
skipped: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'codemod-ignore prefix',
|
|
51
|
+
header: '/* codemod-ignore replace-any-with-unknown */\n',
|
|
52
|
+
skipped: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'transform-ignore prefix',
|
|
56
|
+
header: '/* transform-ignore */\n',
|
|
57
|
+
skipped: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'names on the first line and the terminator on the next',
|
|
61
|
+
header: '/* transformer-ignore replace-any-with-unknown\n */\n',
|
|
62
|
+
skipped: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'prefix on the first line and names on the next',
|
|
66
|
+
header: '/*\n transformer-ignore\n replace-any-with-unknown */\n',
|
|
67
|
+
skipped: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'names spanning several lines are not an ignore comment',
|
|
71
|
+
header: '/* transformer-ignore append-as-const\n note */\n',
|
|
72
|
+
skipped: false,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'an unterminated comment is not an ignore comment',
|
|
76
|
+
header: '// /* transformer-ignore\n',
|
|
77
|
+
skipped: false,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'a later well-formed comment is found after a malformed one',
|
|
81
|
+
header:
|
|
82
|
+
'/* transformer-ignore a\n b */\n/* transformer-ignore replace-any-with-unknown */\n',
|
|
83
|
+
skipped: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'prefixes are tried in order, not by position',
|
|
87
|
+
header:
|
|
88
|
+
'/* codemod-ignore */\n/* transformer-ignore append-as-const */\n',
|
|
89
|
+
skipped: false,
|
|
90
|
+
},
|
|
91
|
+
] as const)('$name', ({ header, skipped }) => {
|
|
92
|
+
assert.strictEqual(
|
|
93
|
+
transform(`${header}${body}`),
|
|
94
|
+
`${header}${skipped ? body : transformedBody}`,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test(
|
|
99
|
+
'finishes in linear time on long whitespace runs in unterminated comments',
|
|
100
|
+
{ timeout: 2000 },
|
|
101
|
+
() => {
|
|
102
|
+
const header = `/* transformer-ignore${' '.repeat(2000)}x\nnote */\n`;
|
|
103
|
+
|
|
104
|
+
assert.strictEqual(
|
|
105
|
+
transform(`${header}${body}`),
|
|
106
|
+
`${header}${transformedBody}`,
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
test(
|
|
112
|
+
'finishes in linear time on many unterminated comments in one line',
|
|
113
|
+
{ timeout: 2000 },
|
|
114
|
+
() => {
|
|
115
|
+
const header = `export const s = \`${'/* transformer-ignore x '.repeat(10_000)}\`;\n`;
|
|
116
|
+
|
|
117
|
+
assert.strictEqual(
|
|
118
|
+
transform(`${header}${body}`),
|
|
119
|
+
`${header}${transformedBody}`,
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as tsm from 'ts-morph';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Determines whether a node is a type literal or an interface that declares a
|
|
5
|
+
* call or construct signature.
|
|
6
|
+
*
|
|
7
|
+
* `Readonly<T>` is a mapped type over `keyof T`, and neither signature kind is a
|
|
8
|
+
* property, so neither survives the mapping: wrapping `{ (x: string): void }`
|
|
9
|
+
* with `Readonly` produces a type that is no longer callable. A type mixing
|
|
10
|
+
* properties with a call signature is worse still — the properties come through
|
|
11
|
+
* and only the signature disappears. So a type literal carrying one must not be
|
|
12
|
+
* wrapped; its members are marked `readonly` in place instead.
|
|
13
|
+
*
|
|
14
|
+
* @param node - The node to check
|
|
15
|
+
* @returns true if the node declares at least one call or construct signature,
|
|
16
|
+
* false for any other node kind
|
|
17
|
+
*/
|
|
18
|
+
export const hasCallOrConstructSignature = (node: tsm.Node): boolean =>
|
|
19
|
+
(node.isKind(tsm.SyntaxKind.TypeLiteral) ||
|
|
20
|
+
node.isKind(tsm.SyntaxKind.InterfaceDeclaration)) &&
|
|
21
|
+
node
|
|
22
|
+
.getMembers()
|
|
23
|
+
.some(
|
|
24
|
+
(member) =>
|
|
25
|
+
member.isKind(tsm.SyntaxKind.CallSignature) ||
|
|
26
|
+
member.isKind(tsm.SyntaxKind.ConstructSignature),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (import.meta.vitest !== undefined) {
|
|
30
|
+
const getFirstNodeOfKind = (
|
|
31
|
+
sourceCode: string,
|
|
32
|
+
kind: tsm.SyntaxKind,
|
|
33
|
+
): tsm.Node => {
|
|
34
|
+
const project = new tsm.Project({ useInMemoryFileSystem: true });
|
|
35
|
+
|
|
36
|
+
const sourceFile = project.createSourceFile('test.ts', sourceCode);
|
|
37
|
+
|
|
38
|
+
return sourceFile.getFirstDescendantByKindOrThrow(kind);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getTypeLiteral = (sourceCode: string): tsm.Node =>
|
|
42
|
+
getFirstNodeOfKind(sourceCode, tsm.SyntaxKind.TypeLiteral);
|
|
43
|
+
|
|
44
|
+
describe('hasCallOrConstructSignature', () => {
|
|
45
|
+
test.each([
|
|
46
|
+
{ name: 'call signature', code: 'type T = { (a: string): void };' },
|
|
47
|
+
{ name: 'construct signature', code: 'type T = { new (a: string): X };' },
|
|
48
|
+
{
|
|
49
|
+
name: 'call signature mixed with a property',
|
|
50
|
+
code: 'type T = { (a: string): void; b: number };',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'overloaded call signatures',
|
|
54
|
+
code: 'type T = { (a: string): void; (a: number): void };',
|
|
55
|
+
},
|
|
56
|
+
])('is true for a type literal with a $name', ({ code }) => {
|
|
57
|
+
assert.isTrue(hasCallOrConstructSignature(getTypeLiteral(code)));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test.each([
|
|
61
|
+
{ name: 'properties only', code: 'type T = { a: number; b: string };' },
|
|
62
|
+
{ name: 'an index signature', code: 'type T = { [k: string]: number };' },
|
|
63
|
+
{
|
|
64
|
+
name: 'a method signature',
|
|
65
|
+
code: 'type T = { a(b: string): void };',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'a function-typed property',
|
|
69
|
+
code: 'type T = { a: (b: string) => void };',
|
|
70
|
+
},
|
|
71
|
+
{ name: 'no members', code: 'type T = {};' },
|
|
72
|
+
])('is false for a type literal with $name', ({ code }) => {
|
|
73
|
+
assert.isFalse(hasCallOrConstructSignature(getTypeLiteral(code)));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('is true for an interface with a call signature', () => {
|
|
77
|
+
assert.isTrue(
|
|
78
|
+
hasCallOrConstructSignature(
|
|
79
|
+
getFirstNodeOfKind(
|
|
80
|
+
'interface I { (a: string): void }',
|
|
81
|
+
tsm.SyntaxKind.InterfaceDeclaration,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('is false for an interface without one', () => {
|
|
88
|
+
assert.isFalse(
|
|
89
|
+
hasCallOrConstructSignature(
|
|
90
|
+
getFirstNodeOfKind(
|
|
91
|
+
'interface I { a: string }',
|
|
92
|
+
tsm.SyntaxKind.InterfaceDeclaration,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test.each([
|
|
99
|
+
{
|
|
100
|
+
name: 'a function type',
|
|
101
|
+
code: 'type T = (a: string) => void;',
|
|
102
|
+
kind: tsm.SyntaxKind.FunctionType,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'a constructor type',
|
|
106
|
+
code: 'type T = new (a: string) => X;',
|
|
107
|
+
kind: tsm.SyntaxKind.ConstructorType,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'a class declaration',
|
|
111
|
+
code: 'class C { constructor(a: string) {} }',
|
|
112
|
+
kind: tsm.SyntaxKind.ClassDeclaration,
|
|
113
|
+
},
|
|
114
|
+
])('is false for $name', ({ code, kind }) => {
|
|
115
|
+
assert.isFalse(
|
|
116
|
+
hasCallOrConstructSignature(getFirstNodeOfKind(code, kind)),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -16,13 +16,9 @@ import * as tsm from 'ts-morph';
|
|
|
16
16
|
export const shouldAvoidParenthesesForReadonly = (node: tsm.Node): boolean => {
|
|
17
17
|
const parent = node.getParent();
|
|
18
18
|
|
|
19
|
-
if (parent === undefined) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
// Check if parent is TypePredicate: x is T
|
|
24
20
|
// In this case, we MUST avoid parentheses: x is readonly T (not x is (readonly T))
|
|
25
21
|
// because `x is (readonly T)` is a syntax error
|
|
26
22
|
// For all other cases, keep parentheses for safety
|
|
27
|
-
return parent
|
|
23
|
+
return parent?.isKind(tsm.SyntaxKind.TypePredicate) ?? false;
|
|
28
24
|
};
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Checks if a string is already wrapped with a single pair of balanced parentheses
|
|
3
|
-
* that encompasses the entire expression.
|
|
4
|
-
*
|
|
5
|
-
* @example
|
|
6
|
-
* isWrappedWithParentheses('(A)') // true
|
|
7
|
-
* isWrappedWithParentheses('((A))') // true (outer pair wraps everything)
|
|
8
|
-
* isWrappedWithParentheses('(A) | (B)') // false (outer parens don't wrap everything)
|
|
9
|
-
* isWrappedWithParentheses('A') // false
|
|
10
|
-
*/
|
|
11
1
|
import { asUint32, range } from 'ts-data-forge';
|
|
12
2
|
|
|
13
3
|
const parenDepthDelta = (char: string | undefined): number => {
|
|
@@ -26,6 +16,19 @@ const parenDepthDelta = (char: string | undefined): number => {
|
|
|
26
16
|
}
|
|
27
17
|
};
|
|
28
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Checks if a string is already wrapped with a single pair of balanced
|
|
21
|
+
* parentheses that encompasses the entire expression.
|
|
22
|
+
*
|
|
23
|
+
* `'(A)'` and `'((A))'` are wrapped — in the second the outer pair still spans
|
|
24
|
+
* everything — while `'A'` is not, and neither is `'(A) | (B)'`, whose first
|
|
25
|
+
* parenthesis closes before the end.
|
|
26
|
+
*
|
|
27
|
+
* Module-local, so `samples/` — which imports the package the way a consumer
|
|
28
|
+
* does — cannot reach it, and it therefore carries no `@example`. See
|
|
29
|
+
* {@link wrapWithParentheses}, the exported form, whose example covers the
|
|
30
|
+
* same cases.
|
|
31
|
+
*/
|
|
29
32
|
const isWrappedWithParentheses = (str: string): boolean => {
|
|
30
33
|
const trimmed = str.trim();
|
|
31
34
|
|
|
@@ -55,18 +58,25 @@ const isWrappedWithParentheses = (str: string): boolean => {
|
|
|
55
58
|
* fully wrapped with balanced parentheses.
|
|
56
59
|
*
|
|
57
60
|
* @example
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* wrapWithParentheses('A
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* assert.deepStrictEqual(wrapWithParentheses('A'), '(A)');
|
|
64
|
+
*
|
|
65
|
+
* // already wrapped, so not '((A))'
|
|
66
|
+
* assert.deepStrictEqual(wrapWithParentheses('(A)'), '(A)');
|
|
67
|
+
*
|
|
68
|
+
* assert.deepStrictEqual(wrapWithParentheses('A | B'), '(A | B)');
|
|
69
|
+
*
|
|
70
|
+
* assert.deepStrictEqual(wrapWithParentheses('(A | B)'), '(A | B)');
|
|
71
|
+
*
|
|
72
|
+
* // the outer parentheses do not span everything, so a pair is added
|
|
73
|
+
* assert.deepStrictEqual(wrapWithParentheses('(A) | (B)'), '((A) | (B))');
|
|
74
|
+
* ```
|
|
63
75
|
*/
|
|
64
76
|
export const wrapWithParentheses = (nodeStr: string): string => {
|
|
65
77
|
const trimmed = nodeStr.trim();
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return `(${trimmed})` as const;
|
|
79
|
+
return isWrappedWithParentheses(trimmed)
|
|
80
|
+
? trimmed
|
|
81
|
+
: (`(${trimmed})` as const);
|
|
72
82
|
};
|