eslint-plugin-ts-data-forge 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/plugin.d.mts.map +1 -1
  3. package/dist/plugin.mjs +1 -0
  4. package/dist/plugin.mjs.map +1 -1
  5. package/dist/rule-types.d.mts +3 -3
  6. package/dist/rule-types.d.mts.map +1 -1
  7. package/dist/rules/ast-utils.d.mts +59 -3
  8. package/dist/rules/ast-utils.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.mjs +142 -31
  10. package/dist/rules/ast-utils.mjs.map +1 -1
  11. package/dist/rules/branded-number-types.mjs.map +1 -1
  12. package/dist/rules/import-utils.d.mts +1 -1
  13. package/dist/rules/import-utils.d.mts.map +1 -1
  14. package/dist/rules/import-utils.mjs +26 -13
  15. package/dist/rules/import-utils.mjs.map +1 -1
  16. package/dist/rules/no-side-effect-import.d.mts +1 -1
  17. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  18. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  19. package/dist/rules/no-unnecessary-type-guard.mjs +161 -101
  20. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  21. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  22. package/dist/rules/prefer-arr-is-array.mjs +25 -11
  23. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts +1 -1
  25. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  26. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +11 -21
  27. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  28. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts +1 -1
  29. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  30. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +16 -28
  31. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-is-max-length-array.d.mts +1 -1
  33. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  34. package/dist/rules/prefer-arr-is-max-length-array.mjs +16 -24
  35. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-min-length-array.d.mts +1 -1
  37. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  38. package/dist/rules/prefer-arr-is-min-length-array.mjs +16 -24
  39. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  40. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  41. package/dist/rules/prefer-arr-is-non-empty.mjs +9 -22
  42. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  43. package/dist/rules/prefer-arr-scan.d.mts +6 -5
  44. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  45. package/dist/rules/prefer-arr-scan.mjs +68 -31
  46. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  47. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  48. package/dist/rules/prefer-arr-sum.mjs +57 -40
  49. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  50. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  51. package/dist/rules/prefer-arr-uniq.mjs +59 -30
  52. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  53. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  54. package/dist/rules/prefer-as-int.mjs +27 -24
  55. package/dist/rules/prefer-as-int.mjs.map +1 -1
  56. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  57. package/dist/rules/prefer-canonical-array-slicing.mjs +59 -28
  58. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  59. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
  60. package/dist/rules/prefer-canonical-length-cast.mjs +41 -16
  61. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  62. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  63. package/dist/rules/prefer-canonical-length-guard.mjs +18 -8
  64. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  65. package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
  66. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +31 -6
  67. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  68. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  69. package/dist/rules/prefer-is-non-null-object.mjs +56 -40
  70. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  71. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  72. package/dist/rules/prefer-is-record-and-has-key.mjs +18 -8
  73. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  74. package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
  75. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  76. package/dist/rules/prefer-num-safe-parse-float.mjs +27 -10
  77. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  78. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  79. package/dist/rules/prefer-num-safe-parse-int.mjs +24 -11
  80. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  81. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  82. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +22 -11
  83. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  84. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  85. package/dist/rules/prefer-range-for-loop.mjs +15 -8
  86. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  87. package/dist/types.d.mts +2 -2
  88. package/dist/types.d.mts.map +1 -1
  89. package/package.json +25 -23
  90. package/src/plugin.mts +5 -4
  91. package/src/plugin.test.mts +0 -13
  92. package/src/rule-types.mts +3 -3
  93. package/src/rules/ast-utils.mts +192 -37
  94. package/src/rules/branded-number-types.mts +1 -1
  95. package/src/rules/import-utils.mts +41 -17
  96. package/src/rules/no-side-effect-import.mts +1 -1
  97. package/src/rules/no-unnecessary-type-guard.mts +249 -148
  98. package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
  99. package/src/rules/prefer-arr-is-array.mts +32 -13
  100. package/src/rules/prefer-arr-is-array.test.mts +40 -0
  101. package/src/rules/prefer-arr-is-bounded-length-array.mts +22 -26
  102. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
  103. package/src/rules/prefer-arr-is-fixed-length-array.mts +26 -38
  104. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
  105. package/src/rules/prefer-arr-is-max-length-array.mts +26 -27
  106. package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
  107. package/src/rules/prefer-arr-is-min-length-array.mts +26 -27
  108. package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
  109. package/src/rules/prefer-arr-is-non-empty.mts +19 -24
  110. package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
  111. package/src/rules/prefer-arr-scan.mts +107 -37
  112. package/src/rules/prefer-arr-scan.test.mts +149 -0
  113. package/src/rules/prefer-arr-sum.mts +87 -50
  114. package/src/rules/prefer-arr-sum.test.mts +97 -0
  115. package/src/rules/prefer-arr-uniq.mts +83 -33
  116. package/src/rules/prefer-arr-uniq.test.mts +71 -0
  117. package/src/rules/prefer-as-int.mts +38 -38
  118. package/src/rules/prefer-as-int.test.mts +51 -0
  119. package/src/rules/prefer-canonical-array-slicing.mts +84 -29
  120. package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
  121. package/src/rules/prefer-canonical-length-cast.mts +56 -18
  122. package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
  123. package/src/rules/prefer-canonical-length-guard.mts +25 -11
  124. package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
  125. package/src/rules/prefer-comparison-over-nullish-guard.mts +42 -9
  126. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
  127. package/src/rules/prefer-is-non-null-object.mts +57 -33
  128. package/src/rules/prefer-is-non-null-object.test.mts +42 -0
  129. package/src/rules/prefer-is-record-and-has-key.mts +27 -12
  130. package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
  131. package/src/rules/prefer-num-safe-parse-float.mts +37 -13
  132. package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
  133. package/src/rules/prefer-num-safe-parse-int.mts +41 -15
  134. package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
  135. package/src/rules/prefer-obj-over-entries-round-trip.mts +34 -12
  136. package/src/rules/prefer-range-for-loop.mts +21 -9
  137. package/src/rules/prefer-range-for-loop.test.mts +75 -0
  138. package/src/rules/rules.mts +1 -1
  139. package/src/types.mts +2 -2
