eslint-plugin-ts-data-forge 0.7.4 → 0.8.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.
@@ -0,0 +1,475 @@
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 * as ts from 'typescript';
8
+ import {
9
+ buildImportFixes,
10
+ getImportedLocalName,
11
+ getTsDataForgeImport,
12
+ } from './import-utils.mjs';
13
+
14
+ type Options = readonly [];
15
+
16
+ type MessageIds = 'useArrUniq' | 'suggestArrUniq';
17
+
18
+ /**
19
+ * `Arr.uniq(xs)` is implemented as `Array.from(new Set(xs))`, so the rewrite
20
+ * does not change behavior. What it does change is the static type: `Arr.uniq`
21
+ * only accepts `readonly Primitive[]` and returns a readonly array. The rule
22
+ * therefore reports only when the argument is an array or tuple of primitives,
23
+ * and it applies the fix automatically only when nothing in sight needs the
24
+ * result to be a mutable array; otherwise the rewrite is offered as a
25
+ * suggestion.
26
+ */
27
+ export const preferArrUniq: TSESLint.RuleModule<MessageIds, Options> = {
28
+ meta: {
29
+ type: 'suggestion',
30
+ docs: {
31
+ description:
32
+ 'Replace `Array.from(new Set(xs))` / `[...new Set(xs)]` with `Arr.uniq(xs)` from ts-data-forge.',
33
+ },
34
+ fixable: 'code',
35
+ hasSuggestions: true,
36
+ schema: [],
37
+ messages: {
38
+ useArrUniq: 'Replace with `Arr.uniq({{arrayName}})` from ts-data-forge.',
39
+ suggestArrUniq:
40
+ 'Replace with `Arr.uniq({{arrayName}})` from ts-data-forge (the result becomes a readonly array).',
41
+ },
42
+ },
43
+
44
+ create: (context) => {
45
+ const sourceCode = context.sourceCode;
46
+
47
+ const program = sourceCode.ast;
48
+
49
+ const tsDataForgeImport = getTsDataForgeImport(program);
50
+
51
+ const services = sourceCode.parserServices;
52
+
53
+ const checker = services?.program?.getTypeChecker();
54
+
55
+ const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap;
56
+
57
+ // Without type information the rule cannot tell an array of primitives
58
+ // from an array of objects, and would suggest code that does not compile.
59
+ if (checker === undefined || esTreeNodeToTSNodeMap === undefined) {
60
+ return {};
61
+ }
62
+
63
+ const getTsNode = (
64
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
65
+ node: TSESTree.Node,
66
+ ): ts.Node | undefined => esTreeNodeToTSNodeMap.get(node);
67
+
68
+ const mut_matches: {
69
+ node: TSESTree.CallExpression | TSESTree.ArrayExpression;
70
+ arrayExpression: TSESTree.Expression;
71
+ autofixable: boolean;
72
+ }[] = [];
73
+
74
+ const check = (
75
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
76
+ node: TSESTree.CallExpression | TSESTree.ArrayExpression,
77
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
78
+ setNode: TSESTree.Node,
79
+ ): void => {
80
+ const arrayExpression = getSetSourceArray(setNode);
81
+
82
+ if (arrayExpression === undefined) return;
83
+
84
+ if (!isGlobal(sourceCode, node, 'Set')) return;
85
+
86
+ const arrayTsNode = getTsNode(arrayExpression);
87
+
88
+ if (arrayTsNode === undefined) return;
89
+
90
+ if (
91
+ !isPrimitiveArrayType(checker, checker.getTypeAtLocation(arrayTsNode))
92
+ )
93
+ return;
94
+
95
+ mut_matches.push({
96
+ node,
97
+ arrayExpression,
98
+ autofixable: !needsMutableResult(sourceCode, checker, getTsNode, node),
99
+ });
100
+ };
101
+
102
+ return {
103
+ CallExpression: (node) => {
104
+ // Array.from(new Set(xs))
105
+ if (
106
+ node.optional ||
107
+ node.callee.type !== AST_NODE_TYPES.MemberExpression ||
108
+ node.callee.computed ||
109
+ node.callee.optional ||
110
+ node.callee.object.type !== AST_NODE_TYPES.Identifier ||
111
+ node.callee.object.name !== 'Array' ||
112
+ node.callee.property.type !== AST_NODE_TYPES.Identifier ||
113
+ node.callee.property.name !== 'from' ||
114
+ // `Array.from(set, fn)` maps as well, which `Arr.uniq` does not.
115
+ !Arr.isFixedLengthTuple(1, node.arguments)
116
+ ) {
117
+ return;
118
+ }
119
+
120
+ if (!isGlobal(sourceCode, node, 'Array')) return;
121
+
122
+ check(node, node.arguments[0]);
123
+ },
124
+ ArrayExpression: (node) => {
125
+ // [...new Set(xs)]
126
+ if (!Arr.isFixedLengthTuple(1, node.elements)) return;
127
+
128
+ const element = node.elements[0];
129
+
130
+ if (element?.type !== AST_NODE_TYPES.SpreadElement) return;
131
+
132
+ check(node, element.argument);
133
+ },
134
+ 'Program:exit': () => {
135
+ const arrLocalName = getImportedLocalName(tsDataForgeImport, 'Arr');
136
+
137
+ const arrName = arrLocalName ?? 'Arr';
138
+
139
+ // The import is added by the first fix only, so that several fixes do
140
+ // not insert it at the same position. When the first match is not
141
+ // autofixable, the first autofixable one carries it instead.
142
+ const firstAutofixable = mut_matches.find((m) => m.autofixable);
143
+
144
+ for (const match of mut_matches) {
145
+ const arrayText = sourceCode.getText(match.arrayExpression);
146
+
147
+ const argumentText =
148
+ match.arrayExpression.type === AST_NODE_TYPES.SequenceExpression
149
+ ? `(${arrayText})`
150
+ : arrayText;
151
+
152
+ const buildFix =
153
+ (withImport: boolean): TSESLint.ReportFixFunction =>
154
+ (fixer) => [
155
+ ...(withImport && arrLocalName === undefined
156
+ ? buildImportFixes(fixer, program, tsDataForgeImport, ['Arr'])
157
+ : []),
158
+ fixer.replaceText(match.node, `${arrName}.uniq(${argumentText})`),
159
+ ];
160
+
161
+ const data = { arrayName: arrayText } as const;
162
+
163
+ if (match.autofixable) {
164
+ context.report({
165
+ node: match.node,
166
+ messageId: 'useArrUniq',
167
+ data,
168
+ fix: buildFix(match === firstAutofixable),
169
+ });
170
+ } else {
171
+ context.report({
172
+ node: match.node,
173
+ messageId: 'useArrUniq',
174
+ data,
175
+ suggest: [
176
+ {
177
+ messageId: 'suggestArrUniq',
178
+ data,
179
+ fix: buildFix(true),
180
+ },
181
+ ],
182
+ });
183
+ }
184
+ }
185
+ },
186
+ };
187
+ },
188
+ defaultOptions: [],
189
+ } as const;
190
+
191
+ /** Methods that `ReadonlyArray` lacks and that mutate the receiver. */
192
+ const mutatingMethods: ReadonlySet<string> = new Set([
193
+ 'copyWithin',
194
+ 'fill',
195
+ 'pop',
196
+ 'push',
197
+ 'reverse',
198
+ 'shift',
199
+ 'sort',
200
+ 'splice',
201
+ 'unshift',
202
+ ]);
203
+
204
+ /**
205
+ * Returns `xs` for `new Set(xs)`, and `undefined` for anything else — including
206
+ * `new Set()`, `new Set<T>(xs)` (whose element type may be wider than `xs`'s)
207
+ * and `new Set(...args)`.
208
+ */
209
+ const getSetSourceArray = (
210
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
211
+ node: TSESTree.Node,
212
+ ): TSESTree.Expression | undefined => {
213
+ if (
214
+ node.type !== AST_NODE_TYPES.NewExpression ||
215
+ node.callee.type !== AST_NODE_TYPES.Identifier ||
216
+ node.callee.name !== 'Set' ||
217
+ node.typeArguments !== undefined ||
218
+ !Arr.isFixedLengthTuple(1, node.arguments)
219
+ ) {
220
+ return undefined;
221
+ }
222
+
223
+ const argument = node.arguments[0];
224
+
225
+ return argument.type === AST_NODE_TYPES.SpreadElement ? undefined : argument;
226
+ };
227
+
228
+ /** Whether `name` at `node` refers to the built-in global, not a local binding. */
229
+ const isGlobal = (
230
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
231
+ sourceCode: TSESLint.SourceCode,
232
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
233
+ node: TSESTree.Node,
234
+ name: string,
235
+ ): boolean => {
236
+ const variable = findVariable(sourceCode.getScope(node), name);
237
+
238
+ // Globals from the TypeScript lib are implicit variables with no definition.
239
+ return variable === undefined || variable.defs.length === 0;
240
+ };
241
+
242
+ const findVariable = (
243
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
244
+ scope: TSESLint.Scope.Scope,
245
+ name: string,
246
+ ): TSESLint.Scope.Variable | undefined => {
247
+ const variable = scope.set.get(name);
248
+
249
+ if (variable !== undefined) return variable;
250
+
251
+ if (scope.upper === null) return undefined;
252
+
253
+ return findVariable(scope.upper, name);
254
+ };
255
+
256
+ /**
257
+ * Whether `type` is an array or tuple (or a union or intersection of them) whose elements are
258
+ * all primitives, i.e. something `Arr.uniq` accepts.
259
+ */
260
+ const isPrimitiveArrayType = (
261
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
262
+ checker: ts.TypeChecker,
263
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
264
+ type: ts.Type,
265
+ ): boolean => {
266
+ const resolved = resolveConstraint(checker, type);
267
+
268
+ if (resolved.isUnion()) {
269
+ return resolved.types.every((t) => isPrimitiveArrayType(checker, t));
270
+ }
271
+
272
+ // `readonly string[] & Brand`, `readonly [...Ar, V] & NonEmptyArray<E>`: one
273
+ // primitive-array member already makes the whole assignable to one.
274
+ if (resolved.isIntersection()) {
275
+ return resolved.types.some((t) => isPrimitiveArrayType(checker, t));
276
+ }
277
+
278
+ if (!checker.isArrayType(resolved) && !checker.isTupleType(resolved)) {
279
+ return false;
280
+ }
281
+
282
+ const typeArguments = checker.getTypeArguments(
283
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
284
+ resolved as ts.TypeReference,
285
+ );
286
+
287
+ // An empty tuple has nothing to deduplicate, but is still accepted.
288
+ return typeArguments.every((t) => isPrimitiveType(checker, t));
289
+ };
290
+
291
+ const isPrimitiveType = (
292
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
293
+ checker: ts.TypeChecker,
294
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
295
+ type: ts.Type,
296
+ ): boolean => {
297
+ const resolved = resolveConstraint(checker, type);
298
+
299
+ if (resolved.isUnion()) {
300
+ return resolved.types.every((t) => isPrimitiveType(checker, t));
301
+ }
302
+
303
+ return (resolved.flags & primitiveTypeFlags) !== 0;
304
+ };
305
+
306
+ const primitiveTypeFlags =
307
+ ts.TypeFlags.StringLike |
308
+ ts.TypeFlags.NumberLike |
309
+ ts.TypeFlags.BigIntLike |
310
+ ts.TypeFlags.BooleanLike |
311
+ ts.TypeFlags.ESSymbolLike |
312
+ ts.TypeFlags.Null |
313
+ ts.TypeFlags.Undefined;
314
+
315
+ const resolveConstraint = (
316
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
317
+ checker: ts.TypeChecker,
318
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
319
+ type: ts.Type,
320
+ ): ts.Type =>
321
+ (type.flags & ts.TypeFlags.TypeParameter) !== 0
322
+ ? (checker.getBaseConstraintOfType(type) ?? type)
323
+ : type;
324
+
325
+ /**
326
+ * Whether the code around `node` visibly relies on the result being a mutable
327
+ * array, so that swapping in the readonly array `Arr.uniq` returns would not
328
+ * compile. Looks at `node` itself and, when it initializes a variable without a
329
+ * type annotation, at every read of that variable.
330
+ */
331
+ const needsMutableResult = (
332
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
333
+ sourceCode: TSESLint.SourceCode,
334
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
335
+ checker: ts.TypeChecker,
336
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
337
+ getTsNode: (esNode: TSESTree.Node) => ts.Node | undefined,
338
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
339
+ node: TSESTree.Node,
340
+ ): boolean => {
341
+ if (isUsedAsMutable(checker, getTsNode, node)) return true;
342
+
343
+ const parent = node.parent;
344
+
345
+ if (
346
+ parent?.type !== AST_NODE_TYPES.VariableDeclarator ||
347
+ parent.init !== node ||
348
+ parent.id.type !== AST_NODE_TYPES.Identifier ||
349
+ parent.id.typeAnnotation !== undefined
350
+ ) {
351
+ return false;
352
+ }
353
+
354
+ const [variable] = sourceCode.getDeclaredVariables(parent);
355
+
356
+ return (
357
+ variable?.references.some(
358
+ (reference) =>
359
+ reference.init !== true &&
360
+ isUsedAsMutable(checker, getTsNode, reference.identifier),
361
+ ) ?? false
362
+ );
363
+ };
364
+
365
+ const isUsedAsMutable = (
366
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
367
+ checker: ts.TypeChecker,
368
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
369
+ getTsNode: (esNode: TSESTree.Node) => ts.Node | undefined,
370
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
371
+ node: TSESTree.Node,
372
+ ): boolean => {
373
+ const parent = node.parent;
374
+
375
+ if (
376
+ parent?.type === AST_NODE_TYPES.MemberExpression &&
377
+ parent.object === node
378
+ ) {
379
+ // xs.push(...), xs.sort(), ...
380
+ if (
381
+ !parent.computed &&
382
+ parent.property.type === AST_NODE_TYPES.Identifier &&
383
+ mutatingMethods.has(parent.property.name)
384
+ ) {
385
+ return true;
386
+ }
387
+
388
+ // xs[0] = ..., xs.length = ..., xs[0]++, delete xs[0]
389
+ const grandParent = parent.parent;
390
+
391
+ return (
392
+ (grandParent.type === AST_NODE_TYPES.AssignmentExpression &&
393
+ grandParent.left === parent) ||
394
+ grandParent.type === AST_NODE_TYPES.UpdateExpression ||
395
+ (grandParent.type === AST_NODE_TYPES.UnaryExpression &&
396
+ grandParent.operator === 'delete')
397
+ );
398
+ }
399
+
400
+ // Passed to a parameter, assigned to a variable, or returned from a function
401
+ // whose declared type is a mutable array.
402
+ const tsNode = getTsNode(node);
403
+
404
+ if (tsNode === undefined) return false;
405
+
406
+ if (!ts.isExpression(tsNode)) return false;
407
+
408
+ const expectedType =
409
+ getDeclaredParameterType(checker, tsNode) ??
410
+ checker.getContextualType(tsNode);
411
+
412
+ return (
413
+ expectedType !== undefined && isMutableArrayOnly(checker, expectedType)
414
+ );
415
+ };
416
+
417
+ /**
418
+ * The declared type of the parameter `argument` is passed to. The contextual
419
+ * type of an argument to a generic function is instantiated from that very
420
+ * argument — `Arr.toSorted(Array.from(set), cmp)` has `Ar` inferred as
421
+ * `string[]` — so it would read as "a mutable array is required" for any
422
+ * generic parameter. The declared type (`Ar extends readonly unknown[]`) is
423
+ * what the rewritten argument actually has to satisfy.
424
+ */
425
+ const getDeclaredParameterType = (
426
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
427
+ checker: ts.TypeChecker,
428
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
429
+ argument: ts.Expression,
430
+ ): ts.Type | undefined => {
431
+ const call = argument.parent;
432
+
433
+ if (!ts.isCallExpression(call) && !ts.isNewExpression(call)) {
434
+ return undefined;
435
+ }
436
+
437
+ const index = call.arguments?.indexOf(argument) ?? -1;
438
+
439
+ if (index === -1) return undefined;
440
+
441
+ const declaration = checker.getResolvedSignature(call)?.getDeclaration();
442
+
443
+ const parameter = declaration?.parameters[index];
444
+
445
+ // A rest parameter's declared type is the array of arguments, not the type of
446
+ // one; the contextual type is the better answer there.
447
+ if (parameter === undefined || parameter.dotDotDotToken !== undefined) {
448
+ return undefined;
449
+ }
450
+
451
+ return checker.getTypeAtLocation(parameter);
452
+ };
453
+
454
+ /**
455
+ * Whether a readonly array cannot be assigned to `type`: it is a mutable array,
456
+ * or a union of mutable arrays and primitives. (A tuple accepted no array to
457
+ * begin with, so it does not matter here.)
458
+ */
459
+ const isMutableArrayOnly = (
460
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
461
+ checker: ts.TypeChecker,
462
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
463
+ type: ts.Type,
464
+ ): boolean => {
465
+ const members = type.isUnion() ? type.types : ([type] as const);
466
+
467
+ return (
468
+ members.every(
469
+ (t) =>
470
+ // `null`, `undefined` and other primitives accept no array at all.
471
+ (t.flags & primitiveTypeFlags) !== 0 ||
472
+ (checker.isArrayType(t) && t.getSymbol()?.getName() === 'Array'),
473
+ ) && members.some((t) => (t.flags & primitiveTypeFlags) === 0)
474
+ );
475
+ };