Repository: nicoespeon/abracadabra (ts)

Issue:
transform logic and function statement to if statement
before:

```typescript
condition && callback();
```

after:

```
if (condition) {
    callback();
}
```

Pull request description:
Add "Convert Guard To If" refactoring
## Overview

New refactoring: "Convert Guard to If"
Closes #794 

![Refactoring in action](https://github.com/user-attachments/assets/4c6755b2-dc58-426c-b13e-217da19a1cba)

## Notes

Unrelated to the issue, but I added a new debug launch configuration named "Run Current Test File".
It allows to run and debug a specific test file directly from VS Code by launching:

```bash
yarn test src/../current-file.test.ts
```

This was particularly helpful while developing the new refactoring.
Let me know if you prefer to remove it.

Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 8ffd73e9..b956d602 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -42,6 +42,16 @@
       ],
       "outFiles": ["${workspaceFolder}/out/**/*.js"],
       "preLaunchTask": "npm: build:browser"
+    },
+    {
+      "name": "Run Current Test File",
+      "type": "node",
+      "request": "launch",
+      "runtimeExecutable": "yarn",
+      "args": ["test", "${relativeFile}"],
+      "cwd": "${workspaceFolder}",
+      "console": "integratedTerminal",
+      "skipFiles": ["<node_internals>/**", "**/node_modules/**"]
     }
   ]
 }
diff --git a/package.json b/package.json
index 0483faef..c3e848ef 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,11 @@
         "title": "Convert to Arrow Function",
         "category": "Abracadabra"
       },