@@ -4,8 +4,9 @@ import {
4
4
  type TSESLint,
5
5
  type TSESTree,
6
6
  } from '@typescript-eslint/utils';
7
- import { type ReadonlyRecord } from 'ts-type-forge';
7
+ import type { ReadonlyRecord } from 'ts-type-forge';
8
8
  import * as ts from 'typescript';
9
+ import { skipTypeWrappers, typeWrapperLayers } from './ast-utils.mjs';
9
10
  import {
10
11
  buildImportFixes,
11
12
  getImportedLocalName,
@@ -48,19 +49,24 @@ const normalizeCountedOp = (
48
49
  : ({ fn, count } as const);
49
50
 
50
51
  const asNonNegativeIntegerLiteral = (
51
- node: TSESTree.Node,
52
- ): number | undefined =>
53
- node.type === AST_NODE_TYPES.Literal &&
54
- typeof node.value === 'number' &&
55
- Number.isInteger(node.value) &&
56
- node.value >= 0
52
+ wrapped: TSESTree.Node,
53
+ ): number | undefined => {
54
+ const node = skipTypeWrappers(wrapped);
55
+
56
+ return node.type === AST_NODE_TYPES.Literal &&
57
+ typeof node.value === 'number' &&
58
+ Number.isInteger(node.value) &&
59
+ node.value >= 0
57
60
  ? node.value
58
61
  : undefined;
62
+ };
59
63
 
60
64
  /** Matches `-N` (unary minus of a positive integer literal) and returns `N`. */
61
65
  const asNegatedPositiveIntegerLiteral = (
62
- node: TSESTree.Node,
66
+ wrapped: TSESTree.Node,
63
67
  ): number | undefined => {
68
+ const node = skipTypeWrappers(wrapped);
69
+
64
70
  if (
65
71
  node.type !== AST_NODE_TYPES.UnaryExpression ||
66
72
  node.operator !== '-' ||
@@ -238,6 +244,18 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
238
244
  const getTypeOf = (node: TSESTree.Node): ts.Type =>
239
245
  checker.getTypeAtLocation(parserServices.esTreeNodeToTSNodeMap.get(node));
240
246
 
247
+ /**
248
+ * Whether `predicate` holds for the type of every layer of type wrappers
249
+ * around `node` (see typeWrapperLayers): a cast neither makes a `Set` an
250
+ * array nor stops an array from being flattened by `concat`, and the fix,
251
+ * which keeps the wrapper, has to type-check as well.
252
+ */
253
+ const holdsThroughWrappers = (
254
+ node: TSESTree.Node,
255
+ predicate: (type: ts.Type) => boolean,
256
+ ): boolean =>
257
+ typeWrapperLayers(node).every((layer) => predicate(getTypeOf(layer)));
258
+
241
259
  /**
242
260
  * `true` only when the type can never be an array in any branch, so
243
261
  * `xs.concat(v)` is guaranteed to append `v` as one element rather than
@@ -395,9 +413,14 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
395
413
  // eslint-disable-next-line total-functions/no-unsafe-type-assertion
396
414
  const rawOperator = body.operator as ComparisonOperator;
397
415
 
398
- const isIndex = (side: TSESTree.Node): boolean =>
399
- side.type === AST_NODE_TYPES.Identifier &&
400
- side.name === indexParam.name;
416
+ const isIndex = (wrapped: TSESTree.Node): boolean => {
417
+ const side = skipTypeWrappers(wrapped);
418
+
419
+ return (
420
+ side.type === AST_NODE_TYPES.Identifier &&
421
+ side.name === indexParam.name
422
+ );
423
+ };
401
424
 
402
425
  const [op, other] = isIndex(body.left)
403
426
  ? ([rawOperator, body.right] as const)
@@ -421,7 +444,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
421
444
  return classifyLiteralComparison(op, n);
422
445
  }
423
446
 
424
- if (!isSimpleReference(receiver)) return undefined;
447
+ if (!isSimpleReference(receiver)) {
448
+ return undefined;
449
+ }
425
450
 
426
451
  const m = matchLengthMinus(other, receiverText);
427
452
 
@@ -435,11 +460,15 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
435
460
  if (args.length === 1) {
436
461
  const [start] = args;
437
462
 
438
- if (start === undefined) return undefined;
463
+ if (start === undefined) {
464
+ return undefined;
465
+ }
439
466
 
440
467
  const n = asNonNegativeIntegerLiteral(start);
441
468
 
442
- if (n !== undefined) return normalizeCountedOp('skip', n);
469
+ if (n !== undefined) {
470
+ return normalizeCountedOp('skip', n);
471
+ }
443
472
 
444
473
  const negated = asNegatedPositiveIntegerLiteral(start);
445
474
 
@@ -461,7 +490,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
461
490
 
462
491
  const n = asNonNegativeIntegerLiteral(end);
463
492
 
464
- if (n !== undefined) return normalizeCountedOp('take', n);
493
+ if (n !== undefined) {
494
+ return normalizeCountedOp('take', n);
495
+ }
465
496
 
466
497
  const negated = asNegatedPositiveIntegerLiteral(end);
467
498
 
@@ -469,7 +500,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
469
500
  return normalizeCountedOp('skipLast', negated);
470
501
  }
471
502
 
472
- if (!isSimpleReference(receiver)) return undefined;
503
+ if (!isSimpleReference(receiver)) {
504
+ return undefined;
505
+ }
473
506
 
474
507
  const m = matchLengthMinus(end, sourceCode.getText(receiver));
475
508
 
@@ -487,12 +520,16 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
487
520
  if (args.length === 1) {
488
521
  const [start] = args;
489
522
 
490
- if (start === undefined) return undefined;
523
+ if (start === undefined) {
524
+ return undefined;
525
+ }
491
526
 
492
527
  const n = asNonNegativeIntegerLiteral(start);
493
528
 
494
529
  // `xs.toSpliced(N)` removes everything from index N on → take N.
495
- if (n !== undefined) return normalizeCountedOp('take', n);
530
+ if (n !== undefined) {
531
+ return normalizeCountedOp('take', n);
532
+ }
496
533
 
497
534
  const negated = asNegatedPositiveIntegerLiteral(start);
498
535
 
@@ -516,12 +553,16 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
516
553
  // `xs.toSpliced(0, N)` removes the first N elements → skip N.
517
554
  const n = asNonNegativeIntegerLiteral(deleteCount);
518
555
 
519
- if (n !== undefined) return normalizeCountedOp('skip', n);
556
+ if (n !== undefined) {
557
+ return normalizeCountedOp('skip', n);
558
+ }
520
559
 
521
560
  // `xs.toSpliced(0, xs.length - N)` keeps the last N → takeLast N.
522
561
  // (Note: `xs.toSpliced(0, -N)` clamps the delete count to 0 and is a
523
562
  // plain copy, so it is deliberately NOT treated as takeLast.)
524
- if (!isSimpleReference(receiver)) return undefined;
563
+ if (!isSimpleReference(receiver)) {
564
+ return undefined;
565
+ }
525
566
 
526
567
  const m = matchLengthMinus(deleteCount, receiverText);
527
568
 
@@ -626,7 +667,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
626
667
  element.type !== AST_NODE_TYPES.SpreadElement &&
627
668
  node.arguments.length === 1 &&
628
669
  arg.type !== AST_NODE_TYPES.SpreadElement &&
629
- isArrayOrTupleType(getTypeOf(arg))
670
+ holdsThroughWrappers(arg, isArrayOrTupleType)
630
671
  ) {
631
672
  report(
632
673
  node,
@@ -649,7 +690,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
649
690
 
650
691
  // The receiver must be an array or tuple (not a string / typed array
651
692
  // / other object that happens to share the method name).
652
- if (!isArrayOrTupleType(getTypeOf(receiver))) return;
693
+ if (!holdsThroughWrappers(receiver, isArrayOrTupleType)) {
694
+ return;
695
+ }
653
696
 
654
697
  const receiverText = sourceCode.getText(receiver);
655
698
 
@@ -657,7 +700,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
657
700
  case 'slice': {
658
701
  const op = classifySliceArgs(node.arguments, receiver);
659
702
 
660
- if (op !== undefined) report(node, op, toArgText(receiver));
703
+ if (op !== undefined) {
704
+ report(node, op, toArgText(receiver));
705
+ }
661
706
 
662
707
  return;
663
708
  }
@@ -669,7 +714,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
669
714
  receiverText,
670
715
  );
671
716
 
672
- if (op !== undefined) report(node, op, toArgText(receiver));
717
+ if (op !== undefined) {
718
+ report(node, op, toArgText(receiver));
719
+ }
673
720
 
674
721
  return;
675
722
  }
@@ -683,7 +730,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
683
730
 
684
731
  const op = classifyFilterCallback(callback, receiver, receiverText);
685
732
 
686
- if (op !== undefined) report(node, op, toArgText(receiver));
733
+ if (op !== undefined) {
734
+ report(node, op, toArgText(receiver));
735
+ }
687
736
 
688
737
  return;
689
738
  }
@@ -721,7 +770,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
721
770
 
722
771
  // `xs.concat(item)` appends only when `item` can never be an
723
772
  // array (otherwise `concat` would flatten it).
724
- if (isDefinitelyNotArrayType(getTypeOf(arg))) {
773
+ if (holdsThroughWrappers(arg, isDefinitelyNotArrayType)) {
725
774
  report(
726
775
  node,
727
776
  { fn: 'toPushed', itemText: toArgText(arg) },
@@ -735,7 +784,9 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
735
784
  ArrayExpression: (node) => {
736
785
  // `[item, ...xs]` → `Arr.toUnshifted(xs, item)`,
737
786
  // `[...xs, item]` → `Arr.toPushed(xs, item)`.
738
- if (node.elements.length !== 2) return;
787
+ if (node.elements.length !== 2) {
788
+ return;
789
+ }
739
790
 
740
791
  const [first, second] = node.elements;
741
792
 
@@ -760,11 +811,15 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
760
811
  // `spread`, `item`, and `fn` are set together (all from the same
761
812
  // tuple), so checking `spread` also guarantees the others are
762
813
  // defined.
763
- if (spread === undefined) return;
814
+ if (spread === undefined) {
815
+ return;
816
+ }
764
817
 
765
818
  // Spreading a non-array iterable (`[v, ...someSet]`) is a genuine
766
819
  // conversion, not an element addition to an array.
767
- if (!isArrayOrTupleType(getTypeOf(spread.argument))) return;
820
+ if (!holdsThroughWrappers(spread.argument, isArrayOrTupleType)) {
821
+ return;
822
+ }
768
823
 
769
824
  report(
770
825
  node,
@@ -586,3 +586,95 @@ describe('prefer-canonical-array-slicing', () => {
586
586
  ],
587
587
  });
588
588
  });
589
+
590
+ describe('prefer-canonical-array-slicing through type wrappers', () => {
591
+ tester.run('prefer-canonical-array-slicing', preferCanonicalArraySlicing, {
592
+ valid: [
593
+ {
594
+ name: 'a concat argument cast away from an array may still be flattened',
595
+ code: dedent`
596
+ declare const xs: readonly number[];
597
+ declare const v: number | readonly number[];
598
+ const ys = xs.concat(v as number);
599
+ `,
600
+ },
601
+ {
602
+ name: 'a spread Set cast to an array is still a conversion',
603
+ code: dedent`
604
+ declare const s: ReadonlySet<number>;
605
+ const ys = [...(s as unknown as readonly number[]), 1];
606
+ `,
607
+ },
608
+ {
609
+ name: 'a receiver cast to an array is still not one',
610
+ code: dedent`
611
+ declare const s: string;
612
+ const ys = (s as unknown as readonly string[]).slice(1);
613
+ `,
614
+ },
615
+ {
616
+ name: 'a concat argument cast to an array is still not one',
617
+ code: dedent`
618
+ declare const s: ReadonlySet<number>;
619
+ const ys = [1].concat(s as unknown as readonly number[]);
620
+ `,
621
+ },
622
+ ],
623
+ invalid: [
624
+ {
625
+ name: 'a count wrapped in `as` or `satisfies`',
626
+ code: dedent`
627
+ declare const xs: readonly number[];
628
+ const a = xs.slice(1 as number);
629
+ const b = xs.slice(0 satisfies number, -1 as number);
630
+ const c = xs.toSpliced(2 satisfies number);
631
+ `,
632
+ output: dedent`
633
+ import { Arr } from 'ts-data-forge';
634
+ declare const xs: readonly number[];
635
+ const a = Arr.tail(xs);
636
+ const b = Arr.butLast(xs);
637
+ const c = Arr.take(xs, 2);
638
+ `,
639
+ errors: [
640
+ { messageId: 'preferCanonicalArraySlicing' },
641
+ { messageId: 'preferCanonicalArraySlicing' },
642
+ { messageId: 'preferCanonicalArraySlicing' },
643
+ ],
644
+ },
645
+ {
646
+ name: 'a filter comparison on a wrapped index',
647
+ code: dedent`
648
+ declare const xs: readonly number[];
649
+ const ys = xs.filter((_, i) => (i satisfies number) >= 1);
650
+ `,
651
+ output: dedent`
652
+ import { Arr } from 'ts-data-forge';
653
+ declare const xs: readonly number[];
654
+ const ys = Arr.tail(xs);
655
+ `,
656
+ errors: [{ messageId: 'preferCanonicalArraySlicing' }],
657
+ },
658
+ {
659
+ name: 'a wrapped receiver and item keep their wrappers',
660
+ code: dedent`
661
+ declare const xs: readonly number[];
662
+ declare const v: number | undefined;
663
+ const ys = (xs satisfies readonly number[]).slice(1);
664
+ const zs = [...xs!, v!];
665
+ `,
666
+ output: dedent`
667
+ import { Arr } from 'ts-data-forge';
668
+ declare const xs: readonly number[];
669
+ declare const v: number | undefined;
670
+ const ys = Arr.tail(xs satisfies readonly number[]);
671
+ const zs = Arr.toPushed(xs!, v!);
672
+ `,
673
+ errors: [
674
+ { messageId: 'preferCanonicalArraySlicing' },
675
+ { messageId: 'preferCanonicalArraySlicing' },
676
+ ],
677
+ },
678
+ ],
679
+ });
680
+ });
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  AST_NODE_TYPES,
3
+ ASTUtils,
3
4
  type TSESLint,
4
5
  type TSESTree,
5
6
  } from '@typescript-eslint/utils';
7
+ import { skipTypeWrappers } from './ast-utils.mjs';
6
8
  import { getImportedLocalName, getTsDataForgeImport } from './import-utils.mjs';
7
9
 
8
10
  type Options = readonly [];
@@ -145,20 +147,26 @@ export const preferCanonicalLengthCast: TSESLint.RuleModule<
145
147
  'Arr',
146
148
  );
147
149
 
148
- if (arrLocalName === undefined) return {};
150
+ if (arrLocalName === undefined) {
151
+ return {};
152
+ }
149
153
 
150
154
  return {
151
155
  CallExpression: (node) => {
152
156
  const castName = getCastName(node, arrLocalName);
153
157
 
154
- if (castName === undefined) return;
158
+ if (castName === undefined) {
159
+ return;
160
+ }
155
161
 
156
162
  const rewrite = CAST_REWRITES.find(
157
163
  (candidate) =>
158
164
  candidate.cast === castName && matchesBounds(node, candidate),
159
165
  );
160
166
 
161
- if (rewrite === undefined) return;
167
+ if (rewrite === undefined) {
168
+ return;
169
+ }
162
170
 
163
171
  const boundsText = node.arguments
164
172
  .slice(0, rewrite.bounds.length)
@@ -179,7 +187,7 @@ export const preferCanonicalLengthCast: TSESLint.RuleModule<
179
187
  node.callee,
180
188
  `${arrLocalName}.${rewrite.replacement}`,
181
189
  ),
182
- ...dropBoundsFix(fixer, node, rewrite),
190
+ ...dropBoundsFix(fixer, sourceCode, node, rewrite),
183
191
  ],
184
192
  });
185
193
  },
@@ -238,7 +246,9 @@ const matchesBounds = (
238
246
  return rewrite.bounds.every((matcher, index) => {
239
247
  const value = bounds[index];
240
248
 
241
- if (value === undefined) return false;
249
+ if (value === undefined) {
250
+ return false;
251
+ }
242
252
 
243
253
  switch (matcher) {
244
254
  case 'anyLength':
@@ -255,11 +265,16 @@ const matchesBounds = (
255
265
 
256
266
  /**
257
267
  * Removes the length arguments the rewrite drops. Each removal runs from the
258
- * dropped argument up to the start of the next one, so the comma, whitespace
259
- * and any comments in between go with it; the ranges never overlap.
268
+ * first token after the delimiter before the dropped argument (the call's `(`
269
+ * or the previous comma) up to the first token after the comma that follows
270
+ * it. Parentheses the source put around the dropped argument, the comma,
271
+ * whitespace and any comments in between go with it; parentheses around the
272
+ * next argument stay. The ranges never overlap.
260
273
  */
261
274
  const dropBoundsFix = (
262
275
  fixer: TSESLint.RuleFixer,
276
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
277
+ sourceCode: TSESLint.SourceCode,
263
278
  // AST nodes hold mutable child arrays, so they are not deeply readonly.
264
279
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
265
280
  node: TSESTree.CallExpression,
@@ -275,23 +290,46 @@ const dropBoundsFix = (
275
290
  // "not of this type".
276
291
  const dropped = node.arguments[index];
277
292
 
278
- const next = node.arguments[index + 1];
293
+ const previous = node.arguments[index - 1];
294
+
295
+ if (dropped === undefined || rewrite.keep.includes(index)) {
296
+ return [];
297
+ }
298
+
299
+ const delimiterBefore =
300
+ previous === undefined
301
+ ? sourceCode.getTokenAfter(node.callee, ASTUtils.isOpeningParenToken)
302
+ : sourceCode.getTokenAfter(previous, ASTUtils.isCommaToken);
279
303
 
280
- if (rewrite.keep.includes(index)) return [];
304
+ const start =
305
+ delimiterBefore === null
306
+ ? null
307
+ : sourceCode.getTokenAfter(delimiterBefore);
281
308
 
282
- return dropped === undefined || next === undefined
309
+ const commaAfter = sourceCode.getTokenAfter(dropped, ASTUtils.isCommaToken);
310
+
311
+ const end =
312
+ commaAfter === null ? null : sourceCode.getTokenAfter(commaAfter);
313
+
314
+ return start === null || end === null
283
315
  ? []
284
- : [fixer.removeRange([dropped.range[0], next.range[0]])];
316
+ : [fixer.removeRange([start.range[0], end.range[0]])];
285
317
  });
286
318
 
287
- /** A non-negative integer literal's value, or `undefined` for anything else. */
319
+ /**
320
+ * A non-negative integer literal's value, or `undefined` for anything else.
321
+ * Read through type wrappers: `1 as const` is still the bound `1`.
322
+ */
288
323
  const getLengthLiteral = (
289
324
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
290
- node: TSESTree.Node,
291
- ): number | undefined =>
292
- node.type === AST_NODE_TYPES.Literal &&
293
- typeof node.value === 'number' &&
294
- Number.isSafeInteger(node.value) &&
295
- node.value >= 0
325
+ wrapped: TSESTree.Node,
326
+ ): number | undefined => {
327
+ const node = skipTypeWrappers(wrapped);
328
+
329
+ return node.type === AST_NODE_TYPES.Literal &&
330
+ typeof node.value === 'number' &&
331
+ Number.isSafeInteger(node.value) &&
332
+ node.value >= 0
296
333
  ? node.value
297
334
  : undefined;
335
+ };
@@ -303,3 +303,114 @@ describe('prefer-canonical-length-cast', () => {
303
303
  ],
304
304
  });
305
305
  });
