eslint-plugin-ts-data-forge 0.10.4 → 0.10.6

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 (93) hide show
  1. package/dist/rules/ast-utils.d.mts +5 -5
  2. package/dist/rules/ast-utils.d.mts.map +1 -1
  3. package/dist/rules/ast-utils.mjs +3 -3
  4. package/dist/rules/ast-utils.mjs.map +1 -1
  5. package/dist/rules/import-binding.d.mts +38 -0
  6. package/dist/rules/import-binding.d.mts.map +1 -0
  7. package/dist/rules/import-binding.mjs +53 -0
  8. package/dist/rules/import-binding.mjs.map +1 -0
  9. package/dist/rules/mutable-result.mjs +37 -37
  10. package/dist/rules/mutable-result.mjs.map +1 -1
  11. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  12. package/dist/rules/no-unnecessary-type-guard.mjs +146 -143
  13. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  14. package/dist/rules/prefer-arr-generate.d.mts.map +1 -1
  15. package/dist/rules/prefer-arr-generate.mjs +129 -131
  16. package/dist/rules/prefer-arr-generate.mjs.map +1 -1
  17. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  18. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +14 -16
  19. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  20. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  21. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +15 -17
  22. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  23. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  24. package/dist/rules/prefer-arr-is-max-length-array.mjs +14 -16
  25. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  26. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  27. package/dist/rules/prefer-arr-is-min-length-array.mjs +14 -16
  28. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  29. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  30. package/dist/rules/prefer-arr-is-non-empty.mjs +14 -16
  31. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  33. package/dist/rules/prefer-arr-scan.mjs +9 -18
  34. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  35. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  36. package/dist/rules/prefer-arr-sum.mjs +16 -21
  37. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  38. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  39. package/dist/rules/prefer-arr-uniq.mjs +38 -43
  40. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  41. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  42. package/dist/rules/prefer-as-int.mjs +19 -26
  43. package/dist/rules/prefer-as-int.mjs.map +1 -1
  44. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  45. package/dist/rules/prefer-canonical-array-slicing.mjs +49 -15
  46. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  47. package/dist/rules/prefer-canonical-length-cast.mjs +15 -15
  48. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  49. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  50. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +100 -99
  51. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  52. package/dist/rules/prefer-pipe-map-nullable.d.mts.map +1 -1
  53. package/dist/rules/prefer-pipe-map-nullable.mjs +2 -4
  54. package/dist/rules/prefer-pipe-map-nullable.mjs.map +1 -1
  55. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  56. package/dist/rules/prefer-range-for-loop.mjs +18 -24
  57. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  58. package/package.json +5 -5
  59. package/src/rules/ast-utils.mts +11 -11
  60. package/src/rules/import-binding.mts +94 -0
  61. package/src/rules/mutable-result.mts +56 -56
  62. package/src/rules/no-unnecessary-type-guard.mts +199 -203
  63. package/src/rules/no-unnecessary-type-guard.test.mts +62 -2
  64. package/src/rules/prefer-arr-generate.mts +184 -194
  65. package/src/rules/prefer-arr-generate.test.mts +262 -134
  66. package/src/rules/prefer-arr-is-array.test.mts +87 -5
  67. package/src/rules/prefer-arr-is-bounded-length-array.mts +22 -33
  68. package/src/rules/prefer-arr-is-fixed-length-array.mts +24 -36
  69. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +16 -7
  70. package/src/rules/prefer-arr-is-max-length-array.mts +21 -34
  71. package/src/rules/prefer-arr-is-max-length-array.test.mts +16 -7
  72. package/src/rules/prefer-arr-is-min-length-array.mts +21 -34
  73. package/src/rules/prefer-arr-is-min-length-array.test.mts +16 -7
  74. package/src/rules/prefer-arr-is-non-empty.mts +17 -34
  75. package/src/rules/prefer-arr-is-non-empty.test.mts +110 -0
  76. package/src/rules/prefer-arr-scan.mts +11 -36
  77. package/src/rules/prefer-arr-scan.test.mts +55 -0
  78. package/src/rules/prefer-arr-sum.mts +19 -33
  79. package/src/rules/prefer-arr-uniq.mts +49 -60
  80. package/src/rules/prefer-arr-uniq.test.mts +46 -20
  81. package/src/rules/prefer-as-int.mts +27 -52
  82. package/src/rules/prefer-as-int.test.mts +79 -29
  83. package/src/rules/prefer-canonical-array-slicing.mts +86 -24
  84. package/src/rules/prefer-canonical-array-slicing.test.mts +133 -34
  85. package/src/rules/prefer-canonical-length-cast.mts +18 -18
  86. package/src/rules/prefer-is-non-null-object.test.mts +62 -37
  87. package/src/rules/prefer-is-record-and-has-key.test.mts +32 -17
  88. package/src/rules/prefer-num-safe-parse-float.test.mts +45 -21
  89. package/src/rules/prefer-num-safe-parse-int.test.mts +29 -14
  90. package/src/rules/prefer-obj-over-entries-round-trip.mts +152 -170
  91. package/src/rules/prefer-obj-over-entries-round-trip.test.mts +48 -6
  92. package/src/rules/prefer-pipe-map-nullable.mts +6 -8
  93. package/src/rules/prefer-range-for-loop.mts +31 -55
