eslint-plugin-ts-data-forge 0.8.6 → 0.9.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 (64) hide show
  1. package/README.md +20 -19
  2. package/dist/plugin.d.mts +2 -0
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +1 -0
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +16 -1
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rule-types.mjs.map +1 -1
  9. package/dist/rules/ast-utils.d.mts +21 -2
  10. package/dist/rules/ast-utils.d.mts.map +1 -1
  11. package/dist/rules/ast-utils.mjs +51 -4
  12. package/dist/rules/ast-utils.mjs.map +1 -1
  13. package/dist/rules/constants.d.mts +2 -0
  14. package/dist/rules/constants.d.mts.map +1 -1
  15. package/dist/rules/constants.mjs +9 -0
  16. package/dist/rules/constants.mjs.map +1 -1
  17. package/dist/rules/mutable-result.d.mts +10 -0
  18. package/dist/rules/mutable-result.d.mts.map +1 -0
  19. package/dist/rules/mutable-result.mjs +145 -0
  20. package/dist/rules/mutable-result.mjs.map +1 -0
  21. package/dist/rules/prefer-arr-generate.d.mts +57 -0
  22. package/dist/rules/prefer-arr-generate.d.mts.map +1 -0
  23. package/dist/rules/prefer-arr-generate.mjs +588 -0
  24. package/dist/rules/prefer-arr-generate.mjs.map +1 -0
  25. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  26. package/dist/rules/prefer-arr-uniq.mjs +2 -149
  27. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  28. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  29. package/dist/rules/prefer-canonical-array-slicing.mjs +47 -28
  30. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  31. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  32. package/dist/rules/prefer-is-non-null-object.mjs +110 -40
  33. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  34. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  35. package/dist/rules/prefer-is-record-and-has-key.mjs +102 -45
  36. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  37. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  38. package/dist/rules/prefer-range-for-loop.mjs +97 -16
  39. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  40. package/dist/rules/rules.d.mts +1 -0
  41. package/dist/rules/rules.d.mts.map +1 -1
  42. package/dist/rules/rules.mjs +2 -0
  43. package/dist/rules/rules.mjs.map +1 -1
  44. package/package.json +11 -11
  45. package/src/plugin.mts +1 -0
  46. package/src/rule-types.mts +17 -1
  47. package/src/rules/ast-utils.mts +72 -5
  48. package/src/rules/constants.mts +12 -0
  49. package/src/rules/mutable-result.mts +204 -0
  50. package/src/rules/prefer-arr-generate.mts +918 -0
  51. package/src/rules/prefer-arr-generate.test.mts +967 -0
  52. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +19 -0
  53. package/src/rules/prefer-arr-is-max-length-array.test.mts +8 -0
  54. package/src/rules/prefer-arr-is-min-length-array.test.mts +8 -0
  55. package/src/rules/prefer-arr-uniq.mts +2 -207
  56. package/src/rules/prefer-canonical-array-slicing.mts +57 -40
  57. package/src/rules/prefer-canonical-array-slicing.test.mts +55 -0
  58. package/src/rules/prefer-is-non-null-object.mts +160 -57
  59. package/src/rules/prefer-is-non-null-object.test.mts +138 -0
  60. package/src/rules/prefer-is-record-and-has-key.mts +148 -67
  61. package/src/rules/prefer-is-record-and-has-key.test.mts +118 -23
  62. package/src/rules/prefer-range-for-loop.mts +142 -21
  63. package/src/rules/prefer-range-for-loop.test.mts +127 -5
  64. package/src/rules/rules.mts +2 -0
