From 4a0969e8ebd75ba54d407dc8ab16b370acc966f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= <mateuszburzynski@gmail.com>
Date: Fri, 8 Mar 2024 14:34:50 +0100
Subject: [PATCH] Implement basic invoke editing

---
 .../source-edits/edit-invoke.test.ts          | 308 ++++++++++++++++++
 new-packages/ts-project/__tests__/utils.ts    |  19 +-
 new-packages/ts-project/src/codeChanges.ts    |  29 +-
 new-packages/ts-project/src/index.ts          | 126 +++++--
 4 files changed, 446 insertions(+), 36 deletions(-)
 create mode 100644 new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts

diff --git a/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts b/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts
new file mode 100644
index 00000000..1655afd9
--- /dev/null
+++ b/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts
@@ -0,0 +1,308 @@
+import { expect, test } from 'vitest';
+import { createTestProject, testdir, ts } from '../utils';
+
+test(`should be possible to update invoke's source`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      source: 'callAn(d)e(r)s',
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callAn(d)e(r)s",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should be possible to add an ID to the existing invoke`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      id: 'importantCall',
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callDavid",
+        id: "importantCall",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should be possible to update invoke's ID`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+          id: "importantCall",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      id: 'veryImportantCall',
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callDavid",
+        id: "veryImportantCall",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should be possible to update invoke's source and ID at the same time`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+          id: "importantCall",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      source: 'callAn(d)e(r)s',
+      id: 'veryImportantCall',
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callAn(d)e(r)s",
+        id: "veryImportantCall",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should be possible to remove invoke's ID`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+          id: "importantCall",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      id: null,
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callDavid",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should remove all \`id\` props when removing invoke's ID`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: {
+          src: "callDavid",
+          id: "importantCall",
+          id: "veryImportantCall",
+        },
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 0,
+      id: null,
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: {
+        src: "callDavid",
+      },
+    });",
+    }
+  `);
+});
+
+test(`should be possible to update nth invoke's source`, async () => {
+  const tmpPath = await testdir({
+    'tsconfig.json': JSON.stringify({}),
+    'index.ts': ts`
+      import { createMachine } from "xstate";
+
+      createMachine({
+        invoke: [
+          {
+            src: "callDavid",
+          },
+          {
+            src: "callAnders",
+          },
+        ],
+      });
+    `,
+  });
+
+  const project = await createTestProject(tmpPath);
+
+  const textEdits = project.editDigraph(
+    {
+      fileName: 'index.ts',
+      machineIndex: 0,
+    },
+    {
+      type: 'edit_invoke',
+      path: [],
+      invokeIndex: 1,
+      source: 'callAn(d)e(r)s',
+    },
+  );
+  expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
+    {
+      "index.ts": "import { createMachine } from "xstate";
+
+    createMachine({
+      invoke: [
+        {
+          src: "callDavid",
+        },
+        {
+          src: "callAn(d)e(r)s",
+        },
+      ],
+    });",
+    }
+  `);
+});
diff --git a/new-packages/ts-project/__tests__/utils.ts b/new-packages/ts-project/__tests__/utils.ts
index a1e861ea..8bc2d0d0 100644
--- a/new-packages/ts-project/__tests__/utils.ts
+++ b/new-packages/ts-project/__tests__/utils.ts
@@ -592,8 +592,25 @@ function produceNewDigraphUsingEdit(
       break;
     }
     case 'remove_invoke':
-    case 'edit_invoke':
       throw new Error(`Not implemented: ${edit.type}`);
+    case 'edit_invoke': {
+      const node = findNodeByStatePath(digraphDraft, edit.path);
+      const blockId = node.data.invoke[edit.invokeIndex];
+      const block = digraphDraft.blocks[blockId];
+      if (edit.source) {
+        block.sourceId = edit.source;
+        digraphDraft.implementations.actors[edit.source] ??= {
+          type: 'actor',
+          id: edit.source,
+          name: edit.source,
+        };
+      }
+      if ('id' in edit) {
+        assert(block.blockType === 'actor');
+        block.properties.id = edit.id ?? '';
+      }
+      break;
+    }
     case 'set_description': {
       const node = findNodeByStatePath(digraphDraft, edit.statePath);
       if (edit.transitionPath) {
diff --git a/new-packages/ts-project/src/codeChanges.ts b/new-packages/ts-project/src/codeChanges.ts
index 57705b98..4f3e4f49 100644
--- a/new-packages/ts-project/src/codeChanges.ts
+++ b/new-packages/ts-project/src/codeChanges.ts
@@ -14,6 +14,7 @@ import {
   findLast,
   findProperty,
   first,
+  getPropertyKey,
   last,
 } from './utils';
 
@@ -407,16 +408,24 @@ export function createCodeChanges(ts: typeof import('typescript')) {
         priority,
       });
     },
-    removeProperty: (property: PropertyAssignment) => {
-      changes.push({
-        type: 'remove_property',
-        sourceFile: property.getSourceFile(),
-        range: {
-          start: property.getStart(),
-          end: property.getEnd(),
-        },
-        property,
-      });
+    removeObjectProperty: (object: ObjectLiteralExpression, name: string) => {
+      for (const property of object.properties) {
+        if (
+          !ts.isPropertyAssignment(property) ||
+          getPropertyKey(undefined, ts, property) !== name
+        ) {
+          continue;
+        }
+        changes.push({
+          type: 'remove_property',
+          sourceFile: property.getSourceFile(),
+          range: {
+            start: property.getStart(),
+            end: property.getEnd(),
+          },
+          property,
+        });
+      }
     },
     replacePropertyName: (property: PropertyAssignment, name: string) => {
       changes.push({
diff --git a/new-packages/ts-project/src/index.ts b/new-packages/ts-project/src/index.ts
index e46fc2c5..e5fe520b 100644
--- a/new-packages/ts-project/src/index.ts
+++ b/new-packages/ts-project/src/index.ts
@@ -639,19 +639,17 @@ function createProjectMachine({
                   break;
                 }
                 if (patch.path[2] === 'data' && patch.path[3] === 'initial') {
+                  if (patch.value === undefined) {
+                    codeChanges.removeObjectProperty(stateNode, 'initial');
+                    break;
+                  }
+
                   const initialProp = findProperty(
                     undefined,
                     host.ts,
                     stateNode,
                     'initial',
                   );
-                  if (patch.value === undefined) {
-                    // this check is defensive, it should always be there
-                    if (initialProp) {
-                      codeChanges.removeProperty(initialProp);
-                    }
-                    break;
-                  }
 
                   if (initialProp) {
                     codeChanges.replaceRange(sourceFile, {
@@ -688,18 +686,16 @@ function createProjectMachine({
                   );
                 }
                 if (patch.path[2] === 'data' && patch.path[3] === 'type') {
+                  if (patch.value === 'normal') {
+                    codeChanges.removeObjectProperty(stateNode, 'type');
+                    break;
+                  }
                   const typeProp = findProperty(
                     undefined,
                     host.ts,
                     stateNode,
                     'type',
                   );
-                  if (patch.value === 'normal') {
-                    if (typeProp) {
-                      codeChanges.removeProperty(typeProp);
-                    }
-                    break;
-                  }
 
                   if (typeProp) {
                     codeChanges.replaceRange(sourceFile, {
@@ -720,18 +716,17 @@ function createProjectMachine({
                   );
                 }
                 if (patch.path[2] === 'data' && patch.path[3] === 'history') {
+                  if (patch.value === undefined || patch.value === 'shallow') {
+                    codeChanges.removeObjectProperty(stateNode, 'history');
+                    break;
+                  }
+
                   const historyProp = findProperty(
                     undefined,
                     host.ts,
                     stateNode,
                     'history',
                   );
-                  if (patch.value === undefined || patch.value === 'shallow') {
-                    if (historyProp) {
-                      codeChanges.removeProperty(historyProp);
-                    }
-                    break;
-                  }
 
                   if (historyProp) {
                     codeChanges.replaceRange(sourceFile, {
@@ -756,18 +751,17 @@ function createProjectMachine({
                   patch.path[2] === 'data' &&
                   patch.path[3] === 'description'
                 ) {
+                  if (!patch.value) {
+                    codeChanges.removeObjectProperty(stateNode, 'description');
+                    break;
+                  }
+
                   const descriptionProp = findProperty(
                     undefined,
                     host.ts,
                     stateNode,
                     'description',
                   );
-                  if (!patch.value) {
-                    if (descriptionProp) {
-                      codeChanges.removeProperty(descriptionProp);
-                    }
-                    break;
-                  }
 
                   const element = c.string(patch.value, {
                     allowMultiline: true,
@@ -828,6 +822,9 @@ function createProjectMachine({
                 const block = currentState.digraph!.blocks[blockId];
                 switch (block.blockType) {
                   case 'action': {
+                    if (patch.path[2] !== 'sourceId') {
+                      break;
+                    }
                     const node = currentState.digraph!.nodes[block.parentId];
                     if (!node) {
                       // there is no way to know where to look for this parent
@@ -927,9 +924,88 @@ function createProjectMachine({
                     break;
                   }
                   case 'actor': {
+                    const node = currentState.digraph!.nodes[block.parentId];
+                    const stateNode = findNodeByAstPath(
+                      host.ts,
+                      createMachineCall,
+                      currentState.astPaths.nodes[node.uniqueId],
+                    );
+                    assert(host.ts.isObjectLiteralExpression(stateNode));
+                    const invokeIndex = node.data.invoke.indexOf(blockId);
+                    const invokeProperty = findProperty(
+                      undefined,
+                      host.ts,
+                      stateNode,
+                      'invoke',
+                    );
+                    assert(!!invokeProperty);
+
+                    let invokeNode = invokeProperty.initializer;
+
+                    if (
+                      host.ts.isArrayLiteralExpression(
+                        invokeProperty.initializer,
+                      )
+                    ) {
+                      invokeNode =
+                        invokeProperty.initializer.elements[invokeIndex];
+                      assert(!!invokeNode);
+                    }
+
+                    assert(host.ts.isObjectLiteralExpression(invokeNode));
+
+                    if (patch.path[2] === 'sourceId') {
+                      const srcProperty = findProperty(
+                        undefined,
+                        host.ts,
+                        invokeNode,
+                        'src',
+                      );
+
+                      assert(!!srcProperty);
+
+                      codeChanges.replaceWith(
+                        srcProperty.initializer,
+                        c.string(block.sourceId),
+                      );
+                      break;
+                    }
+
+                    if (
+                      patch.path[2] === 'properties' &&
+                      patch.path[3] === 'id'
+                    ) {
+                      const actorId = block.properties.id;
+                      if (actorId && !actorId.startsWith('inline:')) {
+                        const idProperty = findProperty(
+                          undefined,
+                          host.ts,
+                          invokeNode,
+                          'id',
+                        );
+                        if (idProperty) {
+                          codeChanges.replaceWith(
+                            idProperty.initializer,
+                            c.string(actorId),
+                          );
+                          break;
+                        }
+                        codeChanges.insertPropertyIntoObject(
+                          invokeNode,
+                          'id',
+                          c.string(actorId),
+                        );
+                        break;
+                      }
+                      codeChanges.removeObjectProperty(invokeNode, 'id');
+                      break;
+                    }
                     break;
                   }
                   case 'guard': {
+                    if (patch.path[2] !== 'sourceId') {
+                      break;
+                    }
                     const edge = currentState.digraph!.edges[block.parentId];
                     const transitionNode = findNodeByAstPath(
                       host.ts,