306
+
307
+ describe('prefer-canonical-length-cast through type wrappers', () => {
308
+ tester.run('prefer-canonical-length-cast', preferCanonicalLengthCast, {
309
+ valid: [
310
+ {
311
+ name: 'a wrapped bound other than the degenerate one',
312
+ code: dedent`
313
+ import { Arr } from 'ts-data-forge';
314
+
315
+ declare const xs: readonly number[];
316
+ const ys = Arr.asMinLengthArray(2 as const, xs);
317
+ `,
318
+ },
319
+ ],
320
+ invalid: [
321
+ {
322
+ name: 'a bound wrapped in `as const`',
323
+ code: dedent`
324
+ import { Arr } from 'ts-data-forge';
325
+
326
+ declare const xs: readonly number[];
327
+ const ys = Arr.asMinLengthArray(1 as const, xs);
328
+ `,
329
+ output: dedent`
330
+ import { Arr } from 'ts-data-forge';
331
+
332
+ declare const xs: readonly number[];
333
+ const ys = Arr.asNonEmptyArray(xs);
334
+ `,
335
+ errors: [{ messageId: 'useCanonicalCast' }],
336
+ },
337
+ {
338
+ name: 'bounds wrapped in `satisfies`, the kept one keeping its wrapper',
339
+ code: dedent`
340
+ import { Arr } from 'ts-data-forge';
341
+
342
+ declare const xs: readonly number[];
343
+ const ys = Arr.asBoundedLengthTuple(3 satisfies number, 3 as const, xs!);
344
+ `,
345
+ output: dedent`
346
+ import { Arr } from 'ts-data-forge';
347
+
348
+ declare const xs: readonly number[];
349
+ const ys = Arr.asFixedLengthTuple(3 satisfies number, xs!);
350
+ `,
351
+ errors: [{ messageId: 'useCanonicalCast' }],
352
+ },
353
+ ],
354
+ });
355
+ });
356
+
357
+ describe('prefer-canonical-length-cast with parenthesized operands', () => {
358
+ tester.run('prefer-canonical-length-cast', preferCanonicalLengthCast, {
359
+ valid: [],
360
+ invalid: [
361
+ {
362
+ name: 'a parenthesized array after the dropped bound',
363
+ code: dedent`
364
+ import { Arr } from 'ts-data-forge';
365
+
366
+ declare const xs: readonly number[];
367
+ const ys = Arr.asMinLengthArray(1, (xs));
368
+ `,
369
+ output: dedent`
370
+ import { Arr } from 'ts-data-forge';
371
+
372
+ declare const xs: readonly number[];
373
+ const ys = Arr.asNonEmptyArray((xs));
374
+ `,
375
+ errors: [{ messageId: 'useCanonicalCast' }],
376
+ },
377
+ {
378
+ name: 'a parenthesized dropped bound',
379
+ code: dedent`
380
+ import { Arr } from 'ts-data-forge';
381
+
382
+ declare const xs: readonly number[];
383
+ const ys = Arr.asMinLengthArray((1), xs);
384
+ `,
385
+ output: dedent`
386
+ import { Arr } from 'ts-data-forge';
387
+
388
+ declare const xs: readonly number[];
389
+ const ys = Arr.asNonEmptyArray(xs);
390
+ `,
391
+ errors: [{ messageId: 'useCanonicalCast' }],
392
+ },
393
+ {
394
+ name: 'a parenthesized second bound of a bounded cast',
395
+ code: dedent`
396
+ import { Arr } from 'ts-data-forge';
397
+
398
+ declare const xs: readonly number[];
399
+ const ys = Arr.asBoundedLengthArray(2, ((2)), xs);
400
+ const zs = Arr.asBoundedLengthArray((0), (0), (xs));
401
+ `,
402
+ output: dedent`
403
+ import { Arr } from 'ts-data-forge';
404
+
405
+ declare const xs: readonly number[];
406
+ const ys = Arr.asFixedLengthArray(2, xs);
407
+ const zs = Arr.asEmptyArray((xs));
408
+ `,
409
+ errors: [
410
+ { messageId: 'useCanonicalCast' },
411
+ { messageId: 'useCanonicalCast' },
412
+ ],
413
+ },
414
+ ],
415
+ });
416
+ });
@@ -3,7 +3,8 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type MutableRecord, type ReadonlyRecord } from 'ts-type-forge';
6
+ import type { MutableRecord, ReadonlyRecord } from 'ts-type-forge';
7
+ import { skipTypeWrappers, toArgumentText } from './ast-utils.mjs';
7
8
  import { getImportedLocalName, getTsDataForgeImport } from './import-utils.mjs';
