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.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/dist/functions/ast-transformers/append-as-const.d.mts +9 -10
  3. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  4. package/dist/functions/ast-transformers/append-as-const.mjs +123 -114
  5. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  6. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +28 -7
  7. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.mjs +452 -253
  9. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
  11. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
  12. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  13. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
  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 +69 -69
  18. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  19. package/dist/functions/constants/mutable-prefixes.d.mts +8 -8
  20. package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
  21. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  22. package/dist/functions/functions/is-readonly-node.d.mts +2 -2
  23. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  24. package/dist/functions/functions/is-readonly-node.mjs +13 -13
  25. package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
  26. package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
  27. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  28. package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
  29. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  30. package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
  31. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
  32. package/dist/functions/utils/replace-node-with-text.mjs +0 -1
  33. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
  34. package/package.json +9 -9
  35. package/src/functions/ast-transformers/append-as-const.mts +242 -231
  36. package/src/functions/ast-transformers/append-as-const.test.mts +39 -6
  37. package/src/functions/ast-transformers/convert-to-readonly-record-import.test.mts +458 -0
  38. package/src/functions/ast-transformers/convert-to-readonly.mts +1003 -694
  39. package/src/functions/ast-transformers/convert-to-readonly.test.mts +80 -33
  40. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
  41. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
  42. package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
  43. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
  44. package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
  45. package/src/functions/ast-transformers/transform-source-code.mts +110 -110
  46. package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
  47. package/src/functions/constants/mutable-prefixes.mts +8 -8
  48. package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
  49. package/src/functions/functions/is-readonly-node.mts +25 -25
  50. package/src/functions/functions/wrap-with-parentheses.mts +138 -52
  51. package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
  52. package/src/functions/utils/replace-node-with-text.mts +10 -11
@@ -49,6 +49,10 @@ import type { TsMorphTransformer } from './types.mjs';
49
49
 
50
50
  const TRANSFORMER_NAME = 'convert-to-readonly';
51
51
 