@@ -0,0 +1,918 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ AST_TOKEN_TYPES,
4
+ ASTUtils,
5
+ type TSESLint,
6
+ type TSESTree,
7
+ } from '@typescript-eslint/utils';
8
+ import {
9
+ buildNamedImportFix,
10
+ createGetLayerTypes,
11
+ getArgumentText,
12
+ getImportDeclarations,
13
+ getImportedLocalName,
14
+ negationOf,
15
+ prefixedNegationOf,
16
+ skipTypeWrappers,
17
+ type NegationContext,
18
+ } from 'eslint-rule-forge';
19
+ import { Arr, hasKey, isRecord } from 'ts-data-forge';
20
+ import type { FixedLengthTuple } from 'ts-type-forge';
21
+ import * as ts from 'typescript';
22
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
23
+ import { needsMutableResult } from './mutable-result.mjs';
24
+
25
+ type Options = readonly [];
26
+
27
+ type MessageIds = 'useArrGenerate' | 'suggestArrGenerate';
28
+
29
+ /** A filter-and-map `flatMap` call, taken apart. */
30
+ type Match = Readonly<{
31
+ node: TSESTree.CallExpression;
32
+ replacement: string;
33
+ autofixable: boolean;
34
+ }>;
35
+
36
+ /**
37
+ * The callback of `xs.flatMap((x) => (cond ? [a, b] : []))` decides per
38
+ * element whether to keep it and what to keep. Written as a loop, that is
39
+ *
40
+ * ```ts
41
+ * Arr.generate(function* () {
42
+ * for (const x of xs) {
43
+ * if (cond) {
44
+ * yield a;
45
+ * yield b;
46
+ * }
47
+ * }
48
+ * });
49
+ * ```
50
+ *
51
+ * A block-bodied callback whose only `return` is its last statement works the
52
+ * same way: the statements before it move into the loop. When the empty array
53
+ * is the consequent (`cond ? [] : [a]`), the condition is negated the short way
54
+ * `negationOf` from eslint-rule-forge writes it (`a !== b`, `!a && !b`).
55
+ *
56
+ * The callback is reported only when the rewrite keeps its meaning:
57
+ *
58
+ * - The receiver is an array or a tuple at every layer of its type wrappers
59
+ * (an object with a `flatMap` method of its own is not touched).
60
+ * - The callback is a non-async arrow function with one parameter, which may
61
+ * be destructured but has no default: an index or array parameter has no
62
+ * counterpart in the loop.
63
+ * - Neither the callback nor the receiver reads `this`, `arguments`, `super`
64
+ * or `new.target`, which the generator function would rebind; the receiver
65
+ * names none of the parameter's bindings, which the loop variable would
66
+ * shadow (`node.children.flatMap((node) => …)`).
67
+ *
68
+ * The fix is applied automatically only when nothing it drops could have
69
+ * mattered; otherwise it is offered as a suggestion:
70
+ *
71
+ * - The result becomes a readonly array, so code that visibly needs a mutable
72
+ * one (see `needsMutableResult`) keeps a suggestion.
73
+ * - A type annotation on the parameter, a return type on the callback, or a
74
+ * type wrapper around the conditional or either branch
75
+ * (`[] as readonly T[]`), may be what types the elements, and the loop has
76
+ * no place for it.
77
+ * - The receiver moves into the generator function, where a narrowing of a
78
+ * property (`s.decl.items` after `s.decl !== null`) or of a variable that is
79
+ * reassigned somewhere no longer applies.
80
+ * - A `filter()` receiver becomes an intermediate array the loop iterates,
81
+ * which reads better as a condition inside the loop.
82
+ * - A comment the rewrite has no place for would be lost.
83
+ *
84
+ * One difference is not checked: `flatMap` skips the holes of a sparse array,
85
+ * while `for…of` visits them as `undefined`.
86
+ */
87
+ export const preferArrGenerate: TSESLint.RuleModule<MessageIds, Options> = {
88
+ meta: {
89
+ type: 'suggestion',
90
+ docs: {
91
+ description:
92
+ 'Replace the filter-and-map idiom `xs.flatMap((x) => (cond ? [f(x)] : []))` with a loop in `Arr.generate` from ts-data-forge.',
93
+ },
94
+ fixable: 'code',
95
+ hasSuggestions: true,
96
+ schema: [],
97
+ messages: {
98
+ useArrGenerate:
99
+ 'Replace this filter-and-map `flatMap` with a loop in `Arr.generate` from ts-data-forge.',
100
+ suggestArrGenerate:
101
+ 'Replace with a loop in `Arr.generate` from ts-data-forge (check the element type, and that the result may be readonly).',
102
+ },
103
+ },
104
+
105
+ create: (context) => {
106
+ const sourceCode = context.sourceCode;
107
+
108
+ const program = sourceCode.ast;
109
+
110
+ const tsDataForgeImports = getImportDeclarations(
111
+ program,
112
+ TS_DATA_FORGE_MODULE,
113
+ );
114
+
115
+ const services = sourceCode.parserServices;
116
+
117
+ const tsProgram = services?.program ?? undefined;
118
+
119
+ const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap;
120
+
121
+ // Without type information the rule cannot tell an array from another
122
+ // object with a `flatMap` method.
123
+ if (tsProgram === undefined || esTreeNodeToTSNodeMap === undefined) {
124
+ return {};
125
+ }
126
+
127
+ const checker = tsProgram.getTypeChecker();
128
+
129
+ const getLayerTypes = createGetLayerTypes({
130
+ program: tsProgram,
131
+ esTreeNodeToTSNodeMap,
132
+ });
133
+
134
+ // The negation is the test of the loop's `if`, where only its truthiness
135
+ // is read, so `!!a` may always be written `a`.
136
+ const negationContext: NegationContext = {
137
+ sourceCode,
138
+ checker,
139
+ getLayerTypes,
140
+ isBoolean: () => true,
141
+ };
142
+
143
+ const getTsNode = (
144
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
145
+ node: TSESTree.Node,
146
+ ): ts.Node | undefined => esTreeNodeToTSNodeMap.get(node);
147
+
148
+ const arrLocalName = getImportedLocalName(tsDataForgeImports, 'Arr');
149
+
150
+ const arrName = arrLocalName ?? 'Arr';
151
+
152
+ const mut_matches: Match[] = [];
153
+
154
+ return {
155
+ CallExpression: (node) => {
156
+ const callee = skipTypeWrappers(node.callee);
157
+
158
+ if (
159
+ node.optional ||
160
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
161
+ callee.optional ||
162
+ callee.computed ||
163
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
164
+ callee.property.name !== 'flatMap' ||
165
+ callee.object.type === AST_NODE_TYPES.Super ||
166
+ // A second argument is `thisArg`.
167
+ !Arr.isFixedLengthTuple(1, node.arguments)
168
+ ) {
169
+ return;
170
+ }
171
+
172
+ const callback = node.arguments[0];
173
+
174
+ // A wrapper around the callback may be what types its parameter, so
175
+ // a wrapped callback is not unwrapped.
176
+ if (
177
+ callback.type !== AST_NODE_TYPES.ArrowFunctionExpression ||
178
+ callback.async ||
179
+ !Arr.isFixedLengthTuple(1, callback.params)
180
+ ) {
181
+ return;
182
+ }
183
+
184
+ const param = callback.params[0];
185
+
186
+ if (
187
+ param.type !== AST_NODE_TYPES.Identifier &&
188
+ param.type !== AST_NODE_TYPES.ObjectPattern &&
189
+ param.type !== AST_NODE_TYPES.ArrayPattern
190
+ ) {
191
+ return;
192
+ }
193
+
194
+ // `(x?: T) => …` may be called without the element.
195
+ if (param.optional) {
196
+ return;
197
+ }
198
+
199
+ const typeArguments = node.typeArguments;
200
+
201
+ // `flatMap<U, This>` has no counterpart for `This`.
202
+ if (typeArguments !== undefined && typeArguments.params.length !== 1) {
203
+ return;
204
+ }
205
+
206
+ const shape = getFilterMapShape(sourceCode, callback);
207
+
208
+ if (shape === undefined) {
209
+ return;
210
+ }
211
+
212
+ const receiver = callee.object;
213
+
214
+ if (
215
+ (shape.prelude ?? []).some((statement) =>
216
+ someDescendant(sourceCode, statement, isReturnStatement, false),
217
+ ) ||
218
+ someDescendant(sourceCode, callback, rebindsInGenerator, true) ||
219
+ someDescendant(sourceCode, receiver, rebindsInGenerator, true)
220
+ ) {
221
+ return;
222
+ }
223
+
224
+ const paramNames = new Set(
225
+ sourceCode
226
+ .getDeclaredVariables(callback)
227
+ .map((variable) => variable.name),
228
+ );
229
+
230
+ if (
231
+ someDescendant(
232
+ sourceCode,
233
+ receiver,
234
+ (n) =>
235
+ n.type === AST_NODE_TYPES.Identifier &&
236
+ paramNames.has(n.name) &&
237
+ isReference(n) &&
238
+ !isBoundWithin(sourceCode, n, receiver),
239
+ true,
240
+ )
241
+ ) {
242
+ return;
243
+ }
244
+
245
+ // At every layer of type wrappers (see typeWrapperLayers).
246
+ const isArrayReceiver = getLayerTypes(receiver).every((type) =>
247
+ isArrayOrTupleType(checker, type),
248
+ );
249
+
250
+ if (!isArrayReceiver) {
251
+ return;
252
+ }
253
+
254
+ const replacement = buildReplacement({
255
+ sourceCode,
256
+ negationContext,
257
+ node,
258
+ arrName,
259
+ typeArgumentsText:
260
+ typeArguments === undefined
261
+ ? ''
262
+ : sourceCode.getText(typeArguments),
263
+ // The loop variable cannot carry the annotation; the suggestion
264
+ // leaves it to the receiver's element type.
265
+ paramText:
266
+ param.typeAnnotation === undefined
267
+ ? sourceCode.getText(param)
268
+ : sourceCode.text
269
+ .slice(param.range[0], param.typeAnnotation.range[0])
270
+ .trimEnd(),
271
+ receiver,
272
+ shape,
273
+ });
274
+
275
+ const autofixable =
276
+ !shape.wrapped &&
277
+ param.typeAnnotation === undefined &&
278
+ callback.returnType === undefined &&
279
+ !isFilterCall(receiver) &&
280
+ !someDescendant(
281
+ sourceCode,
282
+ receiver,
283
+ (n) =>
284
+ isNarrowedHere(
285
+ sourceCode,
286
+ checker,
287
+ getTsNode,
288
+ n,
289
+ n === skipTypeWrappers(receiver),
290
+ ),
291
+ true,
292
+ ) &&
293
+ !losesComments(sourceCode, callback, shape) &&
294
+ !needsMutableResult(sourceCode, checker, getTsNode, node);
295
+
296
+ mut_matches.push({ node, replacement, autofixable });
297
+ },
298
+ 'Program:exit': () => {
299
+ // The import is added by the first fix only, so that several fixes do
300
+ // not insert it at the same position. When the first match is not
301
+ // autofixable, the first autofixable one carries it instead.
302
+ const firstAutofixable = mut_matches.find((m) => m.autofixable);
303
+
304
+ for (const match of mut_matches) {
305
+ const buildFix =
306
+ (withImport: boolean): TSESLint.ReportFixFunction =>
307
+ (fixer) => [
308
+ ...(withImport && arrLocalName === undefined
309
+ ? buildNamedImportFix(fixer, program, TS_DATA_FORGE_MODULE, [
310
+ 'Arr',
311
+ ])
312
+ : []),
313
+ fixer.replaceText(match.node, match.replacement),
314
+ ];
315
+
316
+ if (match.autofixable) {
317
+ context.report({
318
+ node: match.node,
319
+ messageId: 'useArrGenerate',
320
+ fix: buildFix(match === firstAutofixable),
321
+ });
322
+ } else {
323
+ context.report({
324
+ node: match.node,
325
+ messageId: 'useArrGenerate',
326
+ suggest: [
327
+ {
328
+ messageId: 'suggestArrGenerate',
329
+ fix: buildFix(true),
330
+ },
331
+ ],
332
+ });
333
+ }
334
+ }
335
+ },
336
+ };
337
+ },
338
+ defaultOptions: [],
339
+ } as const;
340
+
341
+ /** The parts of a filter-and-map callback that the rewrite keeps. */
342
+ type FilterMapShape = Readonly<{
343
+ /** The statements before the `return` of a block body. */
344
+ prelude: readonly TSESTree.Statement[] | undefined;
345
+ /**
346
+ * The source of a block body from just inside its `{` up to its `return`:
347
+ * the statements before it, and every comment among or above them.
348
+ */
349
+ preludeRange: FixedLengthTuple<2, number> | undefined;
350
+ test: TSESTree.Expression;
351
+ /** Whether the kept branch is the alternate (`cond ? [] : [x]`). */
352
+ negated: boolean;
353
+ elements: readonly TSESTree.Expression[];
354
+ /** Whether a type wrapper surrounds the conditional or either branch. */
355
+ wrapped: boolean;
356
+ }>;
357
+
358
+ const getFilterMapShape = (
359
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
360
+ sourceCode: TSESLint.SourceCode,
361
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
362
+ callback: TSESTree.ArrowFunctionExpression,
363
+ ): FilterMapShape | undefined => {
364
+ const { body } = callback;
365
+
366
+ const returnStatement =
367
+ body.type === AST_NODE_TYPES.BlockStatement ? body.body.at(-1) : undefined;
368
+
369
+ if (
370
+ body.type === AST_NODE_TYPES.BlockStatement &&
371
+ (returnStatement?.type !== AST_NODE_TYPES.ReturnStatement ||
372
+ returnStatement.argument === null)
373
+ ) {
374
+ return undefined;
375
+ }
376
+
377
+ const returned =
378
+ body.type === AST_NODE_TYPES.BlockStatement
379
+ ? // eslint-disable-next-line total-functions/no-unsafe-type-assertion
380
+ (returnStatement as TSESTree.ReturnStatement).argument
381
+ : body;
382
+
383
+ if (returned === null) {
384
+ return undefined;
385
+ }
386
+
387
+ const conditional = skipTypeWrappers(returned);
388
+
389
+ if (conditional.type !== AST_NODE_TYPES.ConditionalExpression) {
390
+ return undefined;
391
+ }
392
+
393
+ const consequent = skipTypeWrappers(conditional.consequent);
394
+
395
+ const alternate = skipTypeWrappers(conditional.alternate);
396
+
397
+ if (
398
+ consequent.type !== AST_NODE_TYPES.ArrayExpression ||
399
+ alternate.type !== AST_NODE_TYPES.ArrayExpression
400
+ ) {
401
+ return undefined;
402
+ }
403
+
404
+ const negated = Arr.isEmpty(consequent.elements);
405
+
406
+ const kept = negated ? alternate : consequent;
407
+
408
+ const dropped = negated ? consequent : alternate;
409
+
410
+ if (!Arr.isEmpty(dropped.elements) || Arr.isEmpty(kept.elements)) {
411
+ return undefined;
412
+ }
413
+
414
+ const elements = kept.elements.filter(
415
+ (element): element is TSESTree.Expression =>
416
+ element !== null && element.type !== AST_NODE_TYPES.SpreadElement,
417
+ );
418
+
419
+ if (elements.length !== kept.elements.length) {
420
+ return undefined;
421
+ }
422
+
423
+ const openingBrace =
424
+ body.type === AST_NODE_TYPES.BlockStatement
425
+ ? sourceCode.getFirstToken(body)
426
+ : null;
427
+
428
+ const preludeStart =
429
+ openingBrace === null
430
+ ? undefined
431
+ : sourceCode.getTokenAfter(openingBrace, { includeComments: true })
432
+ ?.range[0];
433
+
434
+ return {
435
+ prelude:
436
+ body.type === AST_NODE_TYPES.BlockStatement
437
+ ? body.body.slice(0, -1)
438
+ : undefined,
439
+ preludeRange:
440
+ preludeStart === undefined ||
441
+ returnStatement === undefined ||
442
+ preludeStart >= returnStatement.range[0]
443
+ ? undefined
444
+ : [preludeStart, returnStatement.range[0]],
445
+ test: conditional.test,
446
+ negated,
447
+ elements,
448
+ wrapped:
449
+ conditional !== returned ||
450
+ consequent !== conditional.consequent ||
451
+ alternate !== conditional.alternate,
452
+ };
453
+ };
454
+
455
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
456
+ const buildReplacement = ({
457
+ sourceCode,
458
+ negationContext,
459
+ node,
460
+ arrName,
461
+ typeArgumentsText,
462
+ paramText,
463
+ receiver,
464
+ shape,
465
+ }: Readonly<{
466
+ sourceCode: TSESLint.SourceCode;
467
+ negationContext: NegationContext;
468
+ node: TSESTree.CallExpression;
469
+ arrName: string;
470
+ typeArgumentsText: string;
471
+ paramText: string;
472
+ receiver: TSESTree.Expression;
473
+ shape: FilterMapShape;
474
+ }>): string => {
475
+ const indent0 = lineIndentOf(sourceCode, node.range[0]);
476
+
477
+ const indent1 = `${indent0} ` as const;
478
+
479
+ const indent2 = `${indent1} ` as const;
480
+
481
+ const indent3 = `${indent2} ` as const;
482
+
483
+ const condition = reindent(
484
+ sourceCode,
485
+ shape.test.range,
486
+ indent2,
487
+ shape.negated
488
+ ? (negationOf(shape.test, negationContext, undefined) ??
489
+ prefixedNegationOf(shape.test, sourceCode))
490
+ : sourceCode.getText(shape.test),
491
+ );
492
+
493
+ const { preludeRange } = shape;
494
+
495
+ const preludeLines =
496
+ preludeRange === undefined
497
+ ? ([] as const)
498
+ : ([
499
+ `${indent2}${reindent(
500
+ sourceCode,
501
+ preludeRange,
502
+ indent2,
503
+ sourceCode.text.slice(...preludeRange).trimEnd(),
504
+ )}`,
505
+ ] as const);
506
+
507
+ const yieldLines = shape.elements.map(
508
+ (element) =>
509
+ `${indent3}yield ${reindent(
510
+ sourceCode,
511
+ element.range,
512
+ indent3,
513
+ getArgumentText(sourceCode, element),
514
+ )};`,
515
+ );
516
+
517
+ const receiverText = reindent(
518
+ sourceCode,
519
+ receiver.range,
520
+ indent1,
521
+ getArgumentText(sourceCode, receiver),
522
+ );
523
+
524
+ return [
525
+ `${arrName}.generate${typeArgumentsText}(function* () {`,
526
+ `${indent1}for (const ${paramText} of ${receiverText}) {`,
527
+ ...preludeLines,
528
+ `${indent2}if (${condition}) {`,
529
+ ...yieldLines,
530
+ `${indent2}}`,
531
+ `${indent1}}`,
532
+ `${indent0}})`,
533
+ ].join('\n');
534
+ };
535
+
536
+ /**
537
+ * Whether a comment inside `callback` lies outside every piece of source the
538
+ * rewrite carries over, and would therefore be dropped.
539
+ */
540
+ const losesComments = (
541
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
542
+ sourceCode: TSESLint.SourceCode,
543
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
544
+ callback: TSESTree.ArrowFunctionExpression,
545
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
546
+ shape: FilterMapShape,
547
+ ): boolean => {
548
+ const keptRanges: readonly FixedLengthTuple<2, number>[] = [
549
+ shape.test.range,
550
+ ...shape.elements.map((element) => element.range),
551
+ ...(shape.preludeRange === undefined
552
+ ? ([] as const)
553
+ : ([shape.preludeRange] as const)),
554
+ ] as const;
555
+
556
+ return sourceCode
557
+ .getCommentsInside(callback)
558
+ .some((comment) =>
559
+ keptRanges.every(
560
+ ([start, end]) =>
561
+ !(start <= comment.range[0] && comment.range[1] <= end),
562
+ ),
563
+ );
564
+ };
565
+
566
+ /**
567
+ * `text`, the source at `range`, with every line after the first shifted from
568
+ * the indentation of the line `range` starts on to `targetIndent`. Left as it
569
+ * is when a template literal or string spans lines inside it, since shifting
570
+ * those lines would change the string.
571
+ */
572
+ const reindent = (
573
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
574
+ sourceCode: TSESLint.SourceCode,
575
+ range: FixedLengthTuple<2, number>,
576
+ targetIndent: string,
577
+ text: string,
578
+ ): string => {
579
+ if (!text.includes('\n')) {
580
+ return text;
581
+ }
582
+
583
+ const spansLines = sourceCode.ast.tokens.some(
584
+ (token) =>
585
+ range[0] <= token.range[0] &&
586
+ token.range[1] <= range[1] &&
587
+ (token.type === AST_TOKEN_TYPES.Template ||
588
+ token.type === AST_TOKEN_TYPES.String) &&
589
+ token.loc.start.line !== token.loc.end.line,
590
+ );
591
+
592
+ if (spansLines) {
593
+ return text;
594
+ }
595
+
596
+ const sourceIndent = lineIndentOf(sourceCode, range[0]);
597
+
598
+ const [first, ...rest] = text.split('\n');
599
+
600
+ return [
601
+ first,
602
+ ...rest.map((line) =>
603
+ line.startsWith(sourceIndent)
604
+ ? `${targetIndent}${line.slice(sourceIndent.length)}`
605
+ : line,
606
+ ),
607
+ ].join('\n');
608
+ };
609
+
610
+ /** The whitespace at the start of the line that `index` is on. */
611
+ const lineIndentOf = (
612
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
613
+ sourceCode: TSESLint.SourceCode,
614
+ index: number,
615
+ ): string => {
616
+ const { line } = sourceCode.getLocFromIndex(index);
617
+
618
+ const lineText = sourceCode.lines[line - 1] ?? '';
619
+
620
+ return /^\s*/u.exec(lineText)?.[0] ?? '';
621
+ };
622
+
623
+ /** Whether `node`, with its type wrappers taken off, is a `….filter(…)` call. */
624
+ const isFilterCall = (
625
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
626
+ node: TSESTree.Expression,
627
+ ): boolean => {
628
+ const call = skipTypeWrappers(node);
629
+
630
+ if (call.type !== AST_NODE_TYPES.CallExpression) {
631
+ return false;
632
+ }
633
+
634
+ const callee = skipTypeWrappers(call.callee);
635
+
636
+ return (
637
+ callee.type === AST_NODE_TYPES.MemberExpression &&
638
+ !callee.computed &&
639
+ callee.property.type === AST_NODE_TYPES.Identifier &&
640
+ callee.property.name === 'filter'
641
+ );
642
+ };
643
+
644
+ /**
645
+ * Whether `node`, a reference or a member access, has a type here narrower
646
+ * than its declared one, by a narrowing that a function created here would not
647
+ * see. TypeScript keeps the narrowing of a `const`, and of a parameter or `let`
648
+ * that is never reassigned, inside a closure; it drops every other one,
649
+ * including any narrowing of a property.
650
+ */
651
+ const isNarrowedHere = (
652
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
653
+ sourceCode: TSESLint.SourceCode,
654
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
655
+ checker: ts.TypeChecker,
656
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
657
+ getTsNode: (esNode: TSESTree.Node) => ts.Node | undefined,
658
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
659
+ node: TSESTree.Node,
660
+ isReceiver: boolean,
661
+ ): boolean => {
662
+ if (
663
+ node.type !== AST_NODE_TYPES.Identifier &&
664
+ node.type !== AST_NODE_TYPES.MemberExpression
665
+ ) {
666
+ return false;
667
+ }
668
+
669
+ // A method about to be called (`xs.flat()`): its type is that of the method,
670
+ // which no narrowing of interest changes.
671
+ if (
672
+ node.parent.type === AST_NODE_TYPES.CallExpression &&
673
+ node.parent.callee === node
674
+ ) {
675
+ return false;
676
+ }
677
+
678
+ if (node.type === AST_NODE_TYPES.Identifier) {
679
+ if (!isReference(node)) {
680
+ return false;
681
+ }
682
+
683
+ const variable = ASTUtils.findVariable(sourceCode.getScope(node), node);
684
+
685
+ if (
686
+ variable?.references.every(
687
+ (reference) => !reference.isWrite() || reference.init === true,
688
+ ) === true
689
+ ) {
690
+ return false;
691
+ }
692
+ }
693
+
694
+ const tsNode = getTsNode(node);
695
+
696
+ if (tsNode === undefined) {
697
+ return false;
698
+ }
699
+
700
+ const declaredType = getDeclaredType(checker, tsNode);
701
+
702
+ const type = checker.getTypeAtLocation(tsNode);
703
+
704
+ return (
705
+ declaredType !== undefined &&
706
+ declaredType !== type &&
707
+ // The loop only iterates the receiver itself, so a narrowing that leaves
708
+ // it an array of the same elements (`Arr.isNonEmpty(xs)`) loses nothing.
709
+ !(isReceiver && iteratesTheSame(checker, declaredType, type))
710
+ );
711
+ };
712
+
713
+ /**
714
+ * Whether two types are both arrays or tuples whose elements have one type,
715
+ * as the declared and the narrowed type of `xs` after `Arr.isNonEmpty(xs)`
716
+ * are.
717
+ */
718
+ const iteratesTheSame = (
719
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
720
+ checker: ts.TypeChecker,
721
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
722
+ a: ts.Type,
723
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
724
+ b: ts.Type,
725
+ ): boolean => {
726
+ if (!isArrayOrTupleType(checker, a) || !isArrayOrTupleType(checker, b)) {
727
+ return false;
728
+ }
729
+
730
+ const elementOfA = checker.getIndexTypeOfType(a, ts.IndexKind.Number);
731
+
732
+ return (
733
+ elementOfA !== undefined &&
734
+ elementOfA === checker.getIndexTypeOfType(b, ts.IndexKind.Number)
735
+ );
736
+ };
737
+
738
+ /**
739
+ * The type of a variable or a property as declared, before any narrowing, or
740
+ * `undefined` when there is none to compare with (an index read with no
741
+ * property behind it, which nothing narrows).
742
+ *
743
+ * A property's is read off the type of the object it is accessed on, which
744
+ * also covers a property of a mapped type (`Readonly<…>`), whose symbol has no
745
+ * declaration of its own. A variable's is the type at its declaration
746
+ * (`checker.getTypeOfSymbol` would say the same, but is missing before
747
+ * TypeScript 5.1).
748
+ */
749
+ const getDeclaredType = (
750
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
751
+ checker: ts.TypeChecker,
752
+
753
+ tsNode: ts.Node,
754
+ ): ts.Type | undefined => {
755
+ if (
756
+ ts.isPropertyAccessExpression(tsNode) ||
757
+ ts.isElementAccessExpression(tsNode)
758
+ ) {
759
+ const name = ts.isPropertyAccessExpression(tsNode)
760
+ ? tsNode.name.text
761
+ : ts.isStringLiteralLike(tsNode.argumentExpression) ||
762
+ ts.isNumericLiteral(tsNode.argumentExpression)
763
+ ? tsNode.argumentExpression.text
764
+ : undefined;
765
+
766
+ const property =
767
+ name === undefined
768
+ ? undefined
769
+ : checker.getPropertyOfType(
770
+ checker.getTypeAtLocation(tsNode.expression),
771
+ name,
772
+ );
773
+
774
+ // Asked at the object rather than at the access, so that no narrowing of
775
+ // the access applies.
776
+ return property === undefined
777
+ ? undefined
778
+ : checker.getTypeOfSymbolAtLocation(property, tsNode.expression);
779
+ }
780
+
781
+ const symbol = checker.getSymbolAtLocation(tsNode);
782
+
783
+ const declaration = symbol?.valueDeclaration;
784
+
785
+ return symbol === undefined || declaration === undefined
786
+ ? undefined
787
+ : checker.getTypeOfSymbolAtLocation(symbol, declaration);
788
+ };
789
+
790
+ /**
791
+ * Whether the binding `identifier` reads is declared inside `container`, as
792
+ * the parameter of an arrow function in the receiver is: the loop variable
793
+ * does not shadow it.
794
+ */
795
+ const isBoundWithin = (
796
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
797
+ sourceCode: TSESLint.SourceCode,
798
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
799
+ identifier: TSESTree.Identifier,
800
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
801
+ container: TSESTree.Node,
802
+ ): boolean => {
803
+ const variable = ASTUtils.findVariable(
804
+ sourceCode.getScope(identifier),
805
+ identifier,
806
+ );
807
+
808
+ return (
809
+ variable !== null &&
810
+ Arr.isNonEmpty(variable.defs) &&
811
+ variable.defs.every(
812
+ (def) =>
813
+ container.range[0] <= def.name.range[0] &&
814
+ def.name.range[1] <= container.range[1],
815
+ )
816
+ );
817
+ };
818
+
819
+ /** Whether an identifier reads a binding, rather than naming a property or a key. */
820
+ const isReference = (
821
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
822
+ node: TSESTree.Identifier,
823
+ ): boolean => {
824
+ const { parent } = node;
825
+
826
+ return !(
827
+ (parent.type === AST_NODE_TYPES.MemberExpression &&
828
+ parent.property === node &&
829
+ !parent.computed) ||
830
+ (parent.type === AST_NODE_TYPES.Property &&
831
+ parent.key === node &&
832
+ !parent.computed &&
833
+ !parent.shorthand)
834
+ );
835
+ };
836
+
837
+ /**
838
+ * Whether `node` or a node inside it satisfies `predicate`. A nested
839
+ * non-arrow function or class has its own `this`, `arguments` and `return`,
840
+ * and is not entered; a nested arrow function shares `this` and `arguments`,
841
+ * and is entered only when `enterArrows` is set.
842
+ */
843
+ const someDescendant = (
844
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
845
+ sourceCode: TSESLint.SourceCode,
846
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
847
+ node: TSESTree.Node,
848
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
849
+ predicate: (n: TSESTree.Node) => boolean,
850
+ enterArrows: boolean,
851
+ isRoot: boolean = true,
852
+ ): boolean => {
853
+ if (predicate(node)) {
854
+ return true;
855
+ }
856
+
857
+ if (
858
+ !isRoot &&
859
+ (node.type === AST_NODE_TYPES.FunctionDeclaration ||
860
+ node.type === AST_NODE_TYPES.FunctionExpression ||
861
+ node.type === AST_NODE_TYPES.ClassBody ||
862
+ (!enterArrows && node.type === AST_NODE_TYPES.ArrowFunctionExpression))
863
+ ) {
864
+ return false;
865
+ }
866
+
867
+ const keys = sourceCode.visitorKeys[node.type] ?? [];
868
+
869
+ return keys.some((key) => {
870
+ const child: unknown = isRecord(node) ? node[key] : undefined;
871
+
872
+ return (Arr.isArray(child) ? child : [child]).some(
873
+ (c) =>
874
+ isNode(c) &&
875
+ someDescendant(sourceCode, c, predicate, enterArrows, false),
876
+ );
877
+ });
878
+ };
879
+
880
+ const isNode = (value: unknown): value is TSESTree.Node =>
881
+ isRecord(value) && hasKey(value, 'type') && typeof value.type === 'string';
882
+
883
+ const isReturnStatement = (
884
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
885
+ node: TSESTree.Node,
886
+ ): boolean => node.type === AST_NODE_TYPES.ReturnStatement;
887
+
888
+ /** What a generator function binds anew: `this`, `arguments`, `super`, `new.target`. */
889
+ const rebindsInGenerator = (
890
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
891
+ node: TSESTree.Node,
892
+ ): boolean =>
893
+ node.type === AST_NODE_TYPES.ThisExpression ||
894
+ node.type === AST_NODE_TYPES.Super ||
895
+ node.type === AST_NODE_TYPES.MetaProperty ||
896
+ (node.type === AST_NODE_TYPES.Identifier && node.name === 'arguments');
897
+
898
+ /**
899
+ * Whether `type` is an array or tuple, or a union of them, or an intersection
900
+ * with one in it (`readonly T[] & Brand`).
901
+ */
902
+ const isArrayOrTupleType = (
903
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
904
+ checker: ts.TypeChecker,
905
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
906
+ type: ts.Type,
907
+ ): boolean => {
908
+ const resolved =
909
+ (type.flags & ts.TypeFlags.TypeParameter) !== 0
910
+ ? (checker.getBaseConstraintOfType(type) ?? type)
911
+ : type;
912
+
913
+ return resolved.isUnion()
914
+ ? resolved.types.every((t) => isArrayOrTupleType(checker, t))
915
+ : resolved.isIntersection()
916
+ ? resolved.types.some((t) => isArrayOrTupleType(checker, t))
917
+ : checker.isArrayType(resolved) || checker.isTupleType(resolved);
918
+ };