ts-codemod-lib 3.4.4 → 3.5.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 (42) hide show
  1. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  2. package/dist/functions/ast-transformers/append-as-const.mjs +3 -3
  3. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  4. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts.map +1 -1
  5. package/dist/functions/ast-transformers/convert-interface-to-type.mjs +2 -1
  6. package/dist/functions/ast-transformers/convert-interface-to-type.mjs.map +1 -1
  7. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.mjs +2 -2
  9. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  10. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts.map +1 -1
  11. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs +3 -2
  12. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs.map +1 -1
  13. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts.map +1 -1
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +6 -5
  15. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  16. package/dist/functions/functions/has-disable-next-line-comment.d.mts.map +1 -1
  17. package/dist/functions/functions/has-disable-next-line-comment.mjs +56 -4
  18. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  19. package/dist/functions/utils/index.d.mts +1 -0
  20. package/dist/functions/utils/index.d.mts.map +1 -1
  21. package/dist/functions/utils/index.mjs +1 -0
  22. package/dist/functions/utils/index.mjs.map +1 -1
  23. package/dist/functions/utils/replace-node-with-text.d.mts +21 -0
  24. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -0
  25. package/dist/functions/utils/replace-node-with-text.mjs +22 -0
  26. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -0
  27. package/dist/functions/utils/replace-with-debug.d.mts.map +1 -1
  28. package/dist/functions/utils/replace-with-debug.mjs +2 -1
  29. package/dist/functions/utils/replace-with-debug.mjs.map +1 -1
  30. package/package.json +13 -13
  31. package/src/functions/ast-transformers/append-as-const.mts +6 -3
  32. package/src/functions/ast-transformers/convert-interface-to-type.mts +2 -1
  33. package/src/functions/ast-transformers/convert-to-readonly.mts +5 -2
  34. package/src/functions/ast-transformers/convert-to-readonly.test.mts +9 -20
  35. package/src/functions/ast-transformers/replace-any-with-unknown.mts +3 -2
  36. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +7 -5
  37. package/src/functions/functions/has-disable-next-line-comment.mts +79 -6
  38. package/src/functions/functions/has-disable-next-line-comment.test.mts +96 -0
  39. package/src/functions/utils/index.mts +1 -0
  40. package/src/functions/utils/replace-node-with-text.mts +37 -0
  41. package/src/functions/utils/replace-node-with-text.test.mts +89 -0
  42. package/src/functions/utils/replace-with-debug.mts +2 -1