52
+ const READONLY_RECORD_NAME = 'ReadonlyRecord';
53
+
54
+ const READONLY_RECORD_MODULE = 'ts-type-forge';
55
+
52
56
  export const convertToReadonlyTransformer = (
53
57
  options?: ReadonlyTransformerOptions,
54
58
  ): TsMorphTransformer => {
@@ -92,6 +96,7 @@ export const convertToReadonlyTransformer = (
92
96
  options?.mutablePropertyPrefixes ?? DEFAULT_MUTABLE_PREFIXES,
93
97
  ),
94
98
  recordStyle: options?.recordStyle ?? 'ReadonlyRecord',
99
+ importStyle: options?.importStyle ?? 'named',
95
100
  debugPrint: options?.debug === true ? console.debug : () => {},
96
101
  replaceNode:
97
102
  options?.debug === true
@@ -102,8 +107,30 @@ export const convertToReadonlyTransformer = (
102
107
  return {
103
108
  name: TRANSFORMER_NAME,
104
109
  transform: (sourceAst) => {
110
+ const scope =
111
+ optionsInternal.recordStyle === 'ReadonlyRecord' &&
112
+ optionsInternal.importStyle === 'named'
113
+ ? readonlyRecordScope(sourceAst.compilerNode)
114
+ : 'in-scope';
115
+
116
+ // An import would turn a script into a module, and its declarations would
117
+ // stop being global (a `global.d.ts`); in a file whose top level declares
118
+ // the name as a value or a namespace, it could clash. Either gets the
119
+ // built-in style instead.
120
+ const mayImport =
121
+ scope === 'free' && tsm.ts.isExternalModule(sourceAst.compilerNode);
122
+
123
+ const fileOptions: ReadonlyTransformerOptionsInternal =
124
+ scope !== 'in-scope' && !mayImport
125
+ ? ({ ...optionsInternal, recordStyle: 'Readonly<Record>' } as const)
126
+ : optionsInternal;
127
+
105
128
  for (const node of sourceAst.getChildren()) {
106
- transformNode(node, initialReadonlyContext, optionsInternal);
129
+ transformNode(node, initialReadonlyContext, fileOptions);
130
+ }
131
+
132
+ if (mayImport && referencesReadonlyRecord(sourceAst.compilerNode)) {
133
+ addReadonlyRecordImport(sourceAst);
107
134
  }
108
135
  },
109
136
  };
@@ -121,7 +148,7 @@ export type ReadonlyTransformerOptions = DeepReadonly<{
121
148
  */
122
149
  typeName?: string;
123
150
 
124
- /**
151
+ /*
125
152
  * Whether to apply `DeepReadonly` aggressively or remove `DeepReadonly` as
126
153
  * possible.
127
154
  *
@@ -157,8 +184,8 @@ export type ReadonlyTransformerOptions = DeepReadonly<{
157
184
 
158
185
  /**
159
186
  * Prefixes of the names of variables, parameters (but for a constructor
160
- * parameter property, which `mutablePropertyPrefixes` decides) and
161
- * functions that are left mutable: their types are not made readonly.
187
+ * parameter property, which `mutablePropertyPrefixes` decides) and functions
188
+ * that are left mutable: their types are not made readonly.
162
189
  *
163
190
  * @default ['mut_']
164
191
  */
@@ -181,8 +208,8 @@ export type ReadonlyTransformerOptions = DeepReadonly<{
181
208
  * both `mut_` and `#mut_` cover `#mut_x`. Of a named tuple member with such a prefix, only the type
182
209
  * is kept; the tuple is still made `readonly`.
183
210
  *
184
- * A type literal with a property, a method or an accessor of such a prefix
185
- * is not wrapped with `Readonly<...>`, which would make that member readonly
211
+ * A type literal with a property, a method or an accessor of such a prefix is
212
+ * not wrapped with `Readonly<...>`, which would make that member readonly
186
213
  * too; its other members are marked `readonly` one by one instead, and a
187
214
  * `Readonly<...>` written around it is taken off. It is wrapped as before
188
215
  * when it also has a member that cannot be marked `readonly` by itself (a
@@ -201,9 +228,8 @@ export type ReadonlyTransformerOptions = DeepReadonly<{
201
228
  * - `"ReadonlyRecord"`: Uses the `ReadonlyRecord` type utility provided by
202
229
  * `ts-type-forge` (as the `ts-type-forge/prefer-readonly-or-mutable-record`
203
230
  * ESLint rule suggests). Occurrences of `Readonly<Record<K, V>>` are also
204
- * unified to `ReadonlyRecord<K, V>`. Note that no import statement is
205
- * added, so `ReadonlyRecord` has to be available in the transformed code
206
- * (e.g. globally via `ts-type-forge`'s `global.d.mts`).
231
+ * unified to `ReadonlyRecord<K, V>`. How the name is brought into scope
232
+ * is set by `importStyle`.
207
233
  * - `"Readonly<Record>"`: Uses only built-in utility types
208
234
  * (`Readonly<Record<K, V>>`). Occurrences of `ReadonlyRecord<K, V>` are
209
235
  * also unified to `Readonly<Record<K, V>>`.
@@ -215,6 +241,29 @@ export type ReadonlyTransformerOptions = DeepReadonly<{
215
241
  */
216
242
  recordStyle?: 'Readonly<Record>' | 'ReadonlyRecord';
217
243
 
244
+ /**
245
+ * How `ReadonlyRecord` is brought into scope with `recordStyle:
246
+ * "ReadonlyRecord"` (the same choice as the `importStyle` option of
247
+ * `eslint-plugin-ts-type-forge`).
248
+ *
249
+ * - `"named"`: When the transformer writes `ReadonlyRecord` into a file that
250
+ * does not mention the name yet, it also imports it — `import type {
251
+ * ReadonlyRecord } from 'ts-type-forge';`, merged into an existing named
252
+ * import of `ts-type-forge` where there is one. A file that already
253
+ * imports, declares or uses the name (as a global from
254
+ * `ts-type-forge/global`) is left to resolve it as before. A script (a
255
+ * file with no import or export), which an import would turn into a
256
+ * module, and a file whose top level declares the name as a value only,
257
+ * which an import could clash with, get `Readonly<Record<K, V>>` instead.
258
+ * - `"global"`: Imports are never touched, so `ReadonlyRecord` has to be in
259
+ * scope already: globally via `ts-type-forge/global`, or by an import the
260
+ * caller writes itself (as when transforming a file one statement at a
261
+ * time, where each statement would otherwise get an import of its own).
262
+ *
263
+ * @default 'named'
264
+ */
265
+ importStyle?: 'global' | 'named';
266
+
218
267
  debug?: boolean;
219
268
  }>;
220
269
 
@@ -236,6 +285,8 @@ type ReadonlyTransformerOptionsInternal = Readonly<{
236
285
 
237
286
  recordStyle: 'Readonly<Record>' | 'ReadonlyRecord';
238
287
 
288
+ importStyle: 'global' | 'named';
289
+
239
290
  debugPrint: (...args: readonly unknown[]) => void;
240
291
  replaceNode: (node: tsm.Node, newNodeText: string) => void;
241
292
  }>;
@@ -245,6 +296,27 @@ const initialReadonlyContext: ReadonlyContext = {
245
296
  indexedAccessDepth: 0,
246
297
  } as const;
247
298
 
299
+ /** Whether a type alias, interface or class name starts with a `mutableTypePrefixes` entry. */
300
+ const hasMutableTypeName = (
301
+ name: string | undefined,
302
+ options: ReadonlyTransformerOptionsInternal,
303
+ ): boolean =>
304
+ name !== undefined &&
305
+ options.mutableTypePrefixes.some((p) => name.startsWith(p));
306
+
307
+ /**
308
+ * A parameter property is a property of the class, as a `PropertyDeclaration`
309
+ * is; any other parameter is a variable.
310
+ */
311
+ const mutablePrefixesOfParameter = (
312
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
313
+ param: tsm.ParameterDeclaration,
314
+ options: ReadonlyTransformerOptionsInternal,
315
+ ): ISet<string> =>
316
+ param.isParameterProperty()
317
+ ? options.mutablePropertyPrefixes
318
+ : options.mutableVariablePrefixes;
319
+
248
320
  const transformNode = (
249
321
  node: tsm.Node,
250
322
  readonlyContext: ReadonlyContext,
@@ -480,229 +552,705 @@ const transformNode = (
480
552
  }
481
553
  };
482
554
 
483
- //
484
- // Transformer implementation for each node type
485
- //
486
-
487
555
  /**
488
- * - `tr(ReadonlyArray<E>) |-> readonly tr(E)[]`
489
- * - `tr(Readonly<Readonly<E>>) |-> Readonly<tr(E)>`
490
- * - `tr(DeepReadonly<Readonly<E>>) |-> DeepReadonly<tr(E)>`
491
- * - `tr(Readonly<number>) |-> number`
492
- * - `tr(Readonly<{ mut_x: X; y: Y }>) |-> { mut_x: X; readonly y: tr(Y) }` (see
493
- * `isLeftUnwrapped`)
494
- * - `tr(Readonly<E[]>) |-> readonly tr(E)[]`
495
- * - `tr(Readonly<[E1, E2, E3]>) |-> readonly [tr(E1), tr(E2), tr(E3)]`
496
- * - `tr(Readonly<readonly E[]>) |-> readonly tr(E)[]`
497
- * - `tr(Readonly<readonly [E1, E2, E3]>) |-> readonly [tr(E1), tr(E2), tr(E3)]`
498
- * - `tr(Readonly<A | readonly E[]>) |-> Readonly<tr(A)> | readonly tr(E)[]>`
499
- * - `tr(Record<K, V>) |-> Readonly<Record<tr(K), tr(V)>>` (or
500
- * `ReadonlyRecord<tr(K), tr(V)>`, depending on the `recordStyle` option;
501
- * `Readonly<Record<K, V>>`, `ReadonlyRecord<K, V>` and
502
- * `Readonly<ReadonlyRecord<K, V>>` are unified to the same style)
556
+ * The names a top-level statement binds, and whether each can name a type (an
557
+ * import can).
503
558
  */
504
- const transformTypeReferenceNode = (
559
+ const topLevelBindings = (
505
560
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
506
- node: tsm.TypeReferenceNode,
507
- readonlyContext: StrictExclude<
508
- ReadonlyContext,
509
- Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
510
- >,
511
- options: ReadonlyTransformerOptionsInternal,
512
- ): void => {
513
- const typeNameStr = node.getTypeName().getText();
514
-
515
- const typeArguments = node.getTypeArguments();
561
+ statement: tsm.ts.Statement,
562
+ ): readonly Readonly<{ name: string; isType: boolean }>[] => {
563
+ if (tsm.ts.isImportDeclaration(statement)) {
564
+ const clause = statement.importClause;
516
565
 
517
- // Array<T> / ReadonlyArray<T> to readonly T[]
518
- if (typeNameStr === 'Array' || typeNameStr === 'ReadonlyArray') {
519
- if (!Arr.isFixedLengthArray(1, typeArguments)) {
520
- throw new Error(
521
- `Unexpected number of type arguments "${typeArguments.length}" for ${typeNameStr}.`,
522
- );
523
- }
566
+ const bindings = clause?.namedBindings;
524
567
 
525
- // Recursive processing
526
- transformNode(
527
- typeArguments[0],
528
- {
529
- type: 'none',
530
- indexedAccessDepth: readonlyContext.indexedAccessDepth,
531
- },
532
- options,
533
- );
568
+ // A namespace import cannot name a type.
569
+ const namespaceImport =
570
+ bindings !== undefined && tsm.ts.isNamespaceImport(bindings)
571
+ ? ([{ name: bindings.name.text, isType: false }] as const)
572
+ : ([] as const);
534
573
 
535
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
536
- const T = node.getTypeArguments()[0]!;
574
+ const named =
575
+ bindings !== undefined && tsm.ts.isNamedImports(bindings)
576
+ ? bindings.elements.map((element) => ({
577
+ name: element.name.text,
578
+ isType: true,
579
+ }))
580
+ : ([] as const);
537
581
 
538
- options.replaceNode(
539
- node,
540
- readonlyContext.type === 'DeepReadonly'
541
- ? wrapWithParentheses(`${wrapWithParentheses(T.getFullText())}[]`)
542
- : wrapWithParentheses(
543
- `readonly ${wrapWithParentheses(T.getFullText())}[]`,
544
- ),
545
- );
582
+ const defaultImport =
583
+ clause?.name === undefined
584
+ ? ([] as const)
585
+ : ([{ name: clause.name.text, isType: true }] as const);
546
586
 
547
- return;
587
+ return [...defaultImport, ...namespaceImport, ...named];
548
588
  }
549
589
 
550
- // Set<T> to ReadonlySet<T>
551
- if (typeNameStr === 'Set') {
552
- if (!Arr.isFixedLengthArray(1, typeArguments)) {
553
- throw new Error(
554
- `Unexpected number of type arguments "${typeArguments.length}" for Set.`,
555
- );
556
- }
557
-
558
- // Recursive processing
559
- transformNode(
560
- typeArguments[0],
561
- {
562
- type: 'none',
563
- indexedAccessDepth: readonlyContext.indexedAccessDepth,
564
- },
565
- options,
566
- );
590
+ if (tsm.ts.isImportEqualsDeclaration(statement)) {
591
+ return [{ name: statement.name.text, isType: true }];
592
+ }
567
593
 
568
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
569
- const T = node.getTypeArguments()[0]!;
594
+ if (
595
+ tsm.ts.isTypeAliasDeclaration(statement) ||
596
+ tsm.ts.isInterfaceDeclaration(statement) ||
597
+ tsm.ts.isClassDeclaration(statement) ||
598
+ tsm.ts.isEnumDeclaration(statement)
599
+ ) {
600
+ return statement.name === undefined
601
+ ? []
602
+ : [{ name: statement.name.text, isType: true }];
603
+ }
570
604
 
571
- options.replaceNode(node, `ReadonlySet<${T.getFullText()}>`);
605
+ if (
606
+ (tsm.ts.isFunctionDeclaration(statement) ||
607
+ tsm.ts.isModuleDeclaration(statement)) &&
608
+ statement.name !== undefined &&
609
+ tsm.ts.isIdentifier(statement.name)
610
+ ) {
611
+ return [{ name: statement.name.text, isType: false }];
612
+ }
572
613
 
573
- return;
614
+ if (tsm.ts.isVariableStatement(statement)) {
615
+ return statement.declarationList.declarations.flatMap((declaration) =>
616
+ tsm.ts.isIdentifier(declaration.name)
617
+ ? [{ name: declaration.name.text, isType: false }]
618
+ : [],
619
+ );
574
620
  }
575
621
 
576
- // Map<T> to ReadonlyMap<T>
577
- if (typeNameStr === 'Map') {
578
- if (!Arr.isFixedLengthArray(2, typeArguments)) {
579
- throw new Error(
580
- `Unexpected number of type arguments "${typeArguments.length}" for Map.`,
581
- );
582
- }
622
+ return [];
623
+ };
583
624
 
584
- const nextReadonlyContextValue = nextReadonlyContext({
585
- currentReadonlyContext: readonlyContext,
586
- nextReadonlyContextType: 'none',
587
- indexedAccessDepthChange: 'keep',
588
- });
625
+ /**
626
+ * Whether the file has a type reference to `ReadonlyRecord` (an `extends` or
627
+ * `implements` clause included).
628
+ */
629
+ const referencesReadonlyRecord = (node: tsm.ts.Node): boolean =>
630
+ (tsm.ts.isTypeReferenceNode(node) &&
631
+ tsm.ts.isIdentifier(node.typeName) &&
632
+ node.typeName.text === READONLY_RECORD_NAME) ||
633
+ (tsm.ts.isExpressionWithTypeArguments(node) &&
634
+ tsm.ts.isIdentifier(node.expression) &&
635
+ node.expression.text === READONLY_RECORD_NAME) ||
636
+ (tsm.ts.forEachChild(
637
+ node,
638
+ (child) => referencesReadonlyRecord(child) || undefined,
639
+ ) ??
640
+ false);
589
641
 
590
- // Recursive processing
591
- for (const t of typeArguments) {
592
- transformNode(t, nextReadonlyContextValue, options);
593
- }
642
+ /**
643
+ * Whether `ReadonlyRecord` can be written in the file as it is, with
644
+ * `importStyle: "named"`:
645
+ *
646
+ * - `"in-scope"`: the top level imports it or declares it as a type, or the
647
+ * file has a type reference to it, which without either relies on a global.
648
+ * - `"taken"`: the top level declares it as a value or a namespace only, which
649
+ * an import of the name could clash with (an exported one does).
650
+ * - `"free"`: neither; it can be imported.
651
+ *
652
+ * A qualified name (`tf.ReadonlyRecord`), a property name, the original name of
653
+ * an aliased import (`ReadonlyRecord as RR`) and a declaration in an inner
654
+ * scope bind nothing at the top level, so they do not count.
655
+ */
656
+ const readonlyRecordScope = (
657
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
658
+ sourceFile: tsm.ts.SourceFile,
659
+ ): 'free' | 'in-scope' | 'taken' => {
660
+ const topLevelNames = sourceFile.statements.flatMap(topLevelBindings);
661
+
662
+ return topLevelNames.some(
663
+ ({ name, isType }) => isType && name === READONLY_RECORD_NAME,
664
+ ) || referencesReadonlyRecord(sourceFile)
665
+ ? 'in-scope'
666
+ : topLevelNames.some(({ name }) => name === READONLY_RECORD_NAME)
667
+ ? 'taken'
668
+ : 'free';
669
+ };
594
670
 
595
- const [K, V] = node.getTypeArguments();
671
+ /**
672
+ * `end`, moved past the comments that start on the same line after it: one such
673
+ * as `// eslint-disable-line` is about that line.
674
+ */
675
+ const endOfLineComments = (text: string, end: number): number =>
676
+ tsm.ts.getTrailingCommentRanges(text, end)?.at(-1)?.end ?? end;
596
677
 
597
- options.replaceNode(
598
- node,
599
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
600
- `ReadonlyMap<${K!.getFullText()}, ${V!.getFullText()}>`,
601
- );
678
+ /**
679
+ * Where a new import goes in a file with no import and no directive prologue:
680
+ * before the first statement, and before the run of JSDoc and `//` comments
681
+ * right above it with no blank line in between (its documentation, an
682
+ * `eslint-disable-next-line`), which belong to it. A comment separated from it
683
+ * by a blank line, a `/* ... *\/` comment (a file-wide `eslint-disable`, a
684
+ * license), a `///` directive, a pragma (`@ts-nocheck`, `@jsxImportSource`),
685
+ * which TypeScript reads only above the first statement, and a shebang stay
686
+ * above the import.
687
+ */
688
+ const newImportPosition = (
689
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
690
+ sourceFile: tsm.SourceFile,
691
+ ): number => {
692
+ const text = sourceFile.getFullText();
602
693
 
603
- return;
694
+ const first = sourceFile.getStatements().at(0);
695
+
696
+ if (first === undefined) {
697
+ return text.length;
604
698
  }
605
699
 
606
- // Record<K, V> to ReadonlyRecord<K, V> / Readonly<Record<K, V>>
607
- if (typeNameStr === 'Record') {
608
- if (!Arr.isFixedLengthArray(2, typeArguments)) {
609
- throw new Error(
610
- `Unexpected number of type arguments "${typeArguments.length}" for Record.`,
611
- );
612
- }
700
+ const comments =
701
+ tsm.ts.getLeadingCommentRanges(text, first.getFullStart()) ?? [];
613
702
 
614
- const nextReadonlyContextValue = nextReadonlyContext({
615
- currentReadonlyContext: readonlyContext,
616
- nextReadonlyContextType: 'none',
617
- indexedAccessDepthChange: 'keep',
618
- });
703
+ let mut_position = first.getStart();
619
704
 
620
- // Recursive processing
621
- for (const t of typeArguments) {
622
- transformNode(t, nextReadonlyContextValue, options);
623
- }
705
+ for (const comment of comments.toReversed()) {
706
+ const commentText = text.slice(comment.pos, comment.end);
624
707
 
625
- // The bare Record is kept as is in the following cases:
626
- // - DeepReadonly<Record<K, V>> already applies readonly recursively.
627
- // - Readonly<Record<K, V>> is unified to the configured record style by
628
- // the surrounding `Readonly` handler.
629
- // - Record<K, V>[I] does not need readonly, just like
630
- // `Readonly<{ ... }>[I] -> { ... }[I]`.
631
708
  if (
632
- readonlyContext.type === 'none' &&
633
- readonlyContext.indexedAccessDepth === 0
709
+ /\n[^\S\n]*\n/u.test(text.slice(comment.end, mut_position)) ||
710
+ commentText.startsWith('///') ||
711
+ (commentText.startsWith('/*') && !commentText.startsWith('/**')) ||
712
+ /@(?:ts-nocheck|ts-check|jsx)/u.test(commentText)
634
713
  ) {
635
- const [K, V] = node.getTypeArguments();
636
-
637
- options.replaceNode(
638
- node,
639
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
640
- readonlyRecordText(K!.getFullText(), V!.getFullText(), options),
641
- );
714
+ break;
642
715
  }
643
716
 
644
- return;
717
+ mut_position = comment.pos;
645
718
  }
646
719
 
647
- // Unify ReadonlyRecord<K, V> to the configured record style
648
- if (typeNameStr === 'ReadonlyRecord') {
649
- if (!Arr.isFixedLengthArray(2, typeArguments)) {
650
- throw new Error(
651
- `Unexpected number of type arguments "${typeArguments.length}" for ReadonlyRecord.`,
652
- );
653
- }
720
+ return mut_position;
721
+ };
654
722
 
655
- const nextReadonlyContextValue = nextReadonlyContext({
656
- currentReadonlyContext: readonlyContext,
657
- nextReadonlyContextType: 'none',
658
- indexedAccessDepthChange: 'keep',
659
- });
723
+ /** The leading `'use client';`-like statements of the file. */
724
+ const directivePrologue = (
725
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
726
+ sourceFile: tsm.SourceFile,
727
+ ): readonly tsm.ts.Statement[] => {
728
+ const statements = sourceFile.compilerNode.statements;
729
+
730
+ const end = statements.findIndex(
731
+ (statement) =>
732
+ !(
733
+ tsm.ts.isExpressionStatement(statement) &&
734
+ tsm.ts.isStringLiteral(statement.expression)
735
+ ),
736
+ );
660
737
 
661
- // Recursive processing
662
- for (const t of typeArguments) {
663
- transformNode(t, nextReadonlyContextValue, options);
664
- }
738
+ return statements.slice(0, end === -1 ? statements.length : end);
739
+ };
665
740
 
666
- const [K, V] = node.getTypeArguments();
741
+ const insertReadonlyRecordImport = (
742
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
743
+ sourceFile: tsm.SourceFile,
744
+ text: string,
745
+ ): void => {
746
+ const imports = sourceFile.getImportDeclarations();
667
747
 
668
- const bareRecordText =
669
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
670
- `Record<${K!.getFullText()}, ${V!.getFullText()}>` as const;
748
+ const hosts = imports.filter(
749
+ (decl) =>
750
+ decl.getModuleSpecifierValue() === READONLY_RECORD_MODULE &&
751
+ tsm.Node.isNamedImports(decl.getImportClause()?.getNamedBindings()),
752
+ );
671
753
 
672
- // Readonly<ReadonlyRecord<K, V>> -> Readonly<Record<K, V>>
673
- // (the surrounding `Readonly` handler then unifies it to the configured
674
- // record style, so the bare Record never survives into the output)
675
- if (
676
- readonlyContext.type === 'Readonly' &&
677
- readonlyContext.indexedAccessDepth === 0
678
- ) {
679
- options.replaceNode(node, bareRecordText);
754
+ const host = hosts.find((decl) => decl.isTypeOnly()) ?? hosts.at(0);
680
755
 
681
- return;
682
- }
756
+ if (host !== undefined) {
757
+ // `import type { ..., type X }` is an error: the declaration already makes
758
+ // every name type-only.
759
+ host.addNamedImport({
760
+ name: READONLY_RECORD_NAME,
761
+ isTypeOnly: !host.isTypeOnly(),
762
+ });
683
763
 
684
- if (options.recordStyle === 'Readonly<Record>') {
685
- // The ReadonlyRecord name is eliminated everywhere:
686
- // - DeepReadonly<ReadonlyRecord<K, V>> -> DeepReadonly<Record<K, V>>
687
- // - ReadonlyRecord<K, V>[I] -> Record<K, V>[I]
688
- // (readonly is unnecessary under indexed access, as with
689
- // Readonly<{ ... }>[I] -> { ... }[I])
690
- if (
691
- readonlyContext.type !== 'none' ||
692
- readonlyContext.indexedAccessDepth > 0
693
- ) {
694
- options.replaceNode(node, bareRecordText);
764
+ return;
765
+ }
695
766
 
696
- return;
697
- }
767
+ // `import type`, not `import { type X }`, which `verbatimModuleSyntax` keeps
768
+ // as `import {} from 'ts-type-forge'`, a module with nothing to run.
769
+ const declaration =
770
+ `import type { ${READONLY_RECORD_NAME} } from '${READONLY_RECORD_MODULE}';` as const;
698
771
 
699
- options.replaceNode(node, `Readonly<${bareRecordText}>`);
772
+ const anchorEnd =
773
+ imports.at(-1)?.getEnd() ?? directivePrologue(sourceFile).at(-1)?.end;
700
774
 
701
- return;
702
- }
775
+ if (anchorEnd === undefined) {
776
+ sourceFile.insertText(newImportPosition(sourceFile), `${declaration}\n\n`);
777
+ } else {
778
+ sourceFile.insertText(
779
+ endOfLineComments(text, anchorEnd),
780
+ `\n${declaration}`,
781
+ );
782
+ }
783
+ };
703
784
 
704
- // With `recordStyle: "ReadonlyRecord"`, ReadonlyRecord is already the
705
- // configured style. It is deliberately kept even under DeepReadonly or an
785
+ /**
786
+ * Imports `ReadonlyRecord` from `ts-type-forge`: into the braces of an existing
787
+ * import of the module where there is one, or else as a new declaration —
788
+ * after the last import, else after the directive prologue, else before the
789
+ * first statement and the comments attached to it (see
790
+ * {@link newImportPosition}). The line breaks follow the file's.
791
+ */
792
+ const addReadonlyRecordImport = (
793
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
794
+ sourceFile: tsm.SourceFile,
795
+ ): void => {
796
+ const text = sourceFile.getFullText();
797
+
798
+ // ts-morph writes its line breaks as the project's setting says, so the
799
+ // setting follows the file's for the edit.
800
+ const { manipulationSettings } = sourceFile.getProject();
801
+
802
+ const newLineKind = manipulationSettings.getNewLineKind();
803
+
804
+ manipulationSettings.set({
805
+ newLineKind: text.includes('\r\n')
806
+ ? tsm.NewLineKind.CarriageReturnLineFeed
807
+ : tsm.NewLineKind.LineFeed,
808
+ });
809
+
810
+ try {
811
+ insertReadonlyRecordImport(sourceFile, text);
812
+ } finally {
813
+ manipulationSettings.set({ newLineKind });
814
+ }
815
+ };
816
+
817
+ //
818
+ // Transformer implementation for each node type
819
+ //
820
+
821
+ /** Renders a readonly `Record<K, V>` in the configured record style. */
822
+ const readonlyRecordText = (
823
+ keyText: string,
824
+ valueText: string,
825
+ options: ReadonlyTransformerOptionsInternal,
826
+ ): string =>
827
+ options.recordStyle === 'ReadonlyRecord'
828
+ ? (`ReadonlyRecord<${keyText}, ${valueText}>` as const)
829
+ : (`Readonly<Record<${keyText}, ${valueText}>>` as const);
830
+
831
+ /**
832
+ * The comments in `[from, to)` of a source file, each followed by what keeps
833
+ * the next token off it, and nothing else in that range — no whitespace, and no
834
+ * token such as a separator or a parenthesis.
835
+ */
836
+ const commentsBetween = (
837
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
838
+ sourceFile: tsm.SourceFile,
839
+ from: number,
840
+ to: number,
841
+ ): string => {
842
+ if (to <= from) {
843
+ return '';
844
+ }
845
+
846
+ const scanner = tsm.ts.createScanner(
847
+ tsm.ts.ScriptTarget.Latest,
848
+ false,
849
+ tsm.ts.LanguageVariant.Standard,
850
+ sourceFile.getFullText(),
851
+ undefined,
852
+ from,
853
+ to - from,
854
+ );
855
+
856
+ const mut_comments: string[] = [];
857
+
858
+ while (scanner.scan() !== tsm.ts.SyntaxKind.EndOfFileToken) {
859
+ const token = scanner.getToken();
860
+
861
+ // A line comment runs to the end of the line, so it takes one along.
862
+ const separator =
863
+ token === tsm.ts.SyntaxKind.SingleLineCommentTrivia
864
+ ? '\n'
865
+ : token === tsm.ts.SyntaxKind.MultiLineCommentTrivia
866
+ ? ' '
867
+ : undefined;
868
+
869
+ if (separator !== undefined) {
870
+ mut_comments.push(`${scanner.getTokenText()}${separator}`);
871
+ }
872
+ }
873
+
874
+ return mut_comments.join('');
875
+ };
876
+
877
+ /** `DeepReadonly<T>` |-> `T`, anything else |-> `undefined` */
878
+ const getDeepReadonlyTypeArg = (
879
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
880
+ node: tsm.TypeNode,
881
+ deepReadonlyTypeName: string,
882
+ ): tsm.TypeNode | undefined => {
883
+ if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
884
+ return undefined;
885
+ }
886
+
887
+ const typeName = node.getTypeName();
888
+
889
+ const typeArguments = node.getTypeArguments();
890
+
891
+ return typeName.isKind(tsm.SyntaxKind.Identifier) &&
892
+ typeName.getText() === deepReadonlyTypeName &&
893
+ Arr.isFixedLengthArray(1, typeArguments)
894
+ ? typeArguments[0]
895
+ : undefined;
896
+ };
897
+
898
+ const checkIfPropertyNameShouldBeIgnored = (
899
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
900
+ nameNode: tsm.PropertyName,
901
+ options: ReadonlyTransformerOptionsInternal,
902
+ ): boolean => {
903
+ expectType<typeof nameNode, tsm.PropertyName>('=');
904
+
905
+ expectType<
906
+ tsm.PropertyName,
907
+ | tsm.NumericLiteral
908
+ | tsm.BigIntLiteral
909
+ | tsm.NoSubstitutionTemplateLiteral
910
+ | tsm.Identifier
911
+ | tsm.StringLiteral
912
+ | tsm.PrivateIdentifier
913
+ | tsm.ComputedPropertyName // [`mut_x`]: number[]
914
+ >('=');
915
+
916
+ return (
917
+ // mut_x: number[]
918
+ (nameNode.isKind(tsm.SyntaxKind.Identifier) &&
919
+ pipe(nameNode.getText()).map((nm) =>
920
+ options.mutablePropertyPrefixes.some((p) => nm.startsWith(p)),
921
+ ).value) ||
922
+ // "mut_x": number[]
923
+ (nameNode.isKind(tsm.SyntaxKind.StringLiteral) &&
924
+ pipe(nameNode.getLiteralValue()).map((nm) =>
925
+ options.mutablePropertyPrefixes.some((p) => nm.startsWith(p)),
926
+ ).value) ||
927
+ // #memberName: number[] (class only), matched with or after its `#`
928
+ (nameNode.isKind(tsm.SyntaxKind.PrivateIdentifier) &&
929
+ pipe(nameNode.getText()).map((nm) =>
930
+ options.mutablePropertyPrefixes.some(
931
+ (p) => nm.startsWith(p) || nm.startsWith(`#${p}`),
932
+ ),
933
+ ).value) ||
934
+ // ['mut_x']: number[], [`mut_x`]: number[]
935
+ (nameNode.isKind(tsm.SyntaxKind.ComputedPropertyName) &&
936
+ pipe(nameNode.getExpression()).map((exp) => {
937
+ if (
938
+ exp.isKind(tsm.SyntaxKind.StringLiteral) ||
939
+ exp.isKind(tsm.SyntaxKind.NoSubstitutionTemplateLiteral)
940
+ ) {
941
+ const nm = exp.getLiteralValue();
942
+
943
+ return options.mutablePropertyPrefixes.some((p) => nm.startsWith(p));
944
+ }
945
+
946
+ return false;
947
+ }).value)
948
+ );
949
+ };
950
+
951
+ /**
952
+ * Whether a type literal is left without `Readonly<...>` and marked readonly
953
+ * member by member, because it has a property, a method or an accessor of an
954
+ * ignored prefix (`{ mut_x: X[] }`), which `transformMembers` leaves mutable
955
+ * and `Readonly<T>` would not.
956
+ *
957
+ * It keeps its `Readonly<...>` when that would leave another member mutable,
958
+ * since only a property or index signature can be marked `readonly` itself:
959
+ *
960
+ * - a method signature or an accessor, which `Readonly<T>` makes readonly too,
961
+ * unless its name has an ignored prefix as well;
962
+ * - a property or index signature the ignore comment skips (as
963
+ * `transformMembers` does), which gets no `readonly` of its own, unless it
964
+ * is `readonly` already.
965
+ */
966
+ const isLeftUnwrapped = (
967
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
968
+ node: tsm.TypeLiteralNode,
969
+ options: ReadonlyTransformerOptionsInternal,
970
+ ): boolean => {
971
+ const members = node.getMembers();
972
+
973
+ const isMethodOrAccessor = (
974
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
975
+ mb: tsm.TypeElementTypes,
976
+ ): mb is
977
+ | tsm.MethodSignature
978
+ | tsm.GetAccessorDeclaration
979
+ | tsm.SetAccessorDeclaration =>
980
+ mb.isKind(tsm.SyntaxKind.MethodSignature) ||
981
+ mb.isKind(tsm.SyntaxKind.GetAccessor) ||
982
+ mb.isKind(tsm.SyntaxKind.SetAccessor);
983
+
984
+ const isIgnoredMember = (
985
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
986
+ mb: tsm.TypeElementTypes,
987
+ ): boolean =>
988
+ (mb.isKind(tsm.SyntaxKind.PropertySignature) || isMethodOrAccessor(mb)) &&
989
+ checkIfPropertyNameShouldBeIgnored(mb.getNameNode(), options);
990
+
991
+ const needsReadonlyWrapper = (
992
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
993
+ mb: tsm.TypeElementTypes,
994
+ ): boolean =>
995
+ !isIgnoredMember(mb) &&
996
+ (isMethodOrAccessor(mb) ||
997
+ ((mb.isKind(tsm.SyntaxKind.PropertySignature) ||
998
+ mb.isKind(tsm.SyntaxKind.IndexSignature)) &&
999
+ !mb.hasModifier(tsm.SyntaxKind.ReadonlyKeyword) &&
1000
+ hasDisableNextLineComment(mb)));
1001
+
1002
+ return members.some(isIgnoredMember) && !members.some(needsReadonlyWrapper);
1003
+ };
1004
+
1005
+ /** Whether `node`, or a parenthesized type around it, carries the ignore comment. */
1006
+ const hasDisableNextLineCommentAround = (node: tsm.Node): boolean => {
1007
+ if (hasDisableNextLineComment(node, TRANSFORMER_NAME)) {
1008
+ return true;
1009
+ }
1010
+
1011
+ const parent = node.getParent();
1012
+
1013
+ return (
1014
+ parent?.isKind(tsm.SyntaxKind.ParenthesizedType) === true &&
1015
+ hasDisableNextLineCommentAround(parent)
1016
+ );
1017
+ };
1018
+
1019
+ /**
1020
+ * Whether a type literal that is already wrapped, or would be merged into the
1021
+ * `Readonly<...>` of a union or intersection, is taken out of it. See
1022
+ * `isLeftUnwrapped`; in addition, the ignore comment leaves the literal where
1023
+ * it is, and taking `Readonly` off one with a call or construct signature would
1024
+ * bring back the signature it dropped.
1025
+ */
1026
+ const takesOffReadonly = (
1027
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1028
+ node: tsm.TypeLiteralNode,
1029
+ options: ReadonlyTransformerOptionsInternal,
1030
+ ): boolean =>
1031
+ isLeftUnwrapped(node, options) &&
1032
+ !hasCallOrConstructSignature(node) &&
1033
+ !hasDisableNextLineCommentAround(node);
1034
+
1035
+ /**
1036
+ * - `tr(ReadonlyArray<E>) |-> readonly tr(E)[]`
1037
+ * - `tr(Readonly<Readonly<E>>) |-> Readonly<tr(E)>`
1038
+ * - `tr(DeepReadonly<Readonly<E>>) |-> DeepReadonly<tr(E)>`
1039
+ * - `tr(Readonly<number>) |-> number`
1040
+ * - `tr(Readonly<{ mut_x: X; y: Y }>) |-> { mut_x: X; readonly y: tr(Y) }` (see
1041
+ * `isLeftUnwrapped`)
1042
+ * - `tr(Readonly<E[]>) |-> readonly tr(E)[]`
1043
+ * - `tr(Readonly<[E1, E2, E3]>) |-> readonly [tr(E1), tr(E2), tr(E3)]`
1044
+ * - `tr(Readonly<readonly E[]>) |-> readonly tr(E)[]`
1045
+ * - `tr(Readonly<readonly [E1, E2, E3]>) |-> readonly [tr(E1), tr(E2), tr(E3)]`
1046
+ * - `tr(Readonly<A | readonly E[]>) |-> Readonly<tr(A)> | readonly tr(E)[]>`
1047
+ * - `tr(Record<K, V>) |-> Readonly<Record<tr(K), tr(V)>>` (or
1048
+ * `ReadonlyRecord<tr(K), tr(V)>`, depending on the `recordStyle` option;
1049
+ * `Readonly<Record<K, V>>`, `ReadonlyRecord<K, V>` and
1050
+ * `Readonly<ReadonlyRecord<K, V>>` are unified to the same style)
1051
+ */
1052
+ const transformTypeReferenceNode = (
1053
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1054
+ node: tsm.TypeReferenceNode,
1055
+ readonlyContext: StrictExclude<
1056
+ ReadonlyContext,
1057
+ Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
1058
+ >,
1059
+ options: ReadonlyTransformerOptionsInternal,
1060
+ ): void => {
1061
+ const typeNameStr = node.getTypeName().getText();
1062
+
1063
+ const typeArguments = node.getTypeArguments();
1064
+
1065
+ // Array<T> / ReadonlyArray<T> to readonly T[]
1066
+ if (typeNameStr === 'Array' || typeNameStr === 'ReadonlyArray') {
1067
+ if (!Arr.isFixedLengthArray(1, typeArguments)) {
1068
+ throw new Error(
1069
+ `Unexpected number of type arguments "${typeArguments.length}" for ${typeNameStr}.`,
1070
+ );
1071
+ }
1072
+
1073
+ // Recursive processing
1074
+ transformNode(
1075
+ typeArguments[0],
1076
+ {
1077
+ type: 'none',
1078
+ indexedAccessDepth: readonlyContext.indexedAccessDepth,
1079
+ },
1080
+ options,
1081
+ );
1082
+
1083
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1084
+ const T = node.getTypeArguments()[0]!;
1085
+
1086
+ options.replaceNode(
1087
+ node,
1088
+ readonlyContext.type === 'DeepReadonly'
1089
+ ? wrapWithParentheses(`${wrapWithParentheses(T.getFullText())}[]`)
1090
+ : wrapWithParentheses(
1091
+ `readonly ${wrapWithParentheses(T.getFullText())}[]`,
1092
+ ),
1093
+ );
1094
+
1095
+ return;
1096
+ }
1097
+
1098
+ // Set<T> to ReadonlySet<T>
1099
+ if (typeNameStr === 'Set') {
1100
+ if (!Arr.isFixedLengthArray(1, typeArguments)) {
1101
+ throw new Error(
1102
+ `Unexpected number of type arguments "${typeArguments.length}" for Set.`,
1103
+ );
1104
+ }
1105
+
1106
+ // Recursive processing
1107
+ transformNode(
1108
+ typeArguments[0],
1109
+ {
1110
+ type: 'none',
1111
+ indexedAccessDepth: readonlyContext.indexedAccessDepth,
1112
+ },
1113
+ options,
1114
+ );
1115
+
1116
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1117
+ const T = node.getTypeArguments()[0]!;
1118
+
1119
+ options.replaceNode(node, `ReadonlySet<${T.getFullText()}>`);
1120
+
1121
+ return;
1122
+ }
1123
+
1124
+ // Map<T> to ReadonlyMap<T>
1125
+ if (typeNameStr === 'Map') {
1126
+ if (!Arr.isFixedLengthArray(2, typeArguments)) {
1127
+ throw new Error(
1128
+ `Unexpected number of type arguments "${typeArguments.length}" for Map.`,
1129
+ );
1130
+ }
1131
+
1132
+ const nextReadonlyContextValue = nextReadonlyContext({
1133
+ currentReadonlyContext: readonlyContext,
1134
+ nextReadonlyContextType: 'none',
1135
+ indexedAccessDepthChange: 'keep',
1136
+ });
1137
+
1138
+ // Recursive processing
1139
+ for (const t of typeArguments) {
1140
+ transformNode(t, nextReadonlyContextValue, options);
1141
+ }
1142
+
1143
+ const [K, V] = node.getTypeArguments();
1144
+
1145
+ options.replaceNode(
1146
+ node,
1147
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1148
+ `ReadonlyMap<${K!.getFullText()}, ${V!.getFullText()}>`,
1149
+ );
1150
+
1151
+ return;
1152
+ }
1153
+
1154
+ // Record<K, V> to ReadonlyRecord<K, V> / Readonly<Record<K, V>>
1155
+ if (typeNameStr === 'Record') {
1156
+ if (!Arr.isFixedLengthArray(2, typeArguments)) {
1157
+ throw new Error(
1158
+ `Unexpected number of type arguments "${typeArguments.length}" for Record.`,
1159
+ );
1160
+ }
1161
+
1162
+ const nextReadonlyContextValue = nextReadonlyContext({
1163
+ currentReadonlyContext: readonlyContext,
1164
+ nextReadonlyContextType: 'none',
1165
+ indexedAccessDepthChange: 'keep',
1166
+ });
1167
+
1168
+ // Recursive processing
1169
+ for (const t of typeArguments) {
1170
+ transformNode(t, nextReadonlyContextValue, options);
1171
+ }
1172
+
1173
+ // The bare Record is kept as is in the following cases:
1174
+ // - DeepReadonly<Record<K, V>> already applies readonly recursively.
1175
+ // - Readonly<Record<K, V>> is unified to the configured record style by
1176
+ // the surrounding `Readonly` handler.
1177
+ // - Record<K, V>[I] does not need readonly, just like
1178
+ // `Readonly<{ ... }>[I] -> { ... }[I]`.
1179
+ if (
1180
+ readonlyContext.type === 'none' &&
1181
+ readonlyContext.indexedAccessDepth === 0
1182
+ ) {
1183
+ const [K, V] = node.getTypeArguments();
1184
+
1185
+ options.replaceNode(
1186
+ node,
1187
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1188
+ readonlyRecordText(K!.getFullText(), V!.getFullText(), options),
1189
+ );
1190
+ }
1191
+
1192
+ return;
1193
+ }
1194
+
1195
+ // Unify ReadonlyRecord<K, V> to the configured record style
1196
+ if (typeNameStr === 'ReadonlyRecord') {
1197
+ if (!Arr.isFixedLengthArray(2, typeArguments)) {
1198
+ throw new Error(
1199
+ `Unexpected number of type arguments "${typeArguments.length}" for ReadonlyRecord.`,
1200
+ );
1201
+ }
1202
+
1203
+ const nextReadonlyContextValue = nextReadonlyContext({
1204
+ currentReadonlyContext: readonlyContext,
1205
+ nextReadonlyContextType: 'none',
1206
+ indexedAccessDepthChange: 'keep',
1207
+ });
1208
+
1209
+ // Recursive processing
1210
+ for (const t of typeArguments) {
1211
+ transformNode(t, nextReadonlyContextValue, options);
1212
+ }
1213
+
1214
+ const [K, V] = node.getTypeArguments();
1215
+
1216
+ const bareRecordText =
1217
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1218
+ `Record<${K!.getFullText()}, ${V!.getFullText()}>` as const;
1219
+
1220
+ // Readonly<ReadonlyRecord<K, V>> -> Readonly<Record<K, V>>
1221
+ // (the surrounding `Readonly` handler then unifies it to the configured
1222
+ // record style, so the bare Record never survives into the output)
1223
+ if (
1224
+ readonlyContext.type === 'Readonly' &&
1225
+ readonlyContext.indexedAccessDepth === 0
1226
+ ) {
1227
+ options.replaceNode(node, bareRecordText);
1228
+
1229
+ return;
1230
+ }
1231
+
1232
+ if (options.recordStyle === 'Readonly<Record>') {
1233
+ // The ReadonlyRecord name is eliminated everywhere:
1234
+ // - DeepReadonly<ReadonlyRecord<K, V>> -> DeepReadonly<Record<K, V>>
1235
+ // - ReadonlyRecord<K, V>[I] -> Record<K, V>[I]
1236
+ // (readonly is unnecessary under indexed access, as with
1237
+ // Readonly<{ ... }>[I] -> { ... }[I])
1238
+ if (
1239
+ readonlyContext.type !== 'none' ||
1240
+ readonlyContext.indexedAccessDepth > 0
1241
+ ) {
1242
+ options.replaceNode(node, bareRecordText);
1243
+
1244
+ return;
1245
+ }
1246
+
1247
+ options.replaceNode(node, `Readonly<${bareRecordText}>`);
1248
+
1249
+ return;
1250
+ }
1251
+
1252
+ // With `recordStyle: "ReadonlyRecord"`, ReadonlyRecord is already the
1253
+ // configured style. It is deliberately kept even under DeepReadonly or an
706
1254
  // indexed access — stripping it to the bare built-in `Record` there would
707
1255
  // conflict with the `ts-type-forge/prefer-readonly-or-mutable-record`
708
1256
  // ESLint rule.
@@ -786,9 +1334,9 @@ const transformTypeReferenceNode = (
786
1334
  takesOffWrapper ||
787
1335
  (T.isKind(tsm.SyntaxKind.TypeLiteral) && takesOffReadonly(T, options))
788
1336
  ) {
789
- // The comments after the literal, which nothing else carries over, go
790
- // in front of it: a line comment after it would end the type with a
791
- // line break, which a following `[]` cannot be parsed across.
1337
+ // The comments after the literal, which nothing else carries over, go in
1338
+ // front of it: a line comment after it would end the type with a line
1339
+ // break, which a following `[]` cannot be parsed across.
792
1340
  const commentsAfterT = commentsBetween(
793
1341
  node.getSourceFile(),
794
1342
  T.getEnd(),
@@ -985,16 +1533,6 @@ const transformTypeReferenceNode = (
985
1533
  }
986
1534
  };
987
1535
 
988
- /** Renders a readonly `Record<K, V>` in the configured record style. */
989
- const readonlyRecordText = (
990
- keyText: string,
991
- valueText: string,
992
- options: ReadonlyTransformerOptionsInternal,
993
- ): string =>
994
- options.recordStyle === 'ReadonlyRecord'
995
- ? (`ReadonlyRecord<${keyText}, ${valueText}>` as const)
996
- : (`Readonly<Record<${keyText}, ${valueText}>>` as const);
997
-
998
1536
  /** `tr(E[]) |-> tr(E)[]` */
999
1537
  const transformArrayTypeNode = (
1000
1538
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1445,221 +1983,94 @@ const transformConstructorParameter = (
1445
1983
  if (type !== undefined) {
1446
1984
  transformNode(type, initialReadonlyContext, options);
1447
1985
  }
1448
- };
1449
-
1450
- /**
1451
- * - `tr(A & B) -> tr(A) & tr(B)`
1452
- * - `tr(Readonly<A> & Readonly<B>) -> Readonly<tr(A) & tr(B)>`, when `A` and `B`
1453
- * are type literals (see `isMergeableReadonlyTypeReferenceNode`)
1454
- */
1455
- const transformIntersectionTypeNode = (
1456
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1457
- node: tsm.IntersectionTypeNode,
1458
- readonlyContext: StrictExclude<
1459
- ReadonlyContext,
1460
- Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
1461
- >,
1462
- options: ReadonlyTransformerOptionsInternal,
1463
- ): void => {
1464
- transformUnionOrIntersectionTypeNodeImpl(node, readonlyContext, options, '&');
1465
- };
1466
-
1467
- /**
1468
- * - `tr(A | B) |-> tr(A) | tr(B)`
1469
- * - `tr(Readonly<A> | Readonly<B>) |-> Readonly<tr(A) | tr(B)>`
1470
- * - `tr(DeepReadonly<A> | DeepReadonly<B>) |-> DeepReadonly<tr(A) | tr(B)>`
1471
- */
1472
- const transformUnionTypeNode = (
1473
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1474
- node: tsm.UnionTypeNode,
1475
- readonlyContext: StrictExclude<
1476
- ReadonlyContext,
1477
- Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
1478
- >,
1479
- options: ReadonlyTransformerOptionsInternal,
1480
- ): void => {
1481
- transformUnionOrIntersectionTypeNodeImpl(node, readonlyContext, options, '|');
1482
- };
1483
-
1484
- const transformUnionOrIntersectionTypeNodeImpl = (
1485
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1486
- node: tsm.IntersectionTypeNode | tsm.UnionTypeNode,
1487
- readonlyContext: StrictExclude<
1488
- ReadonlyContext,
1489
- Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
1490
- >,
1491
- options: ReadonlyTransformerOptionsInternal,
1492
- operator: '&' | '|',
1493
- ): void => {
1494
- const nextReadonlyContextValue = nextReadonlyContext({
1495
- currentReadonlyContext: readonlyContext,
1496
- nextReadonlyContextType: 'none',
1497
- indexedAccessDepthChange: 'keep',
1498
- });
1499
-
1500
- // Recursive processing
1501
- for (const n of node.getTypeNodes() /* = [A, B] */) {
1502
- transformNode(n, nextReadonlyContextValue, options);
1503
- }
1504
-
1505
- const newTypes = node.getTypeNodes();
1506
-
1507
- const withLeadingComments = (
1508
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1509
- member: tsm.TypeNode,
1510
- memberText: string,
1511
- ): string => `${leadingCommentsOfMember(node, member)}${memberText}` as const;
1512
-
1513
- // Whether a type literal member is wrapped at all; under `DeepReadonly` or an
1514
- // indexed access none is.
1515
- const wrapsTypeLiterals =
1516
- nextReadonlyContextValue.type !== 'DeepReadonly' &&
1517
- nextReadonlyContextValue.indexedAccessDepth === 0;
1518
-
1519
- // `Readonly<A & { b: B }>`: `A` stays under the `Readonly`, which the
1520
- // handler of `Readonly` then takes off as redundant.
1521
- const underReadonly =
1522
- readonlyContext.type === 'Readonly' &&
1523
- readonlyContext.indexedAccessDepth === 0 &&
1524
- isTypeArgumentOfReadonly(node);
1525
-
1526
- const { primitives, arraysAndTuples, typeLiterals, others } =
1527
- groupUnionIntersectionTypes(newTypes, {
1528
- operator,
1529
- underReadonly,
1530
- deepReadonlyTypeName: options.DeepReadonly.typeName,
1531
- keptUnwrapped: (t) => wrapsTypeLiterals && takesOffReadonly(t, options),
1532
- });
1533
-
1534
- options.debugPrint({ primitives, arraysAndTuples, typeLiterals, others });
1535
-
1536
- // Separate empty type literals from non-empty ones when ignoreEmptyObjectTypes is true
1537
- const isEmptyTypeLiteral = (
1538
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1539
- n: tsm.TypeNode,
1540
- ): boolean =>
1541
- n.isKind(tsm.SyntaxKind.TypeLiteral) && Arr.isEmpty(n.getMembers());
1542
-
1543
- const nonEmptyTypeLiterals =
1544
- typeLiterals === undefined
1545
- ? undefined
1546
- : options.ignoreEmptyObjectTypes
1547
- ? typeLiterals.nodes.filter((n) => !isEmptyTypeLiteral(n))
1548
- : typeLiterals.nodes;
1549
-
1550
- const emptyTypeLiterals =
1551
- typeLiterals === undefined || !options.ignoreEmptyObjectTypes
1552
- ? undefined
1553
- : typeLiterals.nodes.filter(isEmptyTypeLiteral);
1554
-
1555
- const typeLiteralsWrappedWithReadonly: readonly [] | readonly [string] =
1556
- nonEmptyTypeLiterals === undefined || Arr.isEmpty(nonEmptyTypeLiterals)
1557
- ? ([] as const)
1558
- : ([
1559
- unionToString({
1560
- types: nonEmptyTypeLiterals.map((n) =>
1561
- withLeadingComments(
1562
- n,
1563
- isMergeableReadonlyTypeReferenceNode(n, operator)
1564
- ? // NOTE: Readonly<A & B> -> (A & B) (spliced into `|`)
1565
- // NOTE: Readonly<A | B> -> A | B (spliced into `|`)
1566
- readonlyTypeArgTextSplicedInto(n, operator)
1567
- : n.isKind(tsm.SyntaxKind.TypeLiteral)
1568
- ? n.getText()
1569
- : // A member kept under `Readonly`; see `underReadonly`
1570
- wrapWithParentheses(n.getText()),
1571
- ),
1572
- ),
1573
- op: operator,
1574
- wrapWithReadonly: wrapsTypeLiterals,
1575
- }),
1576
- ] as const);
1577
-
1578
- // Sort by first occurrence (preserving the original union order as much as possible)
1579
- const sorted: readonly string[] = [
1580
- mapNullable(
1581
- primitives,
1582
- (a) =>
1583
- [
1584
- a.nodes.map((n) => withLeadingComments(n, n.getText())),
1585
- a.firstPosition,
1586
- ] as const,
1587
- ),
1588
- mapNullable(
1589
- arraysAndTuples,
1590
- (a) =>
1591
- [
1592
- a.nodes.map((n) => withLeadingComments(n, n.getText())),
1593
- a.firstPosition,
1594
- ] as const,
1595
- ),
1596
- nonEmptyTypeLiterals !== undefined && Arr.isNonEmpty(nonEmptyTypeLiterals)
1597
- ? mapNullable(
1598
- typeLiterals,
1599
- (a) => [typeLiteralsWrappedWithReadonly, a.firstPosition] as const,
1600
- )
1601
- : undefined,
1602
- emptyTypeLiterals !== undefined && Arr.isNonEmpty(emptyTypeLiterals)
1603
- ? mapNullable(
1604
- typeLiterals,
1605
- (a) =>
1606
- [
1607
- emptyTypeLiterals.map((n) => withLeadingComments(n, n.getText())),
1608
- // Use a position after non-empty type literals if they exist
1609
- a.firstPosition +
1610
- (nonEmptyTypeLiterals !== undefined &&
1611
- Arr.isNonEmpty(nonEmptyTypeLiterals)
1612
- ? 0.5
1613
- : 0),
1614
- ] as const,
1615
- )
1616
- : undefined,
1617
- mapNullable(
1618
- others,
1619
- (a) =>
1620
- [
1621
- // `&` is deliberately not merged, unlike `Readonly<A> & Readonly<B>`
1622
- // above. See `mergeDeepReadonlyUnionMembers` for why.
1623
- operator === '|'
1624
- ? mergeDeepReadonlyUnionMembers(
1625
- a.nodes,
1626
- options.DeepReadonly.typeName,
1627
- withLeadingComments,
1628
- )
1629
- : a.nodes.map((n) =>
1630
- withLeadingComments(n, wrapWithParentheses(n.getText())),
1631
- ),
1632
- a.firstPosition,
1633
- ] as const,
1634
- ),
1635
- ]
1636
- .filter((x) => x !== undefined)
1637
- .toSorted((a, b) => a[1] - b[1])
1638
- .flatMap((a) => a[0]);
1986
+ };
1639
1987
 
1640
- // Readonly<number & { x: X } & { y: Y } & readonly E[]>
1641
- // -> number & readonly E[] & Readonly<{ x: X } & { y: Y }>
1988
+ /**
1989
+ * - `tr(A & B) -> tr(A) & tr(B)`
1990
+ * - `tr(Readonly<A> & Readonly<B>) -> Readonly<tr(A) & tr(B)>`, when `A` and `B`
1991
+ * are type literals (see `isMergeableReadonlyTypeReferenceNode`)
1992
+ */
1993
+ const transformIntersectionTypeNode = (
1994
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1995
+ node: tsm.IntersectionTypeNode,
1996
+ readonlyContext: StrictExclude<
1997
+ ReadonlyContext,
1998
+ Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
1999
+ >,
2000
+ options: ReadonlyTransformerOptionsInternal,
2001
+ ): void => {
2002
+ transformUnionOrIntersectionTypeNodeImpl(node, readonlyContext, options, '&');
2003
+ };
1642
2004
 
1643
- // Readonly<number | { x: X } | { y: Y } | readonly E[]>
1644
- // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
1645
- // Readonly<number & { x: X } & { y: Y } & readonly E[]>
1646
- // -> number & readonly E[] & Readonly<{ x: X } & { y: Y }>
2005
+ /**
2006
+ * - `tr(A | B) |-> tr(A) | tr(B)`
2007
+ * - `tr(Readonly<A> | Readonly<B>) |-> Readonly<tr(A) | tr(B)>`
2008
+ * - `tr(DeepReadonly<A> | DeepReadonly<B>) |-> DeepReadonly<tr(A) | tr(B)>`
2009
+ */
2010
+ const transformUnionTypeNode = (
2011
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2012
+ node: tsm.UnionTypeNode,
2013
+ readonlyContext: StrictExclude<
2014
+ ReadonlyContext,
2015
+ Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
2016
+ >,
2017
+ options: ReadonlyTransformerOptionsInternal,
2018
+ ): void => {
2019
+ transformUnionOrIntersectionTypeNodeImpl(node, readonlyContext, options, '|');
2020
+ };
1647
2021
 
1648
- // Readonly<number | { x: X } | { y: Y } | readonly E[]>
1649
- // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
1650
- //
1651
- // NOTE: The result is wrapped in parentheses (via `unionToString`). This is
1652
- // intentional and required for precedence safety: without it, a member such
1653
- // as a function type (`() => T`), or a mix of `&` and `|`, or a `readonly`
1654
- // operator would produce invalid syntax when this node is spliced back in.
1655
- // Idempotency is guaranteed by `transformParenthesizedTypeNode`, which
1656
- // collapses the redundant nesting (`((X))` -> `(X)`) this wrapping can create
1657
- // — see the note there. That prevents the parentheses from growing without
1658
- // bound when a union/intersection sits inside a `ParenthesizedType`.
1659
- options.replaceNode(
1660
- node,
1661
- unionToString({ types: sorted, op: operator, wrapWithReadonly: false }),
1662
- );
2022
+ /**
2023
+ * The text of `inner`, a type `removeParentheses` took out of `outer`, with the
2024
+ * comments in `outer`'s full text in front of it — before the parentheses and
2025
+ * inside them — and without the parentheses.
2026
+ *
2027
+ * `inner.getFullText()` would drop those before the parentheses, which are as
2028
+ * much a part of `outer` as the rest of it.
2029
+ */
2030
+ const textWithoutParentheses = (
2031
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2032
+ outer: tsm.TypeNode,
2033
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2034
+ inner: tsm.TypeNode,
2035
+ ): string =>
2036
+ `${commentsBetween(outer.getSourceFile(), outer.getPos(), inner.getStart())}${inner.getText()}` as const;
2037
+
2038
+ const unionToStringImpl = ({
2039
+ types,
2040
+ op,
2041
+ wrapWithReadonly,
2042
+ }: Readonly<{
2043
+ types: readonly string[];
2044
+ op: '&' | '|';
2045
+ wrapWithReadonly: boolean | string;
2046
+ }>): string =>
2047
+ Arr.isEmpty(types)
2048
+ ? 'never'
2049
+ : Arr.isFixedLengthArray(1, types)
2050
+ ? wrapWithReadonly === false
2051
+ ? wrapWithParentheses(types[0])
2052
+ : (`${isString(wrapWithReadonly) ? wrapWithReadonly : 'Readonly'}<${types[0]}>` as const)
2053
+ : wrapWithReadonly === false
2054
+ ? wrapWithParentheses(types.join(` ${op} `))
2055
+ : (`${isString(wrapWithReadonly) ? wrapWithReadonly : 'Readonly'}<${types.join(` ${op} `)}>` as const);
2056
+
2057
+ const unionToString = ({
2058
+ types: typesRaw,
2059
+ op,
2060
+ wrapWithReadonly,
2061
+ }: Readonly<{
2062
+ types: readonly string[];
2063
+ op: '&' | '|';
2064
+ wrapWithReadonly: boolean | string;
2065
+ }>): string => {
2066
+ // The members are joined with a separator that supplies its own space, so
2067
+ // whitespace left at a member's edge would accumulate: every run of the
2068
+ // transformer would add one more space before every member but the first, and
2069
+ // a file would never reach a fixed point. Trimming keeps the comments in
2070
+ // front of a member, which are not whitespace.
2071
+ const types = typesRaw.map((s) => s.trim());
2072
+
2073
+ return unionToStringImpl({ types, op, wrapWithReadonly });
1663
2074
  };
1664
2075
 
1665
2076
  /**
@@ -1775,10 +2186,10 @@ const isTypeArgumentOfReadonly = (
1775
2186
  * `replaceWithText` overwrites a node from its `getStart()`, so whatever lies
1776
2187
  * in front of that stays in the file by itself. With no leading `|` / `&`, the
1777
2188
  * first member starts there too, and its comments are the type's: repeating
1778
- * them in the replacement would copy them once per run. Every other member
1779
- * has a separator in front of it, and a comment before the separator belongs
1780
- * to no member's full text, so it is gone once the separator is rewritten. It
1781
- * goes with the member after it, like the comments after the separator.
2189
+ * them in the replacement would copy them once per run. Every other member has
2190
+ * a separator in front of it, and a comment before the separator belongs to no
2191
+ * member's full text, so it is gone once the separator is rewritten. It goes
2192
+ * with the member after it, like the comments after the separator.
1782
2193
  *
1783
2194
  * `member` may be the type inside a member's parentheses; the comments inside
1784
2195
  * them are collected too, and the parentheses are not.
@@ -1802,9 +2213,9 @@ const leadingCommentsOfMember = (
1802
2213
  * `Readonly<T>` |-> the text of `T`, to be spliced into a union (`|`) or an
1803
2214
  * intersection (`&`) of the members it is merged with.
1804
2215
  *
1805
- * `T` is wrapped in parentheses for precedence safety (a function type
1806
- * argument needs them), unless it is a union spliced into a union or an
1807
- * intersection spliced into an intersection, which needs none.
2216
+ * `T` is wrapped in parentheses for precedence safety (a function type argument
2217
+ * needs them), unless it is a union spliced into a union or an intersection
2218
+ * spliced into an intersection, which needs none.
1808
2219
  */
1809
2220
  const readonlyTypeArgTextSplicedInto = (
1810
2221
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1824,127 +2235,187 @@ const readonlyTypeArgTextSplicedInto = (
1824
2235
  : unwrapReadonlyTypeArgText(node);
1825
2236
  };
1826
2237
 
1827
- /**
1828
- * The text of `inner`, a type `removeParentheses` took out of `outer`, with
1829
- * the comments in `outer`'s full text in front of it — before the parentheses
1830
- * and inside them — and without the parentheses.
1831
- *
1832
- * `inner.getFullText()` would drop those before the parentheses, which are
1833
- * as much a part of `outer` as the rest of it.
1834
- */
1835
- const textWithoutParentheses = (
1836
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1837
- outer: tsm.TypeNode,
2238
+ const transformUnionOrIntersectionTypeNodeImpl = (
1838
2239
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1839
- inner: tsm.TypeNode,
1840
- ): string =>
1841
- `${commentsBetween(outer.getSourceFile(), outer.getPos(), inner.getStart())}${inner.getText()}` as const;
2240
+ node: tsm.IntersectionTypeNode | tsm.UnionTypeNode,
2241
+ readonlyContext: StrictExclude<
2242
+ ReadonlyContext,
2243
+ Readonly<{ type: 'readonly'; indexedAccessDepth: SafeUintWithSmallInt }>
2244
+ >,
2245
+ options: ReadonlyTransformerOptionsInternal,
2246
+ operator: '&' | '|',
2247
+ ): void => {
2248
+ const nextReadonlyContextValue = nextReadonlyContext({
2249
+ currentReadonlyContext: readonlyContext,
2250
+ nextReadonlyContextType: 'none',
2251
+ indexedAccessDepthChange: 'keep',
2252
+ });
1842
2253
 
1843
- /**
1844
- * The comments in `[from, to)` of a source file, each followed by what keeps
1845
- * the next token off it, and nothing else in that range — no whitespace, and
1846
- * no token such as a separator or a parenthesis.
1847
- */
1848
- const commentsBetween = (
1849
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1850
- sourceFile: tsm.SourceFile,
1851
- from: number,
1852
- to: number,
1853
- ): string => {
1854
- if (to <= from) {
1855
- return '';
2254
+ // Recursive processing
2255
+ for (const n of node.getTypeNodes() /* = [A, B] */) {
2256
+ transformNode(n, nextReadonlyContextValue, options);
1856
2257
  }
1857
2258
 
1858
- const scanner = tsm.ts.createScanner(
1859
- tsm.ts.ScriptTarget.Latest,
1860
- false,
1861
- tsm.ts.LanguageVariant.Standard,
1862
- sourceFile.getFullText(),
1863
- undefined,
1864
- from,
1865
- to - from,
1866
- );
2259
+ const newTypes = node.getTypeNodes();
1867
2260
 
1868
- const mut_comments: string[] = [];
2261
+ const withLeadingComments = (
2262
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2263
+ member: tsm.TypeNode,
2264
+ memberText: string,
2265
+ ): string => `${leadingCommentsOfMember(node, member)}${memberText}` as const;
1869
2266
 
1870
- while (scanner.scan() !== tsm.ts.SyntaxKind.EndOfFileToken) {
1871
- const token = scanner.getToken();
2267
+ // Whether a type literal member is wrapped at all; under `DeepReadonly` or an
2268
+ // indexed access none is.
2269
+ const wrapsTypeLiterals =
2270
+ nextReadonlyContextValue.type !== 'DeepReadonly' &&
2271
+ nextReadonlyContextValue.indexedAccessDepth === 0;
1872
2272
 
1873
- // A line comment runs to the end of the line, so it takes one along.
1874
- const separator =
1875
- token === tsm.ts.SyntaxKind.SingleLineCommentTrivia
1876
- ? '\n'
1877
- : token === tsm.ts.SyntaxKind.MultiLineCommentTrivia
1878
- ? ' '
1879
- : undefined;
2273
+ // `Readonly<A & { b: B }>`: `A` stays under the `Readonly`, which the handler
2274
+ // of `Readonly` then takes off as redundant.
2275
+ const underReadonly =
2276
+ readonlyContext.type === 'Readonly' &&
2277
+ readonlyContext.indexedAccessDepth === 0 &&
2278
+ isTypeArgumentOfReadonly(node);
1880
2279
 
1881
- if (separator !== undefined) {
1882
- mut_comments.push(`${scanner.getTokenText()}${separator}`);
1883
- }
1884
- }
2280
+ const { primitives, arraysAndTuples, typeLiterals, others } =
2281
+ groupUnionIntersectionTypes(newTypes, {
2282
+ operator,
2283
+ underReadonly,
2284
+ deepReadonlyTypeName: options.DeepReadonly.typeName,
2285
+ keptUnwrapped: (t) => wrapsTypeLiterals && takesOffReadonly(t, options),
2286
+ });
1885
2287
 
1886
- return mut_comments.join('');
1887
- };
2288
+ options.debugPrint({ primitives, arraysAndTuples, typeLiterals, others });
1888
2289
 
1889
- /** `DeepReadonly<T>` |-> `T`, anything else |-> `undefined` */
1890
- const getDeepReadonlyTypeArg = (
1891
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1892
- node: tsm.TypeNode,
1893
- deepReadonlyTypeName: string,
1894
- ): tsm.TypeNode | undefined => {
1895
- if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
1896
- return undefined;
1897
- }
2290
+ // Separate empty type literals from non-empty ones when ignoreEmptyObjectTypes is true
2291
+ const isEmptyTypeLiteral = (
2292
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2293
+ n: tsm.TypeNode,
2294
+ ): boolean =>
2295
+ n.isKind(tsm.SyntaxKind.TypeLiteral) && Arr.isEmpty(n.getMembers());
1898
2296
 
1899
- const typeName = node.getTypeName();
2297
+ const nonEmptyTypeLiterals =
2298
+ typeLiterals === undefined
2299
+ ? undefined
2300
+ : options.ignoreEmptyObjectTypes
2301
+ ? typeLiterals.nodes.filter((n) => !isEmptyTypeLiteral(n))
2302
+ : typeLiterals.nodes;
1900
2303
 
1901
- const typeArguments = node.getTypeArguments();
2304
+ const emptyTypeLiterals =
2305
+ typeLiterals === undefined || !options.ignoreEmptyObjectTypes
2306
+ ? undefined
2307
+ : typeLiterals.nodes.filter(isEmptyTypeLiteral);
2308
+
2309
+ const typeLiteralsWrappedWithReadonly: readonly [] | readonly [string] =
2310
+ nonEmptyTypeLiterals === undefined || Arr.isEmpty(nonEmptyTypeLiterals)
2311
+ ? ([] as const)
2312
+ : ([
2313
+ unionToString({
2314
+ types: nonEmptyTypeLiterals.map((n) =>
2315
+ withLeadingComments(
2316
+ n,
2317
+ isMergeableReadonlyTypeReferenceNode(n, operator)
2318
+ ? // NOTE: Readonly<A & B> -> (A & B) (spliced into `|`)
2319
+ // NOTE: Readonly<A | B> -> A | B (spliced into `|`)
2320
+ readonlyTypeArgTextSplicedInto(n, operator)
2321
+ : n.isKind(tsm.SyntaxKind.TypeLiteral)
2322
+ ? n.getText()
2323
+ : // A member kept under `Readonly`; see `underReadonly`
2324
+ wrapWithParentheses(n.getText()),
2325
+ ),
2326
+ ),
2327
+ op: operator,
2328
+ wrapWithReadonly: wrapsTypeLiterals,
2329
+ }),
2330
+ ] as const);
2331
+
2332
+ // Sort by first occurrence (preserving the original union order as much as possible)
2333
+ const sorted: readonly string[] = [
2334
+ mapNullable(
2335
+ primitives,
2336
+ (a) =>
2337
+ [
2338
+ a.nodes.map((n) => withLeadingComments(n, n.getText())),
2339
+ a.firstPosition,
2340
+ ] as const,
2341
+ ),
2342
+ mapNullable(
2343
+ arraysAndTuples,
2344
+ (a) =>
2345
+ [
2346
+ a.nodes.map((n) => withLeadingComments(n, n.getText())),
2347
+ a.firstPosition,
2348
+ ] as const,
2349
+ ),
2350
+ nonEmptyTypeLiterals !== undefined && Arr.isNonEmpty(nonEmptyTypeLiterals)
2351
+ ? mapNullable(
2352
+ typeLiterals,
2353
+ (a) => [typeLiteralsWrappedWithReadonly, a.firstPosition] as const,
2354
+ )
2355
+ : undefined,
2356
+ emptyTypeLiterals !== undefined && Arr.isNonEmpty(emptyTypeLiterals)
2357
+ ? mapNullable(
2358
+ typeLiterals,
2359
+ (a) =>
2360
+ [
2361
+ emptyTypeLiterals.map((n) => withLeadingComments(n, n.getText())),
2362
+ // Use a position after non-empty type literals if they exist
2363
+ a.firstPosition +
2364
+ (nonEmptyTypeLiterals !== undefined &&
2365
+ Arr.isNonEmpty(nonEmptyTypeLiterals)
2366
+ ? 0.5
2367
+ : 0),
2368
+ ] as const,
2369
+ )
2370
+ : undefined,
2371
+ mapNullable(
2372
+ others,
2373
+ (a) =>
2374
+ [
2375
+ // `&` is deliberately not merged, unlike `Readonly<A> & Readonly<B>`
2376
+ // above. See `mergeDeepReadonlyUnionMembers` for why.
2377
+ operator === '|'
2378
+ ? mergeDeepReadonlyUnionMembers(
2379
+ a.nodes,
2380
+ options.DeepReadonly.typeName,
2381
+ withLeadingComments,
2382
+ )
2383
+ : a.nodes.map((n) =>
2384
+ withLeadingComments(n, wrapWithParentheses(n.getText())),
2385
+ ),
2386
+ a.firstPosition,
2387
+ ] as const,
2388
+ ),
2389
+ ]
2390
+ .filter((x) => x !== undefined)
2391
+ .toSorted((a, b) => a[1] - b[1])
2392
+ .flatMap((a) => a[0]);
1902
2393
 
1903
- return typeName.isKind(tsm.SyntaxKind.Identifier) &&
1904
- typeName.getText() === deepReadonlyTypeName &&
1905
- Arr.isFixedLengthArray(1, typeArguments)
1906
- ? typeArguments[0]
1907
- : undefined;
1908
- };
2394
+ // Readonly<number & { x: X } & { y: Y } & readonly E[]>
2395
+ // -> number & readonly E[] & Readonly<{ x: X } & { y: Y }>
1909
2396
 
1910
- const unionToString = ({
1911
- types: typesRaw,
1912
- op,
1913
- wrapWithReadonly,
1914
- }: Readonly<{
1915
- types: readonly string[];
1916
- op: '&' | '|';
1917
- wrapWithReadonly: boolean | string;
1918
- }>): string => {
1919
- // The members are joined with a separator that supplies its own space, so
1920
- // whitespace left at a member's edge would accumulate: every run of the
1921
- // transformer would add one more space before every member but the first,
1922
- // and a file would never reach a fixed point. Trimming keeps the comments in
1923
- // front of a member, which are not whitespace.
1924
- const types = typesRaw.map((s) => s.trim());
2397
+ // Readonly<number | { x: X } | { y: Y } | readonly E[]>
2398
+ // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
2399
+ // Readonly<number & { x: X } & { y: Y } & readonly E[]>
2400
+ // -> number & readonly E[] & Readonly<{ x: X } & { y: Y }>
1925
2401
 
1926
- return unionToStringImpl({ types, op, wrapWithReadonly });
2402
+ // Readonly<number | { x: X } | { y: Y } | readonly E[]>
2403
+ // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
2404
+ //
2405
+ // NOTE: The result is wrapped in parentheses (via `unionToString`). This is
2406
+ // intentional and required for precedence safety: without it, a member such
2407
+ // as a function type (`() => T`), or a mix of `&` and `|`, or a `readonly`
2408
+ // operator would produce invalid syntax when this node is spliced back in.
2409
+ // Idempotency is guaranteed by `transformParenthesizedTypeNode`, which
2410
+ // collapses the redundant nesting (`((X))` -> `(X)`) this wrapping can create
2411
+ // — see the note there. That prevents the parentheses from growing without
2412
+ // bound when a union/intersection sits inside a `ParenthesizedType`.
2413
+ options.replaceNode(
2414
+ node,
2415
+ unionToString({ types: sorted, op: operator, wrapWithReadonly: false }),
2416
+ );
1927
2417
  };
1928
2418
 
1929
- const unionToStringImpl = ({
1930
- types,
1931
- op,
1932
- wrapWithReadonly,
1933
- }: Readonly<{
1934
- types: readonly string[];
1935
- op: '&' | '|';
1936
- wrapWithReadonly: boolean | string;
1937
- }>): string =>
1938
- Arr.isEmpty(types)
1939
- ? 'never'
1940
- : Arr.isFixedLengthArray(1, types)
1941
- ? wrapWithReadonly === false
1942
- ? wrapWithParentheses(types[0])
1943
- : (`${isString(wrapWithReadonly) ? wrapWithReadonly : 'Readonly'}<${types[0]}>` as const)
1944
- : wrapWithReadonly === false
1945
- ? wrapWithParentheses(types.join(` ${op} `))
1946
- : (`${isString(wrapWithReadonly) ? wrapWithReadonly : 'Readonly'}<${types.join(` ${op} `)}>` as const);
1947
-
1948
2419
  /** Convert ((T)) -> (T) recursively */
1949
2420
  const transformParenthesizedTypeNode = (
1950
2421
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1964,20 +2435,16 @@ const transformParenthesizedTypeNode = (
1964
2435
  const T = node.getTypeNode();
1965
2436
 
1966
2437
  if (
1967
- // remove () if T is TypeReferenceNode
1968
- // e.g. `(Readonly<A>) |-> Readonly<A>`
2438
+ // remove () if T is TypeReferenceNode e.g. `(Readonly<A>) |-> Readonly<A>`
1969
2439
  T.isKind(tsm.SyntaxKind.TypeReference) ||
1970
- // remove () if T is ArrayTypeNode
1971
- // e.g. `(A[]) |-> A[]`
2440
+ // remove () if T is ArrayTypeNode e.g. `(A[]) |-> A[]`
1972
2441
  T.isKind(tsm.SyntaxKind.ArrayType) ||
1973
- // remove () if T is TupleTypeNode
1974
- // e.g. `([A]) |-> [A]`
2442
+ // remove () if T is TupleTypeNode e.g. `([A]) |-> [A]`
1975
2443
  T.isKind(tsm.SyntaxKind.TupleType) ||
1976
- // remove () if T is PrimitiveTypeNode
1977
- // e.g. `(number) |-> number`
2444
+ // remove () if T is PrimitiveTypeNode e.g. `(number) |-> number`
1978
2445
  isAtomicTypeNode(T) ||
1979
- // remove () if T is TypeLiteralNode
1980
- // e.g. `({ member: V }) |-> { member: V }`
2446
+ // remove () if T is TypeLiteralNode e.g.
2447
+ // `({ member: V }) |-> { member: V }`
1981
2448
  T.isKind(tsm.SyntaxKind.TypeLiteral) ||
1982
2449
  // collapse redundant nested parentheses if T is itself a ParenthesizedType
1983
2450
  // e.g. `((X)) |-> (X)`
@@ -2134,161 +2601,3 @@ const transformIndexSignatureDeclaration = (
2134
2601
  }
2135
2602
  }
2136
2603
  };
2137
-
2138
- const checkIfPropertyNameShouldBeIgnored = (
2139
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2140
- nameNode: tsm.PropertyName,
2141
- options: ReadonlyTransformerOptionsInternal,
2142
- ): boolean => {
2143
- expectType<typeof nameNode, tsm.PropertyName>('=');
2144
-
2145
- expectType<
2146
- tsm.PropertyName,
2147
- | tsm.NumericLiteral
2148
- | tsm.BigIntLiteral
2149
- | tsm.NoSubstitutionTemplateLiteral
2150
- | tsm.Identifier
2151
- | tsm.StringLiteral
2152
- | tsm.PrivateIdentifier
2153
- | tsm.ComputedPropertyName // [`mut_x`]: number[]
2154
- >('=');
2155
-
2156
- return (
2157
- // mut_x: number[]
2158
- (nameNode.isKind(tsm.SyntaxKind.Identifier) &&
2159
- pipe(nameNode.getText()).map((nm) =>
2160
- options.mutablePropertyPrefixes.some((p) => nm.startsWith(p)),
2161
- ).value) ||
2162
- // "mut_x": number[]
2163
- (nameNode.isKind(tsm.SyntaxKind.StringLiteral) &&
2164
- pipe(nameNode.getLiteralValue()).map((nm) =>
2165
- options.mutablePropertyPrefixes.some((p) => nm.startsWith(p)),
2166
- ).value) ||
2167
- // #memberName: number[] (class only), matched with or after its `#`
2168
- (nameNode.isKind(tsm.SyntaxKind.PrivateIdentifier) &&
2169
- pipe(nameNode.getText()).map((nm) =>
2170
- options.mutablePropertyPrefixes.some(
2171
- (p) => nm.startsWith(p) || nm.startsWith(`#${p}`),
2172
- ),
2173
- ).value) ||
2174
- // ['mut_x']: number[], [`mut_x`]: number[]
2175
- (nameNode.isKind(tsm.SyntaxKind.ComputedPropertyName) &&
2176
- pipe(nameNode.getExpression()).map((exp) => {
2177
- if (
2178
- exp.isKind(tsm.SyntaxKind.StringLiteral) ||
2179
- exp.isKind(tsm.SyntaxKind.NoSubstitutionTemplateLiteral)
2180
- ) {
2181
- const nm = exp.getLiteralValue();
2182
-
2183
- return options.mutablePropertyPrefixes.some((p) => nm.startsWith(p));
2184
- }
2185
-
2186
- return false;
2187
- }).value)
2188
- );
2189
- };
2190
-
2191
- /**
2192
- * Whether a type literal is left without `Readonly<...>` and marked readonly
2193
- * member by member, because it has a property, a method or an accessor of an
2194
- * ignored prefix (`{ mut_x: X[] }`), which `transformMembers` leaves mutable
2195
- * and `Readonly<T>` would not.
2196
- *
2197
- * It keeps its `Readonly<...>` when that would leave another member mutable,
2198
- * since only a property or index signature can be marked `readonly` itself:
2199
- *
2200
- * - a method signature or an accessor, which `Readonly<T>` makes readonly too,
2201
- * unless its name has an ignored prefix as well;
2202
- * - a property or index signature the ignore comment skips (as
2203
- * `transformMembers` does), which gets no `readonly` of its own, unless it
2204
- * is `readonly` already.
2205
- */
2206
- const isLeftUnwrapped = (
2207
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2208
- node: tsm.TypeLiteralNode,
2209
- options: ReadonlyTransformerOptionsInternal,
2210
- ): boolean => {
2211
- const members = node.getMembers();
2212
-
2213
- const isMethodOrAccessor = (
2214
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2215
- mb: tsm.TypeElementTypes,
2216
- ): mb is
2217
- | tsm.MethodSignature
2218
- | tsm.GetAccessorDeclaration
2219
- | tsm.SetAccessorDeclaration =>
2220
- mb.isKind(tsm.SyntaxKind.MethodSignature) ||
2221
- mb.isKind(tsm.SyntaxKind.GetAccessor) ||
2222
- mb.isKind(tsm.SyntaxKind.SetAccessor);
2223
-
2224
- const isIgnoredMember = (
2225
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2226
- mb: tsm.TypeElementTypes,
2227
- ): boolean =>
2228
- (mb.isKind(tsm.SyntaxKind.PropertySignature) || isMethodOrAccessor(mb)) &&
2229
- checkIfPropertyNameShouldBeIgnored(mb.getNameNode(), options);
2230
-
2231
- const needsReadonlyWrapper = (
2232
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2233
- mb: tsm.TypeElementTypes,
2234
- ): boolean =>
2235
- !isIgnoredMember(mb) &&
2236
- (isMethodOrAccessor(mb) ||
2237
- ((mb.isKind(tsm.SyntaxKind.PropertySignature) ||
2238
- mb.isKind(tsm.SyntaxKind.IndexSignature)) &&
2239
- !mb.hasModifier(tsm.SyntaxKind.ReadonlyKeyword) &&
2240
- hasDisableNextLineComment(mb)));
2241
-
2242
- return members.some(isIgnoredMember) && !members.some(needsReadonlyWrapper);
2243
- };
2244
-
2245
- /** Whether `node`, or a parenthesized type around it, carries the ignore comment. */
2246
- const hasDisableNextLineCommentAround = (node: tsm.Node): boolean => {
2247
- if (hasDisableNextLineComment(node, TRANSFORMER_NAME)) {
2248
- return true;
2249
- }
2250
-
2251
- const parent = node.getParent();
2252
-
2253
- return (
2254
- parent?.isKind(tsm.SyntaxKind.ParenthesizedType) === true &&
2255
- hasDisableNextLineCommentAround(parent)
2256
- );
2257
- };
2258
-
2259
- /**
2260
- * Whether a type literal that is already wrapped, or would be merged into the
2261
- * `Readonly<...>` of a union or intersection, is taken out of it. See
2262
- * `isLeftUnwrapped`; in addition, the ignore comment leaves the literal where
2263
- * it is, and taking `Readonly` off one with a call or construct signature
2264
- * would bring back the signature it dropped.
2265
- */
2266
- const takesOffReadonly = (
2267
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2268
- node: tsm.TypeLiteralNode,
2269
- options: ReadonlyTransformerOptionsInternal,
2270
- ): boolean =>
2271
- isLeftUnwrapped(node, options) &&
2272
- !hasCallOrConstructSignature(node) &&
2273
- !hasDisableNextLineCommentAround(node);
2274
-
2275
- /** Whether a type alias, interface or class name starts with a `mutableTypePrefixes` entry. */
2276
- const hasMutableTypeName = (
2277
- name: string | undefined,
2278
- options: ReadonlyTransformerOptionsInternal,
2279
- ): boolean =>
2280
- name !== undefined &&
2281
- options.mutableTypePrefixes.some((p) => name.startsWith(p));
2282
-
2283
- /**
2284
- * A parameter property is a property of the class, as a `PropertyDeclaration`
2285
- * is; any other parameter is a variable.
2286
- */
2287
- const mutablePrefixesOfParameter = (
2288
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
2289
- param: tsm.ParameterDeclaration,
2290
- options: ReadonlyTransformerOptionsInternal,
2291
- ): ISet<string> =>
2292
- param.isParameterProperty()
2293
- ? options.mutablePropertyPrefixes
2294
- : options.mutableVariablePrefixes;