ts-codemod-lib 4.0.1 → 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.
@@ -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
  *
@@ -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
  }>;
@@ -501,6 +552,268 @@ const transformNode = (
501
552
  }
502
553
  };
503
554
 
555
+ /**
556
+ * The names a top-level statement binds, and whether each can name a type (an
557
+ * import can).
558
+ */
559
+ const topLevelBindings = (
560
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
561
+ statement: tsm.ts.Statement,
562
+ ): readonly Readonly<{ name: string; isType: boolean }>[] => {
563
+ if (tsm.ts.isImportDeclaration(statement)) {
564
+ const clause = statement.importClause;
565
+
566
+ const bindings = clause?.namedBindings;
567
+
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);
573
+
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);
581
+
582
+ const defaultImport =
583
+ clause?.name === undefined
584
+ ? ([] as const)
585
+ : ([{ name: clause.name.text, isType: true }] as const);
586
+
587
+ return [...defaultImport, ...namespaceImport, ...named];
588
+ }
589
+
590
+ if (tsm.ts.isImportEqualsDeclaration(statement)) {
591
+ return [{ name: statement.name.text, isType: true }];
592
+ }
593
+
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
+ }
604
+
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
+ }
613
+
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
+ );
620
+ }
621
+
622
+ return [];
623
+ };
624
+
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);
641
+
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
+ };
670
+
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;
677
+
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();
693
+
694
+ const first = sourceFile.getStatements().at(0);
695
+
696
+ if (first === undefined) {
697
+ return text.length;
698
+ }
699
+
700
+ const comments =
701
+ tsm.ts.getLeadingCommentRanges(text, first.getFullStart()) ?? [];
702
+
703
+ let mut_position = first.getStart();
704
+
705
+ for (const comment of comments.toReversed()) {
706
+ const commentText = text.slice(comment.pos, comment.end);
707
+
708
+ if (
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)
713
+ ) {
714
+ break;
715
+ }
716
+
717
+ mut_position = comment.pos;
718
+ }
719
+
720
+ return mut_position;
721
+ };
722
+
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
+ );
737
+
738
+ return statements.slice(0, end === -1 ? statements.length : end);
739
+ };
740
+
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();
747
+
748
+ const hosts = imports.filter(
749
+ (decl) =>
750
+ decl.getModuleSpecifierValue() === READONLY_RECORD_MODULE &&
751
+ tsm.Node.isNamedImports(decl.getImportClause()?.getNamedBindings()),
752
+ );
753
+
754
+ const host = hosts.find((decl) => decl.isTypeOnly()) ?? hosts.at(0);
755
+
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
+ });
763
+
764
+ return;
765
+ }
766
+
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;
771
+
772
+ const anchorEnd =
773
+ imports.at(-1)?.getEnd() ?? directivePrologue(sourceFile).at(-1)?.end;
774
+
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
+ };
784
+
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
+
504
817
  //
505
818
  // Transformer implementation for each node type
506
819
  //
