eslint-plugin-ts-data-forge 0.6.0 → 0.7.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.
- package/README.md +1 -0
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/plugin.d.mts +2 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +45 -41
- package/dist/plugin.mjs.map +1 -1
- package/dist/rule-types.d.mts +15 -0
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rule-types.mjs +6 -2
- package/dist/rule-types.mjs.map +1 -1
- package/dist/rules/ast-utils.mjs +107 -50
- package/dist/rules/ast-utils.mjs.map +1 -1
- package/dist/rules/branded-number-types.mjs +41 -37
- package/dist/rules/branded-number-types.mjs.map +1 -1
- package/dist/rules/constants.mjs +2 -4
- package/dist/rules/constants.mjs.map +1 -1
- package/dist/rules/import-utils.mjs +80 -56
- package/dist/rules/import-utils.mjs.map +1 -1
- package/dist/rules/index.mjs +2 -2
- package/dist/rules/index.mjs.map +1 -1
- package/dist/rules/no-side-effect-import.mjs +46 -34
- package/dist/rules/no-side-effect-import.mjs.map +1 -1
- package/dist/rules/no-unnecessary-type-guard.mjs +376 -265
- package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-array.mjs +64 -52
- package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs +91 -85
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs +92 -83
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.mjs +92 -80
- package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.mjs +92 -80
- package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-non-empty.mjs +85 -77
- package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
- package/dist/rules/prefer-arr-scan.d.mts +96 -0
- package/dist/rules/prefer-arr-scan.d.mts.map +1 -0
- package/dist/rules/prefer-arr-scan.mjs +482 -0
- package/dist/rules/prefer-arr-scan.mjs.map +1 -0
- package/dist/rules/prefer-arr-sum.mjs +176 -132
- package/dist/rules/prefer-arr-sum.mjs.map +1 -1
- package/dist/rules/prefer-as-int.mjs +77 -71
- package/dist/rules/prefer-as-int.mjs.map +1 -1
- package/dist/rules/prefer-canonical-array-slicing.mjs +503 -343
- package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-cast.mjs +217 -144
- package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-guard.mjs +160 -109
- package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
- package/dist/rules/prefer-comparison-over-nullish-guard.mjs +89 -71
- package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
- package/dist/rules/prefer-is-non-null-object.mjs +91 -72
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.mjs +148 -105
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-float.mjs +101 -77
- package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-int.mjs +103 -81
- package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
- package/dist/rules/prefer-obj-over-entries-round-trip.mjs +299 -194
- package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
- package/dist/rules/prefer-range-for-loop.mjs +121 -99
- package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
- package/dist/rules/rules.d.mts +1 -0
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +19 -20
- package/dist/rules/rules.mjs.map +1 -1
- package/dist/types.mjs +2 -2
- package/dist/types.mjs.map +1 -1
- package/package.json +6 -7
- package/src/plugin.mts +1 -0
- package/src/rule-types.mts +16 -0
- package/src/rules/prefer-arr-scan.mts +693 -0
- package/src/rules/prefer-arr-scan.test.mts +295 -0
- package/src/rules/rules.mts +2 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import { Arr } from 'ts-data-forge';
|
|
7
|
+
import { type FixedLengthTuple } from 'ts-type-forge';
|
|
8
|
+
import * as ts from 'typescript';
|
|
9
|
+
import {
|
|
10
|
+
buildImportFixes,
|
|
11
|
+
getNamedImports,
|
|
12
|
+
getTsDataForgeImport,
|
|
13
|
+
} from './import-utils.mjs';
|
|
14
|
+
|
|
15
|
+
type Options = readonly [];
|
|
16
|
+
|
|
17
|
+
type MessageIds = 'preferArrScanPrefix' | 'preferArrScanAccumulate';
|
|
18
|
+
|
|
19
|
+
/** Array methods that cut a prefix or a suffix out of the array they are called on. */
|
|
20
|
+
const SLICING_METHODS: ReadonlySet<string> = new Set(['slice', 'subarray']);
|
|
21
|
+
|
|
22
|
+
/** A `map` call whose callback ignores the element it is handed. */
|
|
23
|
+
type PrefixCandidate = Readonly<{
|
|
24
|
+
call: TSESTree.CallExpression;
|
|
25
|
+
/** Source text of the array being mapped, as written. */
|
|
26
|
+
arrayText: string;
|
|
27
|
+
body: TSESTree.Node;
|
|
28
|
+
/** Every place the callback reads its index parameter. */
|
|
29
|
+
indexReads: readonly TSESTree.Node[];
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
/** A `slice` / `subarray` call, wherever it appears. */
|
|
33
|
+
type SliceCall = Readonly<{
|
|
34
|
+
call: TSESTree.CallExpression;
|
|
35
|
+
/** Source text of the array being sliced, as written. */
|
|
36
|
+
arrayText: string;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
/** A `reduce` call that spreads its accumulator into a new array. */
|
|
40
|
+
type AccumulationCandidate = Readonly<{
|
|
41
|
+
call: TSESTree.CallExpression;
|
|
42
|
+
/**
|
|
43
|
+
* What the call is replaced with, or `undefined` when the rewrite could not
|
|
44
|
+
* be derived and the report carries no fix.
|
|
45
|
+
*/
|
|
46
|
+
replacement: string | undefined;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A `reduce` callback that appends one value to its accumulator, in either of
|
|
51
|
+
* the two spellings this repository's rules produce.
|
|
52
|
+
*/
|
|
53
|
+
type Accumulation = Readonly<{
|
|
54
|
+
accumulatorParam: TSESTree.Identifier;
|
|
55
|
+
/** The reference to the accumulator that the rewrite drops. */
|
|
56
|
+
accumulatorRef: TSESTree.Node;
|
|
57
|
+
/** The value appended on this step. */
|
|
58
|
+
element: TSESTree.Expression;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
/** One source range and the text that takes its place. */
|
|
62
|
+
type Splice = Readonly<{ range: FixedLengthTuple<2, number>; text: string }>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Report an accumulated value that is rebuilt from scratch for every element,
|
|
66
|
+
* and point at `Arr.scan` from ts-data-forge.
|
|
67
|
+
*
|
|
68
|
+
* ## Why
|
|
69
|
+
*
|
|
70
|
+
* Two shapes compute what `Arr.scan` computes — a value carried from one
|
|
71
|
+
* element to the next — by re-deriving it from the whole prefix each time.
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* // (1) the prefix rebuilt per element: `slice` allocates, and the combining
|
|
75
|
+
* // function runs over the whole prefix again. O(n²).
|
|
76
|
+
* segments.map((_, index) =>
|
|
77
|
+
* path.resolve(root, ...segments.slice(0, index + 1)),
|
|
78
|
+
* );
|
|
79
|
+
*
|
|
80
|
+
* // one pass — and `scan` puts the initial value at the head of its result,
|
|
81
|
+
* // which here is exactly "the root, then each ancestor below it"
|
|
82
|
+
* Arr.scan(
|
|
83
|
+
* segments,
|
|
84
|
+
* (ancestor, segment) => path.resolve(ancestor, segment),
|
|
85
|
+
* root,
|
|
86
|
+
* );
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* // (2) the accumulator copied on every step, for the same O(n²)
|
|
91
|
+
* xs.reduce<readonly number[]>((acc, x) => [...acc, (acc.at(-1) ?? 0) + x], [0]);
|
|
92
|
+
*
|
|
93
|
+
* Arr.scan(xs, (total, x) => total + x, 0);
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* The complexity is the smaller half of it. What the rebuilt form loses is the
|
|
97
|
+
* name of what is being accumulated: (1) says "for each index, combine
|
|
98
|
+
* everything up to it", and a reader has to turn that back into "carry a value
|
|
99
|
+
* along" before the code says what it does.
|
|
100
|
+
*
|
|
101
|
+
* ## Why only one of the two is fixed
|
|
102
|
+
*
|
|
103
|
+
* **(1) cannot be fixed at all**, and the obstacle is not difficulty. Turning
|
|
104
|
+
* `f(seed, ...xs.slice(0, i + 1))` into a `scan` is only valid if
|
|
105
|
+
* `f(seed, a, b, c) === f(f(f(seed, a), b), c)` — that the variadic callee is a
|
|
106
|
+
* left fold. That is a semantic property of the callee, invisible in the AST
|
|
107
|
+
* and in the type: `path.resolve` and `Math.max` have it, `Array.of` and
|
|
108
|
+
* `console.log` do not. Nothing static licenses the rewrite, so this shape is
|
|
109
|
+
* reported and left to a reader. The direction is a second obstacle —
|
|
110
|
+
* `slice(0, i + 1)` runs forwards while `slice(0, xs.length - i)` runs
|
|
111
|
+
* backwards and needs the result reversed — and the length a third, `scan`
|
|
112
|
+
* putting the initial value at the head where `map` has n elements.
|
|
113
|
+
*
|
|
114
|
+
* **(2) is fixed**, because none of those three apply to it: the combining
|
|
115
|
+
* function is the callback body itself, there is no slice and so no direction
|
|
116
|
+
* to infer, and the lengths already agree — a `reduce` seeded with `[init]`
|
|
117
|
+
* yields n+1 elements and so does `Arr.scan(xs, f, init)`.
|
|
118
|
+
*
|
|
119
|
+
* The fix is withheld unless every one of these holds, since each is what makes
|
|
120
|
+
* the two forms equal rather than merely similar:
|
|
121
|
+
*
|
|
122
|
+
* - the initial value is a one-element array literal `[E]`, so `scan`'s initial
|
|
123
|
+
* value is `E`;
|
|
124
|
+
* - the body is exactly `[...acc, ELEMENT]` — one spread of the accumulator and
|
|
125
|
+
* one new element, since `scan` appends exactly one value per step;
|
|
126
|
+
* - every other mention of the accumulator in `ELEMENT` is `acc.at(-1)`, and
|
|
127
|
+
* there is at least one. `scan` hands its callback the previous *value*, not
|
|
128
|
+
* the prefix, so `acc.length`, `acc[0]` or `acc.filter(...)` cannot be
|
|
129
|
+
* expressed and block the fix. `at(-1)` is the only spelling substituted:
|
|
130
|
+
* `Arr.last` returns an `Optional`, not the element, so it is not a
|
|
131
|
+
* substitution at all;
|
|
132
|
+
* - the parameters are plain identifiers with no type annotations — an
|
|
133
|
+
* annotation on the accumulator describes the array, and would be wrong on
|
|
134
|
+
* `scan`'s value parameter — and there are at most three of them, `reduce`'s
|
|
135
|
+
* fourth parameter (the whole array) having no counterpart in `scan`.
|
|
136
|
+
*
|
|
137
|
+
* `acc.at(-1)` is typed `T | undefined` while `scan`'s accumulator is `S`, so a
|
|
138
|
+
* `?? fallback` guarding it becomes redundant after the fix. It is left in
|
|
139
|
+
* place rather than removed: it evaluates identically either way, and deciding
|
|
140
|
+
* that it is dead needs to know that `S` excludes `undefined`, which is type
|
|
141
|
+
* information this rule does not take.
|
|
142
|
+
*
|
|
143
|
+
* ## What is not reported
|
|
144
|
+
*
|
|
145
|
+
* A `map` callback that reads its element parameter. Rebuilding a prefix *and*
|
|
146
|
+
* using the current element is not an accumulation spelled out the long way:
|
|
147
|
+
* `scan` hands its callback the accumulated value and the element, never the
|
|
148
|
+
* prefix, so such a callback is not one it can express.
|
|
149
|
+
*
|
|
150
|
+
* The array has to be spelled the same way in the `map` and in the `slice`.
|
|
151
|
+
* Deciding that two differently written expressions denote one array needs type
|
|
152
|
+
* information and would still be a guess about aliasing.
|
|
153
|
+
*/
|
|
154
|
+
export const preferArrScan: TSESLint.RuleModule<MessageIds, Options> = {
|
|
155
|
+
meta: {
|
|
156
|
+
type: 'suggestion',
|
|
157
|
+
docs: {
|
|
158
|
+
description:
|
|
159
|
+
'Report an accumulated value rebuilt from scratch for every element — `xs.map((_, i) => f(...xs.slice(0, i + 1)))`, or a `reduce` that spreads its accumulator into a new array — and point at `Arr.scan` from ts-data-forge, which does the same in one pass',
|
|
160
|
+
},
|
|
161
|
+
fixable: 'code',
|
|
162
|
+
schema: [],
|
|
163
|
+
messages: {
|
|
164
|
+
preferArrScanPrefix:
|
|
165
|
+
'This rebuilds a prefix of `{{array}}` for every element, so the combining function runs over the whole prefix again each time. `Arr.scan` from ts-data-forge carries the value from one element to the next in a single pass, with the initial value at the head of the result.',
|
|
166
|
+
preferArrScanAccumulate:
|
|
167
|
+
'Rebuilding the accumulator array on every step copies it each time. `Arr.scan` from ts-data-forge accumulates in one pass and returns the initial value followed by every intermediate one.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
create: (context) => {
|
|
172
|
+
const sourceCode = context.sourceCode;
|
|
173
|
+
|
|
174
|
+
const program = sourceCode.ast;
|
|
175
|
+
|
|
176
|
+
const tsDataForgeImport = getTsDataForgeImport(program);
|
|
177
|
+
|
|
178
|
+
const services = sourceCode.parserServices;
|
|
179
|
+
|
|
180
|
+
const checker = services?.program?.getTypeChecker();
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether the value at `node` can be `null` or `undefined`. Answers `true`
|
|
184
|
+
* without type information, which is the conservative direction: it only
|
|
185
|
+
* ever keeps a `?? fallback` that could have been removed.
|
|
186
|
+
*/
|
|
187
|
+
const isNullish = (
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
189
|
+
node: TSESTree.Node,
|
|
190
|
+
): boolean => {
|
|
191
|
+
const tsNode = services?.esTreeNodeToTSNodeMap?.get(node);
|
|
192
|
+
|
|
193
|
+
if (checker === undefined || tsNode === undefined) return true;
|
|
194
|
+
|
|
195
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
196
|
+
|
|
197
|
+
return (type.isUnion() ? type.types : [type]).some(
|
|
198
|
+
(constituent) =>
|
|
199
|
+
(constituent.flags &
|
|
200
|
+
(ts.TypeFlags.Null |
|
|
201
|
+
ts.TypeFlags.Undefined |
|
|
202
|
+
ts.TypeFlags.Void |
|
|
203
|
+
ts.TypeFlags.Any |
|
|
204
|
+
ts.TypeFlags.Unknown)) !==
|
|
205
|
+
0,
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const mut_candidates: PrefixCandidate[] = [];
|
|
210
|
+
|
|
211
|
+
const mut_slices: SliceCall[] = [];
|
|
212
|
+
|
|
213
|
+
const mut_accumulations: AccumulationCandidate[] = [];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The text `xs.reduce(...)` is replaced with, or `undefined` when one of the
|
|
217
|
+
* conditions that make the two forms equal does not hold. See the rule
|
|
218
|
+
* documentation for what each of them rules out.
|
|
219
|
+
*/
|
|
220
|
+
const scanReplacementFor = (
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
222
|
+
call: TSESTree.CallExpression,
|
|
223
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
224
|
+
callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
225
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
226
|
+
initial: TSESTree.Node,
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
228
|
+
accumulation: Accumulation,
|
|
229
|
+
): string | undefined => {
|
|
230
|
+
if (callback.async || callback.generator) return undefined;
|
|
231
|
+
|
|
232
|
+
// `reduce`'s fourth parameter is the whole array, which `scan` never
|
|
233
|
+
// hands its callback; an annotation on the accumulator describes that
|
|
234
|
+
// array, and would be wrong on `scan`'s value parameter.
|
|
235
|
+
if (
|
|
236
|
+
callback.params.length < 2 ||
|
|
237
|
+
callback.params.length > 3 ||
|
|
238
|
+
callback.params.some(
|
|
239
|
+
(param) =>
|
|
240
|
+
param.type !== AST_NODE_TYPES.Identifier ||
|
|
241
|
+
param.typeAnnotation !== undefined,
|
|
242
|
+
)
|
|
243
|
+
) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { accumulatorParam, accumulatorRef, element } = accumulation;
|
|
248
|
+
|
|
249
|
+
// `scan`'s initial value is the single element the reduce was seeded with.
|
|
250
|
+
if (
|
|
251
|
+
initial.type !== AST_NODE_TYPES.ArrayExpression ||
|
|
252
|
+
!Arr.isFixedLengthArray(1, initial.elements)
|
|
253
|
+
) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const [seed] = initial.elements;
|
|
258
|
+
|
|
259
|
+
if (seed === null || seed.type === AST_NODE_TYPES.SpreadElement) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const scope = sourceCode.getScope(callback);
|
|
264
|
+
|
|
265
|
+
const accumulator = scope.variables.find(
|
|
266
|
+
(variable) => variable.name === accumulatorParam.name,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (accumulator === undefined) return undefined;
|
|
270
|
+
|
|
271
|
+
// `acc.at(-1)` is `T | undefined` only because `at` can go out of bounds,
|
|
272
|
+
// which a seeded accumulator never does. So once the value `scan` carries
|
|
273
|
+
// is known not to be nullish, a `?? fallback` guarding the read is dead
|
|
274
|
+
// and the fix takes it with the read — leaving it would hand the author an
|
|
275
|
+
// unnecessary condition that no rule can fix for them.
|
|
276
|
+
const carriesNullish = isNullish(seed) || isNullish(element);
|
|
277
|
+
|
|
278
|
+
const mut_lastReads: Splice[] = [];
|
|
279
|
+
|
|
280
|
+
for (const reference of accumulator.references) {
|
|
281
|
+
const identifier = reference.identifier;
|
|
282
|
+
|
|
283
|
+
// The `...acc` / `Arr.toPushed(acc, …)` target the rewrite drops.
|
|
284
|
+
if (identifier === accumulatorRef) continue;
|
|
285
|
+
|
|
286
|
+
if (identifier.type !== AST_NODE_TYPES.Identifier) return undefined;
|
|
287
|
+
|
|
288
|
+
const lastRead = enclosingLastRead(identifier);
|
|
289
|
+
|
|
290
|
+
if (lastRead === undefined) return undefined;
|
|
291
|
+
|
|
292
|
+
const guard = carriesNullish ? undefined : nullishGuardAround(lastRead);
|
|
293
|
+
|
|
294
|
+
mut_lastReads.push({
|
|
295
|
+
range: (guard ?? lastRead).range,
|
|
296
|
+
text: accumulatorParam.name,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Two reads inside one `?? ` — `acc.at(-1) ?? acc.at(-1)` — would give
|
|
301
|
+
// ranges that contain one another, which no ordering of splices resolves.
|
|
302
|
+
if (overlaps(mut_lastReads)) return undefined;
|
|
303
|
+
|
|
304
|
+
// With no read of the previous value this is a `map` with a seed rather
|
|
305
|
+
// than a scan, and the accumulator parameter would be left unused.
|
|
306
|
+
if (mut_lastReads.length === 0) return undefined;
|
|
307
|
+
|
|
308
|
+
const parameterText = callback.params
|
|
309
|
+
.map((param) => sourceCode.getText(param))
|
|
310
|
+
.join(', ');
|
|
311
|
+
|
|
312
|
+
const elementText = applySplices(
|
|
313
|
+
sourceCode.getText(element),
|
|
314
|
+
element.range[0],
|
|
315
|
+
mut_lastReads,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const arrayText = sourceCode.getText(
|
|
319
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
320
|
+
(call.callee as TSESTree.MemberExpression).object,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
return `Arr.scan(${arrayText}, (${parameterText}) => ${elementText}, ${sourceCode.getText(seed)})`;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The places a `map` callback reads its index parameter, or `undefined`
|
|
328
|
+
* when the callback is not of the shape this rule looks for — the wrong
|
|
329
|
+
* arity, a destructured parameter, or an element parameter it actually
|
|
330
|
+
* reads.
|
|
331
|
+
*/
|
|
332
|
+
const indexReadsOf = (
|
|
333
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
334
|
+
callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
335
|
+
): readonly TSESTree.Node[] | undefined => {
|
|
336
|
+
if (!Arr.isFixedLengthArray(2, callback.params)) return undefined;
|
|
337
|
+
|
|
338
|
+
const [elementParam, indexParam] = callback.params;
|
|
339
|
+
|
|
340
|
+
if (
|
|
341
|
+
elementParam.type !== AST_NODE_TYPES.Identifier ||
|
|
342
|
+
indexParam.type !== AST_NODE_TYPES.Identifier
|
|
343
|
+
) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const scope = sourceCode.getScope(callback);
|
|
348
|
+
|
|
349
|
+
const elementVariable = scope.variables.find(
|
|
350
|
+
(variable) => variable.name === elementParam.name,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const indexVariable = scope.variables.find(
|
|
354
|
+
(variable) => variable.name === indexParam.name,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
if (elementVariable === undefined || indexVariable === undefined) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (elementVariable.references.length > 0) return undefined;
|
|
362
|
+
|
|
363
|
+
return indexVariable.references.map(
|
|
364
|
+
(reference) => reference.identifier as TSESTree.Node,
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
CallExpression: (node) => {
|
|
370
|
+
if (
|
|
371
|
+
node.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
372
|
+
node.callee.computed ||
|
|
373
|
+
node.callee.property.type !== AST_NODE_TYPES.Identifier
|
|
374
|
+
) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const methodName = node.callee.property.name;
|
|
379
|
+
|
|
380
|
+
const arrayText = sourceCode.getText(node.callee.object);
|
|
381
|
+
|
|
382
|
+
if (SLICING_METHODS.has(methodName)) {
|
|
383
|
+
mut_slices.push({ call: node, arrayText });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const [callback, ...rest] = node.arguments;
|
|
387
|
+
|
|
388
|
+
if (
|
|
389
|
+
callback === undefined ||
|
|
390
|
+
(callback.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
391
|
+
callback.type !== AST_NODE_TYPES.FunctionExpression)
|
|
392
|
+
) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
switch (methodName) {
|
|
397
|
+
case 'map': {
|
|
398
|
+
if (rest.length > 0) break;
|
|
399
|
+
|
|
400
|
+
const indexReads = indexReadsOf(callback);
|
|
401
|
+
|
|
402
|
+
if (indexReads === undefined) break;
|
|
403
|
+
|
|
404
|
+
mut_candidates.push({
|
|
405
|
+
call: node,
|
|
406
|
+
arrayText,
|
|
407
|
+
body: callback.body,
|
|
408
|
+
indexReads,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
case 'reduce': {
|
|
415
|
+
// Two arguments, so that the initial value is explicit: a `reduce`
|
|
416
|
+
// without one starts from the first element and has no `scan`
|
|
417
|
+
// spelling at all.
|
|
418
|
+
if (rest.length !== 1 || !appendsToItsAccumulator(callback)) break;
|
|
419
|
+
|
|
420
|
+
const [initial] = rest;
|
|
421
|
+
|
|
422
|
+
const accumulation = accumulationOf(callback);
|
|
423
|
+
|
|
424
|
+
mut_accumulations.push({
|
|
425
|
+
call: node,
|
|
426
|
+
replacement:
|
|
427
|
+
initial === undefined || accumulation === undefined
|
|
428
|
+
? undefined
|
|
429
|
+
: scanReplacementFor(node, callback, initial, accumulation),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
default:
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
// Deferred to the end so that a `slice` nested inside the callback has
|
|
441
|
+
// been seen by the time its `map` is judged, whichever order the traversal
|
|
442
|
+
// reached them in.
|
|
443
|
+
'Program:exit': () => {
|
|
444
|
+
const hasArrImport = getNamedImports(tsDataForgeImport).includes('Arr');
|
|
445
|
+
|
|
446
|
+
// The import is inserted once per pass: two fixes writing the same
|
|
447
|
+
// insertion point would otherwise produce it twice.
|
|
448
|
+
const mut_importNeededBy = hasArrImport
|
|
449
|
+
? undefined
|
|
450
|
+
: mut_accumulations.find(
|
|
451
|
+
(accumulation) => accumulation.replacement !== undefined,
|
|
452
|
+
)?.call;
|
|
453
|
+
|
|
454
|
+
for (const accumulation of mut_accumulations) {
|
|
455
|
+
const { call, replacement } = accumulation;
|
|
456
|
+
|
|
457
|
+
context.report({
|
|
458
|
+
node: call,
|
|
459
|
+
messageId: 'preferArrScanAccumulate',
|
|
460
|
+
fix:
|
|
461
|
+
replacement === undefined
|
|
462
|
+
? undefined
|
|
463
|
+
: (fixer) => [
|
|
464
|
+
...(call === mut_importNeededBy
|
|
465
|
+
? buildImportFixes(fixer, program, tsDataForgeImport, [
|
|
466
|
+
'Arr',
|
|
467
|
+
])
|
|
468
|
+
: []),
|
|
469
|
+
fixer.replaceText(call, replacement),
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const candidate of mut_candidates) {
|
|
475
|
+
const rebuild = mut_slices.find(
|
|
476
|
+
(slice) =>
|
|
477
|
+
slice.arrayText === candidate.arrayText &&
|
|
478
|
+
encloses(candidate.body, slice.call) &&
|
|
479
|
+
candidate.indexReads.some((read) =>
|
|
480
|
+
slice.call.arguments.some((argument) =>
|
|
481
|
+
encloses(argument, read),
|
|
482
|
+
),
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
if (rebuild === undefined) continue;
|
|
487
|
+
|
|
488
|
+
context.report({
|
|
489
|
+
node: candidate.call,
|
|
490
|
+
messageId: 'preferArrScanPrefix',
|
|
491
|
+
data: { array: candidate.arrayText },
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
defaultOptions: [],
|
|
498
|
+
} as const;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The `acc.at(-1)` call an accumulator reference sits at the head of, or
|
|
502
|
+
* `undefined` when the reference is used in any other way — which is what
|
|
503
|
+
* withholds the fix, `scan` having no prefix to offer.
|
|
504
|
+
*/
|
|
505
|
+
const enclosingLastRead = (
|
|
506
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
507
|
+
identifier: TSESTree.Identifier,
|
|
508
|
+
): TSESTree.CallExpression | undefined => {
|
|
509
|
+
const member = identifier.parent;
|
|
510
|
+
|
|
511
|
+
if (
|
|
512
|
+
member.type !== AST_NODE_TYPES.MemberExpression ||
|
|
513
|
+
member.object !== identifier ||
|
|
514
|
+
member.computed ||
|
|
515
|
+
member.property.type !== AST_NODE_TYPES.Identifier ||
|
|
516
|
+
member.property.name !== 'at'
|
|
517
|
+
) {
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const call = member.parent;
|
|
522
|
+
|
|
523
|
+
if (
|
|
524
|
+
call.type !== AST_NODE_TYPES.CallExpression ||
|
|
525
|
+
call.callee !== member ||
|
|
526
|
+
!Arr.isFixedLengthArray(1, call.arguments)
|
|
527
|
+
) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const [argument] = call.arguments;
|
|
532
|
+
|
|
533
|
+
// `at(-1)` only: any other index is not the previous value.
|
|
534
|
+
return argument.type === AST_NODE_TYPES.UnaryExpression &&
|
|
535
|
+
argument.operator === '-' &&
|
|
536
|
+
argument.argument.type === AST_NODE_TYPES.Literal &&
|
|
537
|
+
argument.argument.value === 1
|
|
538
|
+
? call
|
|
539
|
+
: undefined;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The `X ?? fallback` that `read` is the left-hand side of, if it is one.
|
|
544
|
+
*/
|
|
545
|
+
const nullishGuardAround = (
|
|
546
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
547
|
+
read: TSESTree.CallExpression,
|
|
548
|
+
): TSESTree.LogicalExpression | undefined => {
|
|
549
|
+
const parent = read.parent;
|
|
550
|
+
|
|
551
|
+
return parent.type === AST_NODE_TYPES.LogicalExpression &&
|
|
552
|
+
parent.operator === '??' &&
|
|
553
|
+
parent.left === read
|
|
554
|
+
? parent
|
|
555
|
+
: undefined;
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/** Whether any two of the splices cover overlapping source. */
|
|
559
|
+
const overlaps = (splices: readonly Splice[]): boolean =>
|
|
560
|
+
splices
|
|
561
|
+
.toSorted((a, b) => a.range[0] - b.range[0])
|
|
562
|
+
.some(
|
|
563
|
+
(splice, index, sorted) =>
|
|
564
|
+
index > 0 && splice.range[0] < (sorted[index - 1]?.range[1] ?? 0),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* `text`, which starts at `offset` in the file, with each splice applied.
|
|
569
|
+
* Right to left, so that an earlier replacement does not move a later range.
|
|
570
|
+
*/
|
|
571
|
+
const applySplices = (
|
|
572
|
+
text: string,
|
|
573
|
+
offset: number,
|
|
574
|
+
splices: readonly Splice[],
|
|
575
|
+
): string =>
|
|
576
|
+
splices
|
|
577
|
+
.toSorted((a, b) => b.range[0] - a.range[0])
|
|
578
|
+
.reduce(
|
|
579
|
+
(mut_text, splice) =>
|
|
580
|
+
`${mut_text.slice(0, splice.range[0] - offset)}${splice.text}${mut_text.slice(splice.range[1] - offset)}`,
|
|
581
|
+
text,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Whether `inner` lies inside `outer`. Ranges rather than parent pointers,
|
|
586
|
+
* because the two nodes are reached from different visitor calls.
|
|
587
|
+
*/
|
|
588
|
+
const encloses = (
|
|
589
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
590
|
+
outer: TSESTree.Node,
|
|
591
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
592
|
+
inner: TSESTree.Node,
|
|
593
|
+
): boolean =>
|
|
594
|
+
outer.range[0] <= inner.range[0] && inner.range[1] <= outer.range[1];
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Whether the callback's body appends to its own accumulator at all — the
|
|
598
|
+
* O(n²) shape this rule reports, in either spelling and whatever it appends.
|
|
599
|
+
* Deliberately looser than {@link accumulationOf}: appending two values per
|
|
600
|
+
* step is still worth reporting, it is only the rewrite that cannot express it.
|
|
601
|
+
*/
|
|
602
|
+
const appendsToItsAccumulator = (
|
|
603
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
604
|
+
callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
605
|
+
): boolean => {
|
|
606
|
+
const [accumulatorParam] = callback.params;
|
|
607
|
+
|
|
608
|
+
if (accumulatorParam?.type !== AST_NODE_TYPES.Identifier) return false;
|
|
609
|
+
|
|
610
|
+
const { body } = callback;
|
|
611
|
+
|
|
612
|
+
const spelling =
|
|
613
|
+
body.type === AST_NODE_TYPES.ArrayExpression
|
|
614
|
+
? body.elements[0]
|
|
615
|
+
: body.type === AST_NODE_TYPES.CallExpression &&
|
|
616
|
+
body.callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
617
|
+
!body.callee.computed &&
|
|
618
|
+
body.callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
619
|
+
body.callee.property.name === 'toPushed'
|
|
620
|
+
? body.arguments[0]
|
|
621
|
+
: undefined;
|
|
622
|
+
|
|
623
|
+
const target =
|
|
624
|
+
spelling?.type === AST_NODE_TYPES.SpreadElement
|
|
625
|
+
? spelling.argument
|
|
626
|
+
: spelling;
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
target?.type === AST_NODE_TYPES.Identifier &&
|
|
630
|
+
target.name === accumulatorParam.name
|
|
631
|
+
);
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The append this `reduce` callback performs, or `undefined` when its body is
|
|
636
|
+
* not one. Two spellings mean the same thing here, and both have to be
|
|
637
|
+
* recognized: `[...acc, x]` as written, and `Arr.toPushed(acc, x)`, which is
|
|
638
|
+
* what `ts-data-forge/prefer-canonical-array-slicing` rewrites the first into.
|
|
639
|
+
* Seeing only the first would leave this rule blind after a single `--fix`
|
|
640
|
+
* pass, with the O(n²) accumulation still there under the other spelling.
|
|
641
|
+
*/
|
|
642
|
+
const accumulationOf = (
|
|
643
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
644
|
+
callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
|
|
645
|
+
): Accumulation | undefined => {
|
|
646
|
+
const [accumulatorParam] = callback.params;
|
|
647
|
+
|
|
648
|
+
if (accumulatorParam?.type !== AST_NODE_TYPES.Identifier) return undefined;
|
|
649
|
+
|
|
650
|
+
const { body } = callback;
|
|
651
|
+
|
|
652
|
+
// `[...acc, ELEMENT]` — exactly one spread and one new value, since `scan`
|
|
653
|
+
// appends exactly one per step.
|
|
654
|
+
if (body.type === AST_NODE_TYPES.ArrayExpression) {
|
|
655
|
+
if (!Arr.isFixedLengthArray(2, body.elements)) return undefined;
|
|
656
|
+
|
|
657
|
+
const [spread, element] = body.elements;
|
|
658
|
+
|
|
659
|
+
return element === null ||
|
|
660
|
+
element.type === AST_NODE_TYPES.SpreadElement ||
|
|
661
|
+
spread?.type !== AST_NODE_TYPES.SpreadElement ||
|
|
662
|
+
spread.argument.type !== AST_NODE_TYPES.Identifier ||
|
|
663
|
+
spread.argument.name !== accumulatorParam.name
|
|
664
|
+
? undefined
|
|
665
|
+
: { accumulatorParam, accumulatorRef: spread.argument, element };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// `Arr.toPushed(acc, ELEMENT)`, matched on the method name as the rest of
|
|
669
|
+
// this rule matches `slice` and `at`.
|
|
670
|
+
if (body.type === AST_NODE_TYPES.CallExpression) {
|
|
671
|
+
const { callee } = body;
|
|
672
|
+
|
|
673
|
+
if (
|
|
674
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
675
|
+
callee.computed ||
|
|
676
|
+
callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
677
|
+
callee.property.name !== 'toPushed' ||
|
|
678
|
+
!Arr.isFixedLengthArray(2, body.arguments)
|
|
679
|
+
) {
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const [target, element] = body.arguments;
|
|
684
|
+
|
|
685
|
+
return element.type === AST_NODE_TYPES.SpreadElement ||
|
|
686
|
+
target.type !== AST_NODE_TYPES.Identifier ||
|
|
687
|
+
target.name !== accumulatorParam.name
|
|
688
|
+
? undefined
|
|
689
|
+
: { accumulatorParam, accumulatorRef: target, element };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return undefined;
|
|
693
|
+
};
|