Repository: elm-tooling/elm-language-server (ts)

Issue:
Offer to create missing union constructor
If there is no definition for a union constructor and we know the type that it should be, offer to create a variant on that type. This would be most useful with the `Msg` type, when it is common to write out the name of the msg you need before creating it.

Pull request description:
Add a code action to add missing union constructors
Closes #476.

Patch:
diff --git a/src/compiler/typeInference.ts b/src/compiler/typeInference.ts
index 8121474..34823f9 100644
--- a/src/compiler/typeInference.ts
+++ b/src/compiler/typeInference.ts
@@ -1709,9 +1709,7 @@ export class InferenceScope {
   ): void {
     // Bind all patters to a InProgressBinding type
     // so we can use them before we know the type
-    const declaredNames = pattern
-      .descendantsOfType("lower_pattern")
-      .filter((pattern) => pattern.text !== "");
+    const declaredNames = pattern.descendantsOfType("lower_pattern");
     declaredNames.forEach((name) =>
       this.bindings.set(name, TInProgressBinding),
     );
diff --git a/src/compiler/utils/elmUtils.ts b/src/compiler/utils/elmUtils.ts
index 8e050e4..d1150b6 100644
--- a/src/compiler/utils/elmUtils.ts
+++ b/src/compiler/utils/elmUtils.ts
@@ -42,7 +42,7 @@ export function execCmdSync(
     });
   } catch (error) {
     connection.console.warn(JSON.stringify(error));
-    if (error.code && error.code === "ENOENT") {
+    if (error.errno && error.errno === "ENOENT") {
       connection.window.showErrorMessage(
         options.notFoundText
           ? options.notFoundText + ` I'm looking for '${cmd}' at '${cwd}'`
diff --git a/src/compiler/utils/expressionTree.ts b/src/compiler/utils/expressionTree.ts
index 7da959c..4c3a23b 100644
--- a/src/compiler/utils/expressionTree.ts
+++ b/src/compiler/utils/expressionTree.ts
@@ -664,8 +664,7 @@ export function mapSyntaxNodeToExpression(
           node,
         )
           ?.map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined)
-          .filter((pattern) => pattern.text !== "") as ELowerPattern[];
+          .filter(Utils.notUndefined) as ELowerPattern[];
         return recordPattern;
       }
 
diff --git a/src/providers/codeAction/addMissingUnionType.ts b/src/providers/codeAction/addMissingUnionType.ts
new file mode 100644
index 0000000..28f9565
--- /dev/null
+++ b/src/providers/codeAction/addMissingUnionType.ts
@@ -0,0 +1,103 @@
+import { Range, TextEdit } from "vscode-languageserver";
+import { CodeActionProvider, ICodeAction } from "..";
+import { RefactorEditUtils } from "../../util/refactorEditUtils";
+import { TreeUtils } from "../../util/treeUtils";
+import { Diagnostics } from "../../compiler/diagnostics";
+import { Type } from "../../compiler/typeInference";
+import { ICodeActionParams } from "../paramsExtensions";
+import { Utils } from "../../util/utils";
+
+const errorCodes = [Diagnostics.MissingValue.code];
+const fixId = "add_missing_union_type";
+
+CodeActionProvider.registerCodeAction({
+  errorCodes,
+  fixId,
+  getCodeActions: (params: ICodeActionParams) => {
+    const edits = getEdits(params, params.range);
+
+    if (Object.values(edits).some((edit) => edit.length > 0)) {
+      return [
+        CodeActionProvider.getCodeAction(
+          params,
+          `Create missing union constructor`,
+          edits,
+        ),
+      ];
+    }
+
+    return [];
+  },
+  getFixAllCodeAction: (params: ICodeActionParams): ICodeAction | undefined => {
+    return CodeActionProvider.getFixAllCodeAction(
+      "Create all missing union constructors",
+      params,
+      errorCodes,
+      fixId,
+      () => {
+        // Not used
+      },
+      (changes, diagnostic) => {
+        Utils.mergeChanges(changes, getEdits(params, diagnostic.range));
+      },
+    );
+  },
+});
+
+function getEdits(
+  params: ICodeActionParams,
+  range: Range,
+): { [uri: string]: TextEdit[] } {
+  const nodeAtPosition = TreeUtils.getNamedDescendantForRange(
+    params.sourceFile,
+    range,
+  );
+
+  const nodeParent = nodeAtPosition.parent;
+  if (
+    nodeAtPosition.type === "upper_case_identifier" &&
+    nodeParent?.type === "upper_case_qid"
+  ) {
+    const checker = params.program.getTypeChecker();
+
+    let type = checker.findType(nodeAtPosition);
+
+    let paramTypes: Type[] = [];
+
+    if (type.nodeType === "Function" && type.return.nodeType === "Union") {
+      paramTypes = type.params;
+      type = type.return;
+    }
+
+    if (type.nodeType === "Union") {
+      const typeDeclaration =
+        type.module === params.sourceFile.moduleName
+          ? TreeUtils.findTypeDeclaration(params.sourceFile.tree, type.name)
+          : checker
+              .getAllImports(params.sourceFile)
+              .getType(type.name, type.module)[0]?.node;
+
+      if (typeDeclaration) {
+        const typeDeclarationSourceFile = params.program.getSourceFile(
+          typeDeclaration.tree.uri,
+        );
+
+        const edit = RefactorEditUtils.addUnionVariant(
+          typeDeclaration,
+          nodeAtPosition.text,
+          paramTypes.map((type) =>
+            checker.typeToString(type, typeDeclarationSourceFile),
+          ),
+        );
+
+        if (edit) {
+          return {
+            [typeDeclaration.tree.uri]: [edit],
+          };
+        }
+      }
+    }
+  }
+
+  return {};
+}
diff --git a/src/providers/codeAction/index.ts b/src/providers/codeAction/index.ts
index 80599e1..8066d51 100644
--- a/src/providers/codeAction/index.ts
+++ b/src/providers/codeAction/index.ts
@@ -9,3 +9,4 @@ import "./installPackageCodeAction";
 import "./addMissingRecordFieldCodeAction";
 import "./addMissingCaseBranchesCodeAction";
 import "./elmReviewFixAction";
+import "./addMissingUnionType";
diff --git a/src/util/refactorEditUtils.ts b/src/util/refactorEditUtils.ts
index 5fa3930..3dcc68f 100644
--- a/src/util/refactorEditUtils.ts
+++ b/src/util/refactorEditUtils.ts
@@ -486,6 +486,31 @@ export class RefactorEditUtils {
     );
   }
 
+  public static addUnionVariant(
+    typeDeclaration: SyntaxNode,
+    name: string,
+    params: string[],
+  ): TextEdit | undefined {
+    const lastUnionVariant = typeDeclaration.lastNamedChild;
+
+    if (lastUnionVariant) {
+      // Get the '|' unnamed node
+      const spaces = getSpaces(
+        lastUnionVariant.previousSibling?.startPosition.column ??
+          lastUnionVariant.startPosition.column,
+      );
+      return TextEdit.insert(
+        Position.create(
+          lastUnionVariant.endPosition.row,
+          lastUnionVariant.endPosition.column,
+        ),
+        `\n${spaces}| ${name}${params.length > 0 ? " " : ""}${params
+          .map((param) => (param.includes(" ") ? `(${param})` : param))
+          .join(" ")}`,
+      );
+    }
+  }
+
   private static removeValueFromExposingList(
     exposedNodes: SyntaxNode[],
     valueName: string,


Test patch:
diff --git a/test/codeActionTests/addMissingUnionType.test.ts b/test/codeActionTests/addMissingUnionType.test.ts
new file mode 100644
index 0000000..3bd1e61
--- /dev/null
+++ b/test/codeActionTests/addMissingUnionType.test.ts
@@ -0,0 +1,206 @@
+import { testCodeAction } from "./codeActionTestBase";
+
+describe("add missing union type code action", () => {
+  it("should work in the same file", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onClick)
+
+type Msg
+    = Msg1
+    | Msg2
+
+view : Html Msg
+view =
+    div [ onClick NewMsg ] []
+                  --^
+    `;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onClick)
+
+type Msg
+    = Msg1
+    | Msg2
+    | NewMsg
+
+view : Html Msg
+view =
+    div [ onClick NewMsg ] []
+    `;
+
+    await testCodeAction(
+      htmlSources + source,
+      [{ title: "Create missing union constructor" }],
+      expectedSource,
+    );
+  });
+
+  it("should work in another file", async () => {
+    const source = `
+--@ Module.elm
+module Module exposing (..)
+
+type Msg
+    = Msg1
+    | Msg2
+
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onClick)
+
+import Module exposing (Msg(..))
+
+view : Html Msg
+view =
+    div [ onClick NewMsg ] []
+                  --^
+    `;
+
+    const expectedSource = `
+--@ Module.elm
+module Module exposing (..)
+
+type Msg
+    = Msg1
+    | Msg2
+    | NewMsg
+    `;
+
+    await testCodeAction(
+      htmlSources + source,
+      [{ title: "Create missing union constructor" }],
+      expectedSource,
+    );
+  });
+
+  it("should work with multiple parameters", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onInput)
+
+type Msg
+    = Msg1
+    | Msg2
+
+view : Html Msg
+view =
+    div [ onInput NewMsg ] []
+                  --^
+    `;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onInput)
+
+type Msg
+    = Msg1
+    | Msg2
+    | NewMsg Int
+
+view : Html Msg
+view =
+    div [ onInput NewMsg ] []
+    `;
+
+    await testCodeAction(
+      htmlSources + source,
+      [{ title: "Create missing union constructor" }],
+      expectedSource,
+    );
+  });
+
+  it("should work with only one Msg", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onClick)
+
+type Msg
+    = Msg1
+
+view : Html Msg
+view =
+    div [ onClick NewMsg ] []
+                  --^
+    `;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+import Html exposing (Html, div)
+import Html.Events exposing (onClick)
+
+type Msg
+    = Msg1
+    | NewMsg
+
+view : Html Msg
+view =
+    div [ onClick NewMsg ] []
+    `;
+
+    await testCodeAction(
+      htmlSources + source,
+      [{ title: "Create missing union constructor" }],
+      expectedSource,
+    );
+  });
+});
+
+const htmlSources = `
+--@ VirtualDom.elm
+module VirtualDom exposing (..)
+
+type Node msg = Node
+
+type Attribute msg = Attribute
+
+
+--@ Html.elm
+module Html exposing (..)
+
+import VirtualDom
+
+type alias Html msg = VirtualDom.Node msg
+
+type alias Attribute msg = VirtualDom.Attribute msg
+
+div : List (Attribute msg) -> List (Html msg) -> Html msg
+div =
+  Elm.Kernel.VirtualDom.node "div"
+
+
+--@ Html/Events.elm
+module Html.Events exposing (..)
+
+import Html exposing (Attribute)
+
+onClick : msg -> Attribute msg
+onClick msg =
+  on "click" (Json.succeed msg)
+
+onInput : (Int -> msg) -> Attribute msg
+onInput tagger =
+  stopPropagationOn "input" (Json.map alwaysStop (Json.map tagger targetValue))
+
+
+`;
diff --git a/test/codeActionTests/codeActionTestBase.ts b/test/codeActionTests/codeActionTestBase.ts
index f15d856..067b4de 100644
--- a/test/codeActionTests/codeActionTestBase.ts
+++ b/test/codeActionTests/codeActionTestBase.ts
@@ -147,7 +147,7 @@ export async function testCodeAction(
       codeActions[testFixAll ? codeActions.length - 1 : 0].edit!.changes!;
 
     Object.entries(expectedSources).forEach(([uri, source]) => {
-      const edits = changesToApply[URI.file(srcUri + uri).toString()];
+      const edits = changesToApply[URI.file(path.join(srcUri, uri)).toString()];
       if (edits) {
         expect(
           applyEditsToSource(stripCommentLines(result.sources[uri]), edits),
diff --git a/test/diagnosticTests/elmDiagnostics.test.ts b/test/diagnosticTests/elmDiagnostics.test.ts
index 6bccf47..103a346 100644
--- a/test/diagnosticTests/elmDiagnostics.test.ts
+++ b/test/diagnosticTests/elmDiagnostics.test.ts
@@ -1479,18 +1479,6 @@ func result =
         Nothing ->
             ""
 
-  `;
-      await testTypeInference(basicsSources + source, [], true);
-    });
-
-    it("empty record pattern should not have an error", async () => {
-      const source = `
---@ Test.elm
-module Test exposing (..)
-
-f : { a : Int } -> Int
-f {} =
-    5
   `;
       await testTypeInference(basicsSources + source, [], true);
     });