@@ -0,0 +1,21 @@
1
+ import type * as tsm from 'ts-morph';
2
+ /**
3
+ * `node.replaceWithText(newNodeText)`, sparing a one-line text the indentation
4
+ * lookup.
5
+ *
6
+ * `replaceWithText` writes the text through a writer whose indentation it
7
+ * looks up first, for the lines after the first one, and the lookup reparses
8
+ * the whole file in the language service: on top of the reparse every
9
+ * replacement makes anyway, once per replacement, so it is quadratic in the
10
+ * size of the file. A text with no `\n` has no line after the first, the
11
+ * indentation is never applied, and a plain writer from the same project
12
+ * writes the same text — so the lookup is skipped. A multi-line text takes
13
+ * the original path.
14
+ *
15
+ * The writer is passed as `replaceWithText`'s second argument, which its
16
+ * declaration leaves out and ts-morph passes itself when it sets an
17
+ * expression. Were it ever dropped, the method would look the indentation up
18
+ * as before: slower, never different.
19
+ */
20
+ export declare const replaceNodeWithText: (node: tsm.Node, newNodeText: string) => tsm.Node;
21
+ //# sourceMappingURL=replace-node-with-text.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-node-with-text.d.mts","sourceRoot":"","sources":["../../../src/functions/utils/replace-node-with-text.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAErC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,SACxB,GAAG,CAAC,IAAI,eACD,MAAM,KAClB,GAAG,CAAC,IAOA,CAAC"}
@@ -0,0 +1,22 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+ export const replaceNodeWithText = (node, newNodeText) => newNodeText.includes('\n')
20
+ ? node.replaceWithText(newNodeText)
21
+ : node.replaceWithText.call(node, newNodeText, node.getProject().createWriter());
22
+ //# sourceMappingURL=replace-node-with-text.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-node-with-text.mjs","sourceRoot":"","sources":["../../../src/functions/utils/replace-node-with-text.mts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CACjC,IAAc,EACd,WAAmB,EACT,EAAE,CACZ,WAAW,CAAC,QAAQ,CAAC,IAAI,CAAC;IACxB,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;IACnC,CAAC,CAAE,IAAI,CAAC,eAA6C,CAAC,IAAI,CACtD,IAAI,EACJ,WAAW,EACX,IAAI,CAAC,UAAU,EAAE,CAAC,YAAY,EAAE,CACjC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"replace-with-debug.d.mts","sourceRoot":"","sources":["../../../src/functions/utils/replace-with-debug.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAErC,eAAO,MAAM,yBAAyB,SAC9B,GAAG,CAAC,IAAI,eACD,MAAM,KAClB,IAIF,CAAC"}
1
+ {"version":3,"file":"replace-with-debug.d.mts","sourceRoot":"","sources":["../../../src/functions/utils/replace-with-debug.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAGrC,eAAO,MAAM,yBAAyB,SAC9B,GAAG,CAAC,IAAI,eACD,MAAM,KAClB,IAIF,CAAC"}
@@ -1,5 +1,6 @@
1
+ import { replaceNodeWithText } from './replace-node-with-text.mjs';
1
2
  export const replaceNodeWithDebugPrint = (node, newNodeText) => {
2
3
  console.debug(`${node.getText()} -> ${newNodeText}`);
3
- node.replaceWithText(newNodeText);
4
+ replaceNodeWithText(node, newNodeText);
4
5
  };
5
6
  //# sourceMappingURL=replace-with-debug.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"replace-with-debug.mjs","sourceRoot":"","sources":["../../../src/functions/utils/replace-with-debug.mts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,yBAAyB,GAAG,CACvC,IAAc,EACd,WAAmB,EACb,EAAE;IACR,OAAO,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,OAAO,WAAW,EAAE,CAAC,CAAC;IAErD,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC,CAAC;AACpC,CAAC,CAAC"}
1
+ {"version":3,"file":"replace-with-debug.mjs","sourceRoot":"","sources":["../../../src/functions/utils/replace-with-debug.mts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAEnE,MAAM,CAAC,MAAM,yBAAyB,GAAG,CACvC,IAAc,EACd,WAAmB,EACb,EAAE;IACR,OAAO,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,OAAO,WAAW,EAAE,CAAC,CAAC;IAErD,mBAAmB,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;AACzC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-codemod-lib",
3
- "version": "3.4.4",
3
+ "version": "3.5.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript",
@@ -38,7 +38,7 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "dependencies": {
41
- "ts-data-forge": "^14.7.1",
41
+ "ts-data-forge": "^14.8.0",
42
42
  "ts-morph": "^28.0.0",
43
43
  "ts-type-forge": "^9.2.2"
44
44
  },
@@ -51,10 +51,10 @@
51
51
  "dedent": "1.7.2",
52
52
  "esbuild": "0.28.2",
53
53
  "eslint": "10.12.0",
54
- "eslint-config-typed": "5.18.0",
55
- "eslint-plugin-ts-data-forge": "0.8.6",
56
- "eslint-plugin-ts-fortress": "1.3.3",
57
- "eslint-plugin-ts-type-forge": "0.8.8",
54
+ "eslint-config-typed": "5.19.0",
55
+ "eslint-plugin-ts-data-forge": "0.9.0",
56
+ "eslint-plugin-ts-fortress": "1.3.4",
57
+ "eslint-plugin-ts-type-forge": "0.8.9",
58
58
  "jiti": "2.7.0",
59
59
  "npm-run-all2": "9.0.3",
60
60
  "playwright": "1.64.0",
@@ -75,7 +75,7 @@
75
75
  "pnpm": ">=9.0.0"
76
76
  },
77
77
  "volta": {
78
- "node": "26.11.1"
78
+ "node": "26.12.0"
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public",
@@ -86,11 +86,11 @@
86
86
  "check:cspell": "cspell \"**\" --gitignore --gitignore-root ../../ --no-progress",
87
87
  "check:dist": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-dist.mts",
88
88
  "check:ext": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-ext.mts",
89
- "check:lint": "oxlint . && eslint .",
90
- "check:test": "pnpm run z:vitest:node run",
91
- "check:test:browser": "pnpm run z:vitest --project='Browser' run",
92
- "check:test:cov": "pnpm run z:vitest:node run --coverage",
93
- "check:types": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
89
+ "check:lint": "node ../../tools/scripts/cmd/assert-deps-built.mts && oxlint . && eslint .",
90
+ "check:test": "node ../../tools/scripts/cmd/assert-deps-built.mts && pnpm run z:vitest:node run",
91
+ "check:test:browser": "node ../../tools/scripts/cmd/assert-deps-built.mts && pnpm run z:vitest --project='Browser' run",
92
+ "check:test:cov": "node ../../tools/scripts/cmd/assert-deps-built.mts && pnpm run z:vitest:node run --coverage",
93
+ "check:types": "node ../../tools/scripts/cmd/assert-deps-built.mts && node ../../node_modules/typescript-native/bin/tsc --noEmit",
94
94
  "check-all": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-all.mts",
95
95
  "clean": "pnpm run /clean:.*/",
96
96
  "clean:logs": "npx rimraf ./*.log",
@@ -102,7 +102,7 @@
102
102
  "fix:fmt": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-uncommitted.mts --cwd .",
103
103
  "fix:fmt:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-diff-from.mts origin/main --cwd .",
104
104
  "fix:fmt:full": "prettier --write .",
105
- "fix:lint": "oxlint . --fix && eslint . --fix",
105
+ "fix:lint": "node ../../tools/scripts/cmd/assert-deps-built.mts && oxlint . --fix && eslint . --fix",
106
106
  "fmt": "pnpm run fix:fmt",
107
107
  "gen": "run-s gen:index gen:readme gen:jsdoc",
108
108
  "gen:index": "run-s gen:index:src fix:fmt",
@@ -5,7 +5,10 @@ import {
5
5
  hasDisableNextLineComment,
6
6
  isAsConstNode,
7
7
  } from '../functions/index.mjs';
8
- import { replaceNodeWithDebugPrint } from '../utils/index.mjs';
8
+ import {
9
+ replaceNodeWithDebugPrint,
10
+ replaceNodeWithText,
11
+ } from '../utils/index.mjs';
9
12
  import type { TsMorphTransformer } from './types.mjs';
10
13
 
11
14
  const TRANSFORMER_NAME = 'append-as-const';
@@ -27,7 +30,7 @@ export const appendAsConstTransformer = (
27
30
  replaceNode:
28
31
  options?.debug === true
29
32
  ? replaceNodeWithDebugPrint
30
- : (node, newNodeText) => node.replaceWithText(newNodeText),
33
+ : (node, newNodeText) => replaceNodeWithText(node, newNodeText),
31
34
  } as const;
32
35
 
33
36
  return {
@@ -319,7 +322,7 @@ const transformNode = (
319
322
 
320
323
  options.debugPrint(`${node.getText()} -> ${asConstText}`);
321
324
 
322
- transformNode(node.replaceWithText(asConstText), context, options);
325
+ transformNode(replaceNodeWithText(node, asConstText), context, options);
323
326
 
324
327
  return;
325
328
  }
@@ -3,6 +3,7 @@
3
3
  import { Arr } from 'ts-data-forge';
4
4
  import type * as tsm from 'ts-morph';
5
5
  import { hasDisableNextLineComment } from '../functions/index.mjs';
6
+ import { replaceNodeWithText } from '../utils/index.mjs';
6
7
  import type { TsMorphTransformer } from './types.mjs';
7
8
 
8
9
  const TRANSFORMER_NAME = 'convert-interface-to-type';
@@ -91,7 +92,7 @@ export const convertInterfaceToTypeTransformer = (): TsMorphTransformer =>
91
92
  const typeAliasText =
92
93
  `${jsDocText}${exportKeyword}type ${interfaceName}${typeParamsStr} = ${mut_typeBody};` as const;
93
94
 
94
- interfaceDecl.replaceWithText(typeAliasText);
95
+ replaceNodeWithText(interfaceDecl, typeAliasText);
95
96
  };
96
97
 
97
98
  const buildTypeLiteral = (
@@ -29,7 +29,10 @@ import {
29
29
  wrapWithParentheses,
30
30
  type ReadonlyTypeReferenceNode,
31
31
  } from '../functions/index.mjs';
32
- import { replaceNodeWithDebugPrint } from '../utils/index.mjs';
32
+ import {
33
+ replaceNodeWithDebugPrint,
34
+ replaceNodeWithText,
35
+ } from '../utils/index.mjs';
33
36
  import {
34
37
  groupUnionIntersectionTypes,
35
38
  invalidDeepReadonlyTypeName,
@@ -72,7 +75,7 @@ export const convertToReadonlyTransformer = (
72
75
  replaceNode:
73
76
  options?.debug === true
74
77
  ? replaceNodeWithDebugPrint
75
- : (node, newNodeText) => node.replaceWithText(newNodeText),
78
+ : (node, newNodeText) => replaceNodeWithText(node, newNodeText),
76
79
  } as const;
77
80
 
78
81
  return {
@@ -7,9 +7,7 @@ import * as prettierPluginEstree from 'prettier/plugins/estree';
7
7
  import * as prettierPluginTypeScript from 'prettier/plugins/typescript';
8
8
  import * as prettier from 'prettier/standalone';
9
9
  import { expectType } from 'ts-data-forge';
10
- // Aliased: an import named `DeepReadonly` makes `vitest/prefer-describe-function-title`
11
- // rewrite `describe('DeepReadonly', ...)` into a reference to this type.
12
- import type { DeepReadonly as TsTypeForgeDeepReadonly } from 'ts-type-forge';
10
+ import type { DeepReadonly } from 'ts-type-forge';
13
11
  import {
14
12
  convertToReadonlyTransformer,
15
13
  type ReadonlyTransformerOptions,
@@ -3090,34 +3088,25 @@ describe(convertToReadonlyTransformer, () => {
3090
3088
  type B = Readonly<{ b: string[] }>;
3091
3089
 
3092
3090
  // `|` commutes with DeepReadonly, so merging a union is safe.
3093
- expectType<
3094
- TsTypeForgeDeepReadonly<A | B>,
3095
- TsTypeForgeDeepReadonly<A> | TsTypeForgeDeepReadonly<B>
3096
- >('=');
3091
+ expectType<DeepReadonly<A | B>, DeepReadonly<A> | DeepReadonly<B>>('=');
3097
3092
 
3098
- expectType<
3099
- TsTypeForgeDeepReadonly<F | X>,
3100
- TsTypeForgeDeepReadonly<F> | TsTypeForgeDeepReadonly<X>
3101
- >('=');
3093
+ expectType<DeepReadonly<F | X>, DeepReadonly<F> | DeepReadonly<X>>('=');
3102
3094
 
3103
3095
  expectType<
3104
- TsTypeForgeDeepReadonly<readonly string[] | X>,
3105
- TsTypeForgeDeepReadonly<readonly string[]> | TsTypeForgeDeepReadonly<X>
3096
+ DeepReadonly<readonly string[] | X>,
3097
+ DeepReadonly<readonly string[]> | DeepReadonly<X>
3106
3098
  >('=');
3107
3099
 
3108
3100
  // `&` does not: an intersection is tested as a whole and matches every
3109
3101
  // branch any one member matches. `F & X` hits the function branch and
3110
3102
  // comes back unchanged, leaving `x` mutable.
3111
- expectType<TsTypeForgeDeepReadonly<F & X>, F & X>('=');
3103
+ expectType<DeepReadonly<F & X>, F & X>('=');
3112
3104
 
3113
- expectType<
3114
- TsTypeForgeDeepReadonly<F & X>,
3115
- TsTypeForgeDeepReadonly<F> & TsTypeForgeDeepReadonly<X>
3116
- >('!=');
3105
+ expectType<DeepReadonly<F & X>, DeepReadonly<F> & DeepReadonly<X>>('!=');
3117
3106
 
3118
3107
  expectType<
3119
- TsTypeForgeDeepReadonly<readonly string[] & X>,
3120
- TsTypeForgeDeepReadonly<readonly string[]> & TsTypeForgeDeepReadonly<X>
3108
+ DeepReadonly<readonly string[] & X>,
3109
+ DeepReadonly<readonly string[]> & DeepReadonly<X>
3121
3110
  >('!=');
3122
3111
 
3123
3112
  // `Readonly` over type literals does commute with `&`, which is what
@@ -4,6 +4,7 @@ import {
4
4
  isSpreadNamedTupleMemberNode,
5
5
  isSpreadParameterNode,
6
6
  } from '../functions/index.mjs';
7
+ import { replaceNodeWithText } from '../utils/index.mjs';
7
8
  import type { TsMorphTransformer } from './types.mjs';
8
9
 
9
10
  const TRANSFORMER_NAME = 'replace-any-with-unknown';
@@ -37,12 +38,12 @@ const transformNode = (node: tsm.Node): void => {
37
38
  // `[name: E0, ...args: any)]` -> `[name: E0, ...args: unknown[]]`
38
39
  isSpreadNamedTupleMemberNode(parent))
39
40
  ) {
40
- anyKeywordNode.replaceWithText('readonly unknown[]');
41
+ replaceNodeWithText(anyKeywordNode, 'readonly unknown[]');
41
42
 
42
43
  return;
43
44
  }
44
45
 
45
- anyKeywordNode.replaceWithText('unknown');
46
+ replaceNodeWithText(anyKeywordNode, 'unknown');
46
47
 
47
48
  return;
48
49
  }
@@ -2,6 +2,7 @@
2
2
  import { Arr } from 'ts-data-forge';
3
3
  import * as tsm from 'ts-morph';
4
4
  import { hasDisableNextLineComment } from '../functions/index.mjs';
5
+ import { replaceNodeWithText } from '../utils/index.mjs';
5
6
  import type { TsMorphTransformer } from './types.mjs';
6
7
 
7
8
  const TRANSFORMER_NAME = 'replace-record-with-unknown-record';
@@ -88,7 +89,8 @@ const processInterfaceDeclaration = (
88
89
 
89
90
  const interfaceName = interfaceDecl.getName();
90
91
 
91
- interfaceDecl.replaceWithText(
92
+ replaceNodeWithText(
93
+ interfaceDecl,
92
94
  `${exportPrefix}type ${interfaceName} = UnknownRecord;`,
93
95
  );
94
96
 
@@ -128,7 +130,7 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
128
130
  Arr.isFixedLengthArray(1, indexSigs) &&
129
131
  isStringUnknownIndexSignature(indexSigs[0])
130
132
  ) {
131
- node.replaceWithText('UnknownRecord');
133
+ replaceNodeWithText(node, 'UnknownRecord');
132
134
 
133
135
  return true;
134
136
  }
@@ -156,7 +158,7 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
156
158
  Arr.isFixedLengthArray(1, indexSigs) &&
157
159
  isStringUnknownIndexSignature(indexSigs[0])
158
160
  ) {
159
- node.replaceWithText('UnknownRecord');
161
+ replaceNodeWithText(node, 'UnknownRecord');
160
162
 
161
163
  return true;
162
164
  }
@@ -229,7 +231,7 @@ const replaceIfRecordUnknown = (node: tsm.TypeReferenceNode): boolean => {
229
231
  typeArgs[0].getText() === 'string' &&
230
232
  typeArgs[1].getText() === 'unknown'
231
233
  ) {
232
- node.replaceWithText('UnknownRecord');
234
+ replaceNodeWithText(node, 'UnknownRecord');
233
235
 
234
236
  return true;
235
237
  }
@@ -255,7 +257,7 @@ const replaceIfRecordUnknown = (node: tsm.TypeReferenceNode): boolean => {
255
257
  innerTypeArgs[0].getText() === 'string' &&
256
258
  innerTypeArgs[1].getText() === 'unknown'
257
259
  ) {
258
- node.replaceWithText('UnknownRecord');
260
+ replaceNodeWithText(node, 'UnknownRecord');
259
261
 
260
262
  return true;
261
263
  }
@@ -1,3 +1,4 @@
1
+ import { Arr } from 'ts-data-forge';
1
2
  import * as tsm from 'ts-morph';
2
3
  import { IGNORE_LINE_COMMENT_PREFIXES } from '../constants/index.mjs';
3
4
 
@@ -19,16 +20,22 @@ export const hasDisableNextLineComment = (
19
20
  node: tsm.Node,
20
21
  transformerName?: string,
21
22
  ): boolean => {
22
- const nodeStartLine = node.getStartLineNumber();
23
+ // Most nodes have no leading comment, and then nothing below can match.
24
+ // Asking first spares them the line numbers, the costly part.
25
+ const leadingCommentRanges = node.getLeadingCommentRanges();
23
26
 
24
- // Cannot be ignored if it's on the first line
25
- if (nodeStartLine <= 1) {
27
+ if (Arr.isEmpty(leadingCommentRanges)) {
26
28
  return false;
27
29
  }
28
30
 
29
- const sourceFile = node.getSourceFile(); // Get the SourceFile
31
+ const lineNumberAt = lineNumberLookup(node.getSourceFile().compilerNode);
30
32
 
31
- const leadingCommentRanges = node.getLeadingCommentRanges();
33
+ const nodeStartLine = lineNumberAt(node.getStart());
34
+
35
+ // Cannot be ignored if it's on the first line
36
+ if (nodeStartLine <= 1) {
37
+ return false;
38
+ }
32
39
 
33
40
  // Iterate backwards through comments as the closest one is most relevant
34
41
  for (let mut_i = leadingCommentRanges.length - 1; mut_i >= 0; mut_i--) {
@@ -38,7 +45,7 @@ export const hasDisableNextLineComment = (
38
45
  // Get the end position and convert it to line number
39
46
  const commentEndPos = commentRange.getEnd();
40
47
 
41
- const commentEndLine = sourceFile.getLineAndColumnAtPos(commentEndPos).line;
48
+ const commentEndLine = lineNumberAt(commentEndPos);
42
49
 
43
50
  // Check if the comment is on the immediately preceding line
44
51
  if (nodeStartLine === commentEndLine + 1) {
@@ -91,3 +98,69 @@ export const hasDisableNextLineComment = (
91
98
 
92
99
  return false;
93
100
  };
101
+
102
+ /**
103
+ * The 1-based line number of a position in `sourceFile`, counted the way
104
+ * ts-morph's `getStartLineNumber` and `getLineAndColumnAtPos` count it: one
105
+ * plus the number of `\n` before the position (a lone `\r` starts no line,
106
+ * unlike in the compiler's own line map).
107
+ *
108
+ * ts-morph scans the text from its start on every call, which made this
109
+ * check, asked of every node a transformer visits, quadratic in the size of
110
+ * the file. The newline positions are collected once per compiler source file
111
+ * instead. ts-morph replaces that object whenever the file is edited, so an
112
+ * entry never outlives the text it was collected from.
113
+ */
114
+ const lineNumberLookup = (
115
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
116
+ sourceFile: tsm.ts.SourceFile,
117
+ ): ((pos: number) => number) => {
118
+ const cached = newlinePositionsCache.get(sourceFile);
119
+
120
+ const newlinePositions = cached ?? newlinePositionsOf(sourceFile.text);
121
+
122
+ if (cached === undefined) {
123
+ newlinePositionsCache.set(sourceFile, newlinePositions);
124
+ }
125
+
126
+ return (pos) => countLessThan(newlinePositions, pos) + 1;
127
+ };
128
+
129
+ const newlinePositionsCache = new WeakMap<
130
+ tsm.ts.SourceFile,
131
+ readonly number[]
132
+ >();
133
+
134
+ const newlinePositionsOf = (text: string): readonly number[] => {
135
+ const mut_positions: number[] = [];
136
+
137
+ for (
138
+ let mut_index = text.indexOf('\n');
139
+ mut_index !== -1;
140
+ mut_index = text.indexOf('\n', mut_index + 1)
141
+ ) {
142
+ mut_positions.push(mut_index);
143
+ }
144
+
145
+ return mut_positions;
146
+ };
147
+
148
+ /** The number of elements of the ascending `sorted` that are below `value`. */
149
+ const countLessThan = (sorted: readonly number[], value: number): number => {
150
+ let mut_low = 0;
151
+
152
+ let mut_high = sorted.length;
153
+
154
+ while (mut_low < mut_high) {
155
+ const mid = Math.floor((mut_low + mut_high) / 2);
156
+
157
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
158
+ if (sorted[mid]! < value) {
159
+ mut_low = mid + 1;
160
+ } else {
161
+ mut_high = mid;
162
+ }
163
+ }
164
+
165
+ return mut_low;
166
+ };
@@ -0,0 +1,96 @@
1
+ import * as tsm from 'ts-morph';
2
+ import { hasDisableNextLineComment } from './has-disable-next-line-comment.mjs';
3
+
4
+ /** The first type alias in `source`, the node every case asks about. */
5
+ const typeAliasOf = (source: string): tsm.TypeAliasDeclaration => {
6
+ const project = new tsm.Project({ useInMemoryFileSystem: true });
7
+
8
+ return project
9
+ .createSourceFile('source.ts', source)
10
+ .getFirstDescendantByKindOrThrow(tsm.SyntaxKind.TypeAliasDeclaration);
11
+ };
12
+
13
+ describe(hasDisableNextLineComment, () => {
14
+ test.each([
15
+ {
16
+ name: 'a comment on the line above (LF)',
17
+ source: '// transformer-ignore-next-line\ntype A = number[];\n',
18
+ expected: true,
19
+ },
20
+ {
21
+ name: 'a comment on the line above (CRLF)',
22
+ source: '// transformer-ignore-next-line\r\ntype A = number[];\r\n',
23
+ expected: true,
24
+ },
25
+ {
26
+ name: 'a comment two lines above',
27
+ source: '// transformer-ignore-next-line\n\ntype A = number[];\n',
28
+ expected: false,
29
+ },
30
+ {
31
+ name: 'a comment two lines above (CRLF)',
32
+ source: '// transformer-ignore-next-line\r\n\r\ntype A = number[];\r\n',
33
+ expected: false,
34
+ },
35
+ {
36
+ name: 'a later comment on the line above shadows an earlier ignore comment',
37
+ source:
38
+ '// transformer-ignore-next-line\n// something else\ntype A = number[];\n',
39
+ expected: false,
40
+ },
41
+ {
42
+ name: 'the node on the first line',
43
+ source: 'type A = number[];\n',
44
+ expected: false,
45
+ },
46
+ {
47
+ name: 'the node on the first line after a block comment',
48
+ source: '/* transformer-ignore-next-line */ type A = number[];\n',
49
+ expected: false,
50
+ },
51
+ {
52
+ name: 'a lone CR, which does not count as a line break',
53
+ source: '// transformer-ignore-next-line\rtype A = number[];\n',
54
+ expected: false,
55
+ },
56
+ {
57
+ name: 'a lone CR before the comment, deep in the file',
58
+ source:
59
+ 'const x = 1;\r\nconst y = 2;\r// transformer-ignore-next-line\ntype A = number[];\n',
60
+ expected: true,
61
+ },
62
+ {
63
+ name: 'a node far below many lines',
64
+ source: `${'const x = 1;\n'.repeat(500)}// transformer-ignore-next-line\ntype A = number[];\n`,
65
+ expected: true,
66
+ },
67
+ ])('$name', ({ source, expected }) => {
68
+ expect(hasDisableNextLineComment(typeAliasOf(source))).toBe(expected);
69
+ });
70
+
71
+ test('matches the transformer name list', () => {
72
+ const node = typeAliasOf(
73
+ '// transformer-ignore-next-line append-as-const, convert-to-readonly\ntype A = number[];\n',
74
+ );
75
+
76
+ assert.isTrue(hasDisableNextLineComment(node, 'convert-to-readonly'));
77
+
78
+ assert.isFalse(hasDisableNextLineComment(node, 'replace-any-with-unknown'));
79
+ });
80
+
81
+ test('reads the current text after the file has been edited', () => {
82
+ const node = typeAliasOf('type A = number[];\n');
83
+
84
+ assert.isFalse(hasDisableNextLineComment(node));
85
+
86
+ const sourceFile = node.getSourceFile();
87
+
88
+ sourceFile.insertText(0, '// transformer-ignore-next-line\n');
89
+
90
+ const edited = sourceFile.getFirstDescendantByKindOrThrow(
91
+ tsm.SyntaxKind.TypeAliasDeclaration,
92
+ );
93
+
94
+ assert.isTrue(hasDisableNextLineComment(edited));
95
+ });
96
+ });
@@ -1 +1,2 @@
1
+ export * from './replace-node-with-text.mjs';
1
2
  export * from './replace-with-debug.mjs';
@@ -0,0 +1,37 @@
1
+ import type * as tsm from 'ts-morph';
2
+
3
+ /**
4
+ * `node.replaceWithText(newNodeText)`, sparing a one-line text the indentation
5
+ * lookup.
6
+ *
7
+ * `replaceWithText` writes the text through a writer whose indentation it
8
+ * looks up first, for the lines after the first one, and the lookup reparses
9
+ * the whole file in the language service: on top of the reparse every
10
+ * replacement makes anyway, once per replacement, so it is quadratic in the
11
+ * size of the file. A text with no `\n` has no line after the first, the
12
+ * indentation is never applied, and a plain writer from the same project
13
+ * writes the same text — so the lookup is skipped. A multi-line text takes
14
+ * the original path.
15
+ *
16
+ * The writer is passed as `replaceWithText`'s second argument, which its
17
+ * declaration leaves out and ts-morph passes itself when it sets an
18
+ * expression. Were it ever dropped, the method would look the indentation up
19
+ * as before: slower, never different.
20
+ */
21
+ export const replaceNodeWithText = (
22
+ node: tsm.Node,
23
+ newNodeText: string,
24
+ ): tsm.Node =>
25
+ newNodeText.includes('\n')
26
+ ? node.replaceWithText(newNodeText)
27
+ : (node.replaceWithText as ReplaceWithTextWithWriter).call(
28
+ node,
29
+ newNodeText,
30
+ node.getProject().createWriter(),
31
+ );
32
+
33
+ type ReplaceWithTextWithWriter = (
34
+ this: tsm.Node,
35
+ textOrWriterFunction: string,
36
+ writer: tsm.CodeBlockWriter,
37
+ ) => tsm.Node;