eslint-plugin-ts-data-forge 0.9.0 → 0.10.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/plugin.d.mts +2 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -0
- 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.map +1 -1
- package/dist/rules/prefer-pipe-map-nullable.d.mts +24 -0
- package/dist/rules/prefer-pipe-map-nullable.d.mts.map +1 -0
- package/dist/rules/prefer-pipe-map-nullable.mjs +266 -0
- package/dist/rules/prefer-pipe-map-nullable.mjs.map +1 -0
- package/dist/rules/rules.d.mts +1 -0
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +2 -0
- package/dist/rules/rules.mjs.map +1 -1
- package/package.json +4 -4
- package/src/plugin.mts +1 -0
- package/src/rule-types.mts +16 -0
- package/src/rules/prefer-pipe-map-nullable.mts +391 -0
- package/src/rules/prefer-pipe-map-nullable.test.mts +470 -0
- package/src/rules/rules.mts +2 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import {
|
|
7
|
+
buildCalleeResolver,
|
|
8
|
+
getArgumentText,
|
|
9
|
+
getImportDeclarations,
|
|
10
|
+
hasForeignBinding,
|
|
11
|
+
isTypeWrapper,
|
|
12
|
+
skipTypeWrappers,
|
|
13
|
+
} from 'eslint-rule-forge';
|
|
14
|
+
import { Arr } from 'ts-data-forge';
|
|
15
|
+
import * as ts from 'typescript';
|
|
16
|
+
import { TS_DATA_FORGE_MODULE } from './constants.mjs';
|
|
17
|
+
|
|
18
|
+
type Options = readonly [];
|
|
19
|
+
|
|
20
|
+
type MessageIds = 'useMapNullable' | 'suggestMapNullable';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `pipe(x).map(mapNullable(fn))` and `pipe(x).mapNullable(fn)` compute the same
|
|
24
|
+
* value: `mapNullable(fn)` is `(v) => v == null ? undefined : fn(v)`, and
|
|
25
|
+
* `Pipe.mapNullable(fn)` is `pipe(a == null ? undefined : fn(a))`.
|
|
26
|
+
*
|
|
27
|
+
* Only the curried `mapNullable(fn)` imported from ts-data-forge is rewritten,
|
|
28
|
+
* and only on a receiver this rule can see is a pipe: a `pipe(…)` call, or a
|
|
29
|
+
* chain of `map` / `mapNullable` / `mapOptional` calls on one.
|
|
30
|
+
*
|
|
31
|
+
* The static type can differ. `mapNullable` declares `const B` and
|
|
32
|
+
* `Pipe.mapNullable` a plain `B`, so for an inline function `(v) => 'a'` the
|
|
33
|
+
* first infers `'a'` and the second `string`, and `(v) => [v, 1]` becomes
|
|
34
|
+
* `number[]` instead of `readonly [number, 1]`. The fix is therefore applied
|
|
35
|
+
* automatically only when the function's return type is not inferred, or is
|
|
36
|
+
* inferred with nothing such a widening could change; otherwise, and whenever
|
|
37
|
+
* a comment or a type wrapper around `mapNullable` would be dropped, it is
|
|
38
|
+
* offered as a suggestion.
|
|
39
|
+
*/
|
|
40
|
+
export const preferPipeMapNullable: TSESLint.RuleModule<MessageIds, Options> = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: 'suggestion',
|
|
43
|
+
docs: {
|
|
44
|
+
description:
|
|
45
|
+
'Replace `pipe(x).map(mapNullable(fn))` with `pipe(x).mapNullable(fn)`.',
|
|
46
|
+
},
|
|
47
|
+
fixable: 'code',
|
|
48
|
+
hasSuggestions: true,
|
|
49
|
+
schema: [],
|
|
50
|
+
messages: {
|
|
51
|
+
useMapNullable:
|
|
52
|
+
'Replace `.map({{mapNullableName}}(…))` with `.mapNullable(…)`.',
|
|
53
|
+
suggestMapNullable:
|
|
54
|
+
'Replace `.map({{mapNullableName}}(…))` with `.mapNullable(…)` (the inferred type may be widened, or a comment or type assertion dropped).',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
create: (context) => {
|
|
59
|
+
const sourceCode = context.sourceCode;
|
|
60
|
+
|
|
61
|
+
const program = sourceCode.ast;
|
|
62
|
+
|
|
63
|
+
const resolveTsDataForge = buildCalleeResolver(
|
|
64
|
+
getImportDeclarations(program, TS_DATA_FORGE_MODULE),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const pipeResolvers = PIPE_MODULES.map(
|
|
68
|
+
(moduleName) =>
|
|
69
|
+
[
|
|
70
|
+
moduleName,
|
|
71
|
+
buildCalleeResolver(getImportDeclarations(program, moduleName)),
|
|
72
|
+
] as const,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const services = sourceCode.parserServices;
|
|
76
|
+
|
|
77
|
+
const checker = services?.program?.getTypeChecker();
|
|
78
|
+
|
|
79
|
+
const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether `callee` calls the export `name` of the module `resolve`
|
|
83
|
+
* reads, rather than a local binding that shadows the import.
|
|
84
|
+
*/
|
|
85
|
+
const calls = (
|
|
86
|
+
resolve: ReturnType<typeof buildCalleeResolver>,
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
88
|
+
callee: TSESTree.Expression,
|
|
89
|
+
name: string,
|
|
90
|
+
moduleName: string,
|
|
91
|
+
): boolean => {
|
|
92
|
+
if (resolve(callee)?.canonicalName !== name) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unwrapped = skipTypeWrappers(callee);
|
|
97
|
+
|
|
98
|
+
// The named import itself, or the namespace of `m.f`.
|
|
99
|
+
const binding =
|
|
100
|
+
unwrapped.type === AST_NODE_TYPES.MemberExpression
|
|
101
|
+
? skipTypeWrappers(unwrapped.object)
|
|
102
|
+
: unwrapped;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
binding.type === AST_NODE_TYPES.Identifier &&
|
|
106
|
+
!hasForeignBinding(sourceCode, binding, binding.name, moduleName)
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether `node` is `pipe(…)`, or `p.map(…)` / `p.mapNullable(…)` /
|
|
112
|
+
* `p.mapOptional(…)` on such a `p`.
|
|
113
|
+
*/
|
|
114
|
+
const isPipe = (
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
116
|
+
wrapped: TSESTree.Node,
|
|
117
|
+
): boolean => {
|
|
118
|
+
const node = skipTypeWrappers(wrapped);
|
|
119
|
+
|
|
120
|
+
if (node.type !== AST_NODE_TYPES.CallExpression || node.optional) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
pipeResolvers.some(([moduleName, resolve]) =>
|
|
126
|
+
calls(resolve, node.callee, 'pipe', moduleName),
|
|
127
|
+
)
|
|
128
|
+
) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { callee } = node;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
136
|
+
!callee.computed &&
|
|
137
|
+
!callee.optional &&
|
|
138
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
139
|
+
PIPE_CHAIN_METHODS.has(callee.property.name) &&
|
|
140
|
+
isPipe(callee.object)
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether `Pipe.mapNullable(mapFn)` infers the same result type as
|
|
146
|
+
* `mapNullable(mapFn)` did (see the rule's comment).
|
|
147
|
+
*/
|
|
148
|
+
const keepsResultType = (
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
150
|
+
mapNullableCall: TSESTree.CallExpression,
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
152
|
+
mapFn: TSESTree.Expression,
|
|
153
|
+
): boolean => {
|
|
154
|
+
// `satisfies` leaves the function's own type to inference.
|
|
155
|
+
const fn = skipSatisfies(mapFn);
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
(fn.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
159
|
+
fn.type !== AST_NODE_TYPES.FunctionExpression) ||
|
|
160
|
+
fn.returnType !== undefined
|
|
161
|
+
) {
|
|
162
|
+
// A function whose type is fixed before `B` is inferred.
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (checker === undefined || esTreeNodeToTSNodeMap === undefined) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The `B | undefined` that `mapNullable(fn)` returns, with `B` as the
|
|
171
|
+
// `const` type parameter inferred it. The function's own return type
|
|
172
|
+
// is no guide: for `(v) => ({ kind: 'n', v })` it is already widened
|
|
173
|
+
// to `{ kind: string; v: number }`, while `B` keeps `'n'`.
|
|
174
|
+
const signatures = checker
|
|
175
|
+
.getTypeAtLocation(esTreeNodeToTSNodeMap.get(mapNullableCall))
|
|
176
|
+
.getCallSignatures();
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
Arr.isFixedLengthTuple(1, signatures) &&
|
|
180
|
+
!hasConstInferredPart(
|
|
181
|
+
checker,
|
|
182
|
+
checker.getReturnTypeOfSignature(signatures[0]),
|
|
183
|
+
0,
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
CallExpression: (node) => {
|
|
190
|
+
// <pipe>.map(mapNullable(fn))
|
|
191
|
+
if (
|
|
192
|
+
node.optional ||
|
|
193
|
+
node.typeArguments !== undefined ||
|
|
194
|
+
node.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
195
|
+
node.callee.computed ||
|
|
196
|
+
node.callee.optional ||
|
|
197
|
+
node.callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
198
|
+
node.callee.property.name !== 'map' ||
|
|
199
|
+
!Arr.isFixedLengthTuple(1, node.arguments)
|
|
200
|
+
) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const mapNullableCall = node.arguments[0];
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
mapNullableCall.type !== AST_NODE_TYPES.CallExpression ||
|
|
208
|
+
mapNullableCall.optional ||
|
|
209
|
+
// `mapNullable<A, B>(fn)` pins types `Pipe.mapNullable<B>` has no
|
|
210
|
+
// place for.
|
|
211
|
+
mapNullableCall.typeArguments !== undefined ||
|
|
212
|
+
// `mapNullable(x, fn)` is the two-argument form, which returns the
|
|
213
|
+
// mapped value rather than a function.
|
|
214
|
+
!Arr.isFixedLengthTuple(1, mapNullableCall.arguments)
|
|
215
|
+
) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const mapFn = mapNullableCall.arguments[0];
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
mapFn.type === AST_NODE_TYPES.SpreadElement ||
|
|
223
|
+
!calls(
|
|
224
|
+
resolveTsDataForge,
|
|
225
|
+
mapNullableCall.callee,
|
|
226
|
+
'mapNullable',
|
|
227
|
+
TS_DATA_FORGE_MODULE,
|
|
228
|
+
) ||
|
|
229
|
+
!isPipe(node.callee.object)
|
|
230
|
+
) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Everything the fix removes: `mapNullable(` and `)` around `fn`.
|
|
235
|
+
const droppedRanges = [
|
|
236
|
+
[mapNullableCall.range[0], mapFn.range[0]],
|
|
237
|
+
[mapFn.range[1], mapNullableCall.range[1]],
|
|
238
|
+
] as const;
|
|
239
|
+
|
|
240
|
+
const dropsComment = sourceCode
|
|
241
|
+
.getCommentsInside(mapNullableCall)
|
|
242
|
+
.some((comment) =>
|
|
243
|
+
droppedRanges.some(
|
|
244
|
+
([start, end]) =>
|
|
245
|
+
start <= comment.range[0] && comment.range[1] <= end,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const autofixable =
|
|
250
|
+
!dropsComment &&
|
|
251
|
+
!isTypeWrapper(mapNullableCall.callee) &&
|
|
252
|
+
keepsResultType(mapNullableCall, mapFn);
|
|
253
|
+
|
|
254
|
+
const mapProperty = node.callee.property;
|
|
255
|
+
|
|
256
|
+
const fix: TSESLint.ReportFixFunction = (fixer) => [
|
|
257
|
+
fixer.replaceText(mapProperty, 'mapNullable'),
|
|
258
|
+
fixer.replaceText(
|
|
259
|
+
mapNullableCall,
|
|
260
|
+
getArgumentText(sourceCode, mapFn),
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const data = {
|
|
265
|
+
mapNullableName: sourceCode.getText(mapNullableCall.callee),
|
|
266
|
+
} as const;
|
|
267
|
+
|
|
268
|
+
if (autofixable) {
|
|
269
|
+
context.report({
|
|
270
|
+
node,
|
|
271
|
+
messageId: 'useMapNullable',
|
|
272
|
+
data,
|
|
273
|
+
fix,
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
context.report({
|
|
277
|
+
node,
|
|
278
|
+
messageId: 'useMapNullable',
|
|
279
|
+
data,
|
|
280
|
+
suggest: [{ messageId: 'suggestMapNullable', data, fix }],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
defaultOptions: [],
|
|
287
|
+
} as const;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `ts-data-forge` re-exports `pipe` from `ts-std-forge`, so a pipe built
|
|
291
|
+
* through either package is the same object. `mapNullable` is exported by
|
|
292
|
+
* `ts-data-forge` only.
|
|
293
|
+
*/
|
|
294
|
+
const PIPE_MODULES = [TS_DATA_FORGE_MODULE, 'ts-std-forge'] as const;
|
|
295
|
+
|
|
296
|
+
/** The methods of a `Pipe` that return another `Pipe`. */
|
|
297
|
+
const PIPE_CHAIN_METHODS: ReadonlySet<string> = new Set([
|
|
298
|
+
'map',
|
|
299
|
+
'mapNullable',
|
|
300
|
+
'mapOptional',
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
/** Literal types, which a `const` type parameter keeps and a plain one widens. */
|
|
304
|
+
const literalTypeFlags =
|
|
305
|
+
ts.TypeFlags.StringLiteral |
|
|
306
|
+
ts.TypeFlags.NumberLiteral |
|
|
307
|
+
ts.TypeFlags.BigIntLiteral |
|
|
308
|
+
ts.TypeFlags.BooleanLiteral |
|
|
309
|
+
ts.TypeFlags.EnumLiteral |
|
|
310
|
+
ts.TypeFlags.TemplateLiteral |
|
|
311
|
+
ts.TypeFlags.UniqueESSymbol;
|
|
312
|
+
|
|
313
|
+
/** Deeper than this, a type is assumed to have a `const`-inferred part. */
|
|
314
|
+
const MAX_DEPTH = 4;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Whether `type`, inferred for a `const` type parameter, has a part a plain
|
|
318
|
+
* type parameter would infer differently: a literal (`'a'` against `string`)
|
|
319
|
+
* or a tuple (`readonly [number, 1]` against `number[]`), at the top level or
|
|
320
|
+
* inside an array, a generic type's arguments or an object literal's
|
|
321
|
+
* properties. Conservative: a declared literal type, which neither widens,
|
|
322
|
+
* also counts.
|
|
323
|
+
*/
|
|
324
|
+
const hasConstInferredPart = (
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
326
|
+
checker: ts.TypeChecker,
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
328
|
+
type: ts.Type,
|
|
329
|
+
depth: number,
|
|
330
|
+
): boolean => {
|
|
331
|
+
if (depth > MAX_DEPTH) {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// `boolean` is the union `true | false`, which widens to itself.
|
|
336
|
+
if ((type.flags & ts.TypeFlags.Boolean) !== 0) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (type.isUnion() || type.isIntersection()) {
|
|
341
|
+
return type.types.some((t) => hasConstInferredPart(checker, t, depth + 1));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if ((type.flags & literalTypeFlags) !== 0 || checker.isTupleType(type)) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if ((type.flags & ts.TypeFlags.Object) === 0) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
353
|
+
const { objectFlags } = type as ts.ObjectType;
|
|
354
|
+
|
|
355
|
+
// `Promise<'a'>`, `readonly 'a'[]`.
|
|
356
|
+
if (
|
|
357
|
+
(objectFlags & ts.ObjectFlags.Reference) !== 0 &&
|
|
358
|
+
checker
|
|
359
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
360
|
+
.getTypeArguments(type as ts.TypeReference)
|
|
361
|
+
.some((t) => hasConstInferredPart(checker, t, depth + 1))
|
|
362
|
+
) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
// An object literal's type, which reaches here instantiated (`Anonymous`)
|
|
368
|
+
// rather than flagged `ObjectLiteral`.
|
|
369
|
+
(objectFlags &
|
|
370
|
+
(ts.ObjectFlags.ObjectLiteral | ts.ObjectFlags.Anonymous)) !==
|
|
371
|
+
0 &&
|
|
372
|
+
type
|
|
373
|
+
.getProperties()
|
|
374
|
+
.some((property) =>
|
|
375
|
+
hasConstInferredPart(
|
|
376
|
+
checker,
|
|
377
|
+
checker.getTypeOfSymbol(property),
|
|
378
|
+
depth + 1,
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** `node` with every `satisfies` around it taken off. */
|
|
385
|
+
const skipSatisfies = (
|
|
386
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
387
|
+
node: TSESTree.Expression,
|
|
388
|
+
): TSESTree.Expression =>
|
|
389
|
+
node.type === AST_NODE_TYPES.TSSatisfiesExpression
|
|
390
|
+
? skipSatisfies(node.expression)
|
|
391
|
+
: node;
|