eslint-rule-forge 0.4.0 → 0.5.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.
@@ -30,16 +30,20 @@ export type SafeParseRuleConfig = Readonly<{
30
30
  }>;
31
31
 
32
32
  /**
33
- * Builds a rule that replaces the base-10 forms of `parseInt` and
34
- * `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — with
35
- * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
33
+ * Builds a rule that reports the base-10 forms of `parseInt` and
34
+ * `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — and suggests
35
+ * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead. A
36
+ * suggestion, not an autofix: the replacement reads some strings differently.
36
37
  *
37
38
  * A call that passes another radix is left alone: the replacement takes none,
38
39
  * so it would mean something the replacement cannot say.
39
40
  */
40
41
  export const createPreferSafeParseInt = (
41
42
  config: SafeParseRuleConfig,
42
- ): TSESLint.RuleModule<'useSafeParseInt', readonly []> =>
43
+ ): TSESLint.RuleModule<
44
+ 'useSafeParseInt' | 'replaceWithSafeParse',
45
+ readonly []
46
+ > =>
43
47
  createSafeParseRule(config, 'useSafeParseInt', {
44
48
  messages: { useSafeParseInt: messageText(config) },
45
49
  description: `Replace \`parseInt(x)\` or \`Number.parseInt(x, 10)\` with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
@@ -51,8 +55,8 @@ export const createPreferSafeParseInt = (
51
55
  return false;
52
56
  }
53
57
 
54
- // Only base 10: accept a missing radix or an explicit literal `10`,
55
- // read through its type wrappers, which leave the value as it is.
58
+ // Only base 10: accept a missing radix or an explicit literal `10`, read
59
+ // through its type wrappers, which leave the value as it is.
56
60
  const radix =
57
61
  args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
58
62
 
@@ -64,15 +68,20 @@ export const createPreferSafeParseInt = (
64
68
  });
65
69
 
66
70
  /**
67
- * Builds a rule that replaces `parseFloat(x)`, `Number.parseFloat(x)` and
68
- * `Number(x)` with `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
71
+ * Builds a rule that reports `parseFloat(x)`, `Number.parseFloat(x)` and
72
+ * `Number(x)`, and suggests
73
+ * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead, as a
74
+ * suggestion for the same reason.
69
75
  *
70
76
  * Like every rewrite here, `Number(x)` is only flagged when `x` is purely a
71
77
  * `string`, which leaves `Number(someBoolean)` and `Number(someNumber)` alone.
72
78
  */
73
79
  export const createPreferSafeParseFloat = (
74
80
  config: SafeParseRuleConfig,
75
- ): TSESLint.RuleModule<'useSafeParseFloat', readonly []> =>
81
+ ): TSESLint.RuleModule<
82
+ 'useSafeParseFloat' | 'replaceWithSafeParse',
83
+ readonly []
84
+ > =>
76
85
  createSafeParseRule(config, 'useSafeParseFloat', {
77
86
  messages: { useSafeParseFloat: messageText(config) },
78
87
  description: `Replace \`parseFloat(x)\`, \`Number.parseFloat(x)\`, or \`Number(x)\` (when x is a string) with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
@@ -86,6 +95,20 @@ export const createPreferSafeParseFloat = (
86
95
 
87
96
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
88
97
 
98
+ /**
99
+ * Whether every constituent of `type` is string-like, i.e. the type is
100
+ * assignable to the parse function's `string` parameter. A union such as
101
+ * `string | undefined` is rejected so the suggestion never produces a type error.
102
+ */
103
+ const isStringType = (type: ts.Type): boolean => {
104
+ const parts = type.isUnion() ? type.types : ([type] as const);
105
+
106
+ return (
107
+ parts.length > 0 &&
108
+ parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
109
+ );
110
+ };
111
+
89
112
  const createSafeParseRule = <MessageId extends string>(
90
113
  { moduleName, namespace, method }: SafeParseRuleConfig,
91
114
  messageId: MessageId,
@@ -100,14 +123,14 @@ const createSafeParseRule = <MessageId extends string>(
100
123
 
101
124
  /**
102
125
  * Whether the call, its callee read through its type wrappers, is one to
103
- * rewrite.
126
+ * report.
104
127
  */
105
128
  isTarget: (
106
129
  callee: TSESTree.Expression,
107
130
  args: readonly TSESTree.CallExpressionArgument[],
108
131
  ) => boolean;
109
132
  }>,
110
- ): TSESLint.RuleModule<MessageId, readonly []> => {
133
+ ): TSESLint.RuleModule<MessageId | 'replaceWithSafeParse', readonly []> => {
111
134
  // Sorted, as an import sorter would write them.
112
135
  const requiredImports = [namespace, 'Result'].toSorted();
113
136
 
@@ -115,16 +138,19 @@ const createSafeParseRule = <MessageId extends string>(
115
138
  meta: {
116
139
  type: 'suggestion',
117
140
  docs: { description },
118
- fixable: 'code',
141
+ hasSuggestions: true,
119
142
  schema: [],
120
- messages,
143
+ messages: {
144
+ ...messages,
145
+ replaceWithSafeParse: `Replace with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\`.`,
146
+ },
121
147
  defaultOptions: [],
122
148
  },
123
149
 
124
150
  create: (context) => {
125
151
  const sourceCode = context.sourceCode;
126
152
 
127
- const mut_nodesToFix: {
153
+ const mut_reports: {
128
154
  node: TSESTree.CallExpression;
129
155
  argExpression: TSESTree.Expression;
130
156
  }[] = [];
@@ -137,8 +163,7 @@ const createSafeParseRule = <MessageId extends string>(
137
163
 
138
164
  const firstArg = node.arguments[0];
139
165
 
140
- // A spread argument (`parseInt(...rest)`) cannot be rewritten
141
- // safely.
166
+ // A spread argument (`parseInt(...rest)`) cannot be rewritten safely.
142
167
  if (
143
168
  firstArg === undefined ||
144
169
  firstArg.type === AST_NODE_TYPES.SpreadElement
@@ -148,16 +173,16 @@ const createSafeParseRule = <MessageId extends string>(
148
173
 
149
174
  // The argument must be purely `string` at every layer of type
150
175
  // wrappers: the value's own type is what the original sees at run
151
- // time (`parseInt(1e21 as unknown as string)` is 1, the rewrite
152
- // 1e21; `Number(true as unknown as string)` is 1, the rewrite NaN),
153
- // and the type a wrapper states is what the fix, which keeps the
176
+ // time (`parseInt(1e21 as unknown as string)` is 1, the rewrite 1e21;
177
+ // `Number(true as unknown as string)` is 1, the rewrite NaN), and the
178
+ // type a wrapper states is what the suggestion, which keeps the
154
179
  // wrapper, hands the parse function's `string` parameter. Without
155
180
  // type information, skip.
156
181
  if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
157
182
  return;
158
183
  }
159
184
 
160
- mut_nodesToFix.push({ node, argExpression: firstArg });
185
+ mut_reports.push({ node, argExpression: firstArg });
161
186
  },
162
187
  'Program:exit': () => {
163
188
  const binding = bindValueImports(
@@ -173,32 +198,43 @@ const createSafeParseRule = <MessageId extends string>(
173
198
 
174
199
  const resultName = localNameOf('Result');
175
200
 
176
- // Every fix brings its names into scope itself, so that one applied
177
- // alone (an editor's quick fix) is complete.
178
- for (const { node, argExpression } of mut_nodesToFix) {
201
+ // A suggestion, never an autofix: the replacement parses some
202
+ // strings differently (`parseInt('1e3')` is 1, the rewrite 1000;
203
+ // `parseFloat('12px')` is 12, the rewrite NaN; `Number('')` is 0, the
204
+ // rewrite NaN), so applying it is a person's decision. Each one
205
+ // brings its names into scope itself, so that it is complete alone.
206
+ for (const { node, argExpression } of mut_reports) {
179
207
  const argText = getArgumentText(sourceCode, argExpression);
180
208
 
209
+ const data = {
210
+ original: sourceCode.getText(node),
211
+ argName: argText,
212
+ } as const;
213
+
181
214
  context.report({
182
215
  node,
183
216
  messageId,
184
- data: {
185
- original: sourceCode.getText(node),
186
- argName: argText,
187
- },
188
- fix:
217
+ data,
218
+ suggest:
189
219
  binding === undefined ||
190
220
  namespaceName === undefined ||
191
221
  resultName === undefined ||
192
222
  [namespaceName, resultName].some((name) =>
193
223
  hasForeignBinding(sourceCode, node, name, moduleName),
194
224
  )
195
- ? null
196
- : (fixer) => [
197
- ...binding.edits(fixer),
198
- fixer.replaceText(
199
- node,
200
- `${resultName}.unwrapOkOr(${namespaceName}.${method}(${argText}), Number.NaN)`,
201
- ),
225
+ ? []
226
+ : [
227
+ {
228
+ messageId: 'replaceWithSafeParse',
229
+ data,
230
+ fix: (fixer) => [
231
+ ...binding.edits(fixer),
232
+ fixer.replaceText(
233
+ node,
234
+ `${resultName}.unwrapOkOr(${namespaceName}.${method}(${argText}), Number.NaN)`,
235
+ ),
236
+ ],
237
+ },
202
238
  ],
203
239
  });
204
240
  }
@@ -213,7 +249,7 @@ const messageText = ({
213
249
  namespace,
214
250
  method,
215
251
  }: SafeParseRuleConfig): string =>
216
- `Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}.` as const;
252
+ `Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}. It parses some strings differently from the call it replaces, so check the input before applying it.` as const;
217
253
 
218
254
  /** Whether `callee` is the global function `name`, as in `parseInt(...)`. */
219
255
  const isGlobalCall = (callee: TSESTree.Expression, name: string): boolean =>
@@ -229,17 +265,3 @@ const isNumberMethod = (callee: TSESTree.Expression, name: string): boolean =>
229
265
  isIdentifierNamed(callee.object, 'Number') &&
230
266
  callee.property.type === AST_NODE_TYPES.Identifier &&
231
267
  callee.property.name === name;
232
-
233
- /**
234
- * Whether every constituent of `type` is string-like, i.e. the type is
235
- * assignable to the parse function's `string` parameter. A union such as
236
- * `string | undefined` is rejected so the autofix never produces a type error.
237
- */
238
- const isStringType = (type: ts.Type): boolean => {
239
- const parts = type.isUnion() ? type.types : ([type] as const);
240
-
241
- return (
242
- parts.length > 0 &&
243
- parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
244
- );
245
- };
@@ -0,0 +1,57 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2
+ import type { DeepReadonly } from 'ts-type-forge';
3
+
4
+ /**
5
+ * The statement next to `node` (`offset` 1) or before it (-1) in the statement
6
+ * list `node` is one of: the program's, a block's, a class static block's, a
7
+ * namespace's or a `case`'s. What an `export` declares (or, for
8
+ * `export default`, the expression it exports) is read as the `export`
9
+ * statement. `undefined` when there is none, or when `node` is not in a
10
+ * statement list (an arrow function's expression body, a `for` initializer).
11
+ */
12
+ export const siblingOf = (
13
+ node: DeepReadonly<TSESTree.Node>,
14
+ offset: 1 | -1,
15
+ ): DeepReadonly<TSESTree.Node> | undefined => {
16
+ const { parent } = node;
17
+
18
+ const statement =
19
+ (parent?.type === AST_NODE_TYPES.ExportNamedDeclaration ||
20
+ parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration) &&
21
+ parent.declaration === node
22
+ ? parent
23
+ : node;
24
+
25
+ const list = statementListOf(statement);
26
+
27
+ if (list === undefined) {
28
+ return undefined;
29
+ }
30
+
31
+ const index = list.indexOf(statement);
32
+
33
+ return index === -1 ? undefined : list[index + offset];
34
+ };
35
+
36
+ const statementListOf = (
37
+ node: DeepReadonly<TSESTree.Node>,
38
+ ): readonly DeepReadonly<TSESTree.Node>[] | undefined => {
39
+ const { parent } = node;
40
+
41
+ if (parent === undefined) {
42
+ return undefined;
43
+ }
44
+
45
+ if (
46
+ parent.type === AST_NODE_TYPES.Program ||
47
+ parent.type === AST_NODE_TYPES.BlockStatement ||
48
+ parent.type === AST_NODE_TYPES.StaticBlock ||
49
+ parent.type === AST_NODE_TYPES.TSModuleBlock
50
+ ) {
51
+ return parent.body;
52
+ }
53
+
54
+ return parent.type === AST_NODE_TYPES.SwitchCase
55
+ ? parent.consequent
56
+ : undefined;
57
+ };
@@ -89,9 +89,9 @@ export const isIdentifierNamed = (
89
89
  * Whether `holds` is true of the type of every layer of type wrappers around
90
90
  * `node` (see {@link typeWrapperLayers}), outermost to innermost; `false`
91
91
  * without type information. A widening cast (`s as string | number`) is the
92
- * author saying the value may be more than it is, and is respected; a
93
- * narrowing one (`s!`, `v as unknown as string`) cannot vouch for what the
94
- * value's own type does not, and the fix, which keeps the wrapper, has to
92
+ * author saying the value may be more than it is, and is respected; a narrowing
93
+ * one (`s!`, `v as unknown as string`) cannot vouch for what the value's own
94
+ * type does not, and the fix, which keeps the wrapper, has to
95
95
  * type-check at every layer too.
96
96
  */
97
97
  export const typeHoldsAtEveryLayer = (