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.
Files changed (47) hide show
  1. package/README.md +38 -16
  2. package/dist/functions/ast-transformers/append-as-const.mjs +11 -10
  3. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  4. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  5. package/dist/functions/ast-transformers/convert-to-readonly.mjs +16 -12
  6. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  7. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +32 -9
  8. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts.map +1 -1
  9. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +23 -0
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  11. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
  12. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +8 -2
  13. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +10 -19
  15. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/transform-source-code.mjs +101 -49
  18. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  19. package/dist/functions/functions/has-call-or-construct-signature.d.mts +18 -0
  20. package/dist/functions/functions/has-call-or-construct-signature.d.mts.map +1 -0
  21. package/dist/functions/functions/has-call-or-construct-signature.mjs +87 -0
  22. package/dist/functions/functions/has-call-or-construct-signature.mjs.map +1 -0
  23. package/dist/functions/functions/index.d.mts +1 -0
  24. package/dist/functions/functions/index.d.mts.map +1 -1
  25. package/dist/functions/functions/index.mjs +1 -0
  26. package/dist/functions/functions/index.mjs.map +1 -1
  27. package/dist/functions/functions/should-avoid-parentheses-for-readonly.d.mts.map +1 -1
  28. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs +1 -4
  29. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs.map +1 -1
  30. package/dist/functions/functions/wrap-with-parentheses.d.mts +14 -5
  31. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  32. package/dist/functions/functions/wrap-with-parentheses.mjs +25 -14
  33. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  34. package/package.json +43 -41
  35. package/src/functions/ast-transformers/append-as-const.mts +22 -20
  36. package/src/functions/ast-transformers/convert-to-readonly.mts +19 -13
  37. package/src/functions/ast-transformers/convert-to-readonly.test.mts +121 -10
  38. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +32 -9
  39. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +1 -1
  40. package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +8 -1
  41. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +13 -22
  42. package/src/functions/ast-transformers/transform-source-code.mts +158 -57
  43. package/src/functions/ast-transformers/transform-source-code.test.mts +123 -0
  44. package/src/functions/functions/has-call-or-construct-signature.mts +120 -0
  45. package/src/functions/functions/index.mts +1 -0
  46. package/src/functions/functions/should-avoid-parentheses-for-readonly.mts +1 -5
  47. 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 extractFileIgnoreTransformers = (code: string): readonly string[] => {
6
- // Try to find any of the supported file-level ignore comment prefixes
7
- const patterns = [
8
- {
9
- prefix: 'transformer-ignore',
10
- regex: /\/\*\s*transformer-ignore\s*(.*?)\s*\*\//u,
11
- },
12
- {
13
- prefix: 'ts-codemod-ignore',
14
- regex: /\/\*\s*ts-codemod-ignore\s*(.*?)\s*\*\//u,
15
- },
16
- {
17
- prefix: 'codemod-ignore',
18
- regex: /\/\*\s*codemod-ignore\s*(.*?)\s*\*\//u,
19
- },
20
- {
21
- prefix: 'transform-ignore',
22
- regex: /\/\*\s*transform-ignore\s*(.*?)\s*\*\//u,
23
- },
24
- ] as const;
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
- for (const { regex } of patterns) {
27
- const match = regex.exec(code);
32
+ return undefined;
33
+ };
28
34
 
29
- if (match !== null) {
30
- const targetTransformers = match[1]?.trim() ?? '';
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
- // Empty means ignore all transformers
33
- if (targetTransformers === '') {
34
- return [];
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
- // Parse comma-separated transformer names
38
- return targetTransformers.split(',').map((name) => name.trim());
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
- code: string,
47
- transformerName: string | undefined,
48
- ): boolean => {
49
- const ignoredTransformers = extractFileIgnoreTransformers(code);
50
-
51
- // If no file-level ignore comment found, don't skip
52
- const patterns = [
53
- /\/\*\s*transformer-ignore\s*.*?\s*\*\//u,
54
- /\/\*\s*ts-codemod-ignore\s*.*?\s*\*\//u,
55
- /\/\*\s*codemod-ignore\s*.*?\s*\*\//u,
56
- /\/\*\s*transform-ignore\s*.*?\s*\*\//u,
57
- ] as const;
58
-
59
- const hasFileIgnoreComment = patterns.some((regex) => regex.test(code));
60
-
61
- if (!hasFileIgnoreComment && Arr.isEmpty(ignoredTransformers)) {
62
- return false;
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
- // Empty array means ignore all transformers (file-level ignore without specific transformers)
66
- if (Arr.isEmpty(ignoredTransformers)) {
67
- return true;
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
- // If transformer name is not specified, don't skip
71
- if (transformerName === undefined) {
72
- return false;
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
- // Check if the transformer is in the ignore list
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(code, transformerName)) {
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
+ }
@@ -1,3 +1,4 @@
1
+ export * from './has-call-or-construct-signature.mjs';
1
2
  export * from './has-disable-next-line-comment.mjs';
2
3
  export * from './is-as-const-node.mjs';
3
4
  export * from './is-atomic-type-node.mjs';
@@ -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.isKind(tsm.SyntaxKind.TypePredicate);
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
- * wrapWithParentheses('A') // '(A)'
59
- * wrapWithParentheses('(A)') // '(A)' (not '((A))')
60
- * wrapWithParentheses('A | B') // '(A | B)'
61
- * wrapWithParentheses('(A | B)') // '(A | B)' (not '((A | B))')
62
- * wrapWithParentheses('(A) | (B)') // '((A) | (B))' (needs outer parens)
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
- if (isWrappedWithParentheses(trimmed)) {
68
- return trimmed;
69
- }
70
-
71
- return `(${trimmed})` as const;
79
+ return isWrappedWithParentheses(trimmed)
80
+ ? trimmed
81
+ : (`(${trimmed})` as const);
72
82
  };