+      {
+        "command": "abracadabra.convertGuardToIf",
+        "title": "Convert Guard to If",
+        "category": "Abracadabra"
+      },
       {
         "command": "abracadabra.convertIfElseToTernary",
         "title": "Convert If/Else to Ternary",
diff --git a/src/extension.ts b/src/extension.ts
index b90e8707..3ce1c819 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -19,6 +19,7 @@ import addNumericSeparator from "./refactorings/add-numeric-separator";
 import changeSignature from "./refactorings/change-signature";
 import convertForEachToForOf from "./refactorings/convert-for-each-to-for-of";
 import convertForToForEach from "./refactorings/convert-for-to-for-each";
+import convertGuardToIf from "./refactorings/convert-guard-to-if";
 import convertIfElseToSwitch from "./refactorings/convert-if-else-to-switch";
 import convertIfElseToTernary from "./refactorings/convert-if-else-to-ternary";
 import convertLetToConst from "./refactorings/convert-let-to-const";
@@ -118,6 +119,7 @@ const refactorings: { [key: string]: ConfiguredRefactoring } = {
       toggleBraces
     ],
     withActionProvider__NEW: [
+      convertGuardToIf,
       moveLastStatementOutOfIfElse,
       splitMultipleDeclarations
     ]
diff --git a/src/refactorings/convert-guard-to-if/convert-guard-to-if.ts b/src/refactorings/convert-guard-to-if/convert-guard-to-if.ts
new file mode 100644
index 00000000..3898d824
--- /dev/null
+++ b/src/refactorings/convert-guard-to-if/convert-guard-to-if.ts
@@ -0,0 +1,53 @@
+import * as t from "../../ast";
+import { Selection } from "../../editor/selection";
+import { COMMANDS, EditorCommand, RefactoringState } from "../../refactorings";
+
+export function convertGuardToIf(state: RefactoringState): EditorCommand {
+  const updatedCode = updateCode(t.parse(state.code), state.selection);
+
+  if (!updatedCode.hasCodeChanged) {
+    return COMMANDS.showErrorDidNotFind(
+      "a logical expression using '&&' where the right hand side is a function call"
+    );
+  }
+
+  return COMMANDS.write(updatedCode.code);
+}
+
+function updateCode(ast: t.AST, selection: Selection): t.Transformed {
+  return t.transformAST(
+    ast,
+    createVisitor(selection, (path: t.NodePath<t.LogicalExpression>) => {
+      const { parentPath, node } = path;
+      t.replaceWithPreservingComments(
+        parentPath,
+        t.ifStatement(
+          node.left,
+          t.blockStatement([t.expressionStatement(node.right)])
+        )
+      );
+      parentPath.stop();
+    })
+  );
+}
+
+export function createVisitor(
+  selection: Selection,
+  onMatch: (path: t.NodePath<t.LogicalExpression>) => void
+): t.Visitor {
+  return {
+    LogicalExpression(path) {
+      if (!selection.isInsidePath(path)) return;
+
+      if (!isGuardForCallback(path)) return;
+
+      onMatch(path);
+    }
+  };
+}
+
+function isGuardForCallback({
+  node
+}: t.NodePath<t.LogicalExpression>): boolean {
+  return node.operator === "&&" && t.isCallExpression(node.right);
+}
diff --git a/src/refactorings/convert-guard-to-if/index.ts b/src/refactorings/convert-guard-to-if/index.ts
new file mode 100644
index 00000000..1f35464e
--- /dev/null
+++ b/src/refactorings/convert-guard-to-if/index.ts
@@ -0,0 +1,16 @@
+import { RefactoringWithActionProviderConfig } from "../../refactorings";
+import { convertGuardToIf, createVisitor } from "./convert-guard-to-if";
+
+const config: RefactoringWithActionProviderConfig = {
+  command: {
+    key: "convertGuardToIf",
+    operation: convertGuardToIf,
+    title: "Convert Guard To If"
+  },
+  actionProvider: {
+    message: "Convert guard to if",
+    createVisitor
+  }
+};
+
+export default config;


Test patch:
diff --git a/src/refactorings/convert-guard-to-if/convert-guard-to-if.test.ts b/src/refactorings/convert-guard-to-if/convert-guard-to-if.test.ts
new file mode 100644
index 00000000..5718f273
--- /dev/null
+++ b/src/refactorings/convert-guard-to-if/convert-guard-to-if.test.ts
@@ -0,0 +1,78 @@
+import { InMemoryEditor } from "../../editor/adapters/in-memory-editor";
+import { Code } from "../../editor/editor";
+import { Selection } from "../../editor/selection";
+import { convertGuardToIf } from "./convert-guard-to-if";
+
+describe("Convert Guard To If", () => {
+  it("should convert guard on single condition", () => {
+    shouldConvertGuardToIf({
+      code: `condition && callback();`,
+      expected: `if (condition) {
+  callback();
+}`
+    });
+  });
+
+  it("should convert guard on multiple conditions", () => {
+    shouldConvertGuardToIf({
+      code: `condition1 && condition2 && callback();`,
+      expected: `if (condition1 && condition2) {
+  callback();
+}`
+    });
+  });
+
+  it("should not convert expression if the right hand side is not a call expression", () => {
+    shouldNotConvert({
+      code: `condition1 && condition2`
+    });
+  });
+
+  it("should not convert expression if operator is not &&", () => {
+    shouldNotConvert({
+      code: `condition || callback();`
+    });
+  });
+
+  it("should show an error message if refactoring can't be made", () => {
+    const code = `// This is a comment, can't be refactored`;
+
+    const result = convertGuardToIf({
+      state: "new",
+      code,
+      selection: Selection.cursorAt(0, 0)
+    });
+
+    expect(result.action).toBe("show error");
+  });
+});
+
+function shouldConvertGuardToIf({
+  code,
+  expected
+}: {
+  code: Code;
+  expected: Code;
+}) {
+  const editor = new InMemoryEditor(code);
+
+  const result = convertGuardToIf({
+    state: "new",
+    code: editor.code,
+    selection: editor.selection
+  });
+
+  expect(result).toMatchObject({ action: "write", code: expected });
+}
+
+function shouldNotConvert({ code }: { code: Code }) {
+  const editor = new InMemoryEditor(code);
+
+  const result = convertGuardToIf({
+    state: "new",
+    code: editor.code,
+    selection: editor.selection
+  });
+
+  expect(result.action).toBe("show error");
+}