@@ -105,8 +105,7 @@ describe('prefer-num-safe-parse-float', () => {
105
105
  const n = parseFloat(s);
106
106
  `,
107
107
  output: dedent`
108
- import { Result } from 'ts-data-forge';
109
- import { Num } from 'ts-data-forge';
108
+ import { Num, Result } from 'ts-data-forge';
110
109
 
111
110
  const s = "3.14";
112
111
  const n = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
@@ -145,13 +144,22 @@ describe('prefer-num-safe-parse-float', () => {
145
144
  const b = Number.parseFloat(s);
146
145
  const c = Number(s);
147
146
  `,
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
- `,
147
+ output: [
148
+ 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 = Number.parseFloat(s);
153
+ const c = Number(s);
154
+ `,
155
+ dedent`
156
+ import { Num, Result } from 'ts-data-forge';
157
+ const s = "1.5";
158
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
159
+ const b = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
160
+ const c = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
161
+ `,
162
+ ],
155
163
  errors: [
156
164
  { messageId: 'useSafeParseFloat' },
157
165
  { messageId: 'useSafeParseFloat' },
@@ -203,12 +211,20 @@ describe('prefer-num-safe-parse-float through type wrappers', () => {
203
211
  const a = Number(s!);
204
212
  const b = parseFloat(s satisfies string);
205
213
  `,
206
- output: dedent`
207
- import { Num, Result } from 'ts-data-forge';
208
- declare const s: string;
209
- const a = Result.unwrapOkOr(Num.safeParseFloat(s!), Number.NaN);
210
- const b = Result.unwrapOkOr(Num.safeParseFloat(s satisfies string), Number.NaN);
211
- `,
214
+ output: [
215
+ dedent`
216
+ import { Num, Result } from 'ts-data-forge';
217
+ declare const s: string;
218
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s!), Number.NaN);
219
+ const b = parseFloat(s satisfies string);
220
+ `,
221
+ dedent`
222
+ import { Num, Result } from 'ts-data-forge';
223
+ declare const s: string;
224
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s!), Number.NaN);
225
+ const b = Result.unwrapOkOr(Num.safeParseFloat(s satisfies string), Number.NaN);
226
+ `,
227
+ ],
212
228
  errors: [
213
229
  { messageId: 'useSafeParseFloat' },
214
230
  { messageId: 'useSafeParseFloat' },
@@ -221,12 +237,20 @@ describe('prefer-num-safe-parse-float through type wrappers', () => {
221
237
  const a = (Number as NumberConstructor)(s);
222
238
  const b = Number!.parseFloat(s);
223
239
  `,
224
- output: dedent`
225
- import { Num, Result } from 'ts-data-forge';
226
- declare const s: string;
227
- const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
228
- const b = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
229
- `,
240
+ output: [
241
+ dedent`
242
+ import { Num, Result } from 'ts-data-forge';
243
+ declare const s: string;
244
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
245
+ const b = Number!.parseFloat(s);
246
+ `,
247
+ dedent`
248
+ import { Num, Result } from 'ts-data-forge';
249
+ declare const s: string;
250
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
251
+ const b = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
252
+ `,
253
+ ],
230
254
  errors: [
231
255
  { messageId: 'useSafeParseFloat' },
232
256
  { messageId: 'useSafeParseFloat' },
@@ -98,8 +98,7 @@ describe('prefer-num-safe-parse-int', () => {
98
98
  const n = parseInt(s, 10);
99
99
  `,
100
100
  output: dedent`
101
- import { Result } from 'ts-data-forge';
102
- import { Num } from 'ts-data-forge';
101
+ import { Num, Result } from 'ts-data-forge';
103
102
 
104
103
  const s = "123";
105
104
  const n = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
@@ -162,12 +161,20 @@ describe('prefer-num-safe-parse-int through type wrappers', () => {
162
161
  const a = parseInt(s, 10 as const);
163
162
  const b = Number.parseInt(s!, 10 satisfies number);
164
163
  `,
165
- output: dedent`
166
- import { Num, Result } from 'ts-data-forge';
167
- declare const s: string;
168
- const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
169
- const b = Result.unwrapOkOr(Num.safeParseInt(s!), Number.NaN);
170
- `,
164
+ output: [
165
+ dedent`
166
+ import { Num, Result } from 'ts-data-forge';
167
+ declare const s: string;
168
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
169
+ const b = Number.parseInt(s!, 10 satisfies number);
170
+ `,
171
+ dedent`
172
+ import { Num, Result } from 'ts-data-forge';
173
+ declare const s: string;
174
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
175
+ const b = Result.unwrapOkOr(Num.safeParseInt(s!), Number.NaN);
176
+ `,
177
+ ],
171
178
  errors: [
172
179
  { messageId: 'useSafeParseInt' },
173
180
  { messageId: 'useSafeParseInt' },
@@ -180,12 +187,20 @@ describe('prefer-num-safe-parse-int through type wrappers', () => {
180
187
  const a = (parseInt as typeof parseInt)(s);
181
188
  const b = (Number as NumberConstructor)!.parseInt(s, 10);
182
189
  `,
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
- `,
190
+ output: [
191
+ dedent`
192
+ import { Num, Result } from 'ts-data-forge';
193
+ declare const s: string;
194
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
195
+ const b = (Number as NumberConstructor)!.parseInt(s, 10);
196
+ `,
197
+ dedent`
198
+ import { Num, Result } from 'ts-data-forge';
199
+ declare const s: string;
200
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
201
+ const b = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
202
+ `,
203
+ ],
189
204
  errors: [
190
205
  { messageId: 'useSafeParseInt' },
191
206
  { messageId: 'useSafeParseInt' },
@@ -3,16 +3,10 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getArgumentText,
9
- getImportDeclarations,
10
- getImportedLocalName,
11
- getNamedImports,
12
- } from 'eslint-rule-forge';
6
+ import { getArgumentText } from 'eslint-rule-forge';
13
7
  import { Arr } from 'ts-data-forge';
14
8
  import type { ReadonlyRecord } from 'ts-type-forge';
15
- import { TS_DATA_FORGE_MODULE } from './constants.mjs';
9
+ import { tsDataForgeValuesAt } from './import-binding.mjs';
16
10
 
17
11
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
18
12
 
@@ -92,13 +86,6 @@ export const preferObjOverEntriesRoundTrip: TSESLint.RuleModule<
92
86
  create: (context) => {
93
87
  const sourceCode = context.sourceCode;
94
88
 
95
- const program = sourceCode.ast;
96
-
97
- const tsDataForgeImports = getImportDeclarations(
98
- program,
99
- TS_DATA_FORGE_MODULE,
100
- );
101
-
102
89
  const mut_roundTrips: {
103
90
  node: TSESTree.CallExpression;
104
91
  roundTrip: RoundTrip;
@@ -173,47 +160,42 @@ export const preferObjOverEntriesRoundTrip: TSESLint.RuleModule<
173
160
  },
174
161
 
175
162
  'Program:exit': () => {
176
- const namedImports = getNamedImports(tsDataForgeImports);
177
-
178
- const objName =
179
- getImportedLocalName(tsDataForgeImports, 'Obj') ?? 'Obj';
163
+ const objAt = tsDataForgeValuesAt(sourceCode, ['Obj']).at;
180
164
 
181
- const optionalName =
182
- getImportedLocalName(tsDataForgeImports, 'Optional') ?? 'Optional';
165
+ const objOptionalAt = tsDataForgeValuesAt(sourceCode, [
166
+ 'Obj',
167
+ 'Optional',
168
+ ]).at;
183
169
 
184
- const needsOptional = mut_roundTrips.some(
185
- ({ roundTrip }) => roundTrip.body.kind === 'optional',
186
- );
170
+ // Every fix brings the names it writes into scope itself, so that one
171
+ // applied alone (an editor's quick fix) is complete.
172
+ for (const { node, roundTrip } of mut_roundTrips) {
173
+ const binding = (
174
+ roundTrip.body.kind === 'optional' ? objOptionalAt : objAt
175
+ )(node);
187
176
 
188
- const missingImports = (['Obj', 'Optional'] as const).filter(
189
- (name) =>
190
- (name !== 'Optional' || needsOptional) &&
191
- !namedImports.includes(name),
192
- );
177
+ const [objName, optionalName = 'Optional'] =
178
+ binding?.localNames ?? [];
193
179
 
194
- // Note: the import is added by the first fix only, so that several
195
- // fixes in one file do not each insert it at the same position.
196
- for (const [index, { node, roundTrip }] of mut_roundTrips.entries()) {
197
180
  context.report({
198
181
  node,
199
182
  messageId: MESSAGE_ID_OF[roundTrip.method],
200
- fix: (fixer) => {
201
- const recordText = getArgumentText(sourceCode, roundTrip.record);
202
-
203
- const replacement = `${objName}.${roundTrip.method}(${recordText}, ${buildParams(roundTrip)} => ${buildCallbackBody(roundTrip, optionalName)})`;
204
-
205
- const importFixes =
206
- index === 0 && missingImports.length > 0
207
- ? buildNamedImportFix(
208
- fixer,
209
- program,
210
- TS_DATA_FORGE_MODULE,
211
- missingImports,
212
- )
213
- : [];
214
-
215
- return [...importFixes, fixer.replaceText(node, replacement)];
216
- },
183
+ fix:
184
+ binding === undefined || objName === undefined
185
+ ? null
186
+ : (fixer) => {
187
+ const recordText = getArgumentText(
188
+ sourceCode,
189
+ roundTrip.record,
190
+ );
191
+
192
+ const replacement = `${objName}.${roundTrip.method}(${recordText}, ${buildParams(roundTrip)} => ${buildCallbackBody(roundTrip, optionalName)})`;
193
+
194
+ return [
195
+ ...binding.edits(fixer),
196
+ fixer.replaceText(node, replacement),
197
+ ];
198
+ },
217
199
  });
218
200
  }
219
201
  },
@@ -221,6 +203,127 @@ export const preferObjOverEntriesRoundTrip: TSESLint.RuleModule<
221
203
  },
222
204
  } as const;
223
205
 
206
+ const isElidedOrIdentifier = (
207
+ element: TSESTree.DestructuringPattern | null | undefined,
208
+ ): element is TSESTree.Identifier | null | undefined =>
209
+ element === null ||
210
+ element === undefined ||
211
+ element.type === AST_NODE_TYPES.Identifier;
212
+
213
+ /**
214
+ * Reads the `[key, value]` destructuring the entry callback binds. Anything
215
+ * beyond a plain binding — a default, a rest element, a nested pattern — is
216
+ * rejected, because the rewritten callback receives the value and the key
217
+ * directly rather than an entry tuple.
218
+ */
219
+ const parseEntryPattern = (
220
+ pattern: TSESTree.ArrayPattern,
221
+ ): EntryParams | undefined => {
222
+ if (pattern.elements.length > 2) {
223
+ return undefined;
224
+ }
225
+
226
+ const [keyElement, valueElement] = pattern.elements;
227
+
228
+ return isElidedOrIdentifier(keyElement) && isElidedOrIdentifier(valueElement)
229
+ ? {
230
+ keyParam: keyElement ?? undefined,
231
+ valueParam: valueElement ?? undefined,
232
+ }
233
+ : undefined;
234
+ };
235
+
236
+ const isConstAssertion = (node: TSESTree.TSAsExpression): boolean =>
237
+ node.typeAnnotation.type === AST_NODE_TYPES.TSTypeReference &&
238
+ node.typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier &&
239
+ node.typeAnnotation.typeName.name === 'const';
240
+
241
+ /**
242
+ * Looks through the `as const` / `satisfies` an entry tuple usually carries.
243
+ * Any other assertion is left in place, so that a tuple asserted to a wider
244
+ * type than the callback returns is not reported: `Obj` would infer the
245
+ * narrower one and the rewrite would not type-check.
246
+ */
247
+ const stripTypeWrappers = (node: TSESTree.Expression): TSESTree.Expression =>
248
+ node.type === AST_NODE_TYPES.TSSatisfiesExpression ||
249
+ (node.type === AST_NODE_TYPES.TSAsExpression && isConstAssertion(node))
250
+ ? stripTypeWrappers(node.expression)
251
+ : node;
252
+
253
+ /**
254
+ * Matches `[k, value]` — an entry rebuilt under the very key it came from,
255
+ * which is the only shape `Obj.map` / `Obj.filterMap` can express. An entry
256
+ * built under a different key is left alone.
257
+ */
258
+ const parseKeptEntry = (
259
+ node: TSESTree.Expression,
260
+ keyParam: TSESTree.Identifier | undefined,
261
+ ): KeptEntry | undefined => {
262
+ if (keyParam === undefined) {
263
+ return undefined;
264
+ }
265
+
266
+ const tuple = stripTypeWrappers(node);
267
+
268
+ if (
269
+ tuple.type !== AST_NODE_TYPES.ArrayExpression ||
270
+ !Arr.isFixedLengthTuple(2, tuple.elements)
271
+ ) {
272
+ return undefined;
273
+ }
274
+
275
+ const [key, value] = tuple.elements;
276
+
277
+ return key !== null &&
278
+ value !== null &&
279
+ key.type === AST_NODE_TYPES.Identifier &&
280
+ value.type !== AST_NODE_TYPES.SpreadElement &&
281
+ key.name === keyParam.name
282
+ ? { key, value }
283
+ : undefined;
284
+ };
285
+
286
+ /** Matches `[[k, value]]` — the `flatMap` branch that keeps an entry. */
287
+ const parseSingletonEntry = (
288
+ node: TSESTree.Expression,
289
+ keyParam: TSESTree.Identifier | undefined,
290
+ ): KeptEntry | undefined => {
291
+ const array = stripTypeWrappers(node);
292
+
293
+ if (
294
+ array.type !== AST_NODE_TYPES.ArrayExpression ||
295
+ !Arr.isFixedLengthTuple(1, array.elements)
296
+ ) {
297
+ return undefined;
298
+ }
299
+
300
+ const [entry] = array.elements;
301
+
302
+ return entry === null || entry.type === AST_NODE_TYPES.SpreadElement
303
+ ? undefined
304
+ : parseKeptEntry(entry, keyParam);
305
+ };
306
+
307
+ /** Matches `[]` — the `flatMap` branch that drops an entry. */
308
+ const isEmptyArray = (node: TSESTree.Expression): boolean => {
309
+ const array = stripTypeWrappers(node);
310
+
311
+ return (
312
+ array.type === AST_NODE_TYPES.ArrayExpression && array.elements.length === 0
313
+ );
314
+ };
315
+
316
+ const isObjectStaticCall = (
317
+ node: TSESTree.CallExpression,
318
+ name: 'entries' | 'fromEntries',
319
+ ): boolean =>
320
+ node.callee.type === AST_NODE_TYPES.MemberExpression &&
321
+ !node.callee.computed &&
322
+ node.callee.object.type === AST_NODE_TYPES.Identifier &&
323
+ node.callee.object.name === 'Object' &&
324
+ node.callee.property.type === AST_NODE_TYPES.Identifier &&
325
+ node.callee.property.name === name;
326
+
224
327
  /**
225
328
  * Matches `Object.fromEntries(Object.entries(record).map|filter|flatMap(cb))`
226
329
  * and reports which `Obj` function replaces it, or `undefined` when the call is
@@ -377,133 +480,12 @@ type EntryParams = Readonly<{
377
480
  valueParam: TSESTree.Identifier | undefined;
378
481
  }>;
379
482
 
380
- /**
381
- * Reads the `[key, value]` destructuring the entry callback binds. Anything
382
- * beyond a plain binding — a default, a rest element, a nested pattern — is
383
- * rejected, because the rewritten callback receives the value and the key
384
- * directly rather than an entry tuple.
385
- */
386
- const parseEntryPattern = (
387
- pattern: TSESTree.ArrayPattern,
388
- ): EntryParams | undefined => {
389
- if (pattern.elements.length > 2) {
390
- return undefined;
391
- }
392
-
393
- const [keyElement, valueElement] = pattern.elements;
394
-
395
- return isElidedOrIdentifier(keyElement) && isElidedOrIdentifier(valueElement)
396
- ? {
397
- keyParam: keyElement ?? undefined,
398
- valueParam: valueElement ?? undefined,
399
- }
400
- : undefined;
401
- };
402
-
403
- const isElidedOrIdentifier = (
404
- element: TSESTree.DestructuringPattern | null | undefined,
405
- ): element is TSESTree.Identifier | null | undefined =>
406
- element === null ||
407
- element === undefined ||
408
- element.type === AST_NODE_TYPES.Identifier;
409
-
410
483
  type KeptEntry = Readonly<{
411
484
  /** The key identifier that only rebuilds the entry, dropped by the rewrite. */
412
485
  key: TSESTree.Identifier;
413
486
  value: TSESTree.Expression;
414
487
  }>;
415
488
 
416
- /**
417
- * Matches `[k, value]` — an entry rebuilt under the very key it came from,
418
- * which is the only shape `Obj.map` / `Obj.filterMap` can express. An entry
419
- * built under a different key is left alone.
420
- */
421
- const parseKeptEntry = (
422
- node: TSESTree.Expression,
423
- keyParam: TSESTree.Identifier | undefined,
424
- ): KeptEntry | undefined => {
425
- if (keyParam === undefined) {
426
- return undefined;
427
- }
428
-
429
- const tuple = stripTypeWrappers(node);
430
-
431
- if (
432
- tuple.type !== AST_NODE_TYPES.ArrayExpression ||
433
- !Arr.isFixedLengthTuple(2, tuple.elements)
434
- ) {
435
- return undefined;
436
- }
437
-
438
- const [key, value] = tuple.elements;
439
-
440
- return key !== null &&
441
- value !== null &&
442
- key.type === AST_NODE_TYPES.Identifier &&
443
- value.type !== AST_NODE_TYPES.SpreadElement &&
444
- key.name === keyParam.name
445
- ? { key, value }
446
- : undefined;
447
- };
448
-
449
- /** Matches `[[k, value]]` — the `flatMap` branch that keeps an entry. */
450
- const parseSingletonEntry = (
451
- node: TSESTree.Expression,
452
- keyParam: TSESTree.Identifier | undefined,
453
- ): KeptEntry | undefined => {
454
- const array = stripTypeWrappers(node);
455
-
456
- if (
457
- array.type !== AST_NODE_TYPES.ArrayExpression ||
458
- !Arr.isFixedLengthTuple(1, array.elements)
459
- ) {
460
- return undefined;
461
- }
462
-
463
- const [entry] = array.elements;
464
-
465
- return entry === null || entry.type === AST_NODE_TYPES.SpreadElement
466
- ? undefined
467
- : parseKeptEntry(entry, keyParam);
468
- };
469
-
470
- /** Matches `[]` — the `flatMap` branch that drops an entry. */
471
- const isEmptyArray = (node: TSESTree.Expression): boolean => {
472
- const array = stripTypeWrappers(node);
473
-
474
- return (
475
- array.type === AST_NODE_TYPES.ArrayExpression && array.elements.length === 0
476
- );
477
- };
478
-
479
- /**
480
- * Looks through the `as const` / `satisfies` an entry tuple usually carries.
481
- * Any other assertion is left in place, so that a tuple asserted to a wider
482
- * type than the callback returns is not reported: `Obj` would infer the
483
- * narrower one and the rewrite would not type-check.
484
- */
485
- const stripTypeWrappers = (node: TSESTree.Expression): TSESTree.Expression =>
486
- node.type === AST_NODE_TYPES.TSSatisfiesExpression ||
487
- (node.type === AST_NODE_TYPES.TSAsExpression && isConstAssertion(node))
488
- ? stripTypeWrappers(node.expression)
489
- : node;
490
-
491
- const isConstAssertion = (node: TSESTree.TSAsExpression): boolean =>
492
- node.typeAnnotation.type === AST_NODE_TYPES.TSTypeReference &&
493
- node.typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier &&
494
- node.typeAnnotation.typeName.name === 'const';
495
-
496
- const isObjectStaticCall = (
497
- node: TSESTree.CallExpression,
498
- name: 'entries' | 'fromEntries',
499
- ): boolean =>
500
- node.callee.type === AST_NODE_TYPES.MemberExpression &&
501
- !node.callee.computed &&
502
- node.callee.object.type === AST_NODE_TYPES.Identifier &&
503
- node.callee.object.name === 'Object' &&
504
- node.callee.property.type === AST_NODE_TYPES.Identifier &&
505
- node.callee.property.name === name;
506
-
507
489
  /**
508
490
  * `true` when the parameter is still referenced once the identifiers in
509
491
  * `dropped` — the ones that only rebuilt the entry key — are discounted.
@@ -308,6 +308,38 @@ describe('prefer-obj-over-entries-round-trip', () => {
308
308
  `,
309
309
  errors: [{ messageId: 'useObjFilterMap' }],
310
310
  },
311
+ {
312
+ name: 'adds Obj and Optional to one existing import',
313
+ code: dedent`
314
+ import { asInt } from 'ts-data-forge';
315
+ const scores = { alice: 1, bob: 2 };
316
+ const scaled = Object.fromEntries(
317
+ Object.entries(scores).flatMap(([k, v]) =>
318
+ v > 1 ? [[k, v * 10]] : [],
319
+ ),
320
+ );
321
+ `,
322
+ output: dedent`
323
+ import { asInt, Obj, Optional } from 'ts-data-forge';
324
+ const scores = { alice: 1, bob: 2 };
325
+ const scaled = Obj.filterMap(scores, (v) => v > 1 ? Optional.some(v * 10) : Optional.none);
326
+ `,
327
+ errors: [{ messageId: 'useObjFilterMap' }],
328
+ },
329
+ {
330
+ name: 'no fix when an import type binds Optional',
331
+ code: dedent`
332
+ import type { Optional } from 'ts-data-forge';
333
+ const scores = { alice: 1, bob: 2 };
334
+ const scaled = Object.fromEntries(
335
+ Object.entries(scores).flatMap(([k, v]) =>
336
+ v > 1 ? [[k, v * 10]] : [],
337
+ ),
338
+ );
339
+ `,
340
+ output: null,
341
+ errors: [{ messageId: 'useObjFilterMap' }],
342
+ },
311
343
  {
312
344
  name: 'handles a flatMap whose branches are the other way round',
313
345
  code: dedent`
@@ -368,12 +400,22 @@ describe('prefer-obj-over-entries-round-trip', () => {
368
400
  Object.entries(scores).filter(([, v]) => v > 1),
369
401
  );
370
402
  `,
371
- output: dedent`
372
- import { Obj } from 'ts-data-forge';
373
- const scores = { alice: 1, bob: 2 };
374
- const doubled = Obj.map(scores, (v) => v * 2);
375
- const passing = Obj.filter(scores, (v) => v > 1);
376
- `,
403
+ output: [
404
+ dedent`
405
+ import { Obj } from 'ts-data-forge';
406
+ const scores = { alice: 1, bob: 2 };
407
+ const doubled = Obj.map(scores, (v) => v * 2);
408
+ const passing = Object.fromEntries(
409
+ Object.entries(scores).filter(([, v]) => v > 1),
410
+ );
411
+ `,
412
+ dedent`
413
+ import { Obj } from 'ts-data-forge';
414
+ const scores = { alice: 1, bob: 2 };
415
+ const doubled = Obj.map(scores, (v) => v * 2);
416
+ const passing = Obj.filter(scores, (v) => v > 1);
417
+ `,
418
+ ],
377
419
  errors: [{ messageId: 'useObjMap' }, { messageId: 'useObjFilter' }],
378
420
  },
379
421
  {
@@ -7,6 +7,7 @@ import {
7
7
  buildCalleeResolver,
8
8
  getArgumentText,
9
9
  getImportDeclarations,
10
+ hasCommentWithin,
10
11
  hasForeignBinding,
11
12
  isTypeWrapper,
12
13
  skipTypeWrappers,
@@ -238,14 +239,11 @@ export const preferPipeMapNullable: TSESLint.RuleModule<MessageIds, Options> = {
238
239
  [mapFn.range[1], mapNullableCall.range[1]],
239
240
  ] as const;
240
241
 
241
- const dropsComment = sourceCode
242
- .getCommentsInside(mapNullableCall)
243
- .some((comment) =>
244
- droppedRanges.some(
245
- ([start, end]) =>
246
- start <= comment.range[0] && comment.range[1] <= end,
247
- ),
248
- );
242
+ const dropsComment = hasCommentWithin(
243
+ sourceCode,
244
+ mapNullableCall,
245
+ droppedRanges,
246
+ );
249
247
 
250
248
  const autofixable =
251
249
  !dropsComment &&