eslint-plugin-ts-data-forge 0.1.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/LICENSE +201 -0
- package/README.md +57 -0
- package/package.json +74 -0
- package/src/index.mts +7 -0
- package/src/plugin.mts +9 -0
- package/src/rule-types.mts +326 -0
- package/src/rules/ast-utils.mts +169 -0
- package/src/rules/branded-number-types.mts +47 -0
- package/src/rules/constants.mts +1 -0
- package/src/rules/import-utils.mts +152 -0
- package/src/rules/index.mts +1 -0
- package/src/rules/no-unnecessary-type-guard.mts +515 -0
- package/src/rules/no-unnecessary-type-guard.test.mts +368 -0
- package/src/rules/prefer-arr-is-array.mts +98 -0
- package/src/rules/prefer-arr-is-array.test.mts +62 -0
- package/src/rules/prefer-arr-is-bounded-length-array.mts +143 -0
- package/src/rules/prefer-arr-is-bounded-length-array.test.mts +154 -0
- package/src/rules/prefer-arr-is-fixed-length-array.mts +149 -0
- package/src/rules/prefer-arr-is-fixed-length-array.test.mts +214 -0
- package/src/rules/prefer-arr-is-max-length-array.mts +141 -0
- package/src/rules/prefer-arr-is-max-length-array.test.mts +188 -0
- package/src/rules/prefer-arr-is-min-length-array.mts +141 -0
- package/src/rules/prefer-arr-is-min-length-array.test.mts +202 -0
- package/src/rules/prefer-arr-is-non-empty.mts +131 -0
- package/src/rules/prefer-arr-is-non-empty.test.mts +90 -0
- package/src/rules/prefer-arr-sum.mts +269 -0
- package/src/rules/prefer-arr-sum.test.mts +171 -0
- package/src/rules/prefer-as-int.mts +131 -0
- package/src/rules/prefer-as-int.test.mts +267 -0
- package/src/rules/prefer-canonical-array-slicing.mts +800 -0
- package/src/rules/prefer-canonical-array-slicing.test.mts +588 -0
- package/src/rules/prefer-comparison-over-nullish-guard.mts +141 -0
- package/src/rules/prefer-comparison-over-nullish-guard.test.mts +171 -0
- package/src/rules/prefer-is-non-null-object.mts +145 -0
- package/src/rules/prefer-is-non-null-object.test.mts +156 -0
- package/src/rules/prefer-is-record-and-has-key.mts +156 -0
- package/src/rules/prefer-is-record-and-has-key.test.mts +217 -0
- package/src/rules/prefer-num-safe-parse-float.mts +166 -0
- package/src/rules/prefer-num-safe-parse-float.test.mts +163 -0
- package/src/rules/prefer-num-safe-parse-int.mts +166 -0
- package/src/rules/prefer-num-safe-parse-int.test.mts +137 -0
- package/src/rules/prefer-range-for-loop.mts +179 -0
- package/src/rules/prefer-range-for-loop.test.mts +156 -0
- package/src/rules/rules.mts +36 -0
- package/src/types.mts +4 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import {
|
|
7
|
+
buildImportFixes,
|
|
8
|
+
getNamedImports,
|
|
9
|
+
getTsDataForgeImport,
|
|
10
|
+
} from './import-utils.mjs';
|
|
11
|
+
|
|
12
|
+
type Options = readonly [];
|
|
13
|
+
|
|
14
|
+
type MessageIds = 'useIsRecordAndHasKey';
|
|
15
|
+
|
|
16
|
+
export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
17
|
+
{
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'suggestion',
|
|
20
|
+
docs: {
|
|
21
|
+
description:
|
|
22
|
+
'Replace `Object.hasOwn(obj, key)` or `key in obj` with `isRecord(obj) && hasKey(obj, key)` from ts-data-forge',
|
|
23
|
+
},
|
|
24
|
+
fixable: 'code',
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
useIsRecordAndHasKey:
|
|
28
|
+
'Replace `{{original}}` with `isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
create: (context) => {
|
|
33
|
+
const sourceCode = context.sourceCode;
|
|
34
|
+
|
|
35
|
+
const program = sourceCode.ast;
|
|
36
|
+
|
|
37
|
+
const tsDataForgeImport = getTsDataForgeImport(program);
|
|
38
|
+
|
|
39
|
+
const mut_nodesToFix: {
|
|
40
|
+
node: TSESTree.CallExpression | TSESTree.BinaryExpression;
|
|
41
|
+
objExpression: TSESTree.Expression;
|
|
42
|
+
keyExpression: TSESTree.Expression;
|
|
43
|
+
}[] = [];
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
// Handle Object.hasOwn(obj, key)
|
|
47
|
+
CallExpression: (node) => {
|
|
48
|
+
if (
|
|
49
|
+
node.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
50
|
+
node.callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
51
|
+
node.callee.object.name !== 'Object' ||
|
|
52
|
+
node.callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
53
|
+
node.callee.property.name !== 'hasOwn'
|
|
54
|
+
) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check arguments: Object.hasOwn(obj, key)
|
|
59
|
+
if (node.arguments.length !== 2) return;
|
|
60
|
+
|
|
61
|
+
const objArg = node.arguments[0];
|
|
62
|
+
|
|
63
|
+
const keyArg = node.arguments[1];
|
|
64
|
+
|
|
65
|
+
if (objArg === undefined || keyArg === undefined) return;
|
|
66
|
+
|
|
67
|
+
// Type guard: ensure arguments are Expressions (not SpreadElement)
|
|
68
|
+
if (
|
|
69
|
+
objArg.type === AST_NODE_TYPES.SpreadElement ||
|
|
70
|
+
keyArg.type === AST_NODE_TYPES.SpreadElement
|
|
71
|
+
) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mut_nodesToFix.push({
|
|
76
|
+
node,
|
|
77
|
+
objExpression: objArg,
|
|
78
|
+
keyExpression: keyArg,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Handle key in obj
|
|
83
|
+
BinaryExpression: (node) => {
|
|
84
|
+
if (node.operator !== 'in') return;
|
|
85
|
+
|
|
86
|
+
const keyExpression = node.left;
|
|
87
|
+
|
|
88
|
+
const objExpression = node.right;
|
|
89
|
+
|
|
90
|
+
// Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
|
|
91
|
+
if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) return;
|
|
92
|
+
|
|
93
|
+
mut_nodesToFix.push({
|
|
94
|
+
node,
|
|
95
|
+
objExpression,
|
|
96
|
+
keyExpression,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
'Program:exit': () => {
|
|
101
|
+
const namedImports = getNamedImports(tsDataForgeImport);
|
|
102
|
+
|
|
103
|
+
const hasIsRecordImport = namedImports.includes('isRecord');
|
|
104
|
+
|
|
105
|
+
const hasHasKeyImport = namedImports.includes('hasKey');
|
|
106
|
+
|
|
107
|
+
for (const [
|
|
108
|
+
index,
|
|
109
|
+
{ node, objExpression, keyExpression },
|
|
110
|
+
] of mut_nodesToFix.entries()) {
|
|
111
|
+
const objText = sourceCode.getText(objExpression);
|
|
112
|
+
|
|
113
|
+
const keyText = sourceCode.getText(keyExpression);
|
|
114
|
+
|
|
115
|
+
const originalText = sourceCode.getText(node);
|
|
116
|
+
|
|
117
|
+
context.report({
|
|
118
|
+
node,
|
|
119
|
+
messageId: 'useIsRecordAndHasKey',
|
|
120
|
+
data: {
|
|
121
|
+
original: originalText,
|
|
122
|
+
objName: objText,
|
|
123
|
+
keyName: keyText,
|
|
124
|
+
},
|
|
125
|
+
fix: (fixer) => {
|
|
126
|
+
const replacement = `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`;
|
|
127
|
+
|
|
128
|
+
const mut_importsToAdd: string[] = [];
|
|
129
|
+
|
|
130
|
+
if (!hasIsRecordImport) {
|
|
131
|
+
mut_importsToAdd.push('isRecord');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!hasHasKeyImport) {
|
|
135
|
+
mut_importsToAdd.push('hasKey');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const importFixes =
|
|
139
|
+
index === 0 && mut_importsToAdd.length > 0
|
|
140
|
+
? buildImportFixes(
|
|
141
|
+
fixer,
|
|
142
|
+
program,
|
|
143
|
+
tsDataForgeImport,
|
|
144
|
+
mut_importsToAdd,
|
|
145
|
+
)
|
|
146
|
+
: [];
|
|
147
|
+
|
|
148
|
+
return [...importFixes, fixer.replaceText(node, replacement)];
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
defaultOptions: [],
|
|
156
|
+
} as const;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import parser from '@typescript-eslint/parser';
|
|
2
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { preferIsRecordAndHasKey } from './prefer-is-record-and-has-key.mjs';
|
|
5
|
+
|
|
6
|
+
const tester = new RuleTester({
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser,
|
|
9
|
+
parserOptions: {
|
|
10
|
+
ecmaVersion: 2020,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
projectService: {
|
|
13
|
+
allowDefaultProject: ['*.ts*'],
|
|
14
|
+
},
|
|
15
|
+
tsconfigRootDir: `${import.meta.dirname}/../..`,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('prefer-is-record-and-has-key', () => {
|
|
21
|
+
tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
|
|
22
|
+
valid: [
|
|
23
|
+
{
|
|
24
|
+
name: 'ignores other Object methods',
|
|
25
|
+
code: dedent`
|
|
26
|
+
const obj = { a: 1 };
|
|
27
|
+
const keys = Object.keys(obj);
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'ignores hasOwn with wrong number of arguments',
|
|
32
|
+
code: dedent`
|
|
33
|
+
const obj = { a: 1 };
|
|
34
|
+
const ok = Object.hasOwn(obj);
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
invalid: [
|
|
39
|
+
{
|
|
40
|
+
name: 'replaces Object.hasOwn(obj, key) with isRecord && hasKey',
|
|
41
|
+
code: dedent`
|
|
42
|
+
const obj = { a: 1 };
|
|
43
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
44
|
+
`,
|
|
45
|
+
output: dedent`
|
|
46
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
47
|
+
const obj = { a: 1 };
|
|
48
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
49
|
+
`,
|
|
50
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'replaces key in obj with isRecord && hasKey',
|
|
54
|
+
code: dedent`
|
|
55
|
+
const obj = { a: 1 };
|
|
56
|
+
const ok = 'a' in obj;
|
|
57
|
+
`,
|
|
58
|
+
output: dedent`
|
|
59
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
60
|
+
const obj = { a: 1 };
|
|
61
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
62
|
+
`,
|
|
63
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'works with variable key',
|
|
67
|
+
code: dedent`
|
|
68
|
+
const obj = { a: 1 };
|
|
69
|
+
const key = 'a';
|
|
70
|
+
const ok = Object.hasOwn(obj, key);
|
|
71
|
+
`,
|
|
72
|
+
output: dedent`
|
|
73
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
74
|
+
const obj = { a: 1 };
|
|
75
|
+
const key = 'a';
|
|
76
|
+
const ok = (isRecord(obj) && hasKey(obj, key));
|
|
77
|
+
`,
|
|
78
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'works with in and variable key',
|
|
82
|
+
code: dedent`
|
|
83
|
+
const obj = { a: 1 };
|
|
84
|
+
const key = 'a';
|
|
85
|
+
const ok = key in obj;
|
|
86
|
+
`,
|
|
87
|
+
output: dedent`
|
|
88
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
89
|
+
const obj = { a: 1 };
|
|
90
|
+
const key = 'a';
|
|
91
|
+
const ok = (isRecord(obj) && hasKey(obj, key));
|
|
92
|
+
`,
|
|
93
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'keeps existing isRecord import',
|
|
97
|
+
code: dedent`
|
|
98
|
+
import { isRecord } from 'ts-data-forge';
|
|
99
|
+
|
|
100
|
+
const obj = { a: 1 };
|
|
101
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
102
|
+
`,
|
|
103
|
+
output: dedent`
|
|
104
|
+
import { hasKey } from 'ts-data-forge';
|
|
105
|
+
import { isRecord } from 'ts-data-forge';
|
|
106
|
+
|
|
107
|
+
const obj = { a: 1 };
|
|
108
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
109
|
+
`,
|
|
110
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'keeps existing hasKey import',
|
|
114
|
+
code: dedent`
|
|
115
|
+
import { hasKey } from 'ts-data-forge';
|
|
116
|
+
|
|
117
|
+
const obj = { a: 1 };
|
|
118
|
+
const ok = 'a' in obj;
|
|
119
|
+
`,
|
|
120
|
+
output: dedent`
|
|
121
|
+
import { isRecord } from 'ts-data-forge';
|
|
122
|
+
import { hasKey } from 'ts-data-forge';
|
|
123
|
+
|
|
124
|
+
const obj = { a: 1 };
|
|
125
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
126
|
+
`,
|
|
127
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'keeps both existing imports',
|
|
131
|
+
code: dedent`
|
|
132
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
133
|
+
|
|
134
|
+
const obj = { a: 1 };
|
|
135
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
136
|
+
`,
|
|
137
|
+
output: dedent`
|
|
138
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
139
|
+
|
|
140
|
+
const obj = { a: 1 };
|
|
141
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
142
|
+
`,
|
|
143
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'replaces multiple checks',
|
|
147
|
+
code: dedent`
|
|
148
|
+
const obj = { a: 1 };
|
|
149
|
+
const ok1 = Object.hasOwn(obj, 'a');
|
|
150
|
+
const ok2 = 'b' in obj;
|
|
151
|
+
`,
|
|
152
|
+
output: dedent`
|
|
153
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
154
|
+
const obj = { a: 1 };
|
|
155
|
+
const ok1 = (isRecord(obj) && hasKey(obj, 'a'));
|
|
156
|
+
const ok2 = (isRecord(obj) && hasKey(obj, 'b'));
|
|
157
|
+
`,
|
|
158
|
+
errors: [
|
|
159
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
160
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'works with complex object expressions',
|
|
165
|
+
code: dedent`
|
|
166
|
+
const data = { nested: { value: 1 } };
|
|
167
|
+
const ok = Object.hasOwn(data.nested, 'value');
|
|
168
|
+
`,
|
|
169
|
+
output: dedent`
|
|
170
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
171
|
+
const data = { nested: { value: 1 } };
|
|
172
|
+
const ok = (isRecord(data.nested) && hasKey(data.nested, 'value'));
|
|
173
|
+
`,
|
|
174
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'works with in and complex object expressions',
|
|
178
|
+
code: dedent`
|
|
179
|
+
const data = { nested: { value: 1 } };
|
|
180
|
+
const ok = 'value' in data.nested;
|
|
181
|
+
`,
|
|
182
|
+
output: dedent`
|
|
183
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
184
|
+
const data = { nested: { value: 1 } };
|
|
185
|
+
const ok = (isRecord(data.nested) && hasKey(data.nested, 'value'));
|
|
186
|
+
`,
|
|
187
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'correctly wraps negated Object.hasOwn with parentheses',
|
|
191
|
+
code: dedent`
|
|
192
|
+
const obj = { a: 1 };
|
|
193
|
+
const ok = !Object.hasOwn(obj, 'a');
|
|
194
|
+
`,
|
|
195
|
+
output: dedent`
|
|
196
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
197
|
+
const obj = { a: 1 };
|
|
198
|
+
const ok = !(isRecord(obj) && hasKey(obj, 'a'));
|
|
199
|
+
`,
|
|
200
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'correctly wraps negated key in obj with parentheses',
|
|
204
|
+
code: dedent`
|
|
205
|
+
const obj = { a: 1 };
|
|
206
|
+
const ok = !('a' in obj);
|
|
207
|
+
`,
|
|
208
|
+
output: dedent`
|
|
209
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
210
|
+
const obj = { a: 1 };
|
|
211
|
+
const ok = !((isRecord(obj) && hasKey(obj, 'a')));
|
|
212
|
+
`,
|
|
213
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
}, 20000);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import * as ts from 'typescript';
|
|
7
|
+
import {
|
|
8
|
+
buildImportFixes,
|
|
9
|
+
getNamedImports,
|
|
10
|
+
getTsDataForgeImport,
|
|
11
|
+
} from './import-utils.mjs';
|
|
12
|
+
|
|
13
|
+
type Options = readonly [];
|
|
14
|
+
|
|
15
|
+
type MessageIds = 'useSafeParseFloat';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns `true` when every constituent of `type` is string-like, i.e. the
|
|
19
|
+
* type is assignable to the `string` parameter of `Num.safeParseFloat`. A
|
|
20
|
+
* union such as `string | undefined` is rejected so the autofix never
|
|
21
|
+
* produces a type error.
|
|
22
|
+
*/
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
24
|
+
const isStringType = (type: ts.Type): boolean => {
|
|
25
|
+
const parts = type.isUnion() ? type.types : ([type] as const);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
parts.length > 0 &&
|
|
29
|
+
parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detects `parseFloat(x)`, `Number.parseFloat(x)`, and `Number(x)` (when x
|
|
35
|
+
* is typed as `string`) and replaces them with
|
|
36
|
+
* `Result.unwrapOkOr(Num.safeParseFloat(x), Number.NaN)` (ts-data-forge).
|
|
37
|
+
*
|
|
38
|
+
* `Number(x)` is only flagged when x is purely a string type to avoid false
|
|
39
|
+
* positives for `Number(someBoolean)` or `Number(someNumber)` uses.
|
|
40
|
+
*/
|
|
41
|
+
export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
|
|
42
|
+
{
|
|
43
|
+
meta: {
|
|
44
|
+
type: 'suggestion',
|
|
45
|
+
docs: {
|
|
46
|
+
description:
|
|
47
|
+
'Replace `parseFloat(x)`, `Number.parseFloat(x)`, or `Number(x)` (when x is a string) with `Result.unwrapOkOr(Num.safeParseFloat(x), Number.NaN)` from ts-data-forge.',
|
|
48
|
+
},
|
|
49
|
+
fixable: 'code',
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
useSafeParseFloat:
|
|
53
|
+
'Replace `{{original}}` with `Result.unwrapOkOr(Num.safeParseFloat({{argName}}), Number.NaN)` from ts-data-forge.',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
create: (context) => {
|
|
58
|
+
const sourceCode = context.sourceCode;
|
|
59
|
+
|
|
60
|
+
const program = sourceCode.ast;
|
|
61
|
+
|
|
62
|
+
const tsDataForgeImport = getTsDataForgeImport(program);
|
|
63
|
+
|
|
64
|
+
const services = context.sourceCode.parserServices;
|
|
65
|
+
|
|
66
|
+
const mut_nodesToFix: {
|
|
67
|
+
node: TSESTree.CallExpression;
|
|
68
|
+
argExpression: TSESTree.Expression;
|
|
69
|
+
}[] = [];
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
CallExpression: (node) => {
|
|
73
|
+
const { callee } = node;
|
|
74
|
+
|
|
75
|
+
// Match `parseFloat(...)` (global function).
|
|
76
|
+
const isGlobalParseFloat =
|
|
77
|
+
callee.type === AST_NODE_TYPES.Identifier &&
|
|
78
|
+
callee.name === 'parseFloat';
|
|
79
|
+
|
|
80
|
+
// Match `Number.parseFloat(...)`.
|
|
81
|
+
const isNumberParseFloat =
|
|
82
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
83
|
+
!callee.computed &&
|
|
84
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
85
|
+
callee.object.name === 'Number' &&
|
|
86
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
87
|
+
callee.property.name === 'parseFloat';
|
|
88
|
+
|
|
89
|
+
// Match `Number(...)` called as a function (not `new Number(...)`).
|
|
90
|
+
const isNumberCall =
|
|
91
|
+
callee.type === AST_NODE_TYPES.Identifier &&
|
|
92
|
+
callee.name === 'Number';
|
|
93
|
+
|
|
94
|
+
if (!isGlobalParseFloat && !isNumberParseFloat && !isNumberCall)
|
|
95
|
+
return;
|
|
96
|
+
|
|
97
|
+
const args = node.arguments;
|
|
98
|
+
|
|
99
|
+
const firstArg = args[0];
|
|
100
|
+
|
|
101
|
+
if (firstArg === undefined) return;
|
|
102
|
+
|
|
103
|
+
// Spread argument (`parseFloat(...rest)`) cannot be rewritten safely.
|
|
104
|
+
if (firstArg.type === AST_NODE_TYPES.SpreadElement) return;
|
|
105
|
+
|
|
106
|
+
// The argument must be purely `string` so the autofix is type-safe
|
|
107
|
+
// against `Num.safeParseFloat(s: string)`. Without type information,
|
|
108
|
+
// skip.
|
|
109
|
+
if (services?.program == null) return;
|
|
110
|
+
|
|
111
|
+
const checker = services.program.getTypeChecker();
|
|
112
|
+
|
|
113
|
+
const tsNode = services.esTreeNodeToTSNodeMap?.get(firstArg);
|
|
114
|
+
|
|
115
|
+
if (tsNode === undefined) return;
|
|
116
|
+
|
|
117
|
+
const argType = checker.getTypeAtLocation(tsNode);
|
|
118
|
+
|
|
119
|
+
if (!isStringType(argType)) return;
|
|
120
|
+
|
|
121
|
+
mut_nodesToFix.push({ node, argExpression: firstArg });
|
|
122
|
+
},
|
|
123
|
+
'Program:exit': () => {
|
|
124
|
+
const namedImports = getNamedImports(tsDataForgeImport);
|
|
125
|
+
|
|
126
|
+
const missingImports = (['Num', 'Result'] as const).filter(
|
|
127
|
+
(name) => !namedImports.includes(name),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
for (const [
|
|
131
|
+
index,
|
|
132
|
+
{ node, argExpression },
|
|
133
|
+
] of mut_nodesToFix.entries()) {
|
|
134
|
+
const argText = sourceCode.getText(argExpression);
|
|
135
|
+
|
|
136
|
+
const originalText = sourceCode.getText(node);
|
|
137
|
+
|
|
138
|
+
context.report({
|
|
139
|
+
node,
|
|
140
|
+
messageId: 'useSafeParseFloat',
|
|
141
|
+
data: {
|
|
142
|
+
original: originalText,
|
|
143
|
+
argName: argText,
|
|
144
|
+
},
|
|
145
|
+
fix: (fixer) => {
|
|
146
|
+
const core = `Result.unwrapOkOr(Num.safeParseFloat(${argText}), Number.NaN)`;
|
|
147
|
+
|
|
148
|
+
const importFixes =
|
|
149
|
+
index === 0 && missingImports.length > 0
|
|
150
|
+
? buildImportFixes(
|
|
151
|
+
fixer,
|
|
152
|
+
program,
|
|
153
|
+
tsDataForgeImport,
|
|
154
|
+
missingImports,
|
|
155
|
+
)
|
|
156
|
+
: [];
|
|
157
|
+
|
|
158
|
+
return [...importFixes, fixer.replaceText(node, core)];
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
defaultOptions: [],
|
|
166
|
+
} as const;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import parser from '@typescript-eslint/parser';
|
|
2
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { preferNumSafeParseFloat } from './prefer-num-safe-parse-float.mjs';
|
|
5
|
+
|
|
6
|
+
const tester = new RuleTester({
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser,
|
|
9
|
+
parserOptions: {
|
|
10
|
+
ecmaVersion: 2020,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
projectService: {
|
|
13
|
+
allowDefaultProject: ['*.ts*'],
|
|
14
|
+
},
|
|
15
|
+
tsconfigRootDir: `${import.meta.dirname}/../..`,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('prefer-num-safe-parse-float', () => {
|
|
21
|
+
tester.run('prefer-num-safe-parse-float', preferNumSafeParseFloat, {
|
|
22
|
+
valid: [
|
|
23
|
+
{
|
|
24
|
+
name: 'ignores number arguments to Number() (autofix would be unsafe)',
|
|
25
|
+
code: dedent`
|
|
26
|
+
const x = 42;
|
|
27
|
+
const n = Number(x);
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'ignores boolean arguments to Number()',
|
|
32
|
+
code: dedent`
|
|
33
|
+
const b = true;
|
|
34
|
+
const n = Number(b);
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'ignores never-typed arguments (string & number = never)',
|
|
39
|
+
code: dedent`
|
|
40
|
+
declare const x: string & number;
|
|
41
|
+
const n = parseFloat(x);
|
|
42
|
+
`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'ignores spread arguments',
|
|
46
|
+
code: dedent`
|
|
47
|
+
declare const args: [string];
|
|
48
|
+
const n = parseFloat(...args);
|
|
49
|
+
`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'ignores parseInt (handled by prefer-num-safe-parse-int)',
|
|
53
|
+
code: dedent`
|
|
54
|
+
const s = "123";
|
|
55
|
+
const n = parseInt(s, 10);
|
|
56
|
+
`,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
invalid: [
|
|
60
|
+
{
|
|
61
|
+
name: 'replaces parseFloat(s) and imports Num + Result',
|
|
62
|
+
code: dedent`
|
|
63
|
+
const s = "3.14";
|
|
64
|
+
const n = parseFloat(s);
|
|
65
|
+
`,
|
|
66
|
+
output: dedent`
|
|
67
|
+
import { Num, Result } from 'ts-data-forge';
|
|
68
|
+
const s = "3.14";
|
|
69
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
70
|
+
`,
|
|
71
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'replaces Number.parseFloat(s)',
|
|
75
|
+
code: dedent`
|
|
76
|
+
const s = "3.14";
|
|
77
|
+
const n = Number.parseFloat(s);
|
|
78
|
+
`,
|
|
79
|
+
output: dedent`
|
|
80
|
+
import { Num, Result } from 'ts-data-forge';
|
|
81
|
+
const s = "3.14";
|
|
82
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
83
|
+
`,
|
|
84
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'replaces Number(s) when s is a string',
|
|
88
|
+
code: dedent`
|
|
89
|
+
const s = "3.14";
|
|
90
|
+
const n = Number(s);
|
|
91
|
+
`,
|
|
92
|
+
output: dedent`
|
|
93
|
+
import { Num, Result } from 'ts-data-forge';
|
|
94
|
+
const s = "3.14";
|
|
95
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
96
|
+
`,
|
|
97
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'adds only the missing Result import when Num is present',
|
|
101
|
+
code: dedent`
|
|
102
|
+
import { Num } from 'ts-data-forge';
|
|
103
|
+
|
|
104
|
+
const s = "3.14";
|
|
105
|
+
const n = parseFloat(s);
|
|
106
|
+
`,
|
|
107
|
+
output: dedent`
|
|
108
|
+
import { Result } from 'ts-data-forge';
|
|
109
|
+
import { Num } from 'ts-data-forge';
|
|
110
|
+
|
|
111
|
+
const s = "3.14";
|
|
112
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
113
|
+
`,
|
|
114
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'preserves a complex string argument expression',
|
|
118
|
+
code: dedent`
|
|
119
|
+
const get = (k: string): string => k;
|
|
120
|
+
const n = parseFloat(get("x").trim());
|
|
121
|
+
`,
|
|
122
|
+
output: dedent`
|
|
123
|
+
import { Num, Result } from 'ts-data-forge';
|
|
124
|
+
const get = (k: string): string => k;
|
|
125
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat(get("x").trim()), Number.NaN);
|
|
126
|
+
`,
|
|
127
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'replaces Number(s) with a string literal argument',
|
|
131
|
+
code: dedent`
|
|
132
|
+
const n = Number("3.14");
|
|
133
|
+
`,
|
|
134
|
+
output: dedent`
|
|
135
|
+
import { Num, Result } from 'ts-data-forge';
|
|
136
|
+
const n = Result.unwrapOkOr(Num.safeParseFloat("3.14"), Number.NaN);
|
|
137
|
+
`,
|
|
138
|
+
errors: [{ messageId: 'useSafeParseFloat' }],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'replaces all three forms in one file',
|
|
142
|
+
code: dedent`
|
|
143
|
+
const s = "1.5";
|
|
144
|
+
const a = parseFloat(s);
|
|
145
|
+
const b = Number.parseFloat(s);
|
|
146
|
+
const c = Number(s);
|
|
147
|
+
`,
|
|
148
|
+
output: dedent`
|
|
149
|
+
import { Num, Result } from 'ts-data-forge';
|
|
150
|
+
const s = "1.5";
|
|
151
|
+
const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
152
|
+
const b = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
153
|
+
const c = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
|
|
154
|
+
`,
|
|
155
|
+
errors: [
|
|
156
|
+
{ messageId: 'useSafeParseFloat' },
|
|
157
|
+
{ messageId: 'useSafeParseFloat' },
|
|
158
|
+
{ messageId: 'useSafeParseFloat' },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
}, 20000);
|