@@ -215,6 +215,13 @@ describe(convertToReadonlyTransformer, () => {
215
215
 
216
216
  describe('Records', () => {
217
217
  describe('recordStyle: "ReadonlyRecord" (default)', () => {
218
+ // These sources are scripts, which get `Readonly<Record<K, V>>` unless
219
+ // the name is in scope; how it is brought into scope is tested in
220
+ // convert-to-readonly-record-import.test.mts.
221
+ const options = {
222
+ importStyle: 'global',
223
+ } as const satisfies ReadonlyTransformerOptions;
224
+
218
225
  test.each([
219
226
  {
220
227
  name: 'Type alias',
@@ -224,6 +231,7 @@ describe(convertToReadonlyTransformer, () => {
224
231
  expected: dedent`
225
232
  type Foo = ReadonlyRecord<string, number>
226
233
  `,
234
+ options,
227
235
  },
228
236
  {
229
237
  name: 'ReadonlyRecord<K, V> is unchanged',
@@ -233,6 +241,7 @@ describe(convertToReadonlyTransformer, () => {
233
241
  expected: dedent`
234
242
  type Foo = ReadonlyRecord<string, number>
235
243
  `,
244
+ options,
236
245
  },
237
246
  {
238
247
  name: 'Readonly<Record<K, V>> is unified to ReadonlyRecord<K, V>',
@@ -242,6 +251,7 @@ describe(convertToReadonlyTransformer, () => {
242
251
  expected: dedent`
243
252
  type Foo = ReadonlyRecord<string, number>
244
253
  `,
254
+ options,
245
255
  },
246
256
  {
247
257
  name: 'Redundant Readonly<ReadonlyRecord<K, V>> is normalized',
@@ -251,6 +261,7 @@ describe(convertToReadonlyTransformer, () => {
251
261
  expected: dedent`
252
262
  type Foo = ReadonlyRecord<string, number>
253
263
  `,
264
+ options,
254
265
  },
255
266
  {
256
267
  name: 'Value type is converted recursively',
@@ -260,6 +271,7 @@ describe(convertToReadonlyTransformer, () => {
260
271
  expected: dedent`
261
272
  type Foo = ReadonlyRecord<string, readonly number[]>
262
273
  `,
274
+ options,
263
275
  },
264
276
  {
265
277
  name: 'Value type inside Readonly<Record<K, V>> is converted recursively',
@@ -269,6 +281,7 @@ describe(convertToReadonlyTransformer, () => {
269
281
  expected: dedent`
270
282
  type Foo = ReadonlyRecord<string, readonly number[]>
271
283
  `,
284
+ options,
272
285
  },
273
286
  {
274
287
  name: 'Value type inside ReadonlyRecord<K, V> is converted recursively',
@@ -278,6 +291,7 @@ describe(convertToReadonlyTransformer, () => {
278
291
  expected: dedent`
279
292
  type Foo = ReadonlyRecord<string, readonly number[]>
280
293
  `,
294
+ options,
281
295
  },
282
296
  {
283
297
  name: 'In type literal member',
@@ -287,6 +301,7 @@ describe(convertToReadonlyTransformer, () => {
287
301
  expected: dedent`
288
302
  type Foo = Readonly<{ a: ReadonlyRecord<string, number> }>
289
303
  `,
304
+ options,
290
305
  },
291
306
  {
292
307
  name: 'In function args',
@@ -296,6 +311,7 @@ describe(convertToReadonlyTransformer, () => {
296
311
  expected: dedent`
297
312
  function foo(a: ReadonlyRecord<string, number>, b: Promise<ReadonlyRecord<string, number>>) {}
298
313
  `,
314
+ options,
299
315
  },
300
316
  {
301
317
  name: 'DeepReadonly<Record<K, V>> is unchanged',
@@ -305,6 +321,7 @@ describe(convertToReadonlyTransformer, () => {
305
321
  expected: dedent`
306
322
  type Foo = DeepReadonly<Record<string, number[]>>
307
323
  `,
324
+ options,
308
325
  },
309
326
  {
310
327
  name: 'DeepReadonly<ReadonlyRecord<K, V>> is unchanged',
@@ -314,6 +331,7 @@ describe(convertToReadonlyTransformer, () => {
314
331
  expected: dedent`
315
332
  type Foo = DeepReadonly<ReadonlyRecord<string, number>>
316
333
  `,
334
+ options,
317
335
  },
318
336
  {
319
337
  name: 'Record<K, V> under indexed access is kept as is',
@@ -323,6 +341,7 @@ describe(convertToReadonlyTransformer, () => {
323
341
  expected: dedent`
324
342
  type Foo = Record<string, number>[string]
325
343
  `,
344
+ options,
326
345
  },
327
346
  {
328
347
  name: 'ReadonlyRecord<K, V> under indexed access is unchanged',
@@ -332,6 +351,7 @@ describe(convertToReadonlyTransformer, () => {
332
351
  expected: dedent`
333
352
  type Foo = ReadonlyRecord<string, number>[string]
334
353
  `,
354
+ options,
335
355
  },
336
356
  ])('$name', testFn);
337
357
  });