From 2259ee660e135b6b63eb91c31fd6d9abf1782331 Mon Sep 17 00:00:00 2001
From: Rebecca Stevens <rebecca.stevens@outlook.co.nz>
Date: Mon, 15 Apr 2024 20:02:09 +1200
Subject: [PATCH] fix(prefer-tacit): don't check member functions by default

When checking a member function, add a bind to any suggestions.

fix #805
---
 docs/rules/prefer-tacit.md             | 56 ++++++++++++++++++++++++++
 src/rules/prefer-tacit.ts              | 42 ++++++++++++++++---
 tests/rules/prefer-tacit/ts/invalid.ts | 47 +++++++++++++++++++++
 tests/rules/prefer-tacit/ts/valid.ts   | 14 +++++++
 4 files changed, 154 insertions(+), 5 deletions(-)

diff --git a/docs/rules/prefer-tacit.md b/docs/rules/prefer-tacit.md
index 9f00dff79..de8e16df9 100644
--- a/docs/rules/prefer-tacit.md
+++ b/docs/rules/prefer-tacit.md
@@ -41,4 +41,60 @@ function f(x) {
 }
 
 const foo = [1, 2, 3].map(f);
+
+const bar = { f };
+const baz = [1, 2, 3].map((x) => bar.f(x)); // Allowed unless using `checkMemberExpressions`
+```
+
+## Options
+
+This rule accepts an options object of the following type:
+
+```ts
+type Options = {
+  checkMemberExpressions: boolean;
+};
+```
+
+### Default Options
+
+```ts
+type Options = {
+  checkMemberExpressions: false;
+};
+```
+
+### `checkMemberExpressions`
+
+If `true`, calls of member expressions are checked as well.
+If `false`, only calls of identifiers are checked.
+
+#### ❌ Incorrect
+
+<!-- eslint-skip -->
+
+```ts
+/* eslint functional/prefer-tacit: ["error", { "checkMemberExpressions": true }] */
+
+const bar = {
+  f(x) {
+    return x + 1;
+  }
+}
+
+const foo = [1, 2, 3].map((x) => bar.f(x));
+```
+
+#### ✅ Correct
+
+```ts
+/* eslint functional/prefer-tacit: ["error", { "checkMemberExpressions": true }] */
+
+const bar = {
+  f(x) {
+    return x + 1;
+  }
+}
+
+const foo = [1, 2, 3].map(bar.f.bind(bar));
 ```
diff --git a/src/rules/prefer-tacit.ts b/src/rules/prefer-tacit.ts
index e137dbc7a..2b9ed5fe8 100644
--- a/src/rules/prefer-tacit.ts
+++ b/src/rules/prefer-tacit.ts
@@ -25,6 +25,7 @@ import {
   isBlockStatement,
   isCallExpression,
   isIdentifier,
+  isMemberExpression,
   isReturnStatement,
 } from "#eslint-plugin-functional/utils/type-guards";
 
@@ -41,17 +42,35 @@ export const fullName = `${ruleNameScope}/${name}`;
 /**
  * The options this rule can take.
  */
-type Options = [];
+type Options = [
+  {
+    checkMemberExpressions: boolean;
+  },
+];
 
 /**
  * The schema for the rule options.
  */
-const schema: JSONSchema4[] = [];
+const schema: JSONSchema4[] = [
+  {
+    type: "object",
+    properties: {
+      checkMemberExpressions: {
+        type: "boolean",
+      },
+    },
+    additionalProperties: false,
+  },
+];
 
 /**
  * The default options for the rule.
  */
-const defaultOptions: Options = [];
+const defaultOptions: Options = [
+  {
+    checkMemberExpressions: false,
+  },
+];
 
 /**
  * The possible error messages.
@@ -135,8 +154,12 @@ function fixFunctionCallToReference(
 
   return [
     fixer.replaceText(
-      node as TSESTree.Node,
-      context.sourceCode.getText(caller.callee as TSESTree.Node),
+      node,
+      isMemberExpression(caller.callee)
+        ? `${context.sourceCode.getText(
+            caller.callee,
+          )}.bind(${context.sourceCode.getText(caller.callee.object)})`
+        : context.sourceCode.getText(caller.callee),
     ),
   ];
 }
@@ -196,6 +219,15 @@ function getCallDescriptors(
   options: Options,
   caller: TSESTree.CallExpression,
 ): Array<ReportDescriptor<keyof typeof errorMessages>> {
+  const [{ checkMemberExpressions }] = options;
+
+  if (
+    !isIdentifier(caller.callee) &&
+    !(checkMemberExpressions && isMemberExpression(caller.callee))
+  ) {
+    return [];
+  }
+
   if (
     node.params.length === caller.arguments.length &&
     node.params.every((param, index) => {
diff --git a/tests/rules/prefer-tacit/ts/invalid.ts b/tests/rules/prefer-tacit/ts/invalid.ts
index be779509a..21e775a23 100644
--- a/tests/rules/prefer-tacit/ts/invalid.ts
+++ b/tests/rules/prefer-tacit/ts/invalid.ts
@@ -293,6 +293,53 @@ const tests: Array<
       },
     ],
   },
+  // Member Call Expression
+  {
+    code: dedent`
+      [''].filter(str => /a/.test(str));
+    `,
+    optionsSet: [[{ checkMemberExpressions: true }]],
+    errors: [
+      {
+        messageId: "generic",
+        type: AST_NODE_TYPES.ArrowFunctionExpression,
+        line: 1,
+        column: 13,
+        suggestions: [
+          {
+            messageId: "generic",
+            output: dedent`
+              [''].filter(/a/.test.bind(/a/));
+            `,
+          },
+        ],
+      },
+    ],
+  },
+  {
+    code: dedent`
+      declare const a: { b(arg: string): string; };
+      function foo(x) { return a.b(x); }
+    `,
+    optionsSet: [[{ checkMemberExpressions: true }]],
+    errors: [
+      {
+        messageId: "generic",
+        type: AST_NODE_TYPES.FunctionDeclaration,
+        line: 2,
+        column: 1,
+        suggestions: [
+          {
+            messageId: "generic",
+            output: dedent`
+              declare const a: { b(arg: string): string; };
+              const foo = a.b.bind(a);
+            `,
+          },
+        ],
+      },
+    ],
+  },
 ];
 
 export default tests;
diff --git a/tests/rules/prefer-tacit/ts/valid.ts b/tests/rules/prefer-tacit/ts/valid.ts
index 0497394ae..39630206c 100644
--- a/tests/rules/prefer-tacit/ts/valid.ts
+++ b/tests/rules/prefer-tacit/ts/valid.ts
@@ -86,6 +86,20 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
     dependencyConstraints: { typescript: "4.7.0" },
     optionsSet: [[]],
   },
+  // Member Call Expression
+  {
+    code: dedent`
+      [''].filter(str => /a/.test(str))
+    `,
+    optionsSet: [[{ checkMemberExpressions: false }]],
+  },
+  {
+    code: dedent`
+      declare const a: { b(arg: string): string; };
+      function foo(x) { return a.b(x); }
+    `,
+    optionsSet: [[{ checkMemberExpressions: false }]],
+  },
 ];
 
 export default tests;