eslint-rule-forge 0.3.1 → 0.4.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.
Files changed (40) hide show
  1. package/README.md +32 -33
  2. package/dist/comments.d.mts +10 -0
  3. package/dist/comments.d.mts.map +1 -0
  4. package/dist/comments.mjs +11 -0
  5. package/dist/comments.mjs.map +1 -0
  6. package/dist/imports.d.mts +47 -6
  7. package/dist/imports.d.mts.map +1 -1
  8. package/dist/imports.mjs +123 -6
  9. package/dist/imports.mjs.map +1 -1
  10. package/dist/index.d.mts +1 -0
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +1 -0
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/negation.mjs.map +1 -1
  15. package/dist/rules/no-side-effect-import.mjs +1 -1
  16. package/dist/rules/no-side-effect-import.mjs.map +1 -1
  17. package/dist/rules/prefer-array-is-array.d.mts.map +1 -1
  18. package/dist/rules/prefer-array-is-array.mjs +14 -20
  19. package/dist/rules/prefer-array-is-array.mjs.map +1 -1
  20. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  21. package/dist/rules/prefer-is-non-null-object.mjs +25 -26
  22. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  23. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  24. package/dist/rules/prefer-is-record-and-has-key.mjs +33 -45
  25. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  26. package/dist/rules/prefer-safe-parse.d.mts.map +1 -1
  27. package/dist/rules/prefer-safe-parse.mjs +17 -16
  28. package/dist/rules/prefer-safe-parse.mjs.map +1 -1
  29. package/dist/type-wrappers.mjs.map +1 -1
  30. package/package.json +7 -7
  31. package/src/comments.mts +23 -0
  32. package/src/imports.mts +217 -17
  33. package/src/index.mts +1 -0
  34. package/src/negation.mts +26 -24
  35. package/src/rules/no-side-effect-import.mts +1 -1
  36. package/src/rules/prefer-array-is-array.mts +24 -37
  37. package/src/rules/prefer-is-non-null-object.mts +38 -43
  38. package/src/rules/prefer-is-record-and-has-key.mts +43 -67
  39. package/src/rules/prefer-safe-parse.mts +32 -39
  40. package/src/type-wrappers.mts +1 -1
@@ -4,11 +4,8 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import * as ts from 'typescript';
7
- import {
8
- buildNamedImportFix,
9
- getImportDeclarations,
10
- getNamedImports,
11
- } from '../imports.mjs';
7
+ import { hasForeignBinding } from '../bindings.mjs';
8
+ import { bindValueImports } from '../imports.mjs';
12
9
  import { getArgumentText } from '../parentheses.mjs';
