eslint-rule-forge 0.3.2 → 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.
@@ -3,11 +3,8 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getImportDeclarations,
9
- getNamedImports,
10
- } from '../imports.mjs';
6
+ import { hasForeignBinding } from '../bindings.mjs';
7
+ import { bindValueImports } from '../imports.mjs';
11
8
  import { getRangeWithParens, parenthesesDepth } from '../parentheses.mjs';
12
9
  import { skipTypeWrappers } from '../type-wrappers.mjs';
13
10
 
@@ -39,11 +36,8 @@ export const createPreferIsNonNullObject = ({
39
36
  create: (context) => {
40
37
  const sourceCode = context.sourceCode;
41
38
 
42
- const program = sourceCode.ast;
43
-
44
- const moduleImports = getImportDeclarations(program, moduleName);
45
-
46
- const mut_pairsToFix: NonNullObjectPair[] = [];
39
+ const mut_pairsToFix: (NonNullObjectPair &
40
+ Readonly<{ chain: TSESTree.LogicalExpression }>)[] = [];
47
41
 
48
42
  return {
49
43
  LogicalExpression: (node) => {
@@ -52,42 +46,43 @@ export const createPreferIsNonNullObject = ({
52
46
  return;
53
47
  }
54
48
 
55
- mut_pairsToFix.push(...findNonNullObjectPairs(sourceCode, node));
49
+ mut_pairsToFix.push(
50
+ ...findNonNullObjectPairs(sourceCode, node).map((pair) => ({
51
+ ...pair,
52
+ chain: node,
53
+ })),
54
+ );
56
55
  },
57
56
  'Program:exit': () => {
58
- const hasIsNonNullObjectImport =
59
- getNamedImports(moduleImports).includes('isNonNullObject');
60
-
61
- // The import fix lives on the first report only:
62
- // `insertTextBefore(program, …)` produces overlapping ranges
63
- // otherwise and only one of them would survive a pass. A first
64
- // report silenced by an `eslint-disable` comment therefore takes
65
- // the import with it.
66
- for (const [
67
- index,
68
- { range, identifierName },
69
- ] of mut_pairsToFix.entries()) {
57
+ const binding = bindValueImports(sourceCode, moduleName, [
58
+ 'isNonNullObject',
59
+ ]);
60
+
61
+ const localName = binding?.localNames[0];
62
+
63
+ // Every fix brings `isNonNullObject` into scope itself, so that one
64
+ // applied alone (an editor's quick fix) is complete.
65
+ for (const { range, identifierName, chain } of mut_pairsToFix) {
66
+ const loc = {
67
+ start: sourceCode.getLocFromIndex(range[0]),
68
+ end: sourceCode.getLocFromIndex(range[1]),
69
+ } as const;
70
+
70
71
  context.report({
71
- loc: {
72
- start: sourceCode.getLocFromIndex(range[0]),
73
- end: sourceCode.getLocFromIndex(range[1]),
74
- },
72
+ loc,
75
73
  messageId: 'useIsNonNullObject',
76
- fix: (fixer) => {
77
- const replacement = `isNonNullObject(${identifierName})`;
78
-
79
- const importFixes =
80
- index === 0 && !hasIsNonNullObjectImport
81
- ? buildNamedImportFix(fixer, program, moduleName, [
82
- 'isNonNullObject',
83
- ])
84
- : [];
85
-
86
- return [
87
- ...importFixes,
88
- fixer.replaceTextRange(range, replacement),
89
- ];
90
- },
74
+ fix:
75
+ binding === undefined ||
76
+ localName === undefined ||
77
+ hasForeignBinding(sourceCode, chain, localName, moduleName)
78
+ ? null
79
+ : (fixer) => [
80
+ ...binding.edits(fixer),
81
+ fixer.replaceTextRange(
82
+ range,
83
+ `${localName}(${identifierName})`,
84
+ ),
85
+ ],
91
86
  });
92
87
  }
93
88
  },
@@ -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,
@@ -45,10 +42,6 @@ export const createPreferIsRecordAndHasKey = ({
45
42
  create: (context) => {
46
43
  const sourceCode = context.sourceCode;
47
44
 
48
- const program = sourceCode.ast;
49
-
50
- const moduleImports = getImportDeclarations(program, moduleName);
51
-
52
45
  /**
53
46
  * Whether the `isRecord(...)` half of the rewrite would be dead code
54
47
  * because the object already satisfies `hasKey`'s
@@ -159,81 +152,64 @@ export const createPreferIsRecordAndHasKey = ({
159
152
  },
160
153
 
161
154
  'Program:exit': () => {
162
- const namedImports = getNamedImports(moduleImports);
163
-
164
- 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) {
165
167
  // A record-typed object makes the `isRecord(...)` conjunct always
166
168
  // true, so it is left out of the replacement entirely.
167
- const needsIsRecord = !isKnownRecord(entry.objExpression);
169
+ const needsIsRecord = !isKnownRecord(objExpression);
168
170
 
169
- return {
170
- ...entry,
171
- needsIsRecord,
172
- // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
173
- fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
174
- };
175
- });
176
-
177
- const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
171
+ const binding = needsIsRecord ? withIsRecord : hasKeyOnly;
178
172
 
179
- const importsToAdd: readonly string[] =
180
- fixableRewrites.length === 0
181
- ? []
182
- : [
183
- ...(fixableRewrites.some(
184
- ({ needsIsRecord }) => needsIsRecord,
185
- ) && !namedImports.includes('isRecord')
186
- ? ['isRecord']
187
- : []),
188
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
189
- ];
173
+ const hasKeyName = binding?.localNames.at(-1);
190
174
 
191
- const importCarrier = fixableRewrites[0];
192
-
193
- for (const rewrite of rewrites) {
194
- const { node, objExpression, keyExpression, needsIsRecord } =
195
- rewrite;
175
+ const isRecordName = needsIsRecord
176
+ ? binding?.localNames[0]
177
+ : undefined;
196
178
 
197
179
  const objText = getArgumentText(sourceCode, objExpression);
198
180
 
199
181
  const keyText = getArgumentText(sourceCode, keyExpression);
200
182
 
201
- 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
+ );
202
193
 
203
194
  context.report({
204
195
  node,
205
196
  messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
206
197
  data: {
207
- original: originalText,
198
+ original: sourceCode.getText(node),
208
199
  objName: objText,
209
200
  keyName: keyText,
210
201
  },
211
- fix: !rewrite.fixable
212
- ? undefined
213
- : (fixer) => {
214
- const replacement = needsIsRecord
215
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
216
- : `hasKey(${objText}, ${keyText})`;
217
-
218
- // All import fixes have to live on the first fixable
219
- // report: `insertTextBefore(program, …)` produces
220
- // overlapping ranges otherwise and only one of them would
221
- // survive a pass.
222
- const importFixes =
223
- rewrite === importCarrier && importsToAdd.length > 0
224
- ? buildNamedImportFix(
225
- fixer,
226
- program,
227
- moduleName,
228
- importsToAdd,
229
- )
230
- : [];
231
-
232
- return [
233
- ...importFixes,
234
- fixer.replaceText(node, replacement),
235
- ];
236
- },
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,
237
213
  });
238
214
  }
239
215
  },
@@ -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
 
@@ -129,10 +124,6 @@ const createSafeParseRule = <MessageId extends string>(
129
124
  create: (context) => {
130
125
  const sourceCode = context.sourceCode;
131
126
 
132
- const program = sourceCode.ast;
133
-
134
- const moduleImports = getImportDeclarations(program, moduleName);
135
-
136
127
  const mut_nodesToFix: {
137
128
  node: TSESTree.CallExpression;
138
129
  argExpression: TSESTree.Expression;
@@ -169,19 +160,22 @@ const createSafeParseRule = <MessageId extends string>(
169
160
  mut_nodesToFix.push({ node, argExpression: firstArg });
170
161
  },
171
162
  'Program:exit': () => {
172
- const namedImports = getNamedImports(moduleImports);
173
-
174
- const missingImports = requiredImports.filter(
175
- (name) => !namedImports.includes(name),
163
+ const binding = bindValueImports(
164
+ sourceCode,
165
+ moduleName,
166
+ requiredImports,
176
167
  );
177
168
 
178
- // The import fix lives on the first report only:
179
- // `insertTextBefore(program, …)` produces overlapping ranges
180
- // otherwise and only one of them would survive a pass.
181
- for (const [
182
- index,
183
- { node, argExpression },
184
- ] 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) {
185
179
  const argText = getArgumentText(sourceCode, argExpression);
186
180
 
187
181
  context.report({
@@ -191,22 +185,21 @@ const createSafeParseRule = <MessageId extends string>(
191
185
  original: sourceCode.getText(node),
192
186
  argName: argText,
193
187
  },
194
- fix: (fixer) => {
195
- const core =
196
- `Result.unwrapOkOr(${parseFunction}(${argText}), Number.NaN)` as const;
197
-
198
- const importFixes =
199
- index === 0 && missingImports.length > 0
200
- ? buildNamedImportFix(
201
- fixer,
202
- program,
203
- moduleName,
204
- missingImports,
205
- )
206
- : ([] as const);
207
-
208
- return [...importFixes, fixer.replaceText(node, core)];
209
- },
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
+ ],
210
203
  });
211
204
  }
212
205
  },