eslint-plugin-ts-data-forge 0.9.0 → 0.10.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.
- package/README.md +1 -0
- package/dist/plugin.d.mts +3 -1
- 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 +16 -1
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rule-types.mjs.map +1 -1
- package/dist/rules/ast-utils.d.mts +0 -16
- package/dist/rules/ast-utils.d.mts.map +1 -1
- package/dist/rules/ast-utils.mjs +0 -36
- package/dist/rules/ast-utils.mjs.map +1 -1
- package/dist/rules/no-side-effect-import.d.mts +1 -8
- package/dist/rules/no-side-effect-import.d.mts.map +1 -1
- package/dist/rules/no-side-effect-import.mjs +7 -43
- package/dist/rules/no-side-effect-import.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-array.d.mts +1 -5
- package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-array.mjs +5 -83
- package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
- package/dist/rules/prefer-is-non-null-object.d.mts +1 -5
- package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
- package/dist/rules/prefer-is-non-null-object.mjs +4 -184
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.d.mts +1 -5
- package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.mjs +4 -219
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-float.d.mts +1 -15
- package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
- package/dist/rules/prefer-num-safe-parse-float.mjs +6 -123
- package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-int.d.mts +1 -13
- package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
- package/dist/rules/prefer-num-safe-parse-int.mjs +6 -121
- package/dist/rules/prefer-num-safe-parse-int.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 +2 -1
- 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 +6 -6
- package/src/plugin.mts +1 -0
- package/src/rule-types.mts +17 -1
- package/src/rules/ast-utils.mts +0 -51
- package/src/rules/no-side-effect-import.mts +7 -61
- package/src/rules/prefer-arr-is-array.mts +5 -122
- package/src/rules/prefer-arr-is-array.test.mts +50 -7
- package/src/rules/prefer-is-non-null-object.mts +4 -274
- package/src/rules/prefer-is-record-and-has-key.mts +4 -318
- package/src/rules/prefer-num-safe-parse-float.mts +6 -193
- package/src/rules/prefer-num-safe-parse-float.test.mts +18 -0
- package/src/rules/prefer-num-safe-parse-int.mts +6 -192
- package/src/rules/prefer-num-safe-parse-int.test.mts +18 -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
|
@@ -1,194 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AST_NODE_TYPES,
|
|
3
|
-
type TSESLint,
|
|
4
|
-
type TSESTree,
|
|
5
|
-
} from '@typescript-eslint/utils';
|
|
6
|
-
import {
|
|
7
|
-
buildNamedImportFix,
|
|
8
|
-
getArgumentText,
|
|
9
|
-
getImportDeclarations,
|
|
10
|
-
getNamedImports,
|
|
11
|
-
skipTypeWrappers,
|
|
12
|
-
typeWrapperLayers,
|
|
13
|
-
} from 'eslint-rule-forge';
|
|
14
|
-
import * as ts from 'typescript';
|
|
1
|
+
import { createPreferSafeParseInt } from 'eslint-rule-forge';
|
|
15
2
|
import { TS_DATA_FORGE_MODULE } from './constants.mjs';
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Returns `true` when every constituent of `type` is string-like, i.e. the type
|
|
23
|
-
* is assignable to the `string` parameter of `Num.safeParseInt`. A union such
|
|
24
|
-
* as `string | undefined` is rejected so the autofix never produces a type
|
|
25
|
-
* error.
|
|
26
|
-
*/
|
|
27
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
28
|
-
const isStringType = (type: ts.Type): boolean => {
|
|
29
|
-
const parts = type.isUnion() ? type.types : ([type] as const);
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
parts.length > 0 &&
|
|
33
|
-
parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
|
|
34
|
-
);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Detects the base-10 forms of `parseInt` / `Number.parseInt` (`parseInt(x)` or
|
|
39
|
-
* `parseInt(x, 10)`) and replaces them with
|
|
40
|
-
* `Result.unwrapOkOr(Num.safeParseInt(x), Number.NaN)` (ts-data-forge).
|
|
41
|
-
*
|
|
42
|
-
* Other radixes (e.g. `parseInt(x, 16)`) are intentionally left untouched, as
|
|
43
|
-
* `Num.safeParseInt` only supports base 10.
|
|
44
|
-
*/
|
|
45
|
-
export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
|
|
46
|
-
meta: {
|
|
47
|
-
type: 'suggestion',
|
|
48
|
-
docs: {
|
|
49
|
-
description:
|
|
50
|
-
'Replace `parseInt(x, 10)` with `Result.unwrapOkOr(Num.safeParseInt(x), Number.NaN)` from ts-data-forge.',
|
|
51
|
-
},
|
|
52
|
-
fixable: 'code',
|
|
53
|
-
schema: [],
|
|
54
|
-
messages: {
|
|
55
|
-
useSafeParseInt:
|
|
56
|
-
'Replace `{{original}}` with `Result.unwrapOkOr(Num.safeParseInt({{argName}}), Number.NaN)` from ts-data-forge.',
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
create: (context) => {
|
|
61
|
-
const sourceCode = context.sourceCode;
|
|
62
|
-
|
|
63
|
-
const program = sourceCode.ast;
|
|
64
|
-
|
|
65
|
-
const tsDataForgeImports = getImportDeclarations(
|
|
66
|
-
program,
|
|
67
|
-
TS_DATA_FORGE_MODULE,
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const services = context.sourceCode.parserServices;
|
|
71
|
-
|
|
72
|
-
const mut_nodesToFix: {
|
|
73
|
-
node: TSESTree.CallExpression;
|
|
74
|
-
argExpression: TSESTree.Expression;
|
|
75
|
-
}[] = [];
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
CallExpression: (node) => {
|
|
79
|
-
const { callee } = node;
|
|
80
|
-
|
|
81
|
-
// Match `parseInt(...)` or `Number.parseInt(...)`.
|
|
82
|
-
const isGlobalParseInt =
|
|
83
|
-
callee.type === AST_NODE_TYPES.Identifier &&
|
|
84
|
-
callee.name === 'parseInt';
|
|
85
|
-
|
|
86
|
-
const isNumberParseInt =
|
|
87
|
-
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
88
|
-
!callee.computed &&
|
|
89
|
-
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
90
|
-
callee.object.name === 'Number' &&
|
|
91
|
-
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
92
|
-
callee.property.name === 'parseInt';
|
|
93
|
-
|
|
94
|
-
if (!isGlobalParseInt && !isNumberParseInt) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const args = node.arguments;
|
|
99
|
-
|
|
100
|
-
const firstArg = args[0];
|
|
101
|
-
|
|
102
|
-
if (firstArg === undefined) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Spread argument (`parseInt(...rest)`) cannot be rewritten safely.
|
|
107
|
-
if (firstArg.type === AST_NODE_TYPES.SpreadElement) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Only base 10: accept a missing radix or an explicit literal `10`,
|
|
112
|
-
// under any type wrapper (`10 as const`).
|
|
113
|
-
if (args.length >= 2) {
|
|
114
|
-
const radix =
|
|
115
|
-
args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
|
|
116
|
-
|
|
117
|
-
if (radix?.type !== AST_NODE_TYPES.Literal || radix.value !== 10) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// The argument must be a `string` so the autofix is type-safe against
|
|
123
|
-
// `Num.safeParseInt(s: string)`. Without type information, skip. Every
|
|
124
|
-
// layer of type wrappers is asked (see typeWrapperLayers): a cast
|
|
125
|
-
// does not make a number a string.
|
|
126
|
-
if (services?.program == null) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const checker = services.program.getTypeChecker();
|
|
131
|
-
|
|
132
|
-
const isString = (
|
|
133
|
-
// AST nodes hold mutable child arrays, so they are not deeply readonly.
|
|
134
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
135
|
-
expression: TSESTree.Node,
|
|
136
|
-
): boolean => {
|
|
137
|
-
const tsNode = services.esTreeNodeToTSNodeMap?.get(expression);
|
|
138
|
-
|
|
139
|
-
return (
|
|
140
|
-
tsNode !== undefined &&
|
|
141
|
-
isStringType(checker.getTypeAtLocation(tsNode))
|
|
142
|
-
);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
if (!typeWrapperLayers(firstArg).every(isString)) {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
mut_nodesToFix.push({ node, argExpression: firstArg });
|
|
150
|
-
},
|
|
151
|
-
'Program:exit': () => {
|
|
152
|
-
const namedImports = getNamedImports(tsDataForgeImports);
|
|
153
|
-
|
|
154
|
-
const missingImports = (['Num', 'Result'] as const).filter(
|
|
155
|
-
(name) => !namedImports.includes(name),
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
for (const [
|
|
159
|
-
index,
|
|
160
|
-
{ node, argExpression },
|
|
161
|
-
] of mut_nodesToFix.entries()) {
|
|
162
|
-
const argText = getArgumentText(sourceCode, argExpression);
|
|
163
|
-
|
|
164
|
-
const originalText = sourceCode.getText(node);
|
|
165
|
-
|
|
166
|
-
context.report({
|
|
167
|
-
node,
|
|
168
|
-
messageId: 'useSafeParseInt',
|
|
169
|
-
data: {
|
|
170
|
-
original: originalText,
|
|
171
|
-
argName: argText,
|
|
172
|
-
},
|
|
173
|
-
fix: (fixer) => {
|
|
174
|
-
const core = `Result.unwrapOkOr(Num.safeParseInt(${argText}), Number.NaN)`;
|
|
175
|
-
|
|
176
|
-
const importFixes =
|
|
177
|
-
index === 0 && missingImports.length > 0
|
|
178
|
-
? buildNamedImportFix(
|
|
179
|
-
fixer,
|
|
180
|
-
program,
|
|
181
|
-
TS_DATA_FORGE_MODULE,
|
|
182
|
-
missingImports,
|
|
183
|
-
)
|
|
184
|
-
: [];
|
|
185
|
-
|
|
186
|
-
return [...importFixes, fixer.replaceText(node, core)];
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
};
|
|
192
|
-
},
|
|
193
|
-
defaultOptions: [],
|
|
194
|
-
} as const;
|
|
4
|
+
export const preferNumSafeParseInt = createPreferSafeParseInt({
|
|
5
|
+
moduleName: TS_DATA_FORGE_MODULE,
|
|
6
|
+
namespace: 'Num',
|
|
7
|
+
method: 'safeParseInt',
|
|
8
|
+
});
|
|
@@ -173,6 +173,24 @@ describe('prefer-num-safe-parse-int through type wrappers', () => {
|
|
|
173
173
|
{ messageId: 'useSafeParseInt' },
|
|
174
174
|
],
|
|
175
175
|
},
|
|
176
|
+
{
|
|
177
|
+
name: 'the callee read through `as` and `!`',
|
|
178
|
+
code: dedent`
|
|
179
|
+
declare const s: string;
|
|
180
|
+
const a = (parseInt as typeof parseInt)(s);
|
|
181
|
+
const b = (Number as NumberConstructor)!.parseInt(s, 10);
|
|
182
|
+
`,
|
|
183
|
+
output: dedent`
|
|
184
|
+
import { Num, Result } from 'ts-data-forge';
|
|
185
|
+
declare const s: string;
|
|
186
|
+
const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
|
|
187
|
+
const b = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
|
|
188
|
+
`,
|
|
189
|
+
errors: [
|
|
190
|
+
{ messageId: 'useSafeParseInt' },
|
|
191
|
+
{ messageId: 'useSafeParseInt' },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
176
194
|
],
|
|
177
195
|
});
|
|
178
196
|
}, 20000);
|
|
@@ -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;
|