13
10
  import {
14
11
  isIdentifierNamed,
@@ -39,15 +36,12 @@ export const createPreferIsRecordAndHasKey = ({
39
36
  useIsRecordAndHasKey: `Replace \`{{original}}\` with \`isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})\` from ${moduleName}.`,
40
37
  useHasKey: `Replace \`{{original}}\` with \`hasKey({{objName}}, {{keyName}})\` from ${moduleName}.`,
41
38
  },
39
+ defaultOptions: [],
42
40
  },
43
41
 
44
42
  create: (context) => {
45
43
  const sourceCode = context.sourceCode;
46
44
 
47
- const program = sourceCode.ast;
48
-
49
- const moduleImports = getImportDeclarations(program, moduleName);
50
-
51
45
  /**
52
46
  * Whether the `isRecord(...)` half of the rewrite would be dead code
53
47
  * because the object already satisfies `hasKey`'s
@@ -158,87 +152,69 @@ export const createPreferIsRecordAndHasKey = ({
158
152
  },
159
153
 
160
154
  'Program:exit': () => {
161
- const namedImports = getNamedImports(moduleImports);
162
-
163
- const rewrites = mut_nodesToFix.map((entry) => {
155
+ // Bound once for each shape of rewrite: a file may import `hasKey`
156
+ // but not `isRecord`, which only the rewrites that need it add.
157
+ const withIsRecord = bindValueImports(sourceCode, moduleName, [
158
+ 'isRecord',
159
+ 'hasKey',
160
+ ]);
161
+
162
+ const hasKeyOnly = bindValueImports(sourceCode, moduleName, [
163
+ 'hasKey',
164
+ ]);
165
+
166
+ for (const { node, objExpression, keyExpression } of mut_nodesToFix) {
164
167
  // A record-typed object makes the `isRecord(...)` conjunct always
165
168
  // true, so it is left out of the replacement entirely.
166
- const needsIsRecord = !isKnownRecord(entry.objExpression);
169
+ const needsIsRecord = !isKnownRecord(objExpression);
167
170
 
168
- return {
169
- ...entry,
170
- needsIsRecord,
171
- // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
172
- fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
173
- };
174
- });
175
-
176
- const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
171
+ const binding = needsIsRecord ? withIsRecord : hasKeyOnly;
177
172
 
178
- const importsToAdd: readonly string[] =
179
- fixableRewrites.length === 0
180
- ? []
181
- : [
182
- ...(fixableRewrites.some(
183
- ({ needsIsRecord }) => needsIsRecord,
184
- ) && !namedImports.includes('isRecord')
185
- ? ['isRecord']
186
- : []),
187
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
188
- ];
173
+ const hasKeyName = binding?.localNames.at(-1);
189
174
 
190
- const importCarrier = fixableRewrites[0];
191
-
192
- for (const rewrite of rewrites) {
193
- const { node, objExpression, keyExpression, needsIsRecord } =
194
- rewrite;
175
+ const isRecordName = needsIsRecord
176
+ ? binding?.localNames[0]
177
+ : undefined;
195
178
 
196
179
  const objText = getArgumentText(sourceCode, objExpression);
197
180
 
198
181
  const keyText = getArgumentText(sourceCode, keyExpression);
199
182
 
200
- const originalText = sourceCode.getText(node);
183
+ const fixable =
184
+ binding !== undefined &&
185
+ hasKeyName !== undefined &&
186
+ (!needsIsRecord || isRecordName !== undefined) &&
187
+ // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
188
+ (!needsIsRecord || isSideEffectFree(objExpression)) &&
189
+ binding.localNames.every(
190
+ (name) =>
191
+ !hasForeignBinding(sourceCode, node, name, moduleName),
192
+ );
201
193
 
202
194
  context.report({
203
195
  node,
204
196
  messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
205
197
  data: {
206
- original: originalText,
198
+ original: sourceCode.getText(node),
207
199
  objName: objText,
208
200
  keyName: keyText,
209
201
  },
210
- fix: !rewrite.fixable
211
- ? undefined
212
- : (fixer) => {
213
- const replacement = needsIsRecord
214
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
215
- : `hasKey(${objText}, ${keyText})`;
216
-
217
- // All import fixes have to live on the first fixable
218
- // report: `insertTextBefore(program, …)` produces
219
- // overlapping ranges otherwise and only one of them would
220
- // survive a pass.
221
- const importFixes =
222
- rewrite === importCarrier && importsToAdd.length > 0
223
- ? buildNamedImportFix(
224
- fixer,
225
- program,
226
- moduleName,
227
- importsToAdd,
228
- )
229
- : [];
230
-
231
- return [
232
- ...importFixes,
233
- fixer.replaceText(node, replacement),
234
- ];
235
- },
202
+ fix: fixable
203
+ ? (fixer) => [
204
+ ...binding.edits(fixer),
205
+ fixer.replaceText(
206
+ node,
207
+ needsIsRecord
208
+ ? `(${isRecordName}(${objText}) && ${hasKeyName}(${objText}, ${keyText}))`
209
+ : `${hasKeyName}(${objText}, ${keyText})`,
210
+ ),
211
+ ]
212
+ : null,
236
213
  });
237
214
  }
238
215
  },
239
216
  };
240
217
  },
241
- defaultOptions: [],
242
218
  }) as const;
243
219
 
244
220
  /**
@@ -5,11 +5,8 @@ import {
5
5
  } from '@typescript-eslint/utils';
6
6
  import type { ReadonlyRecord } from 'ts-type-forge';
7
7
  import * as ts from 'typescript';
8
- import {
9
- buildNamedImportFix,
10
- getImportDeclarations,
11
- getNamedImports,
12
- } from '../imports.mjs';
8
+ import { hasForeignBinding } from '../bindings.mjs';
9
+ import { bindValueImports } from '../imports.mjs';
13
10
  import { getArgumentText } from '../parentheses.mjs';
14
11
  import {
15
12
  isIdentifierNamed,
@@ -111,8 +108,6 @@ const createSafeParseRule = <MessageId extends string>(
111
108
  ) => boolean;
112
109
  }>,
113
110
  ): TSESLint.RuleModule<MessageId, readonly []> => {
114
- const parseFunction = `${namespace}.${method}` as const;
115
-
116
111
  // Sorted, as an import sorter would write them.
117
112
  const requiredImports = [namespace, 'Result'].toSorted();
118
113
 
@@ -123,15 +118,12 @@ const createSafeParseRule = <MessageId extends string>(
123
118
  fixable: 'code',
124
119
  schema: [],
125
120
  messages,
121
+ defaultOptions: [],
126
122
  },
127
123
 
128
124
  create: (context) => {
129
125
  const sourceCode = context.sourceCode;
130
126
 
131
- const program = sourceCode.ast;
132
-
133
- const moduleImports = getImportDeclarations(program, moduleName);
134
-
135
127
  const mut_nodesToFix: {
136
128
  node: TSESTree.CallExpression;
137
129
  argExpression: TSESTree.Expression;
@@ -168,19 +160,22 @@ const createSafeParseRule = <MessageId extends string>(
168
160
  mut_nodesToFix.push({ node, argExpression: firstArg });
169
161
  },
170
162
  'Program:exit': () => {
171
- const namedImports = getNamedImports(moduleImports);
172
-
173
- const missingImports = requiredImports.filter(
174
- (name) => !namedImports.includes(name),
163
+ const binding = bindValueImports(
164
+ sourceCode,
165
+ moduleName,
166
+ requiredImports,
175
167
  );
176
168
 
177
- // The import fix lives on the first report only:
178
- // `insertTextBefore(program, …)` produces overlapping ranges
179
- // otherwise and only one of them would survive a pass.
180
- for (const [
181
- index,
182
- { node, argExpression },
183
- ] of mut_nodesToFix.entries()) {
169
+ const localNameOf = (name: string): string | undefined =>
170
+ binding?.localNames[requiredImports.indexOf(name)];
171
+
172
+ const namespaceName = localNameOf(namespace);
173
+
174
+ const resultName = localNameOf('Result');
175
+
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) {
184
179
  const argText = getArgumentText(sourceCode, argExpression);
185
180
 
186
181
  context.report({
@@ -190,28 +185,26 @@ const createSafeParseRule = <MessageId extends string>(
190
185
  original: sourceCode.getText(node),
191
186
  argName: argText,
192
187
  },
193
- fix: (fixer) => {
194
- const core =
195
- `Result.unwrapOkOr(${parseFunction}(${argText}), Number.NaN)` as const;
196
-
197
- const importFixes =
198
- index === 0 && missingImports.length > 0
199
- ? buildNamedImportFix(
200
- fixer,
201
- program,
202
- moduleName,
203
- missingImports,
204
- )
205
- : ([] as const);
206
-
207
- return [...importFixes, fixer.replaceText(node, core)];
208
- },
188
+ fix:
189
+ binding === undefined ||
190
+ namespaceName === undefined ||
191
+ resultName === undefined ||
192
+ [namespaceName, resultName].some((name) =>
193
+ hasForeignBinding(sourceCode, node, name, moduleName),
194
+ )
195
+ ? null
196
+ : (fixer) => [
197
+ ...binding.edits(fixer),
198
+ fixer.replaceText(
199
+ node,
200
+ `${resultName}.unwrapOkOr(${namespaceName}.${method}(${argText}), Number.NaN)`,
201
+ ),
202
+ ],
209
203
  });
210
204
  }
211
205
  },
212
206
  };
213
207
  },
214
- defaultOptions: [],
215
208
  } as const;
216
209
  };
217
210
 
@@ -119,7 +119,7 @@ export const typeHoldsAtEveryLayer = (
119
119
  });
120
120
  };
121
121
 
122
- const TYPE_WRAPPERS: ReadonlySet<AST_NODE_TYPES> = new Set([
122
+ const TYPE_WRAPPERS: ReadonlySet<AST_NODE_TYPES> = new Set<AST_NODE_TYPES>([
123
123
  AST_NODE_TYPES.TSAsExpression,
124
124
  AST_NODE_TYPES.TSNonNullExpression,
125
125
  AST_NODE_TYPES.TSSatisfiesExpression,