8
9
  import { preferArrIsBoundedLengthArray } from './prefer-arr-is-bounded-length-array.mjs';
9
10
  import { preferArrIsFixedLengthArray } from './prefer-arr-is-fixed-length-array.mjs';
@@ -124,7 +125,9 @@ export const preferCanonicalLengthGuard: TSESLint.RuleModule<
124
125
  rule.create(context),
125
126
  );
126
127
 
127
- if (arrLocalName === undefined) return mergeVisitors(comparisonVisitors);
128
+ if (arrLocalName === undefined) {
129
+ return mergeVisitors(comparisonVisitors);
130
+ }
128
131
 
129
132
  return mergeVisitors([
130
133
  ...comparisonVisitors,
@@ -132,7 +135,9 @@ export const preferCanonicalLengthGuard: TSESLint.RuleModule<
132
135
  CallExpression: (node) => {
133
136
  const guardName = getGuardName(node, arrLocalName);
134
137
 
135
- if (guardName === undefined) return;
138
+ if (guardName === undefined) {
139
+ return;
140
+ }
136
141
 
137
142
  const rewrite = GUARD_REWRITES.find(
138
143
  (entry) => entry.guard === guardName,
@@ -145,9 +150,11 @@ export const preferCanonicalLengthGuard: TSESLint.RuleModule<
145
150
  // The array is the last argument; the bounds precede it.
146
151
  const array = node.arguments[rewrite.bounds.length];
147
152
 
148
- if (array === undefined) return;
153
+ if (array === undefined) {
154
+ return;
155
+ }
149
156
 
150
- const arrayText = sourceCode.getText(array);
157
+ const arrayText = toArgumentText(array, sourceCode);
151
158
 
152
159
  context.report({
153
160
  node,
@@ -185,7 +192,9 @@ const mergeVisitors = (
185
192
 
186
193
  for (const visitor of visitors) {
187
194
  for (const [selector, handler] of Object.entries(visitor)) {
188
- if (typeof handler !== 'function') continue;
195
+ if (typeof handler !== 'function') {
196
+ continue;
197
+ }
189
198
 
190
199
  const mut_handlers = mut_merged[selector] ?? [];
191
200
 
@@ -226,7 +235,8 @@ const getGuardName = (
226
235
 
227
236
  /**
228
237
  * Whether the call is `guard(...bounds, array)` with every bound written as the
229
- * exact numeric literal the rewrite requires.
238
+ * exact numeric literal the rewrite requires, under any type wrapper
239
+ * (`0 as const` is still the bound 0).
230
240
  *
231
241
  * The arity check also excludes the curried form (`guard(...bounds)`): the
232
242
  * degenerate guards take only the array, so there is nothing to rewrite it to.
@@ -242,10 +252,14 @@ const matchesBounds = (
242
252
  return false;
243
253
  }
244
254
 
245
- return args.every(
246
- (arg, index) =>
255
+ return args.every((arg, index) => {
256
+ const bound = skipTypeWrappers(arg);
257
+
258
+ return (
247
259
  arg.type !== AST_NODE_TYPES.SpreadElement &&
248
260
  (index === bounds.length ||
249
- (arg.type === AST_NODE_TYPES.Literal && arg.value === bounds[index])),
250
- );
261
+ (bound.type === AST_NODE_TYPES.Literal &&
262
+ bound.value === bounds[index]))
263
+ );
264
+ });
251
265
  };