eslint-plugin-ts-data-forge 0.4.8 → 0.5.1

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,546 @@
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 ReadonlyRecord } from 'ts-type-forge';
8
+ import {
9
+ buildImportFixes,
10
+ getImportedLocalName,
11
+ getNamedImports,
12
+ getTsDataForgeImport,
13
+ } from './import-utils.mjs';
14
+
15
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
16
+
17
+ type Options = readonly [];
18
+
19
+ type MessageIds = 'useObjFilter' | 'useObjFilterMap' | 'useObjMap';
20
+
21
+ /** The `Obj` function an entries round trip is rewritten to. */
22
+ type ObjMethod = 'filter' | 'filterMap' | 'map';
23
+
24
+ const MESSAGE_ID_OF = {
25
+ map: 'useObjMap',
26
+ filter: 'useObjFilter',
27
+ filterMap: 'useObjFilterMap',
28
+ } as const satisfies ReadonlyRecord<ObjMethod, MessageIds>;
29
+
30
+ /**
31
+ * The body the rewritten callback gets:
32
+ *
33
+ * - `expression` — the mapped value (`Obj.map`) or the predicate (`Obj.filter`),
34
+ * used verbatim.
35
+ * - `optional` — the two branches of the `flatMap` callback's conditional,
36
+ * rebuilt as `Optional.some(...)` / `Optional.none` (`Obj.filterMap`).
37
+ */
38
+ type CallbackBody = Readonly<
39
+ | { kind: 'expression'; expression: TSESTree.Expression }
40
+ | {
41
+ kind: 'optional';
42
+ test: TSESTree.Expression;
43
+ kept: TSESTree.Expression;
44
+ keepOnTrue: boolean;
45
+ }
46
+ >;
47
+
48
+ type RoundTrip = Readonly<{
49
+ method: ObjMethod;
50
+ /** The record `Object.entries` was called on. */
51
+ record: TSESTree.Expression;
52
+ /** The callback given to `map` / `filter` / `flatMap`. */
53
+ callback: TSESTree.ArrowFunctionExpression;
54
+ /** `k` in `([k, v]) => …`, absent when the entry pattern elides it. */
55
+ keyParam: TSESTree.Identifier | undefined;
56
+ /** `v` in `([k, v]) => …`, absent when the entry pattern elides it. */
57
+ valueParam: TSESTree.Identifier | undefined;
58
+ /**
59
+ * The key identifiers the rewrite drops — the ones that only rebuilt the
60
+ * entry. They are excluded when deciding whether the callback still needs a
61
+ * key parameter.
62
+ */
63
+ dropped: readonly TSESTree.Identifier[];
64
+ body: CallbackBody;
65
+ }>;
66
+
67
+ export const preferObjOverEntriesRoundTrip: TSESLint.RuleModule<
68
+ MessageIds,
69
+ Options
70
+ > = {
71
+ meta: {
72
+ type: 'suggestion',
73
+ docs: {
74
+ description:
75
+ 'Replace an `Object.fromEntries(Object.entries(record).map/filter/flatMap(...))` round trip with `Obj.map` / `Obj.filter` / `Obj.filterMap` from ts-data-forge.',
76
+ },
77
+ fixable: 'code',
78
+ schema: [],
79
+ messages: {
80
+ useObjMap:
81
+ 'Replace this `Object.fromEntries(Object.entries(…).map(…))` round trip with `Obj.map` from ts-data-forge, which keeps the key set of the record in the result type.',
82
+ useObjFilter:
83
+ 'Replace this `Object.fromEntries(Object.entries(…).filter(…))` round trip with `Obj.filter` from ts-data-forge, which keeps the key and value types of the record in the result type.',
84
+ useObjFilterMap:
85
+ 'Replace this `Object.fromEntries(Object.entries(…).flatMap(…))` round trip with `Obj.filterMap` from ts-data-forge, which keeps the key types of the record in the result type.',
86
+ },
87
+ },
88
+
89
+ create: (context) => {
90
+ const sourceCode = context.sourceCode;
91
+
92
+ const program = sourceCode.ast;
93
+
94
+ const tsDataForgeImport = getTsDataForgeImport(program);
95
+
96
+ const mut_roundTrips: {
97
+ node: TSESTree.CallExpression;
98
+ roundTrip: RoundTrip;
99
+ }[] = [];
100
+
101
+ /**
102
+ * Rebuilds the callback's parameter list. The entry pattern binds
103
+ * `[key, value]`; `Obj` passes `(value, key)`, and either parameter is
104
+ * dropped when the rewritten body no longer refers to it.
105
+ */
106
+ const buildParams = (roundTrip: RoundTrip): string => {
107
+ const scope = sourceCode.getScope(roundTrip.callback);
108
+
109
+ const dropped = new Set<TSESTree.Node>(roundTrip.dropped);
110
+
111
+ const keyName =
112
+ roundTrip.keyParam !== undefined &&
113
+ isReferencedOutside(scope, roundTrip.keyParam.name, dropped)
114
+ ? roundTrip.keyParam.name
115
+ : undefined;
116
+
117
+ const valueName = roundTrip.valueParam?.name;
118
+
119
+ if (keyName !== undefined) {
120
+ // `Obj` puts the value first, so a callback that only needs the key
121
+ // still has to name a value parameter.
122
+ return `(${valueName ?? unusedValueParamName(scope)}, ${keyName})`;
123
+ }
124
+
125
+ return valueName !== undefined &&
126
+ isReferencedOutside(scope, valueName, dropped)
127
+ ? `(${valueName})`
128
+ : '()';
129
+ };
130
+
131
+ const buildCallbackBody = (
132
+ roundTrip: RoundTrip,
133
+ optionalName: string,
134
+ ): string => {
135
+ const { body } = roundTrip;
136
+
137
+ if (body.kind === 'expression') {
138
+ return arrowBodyText(
139
+ sourceCode.getText(body.expression),
140
+ body.expression,
141
+ );
142
+ }
143
+
144
+ const testText = conditionalTestText(
145
+ sourceCode.getText(body.test),
146
+ body.test,
147
+ );
148
+
149
+ const someText = `${optionalName}.some(${argumentText(sourceCode.getText(body.kept), body.kept)})`;
150
+
151
+ const noneText = `${optionalName}.none`;
152
+
153
+ return body.keepOnTrue
154
+ ? `${testText} ? ${someText} : ${noneText}`
155
+ : `${testText} ? ${noneText} : ${someText}`;
156
+ };
157
+
158
+ return {
159
+ CallExpression: (node) => {
160
+ const roundTrip = parseEntriesRoundTrip(node);
161
+
162
+ if (roundTrip === undefined) return;
163
+
164
+ mut_roundTrips.push({ node, roundTrip });
165
+ },
166
+
167
+ 'Program:exit': () => {
168
+ const namedImports = getNamedImports(tsDataForgeImport);
169
+
170
+ const objName = getImportedLocalName(tsDataForgeImport, 'Obj') ?? 'Obj';
171
+
172
+ const optionalName =
173
+ getImportedLocalName(tsDataForgeImport, 'Optional') ?? 'Optional';
174
+
175
+ const needsOptional = mut_roundTrips.some(
176
+ ({ roundTrip }) => roundTrip.body.kind === 'optional',
177
+ );
178
+
179
+ const missingImports = (['Obj', 'Optional'] as const).filter(
180
+ (name) =>
181
+ (name !== 'Optional' || needsOptional) &&
182
+ !namedImports.includes(name),
183
+ );
184
+
185
+ // Note: the import is added by the first fix only, so that several
186
+ // fixes in one file do not each insert it at the same position.
187
+ for (const [index, { node, roundTrip }] of mut_roundTrips.entries()) {
188
+ context.report({
189
+ node,
190
+ messageId: MESSAGE_ID_OF[roundTrip.method],
191
+ fix: (fixer) => {
192
+ const recordText = argumentText(
193
+ sourceCode.getText(roundTrip.record),
194
+ roundTrip.record,
195
+ );
196
+
197
+ const replacement = `${objName}.${roundTrip.method}(${recordText}, ${buildParams(roundTrip)} => ${buildCallbackBody(roundTrip, optionalName)})`;
198
+
199
+ const importFixes =
200
+ index === 0 && missingImports.length > 0
201
+ ? buildImportFixes(
202
+ fixer,
203
+ program,
204
+ tsDataForgeImport,
205
+ missingImports,
206
+ )
207
+ : [];
208
+
209
+ return [...importFixes, fixer.replaceText(node, replacement)];
210
+ },
211
+ });
212
+ }
213
+ },
214
+ };
215
+ },
216
+ defaultOptions: [],
217
+ } as const;
218
+
219
+ /**
220
+ * Matches `Object.fromEntries(Object.entries(record).map|filter|flatMap(cb))`
221
+ * and reports which `Obj` function replaces it, or `undefined` when the call is
222
+ * not such a round trip or `cb` has a shape `Obj` cannot express (a key
223
+ * transform, a block body, an entry-index parameter, …).
224
+ */
225
+ const parseEntriesRoundTrip = (
226
+ node: TSESTree.CallExpression,
227
+ ): RoundTrip | undefined => {
228
+ if (!isObjectStaticCall(node, 'fromEntries')) return undefined;
229
+
230
+ if (!Arr.isFixedLengthTuple(1, node.arguments)) return undefined;
231
+
232
+ const [transform] = node.arguments;
233
+
234
+ if (transform.type !== AST_NODE_TYPES.CallExpression) return undefined;
235
+
236
+ const { callee } = transform;
237
+
238
+ if (
239
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
240
+ callee.computed ||
241
+ callee.property.type !== AST_NODE_TYPES.Identifier
242
+ ) {
243
+ return undefined;
244
+ }
245
+
246
+ const arrayMethod = callee.property.name;
247
+
248
+ if (
249
+ arrayMethod !== 'map' &&
250
+ arrayMethod !== 'filter' &&
251
+ arrayMethod !== 'flatMap'
252
+ ) {
253
+ return undefined;
254
+ }
255
+
256
+ const entriesCall = callee.object;
257
+
258
+ if (
259
+ entriesCall.type !== AST_NODE_TYPES.CallExpression ||
260
+ !isObjectStaticCall(entriesCall, 'entries') ||
261
+ !Arr.isFixedLengthTuple(1, entriesCall.arguments)
262
+ ) {
263
+ return undefined;
264
+ }
265
+
266
+ const [record] = entriesCall.arguments;
267
+
268
+ if (record.type === AST_NODE_TYPES.SpreadElement) return undefined;
269
+
270
+ // A second argument (`thisArg`) or a callback that also takes the entry index
271
+ // has no counterpart in `Obj`.
272
+ if (!Arr.isFixedLengthTuple(1, transform.arguments)) return undefined;
273
+
274
+ const [callback] = transform.arguments;
275
+
276
+ if (
277
+ callback.type !== AST_NODE_TYPES.ArrowFunctionExpression ||
278
+ callback.async ||
279
+ // An explicit return type is either redundant or a type predicate; the
280
+ // latter would be lost by the rewrite.
281
+ callback.returnType !== undefined ||
282
+ !Arr.isFixedLengthTuple(1, callback.params) ||
283
+ callback.body.type === AST_NODE_TYPES.BlockStatement
284
+ ) {
285
+ return undefined;
286
+ }
287
+
288
+ const [entryPattern] = callback.params;
289
+
290
+ if (entryPattern.type !== AST_NODE_TYPES.ArrayPattern) return undefined;
291
+
292
+ const entryParams = parseEntryPattern(entryPattern);
293
+
294
+ if (entryParams === undefined) return undefined;
295
+
296
+ const body = callback.body;
297
+
298
+ const common = { record, callback, ...entryParams } as const;
299
+
300
+ switch (arrayMethod) {
301
+ case 'filter':
302
+ return {
303
+ ...common,
304
+ method: 'filter',
305
+ dropped: [],
306
+ body: { kind: 'expression', expression: body },
307
+ };
308
+
309
+ case 'map': {
310
+ const entry = parseKeptEntry(body, entryParams.keyParam);
311
+
312
+ return entry === undefined
313
+ ? undefined
314
+ : {
315
+ ...common,
316
+ method: 'map',
317
+ dropped: [entry.key],
318
+ body: { kind: 'expression', expression: entry.value },
319
+ };
320
+ }
321
+
322
+ case 'flatMap': {
323
+ if (body.type !== AST_NODE_TYPES.ConditionalExpression) return undefined;
324
+
325
+ const onTrue = parseSingletonEntry(body.consequent, entryParams.keyParam);
326
+
327
+ const onFalse = parseSingletonEntry(body.alternate, entryParams.keyParam);
328
+
329
+ const keepOnTrue = onTrue !== undefined && isEmptyArray(body.alternate);
330
+
331
+ const kept = keepOnTrue
332
+ ? onTrue
333
+ : onFalse !== undefined && isEmptyArray(body.consequent)
334
+ ? onFalse
335
+ : undefined;
336
+
337
+ return kept === undefined
338
+ ? undefined
339
+ : {
340
+ ...common,
341
+ method: 'filterMap',
342
+ dropped: [kept.key],
343
+ body: {
344
+ kind: 'optional',
345
+ test: body.test,
346
+ kept: kept.value,
347
+ keepOnTrue,
348
+ },
349
+ };
350
+ }
351
+ }
352
+ };
353
+
354
+ type EntryParams = Readonly<{
355
+ keyParam: TSESTree.Identifier | undefined;
356
+ valueParam: TSESTree.Identifier | undefined;
357
+ }>;
358
+
359
+ /**
360
+ * Reads the `[key, value]` destructuring the entry callback binds. Anything
361
+ * beyond a plain binding — a default, a rest element, a nested pattern — is
362
+ * rejected, because the rewritten callback receives the value and the key
363
+ * directly rather than an entry tuple.
364
+ */
365
+ const parseEntryPattern = (
366
+ pattern: TSESTree.ArrayPattern,
367
+ ): EntryParams | undefined => {
368
+ if (pattern.elements.length > 2) return undefined;
369
+
370
+ const [keyElement, valueElement] = pattern.elements;
371
+
372
+ return isElidedOrIdentifier(keyElement) && isElidedOrIdentifier(valueElement)
373
+ ? {
374
+ keyParam: keyElement ?? undefined,
375
+ valueParam: valueElement ?? undefined,
376
+ }
377
+ : undefined;
378
+ };
379
+
380
+ const isElidedOrIdentifier = (
381
+ element: TSESTree.DestructuringPattern | null | undefined,
382
+ ): element is TSESTree.Identifier | null | undefined =>
383
+ element === null ||
384
+ element === undefined ||
385
+ element.type === AST_NODE_TYPES.Identifier;
386
+
387
+ type KeptEntry = Readonly<{
388
+ /** The key identifier that only rebuilds the entry, dropped by the rewrite. */
389
+ key: TSESTree.Identifier;
390
+ value: TSESTree.Expression;
391
+ }>;
392
+
393
+ /**
394
+ * Matches `[k, value]` — an entry rebuilt under the very key it came from,
395
+ * which is the only shape `Obj.map` / `Obj.filterMap` can express. An entry
396
+ * built under a different key is left alone.
397
+ */
398
+ const parseKeptEntry = (
399
+ node: TSESTree.Expression,
400
+ keyParam: TSESTree.Identifier | undefined,
401
+ ): KeptEntry | undefined => {
402
+ if (keyParam === undefined) return undefined;
403
+
404
+ const tuple = stripTypeWrappers(node);
405
+
406
+ if (
407
+ tuple.type !== AST_NODE_TYPES.ArrayExpression ||
408
+ !Arr.isFixedLengthTuple(2, tuple.elements)
409
+ ) {
410
+ return undefined;
411
+ }
412
+
413
+ const [key, value] = tuple.elements;
414
+
415
+ return key !== null &&
416
+ value !== null &&
417
+ key.type === AST_NODE_TYPES.Identifier &&
418
+ value.type !== AST_NODE_TYPES.SpreadElement &&
419
+ key.name === keyParam.name
420
+ ? { key, value }
421
+ : undefined;
422
+ };
423
+
424
+ /** Matches `[[k, value]]` — the `flatMap` branch that keeps an entry. */
425
+ const parseSingletonEntry = (
426
+ node: TSESTree.Expression,
427
+ keyParam: TSESTree.Identifier | undefined,
428
+ ): KeptEntry | undefined => {
429
+ const array = stripTypeWrappers(node);
430
+
431
+ if (
432
+ array.type !== AST_NODE_TYPES.ArrayExpression ||
433
+ !Arr.isFixedLengthTuple(1, array.elements)
434
+ ) {
435
+ return undefined;
436
+ }
437
+
438
+ const [entry] = array.elements;
439
+
440
+ return entry === null || entry.type === AST_NODE_TYPES.SpreadElement
441
+ ? undefined
442
+ : parseKeptEntry(entry, keyParam);
443
+ };
444
+
445
+ /** Matches `[]` — the `flatMap` branch that drops an entry. */
446
+ const isEmptyArray = (node: TSESTree.Expression): boolean => {
447
+ const array = stripTypeWrappers(node);
448
+
449
+ return (
450
+ array.type === AST_NODE_TYPES.ArrayExpression && array.elements.length === 0
451
+ );
452
+ };
453
+
454
+ /**
455
+ * Looks through the `as const` / `satisfies` an entry tuple usually carries.
456
+ * Any other assertion is left in place, so that a tuple asserted to a wider
457
+ * type than the callback returns is not reported: `Obj` would infer the
458
+ * narrower one and the rewrite would not type-check.
459
+ */
460
+ const stripTypeWrappers = (node: TSESTree.Expression): TSESTree.Expression =>
461
+ node.type === AST_NODE_TYPES.TSSatisfiesExpression ||
462
+ (node.type === AST_NODE_TYPES.TSAsExpression && isConstAssertion(node))
463
+ ? stripTypeWrappers(node.expression)
464
+ : node;
465
+
466
+ const isConstAssertion = (node: TSESTree.TSAsExpression): boolean =>
467
+ node.typeAnnotation.type === AST_NODE_TYPES.TSTypeReference &&
468
+ node.typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier &&
469
+ node.typeAnnotation.typeName.name === 'const';
470
+
471
+ const isObjectStaticCall = (
472
+ node: TSESTree.CallExpression,
473
+ name: 'entries' | 'fromEntries',
474
+ ): boolean =>
475
+ node.callee.type === AST_NODE_TYPES.MemberExpression &&
476
+ !node.callee.computed &&
477
+ node.callee.object.type === AST_NODE_TYPES.Identifier &&
478
+ node.callee.object.name === 'Object' &&
479
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
480
+ node.callee.property.name === name;
481
+
482
+ /**
483
+ * `true` when the parameter is still referenced once the identifiers in
484
+ * `dropped` — the ones that only rebuilt the entry key — are discounted.
485
+ * References from nested functions resolve to the same variable, so they count.
486
+ */
487
+ const isReferencedOutside = (
488
+ scope: TSESLint.Scope.Scope,
489
+ name: string,
490
+ dropped: ReadonlySet<TSESTree.Node>,
491
+ ): boolean =>
492
+ scope.variables
493
+ .find((variable) => variable.name === name)
494
+ ?.references.some((reference) => !dropped.has(reference.identifier)) ??
495
+ false;
496
+
497
+ /**
498
+ * A name for the value parameter of a callback that only uses the key. `_value`
499
+ * is what the `Obj` documentation uses; a suffix is added if that name is
500
+ * already referenced inside the callback, so the new parameter shadows nothing.
501
+ */
502
+ const unusedValueParamName = (scope: TSESLint.Scope.Scope): string => {
503
+ const taken = new Set<string>(
504
+ scope.through
505
+ .map((reference) => reference.identifier.name)
506
+ .concat(scope.variables.map((variable) => variable.name)),
507
+ );
508
+
509
+ const firstFree = (candidate: string, suffix: number): string =>
510
+ taken.has(candidate)
511
+ ? firstFree(`_value${suffix + 1}`, suffix + 1)
512
+ : candidate;
513
+
514
+ return firstFree('_value', 1);
515
+ };
516
+
517
+ /** Expressions that bind looser than a conditional's test position. */
518
+ const NEEDS_PARENS_AS_TEST: ReadonlySet<string> = new Set<string>([
519
+ AST_NODE_TYPES.ArrowFunctionExpression,
520
+ AST_NODE_TYPES.AssignmentExpression,
521
+ AST_NODE_TYPES.ConditionalExpression,
522
+ AST_NODE_TYPES.SequenceExpression,
523
+ AST_NODE_TYPES.YieldExpression,
524
+ ]);
525
+
526
+ const conditionalTestText = (
527
+ text: string,
528
+ node: TSESTree.Expression,
529
+ ): string =>
530
+ NEEDS_PARENS_AS_TEST.has(node.type) ? (`(${text})` as const) : text;
531
+
532
+ /** A comma expression would read as another argument without parentheses. */
533
+ const argumentText = (text: string, node: TSESTree.Expression): string =>
534
+ node.type === AST_NODE_TYPES.SequenceExpression
535
+ ? (`(${text})` as const)
536
+ : text;
537
+
538
+ /**
539
+ * A concise arrow body cannot start with `{`, nor hold a bare comma. The text
540
+ * is what decides the first case rather than the node type, because an object
541
+ * literal keeps its leading brace under an `as const`.
542
+ */
543
+ const arrowBodyText = (text: string, node: TSESTree.Expression): string =>
544
+ text.startsWith('{') || node.type === AST_NODE_TYPES.SequenceExpression
545
+ ? (`(${text})` as const)
546
+ : text;