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

Issue:
Add code action to extract type alias

Pull request description:
Add a code action for extracting a type alias
Closes #474. 

Patch:
diff --git a/README.md b/README.md
index 44dea86..dc22fc8 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ This is the language server implementation for the Elm programming language.
 
 Note for VSCode users: The [plugin](https://github.com/elm-tooling/elm-language-client-vscode) contains the language-server. No installation necessary.
 
-The server can be installed via `npm` (or from source). Please make sure to use **node 16**.
+The server can be installed via `npm` (or from source).
 
 ```sh
 npm install -g @elm-tooling/elm-language-server
diff --git a/src/providers/codeAction/extractFunctionCodeAction.ts b/src/providers/codeAction/extractFunctionCodeAction.ts
index 8f083f0..1a9cb6e 100644
--- a/src/providers/codeAction/extractFunctionCodeAction.ts
+++ b/src/providers/codeAction/extractFunctionCodeAction.ts
@@ -22,6 +22,7 @@ CodeActionProvider.registerRefactorAction(refactorName, {
 
     if (
       node.type.includes("expr") &&
+      node.type !== "type_expression" &&
       node.startPosition.column === params.range.start.character &&
       node.startPosition.row === params.range.start.line &&
       node.endPosition.column === params.range.end.character &&
diff --git a/src/providers/codeAction/extractTypeAliasCodeAction.ts b/src/providers/codeAction/extractTypeAliasCodeAction.ts
new file mode 100644
index 0000000..e312f68
--- /dev/null
+++ b/src/providers/codeAction/extractTypeAliasCodeAction.ts
@@ -0,0 +1,222 @@
+import { CodeActionKind, Position, TextEdit } from "vscode-languageserver";
+import { SyntaxNode } from "web-tree-sitter";
+import { RefactorEditUtils } from "../../util/refactorEditUtils";
+import { TreeUtils } from "../../util/treeUtils";
+import {
+  CodeActionProvider,
+  IRefactorCodeAction,
+  IRefactorEdit,
+} from "../codeActionProvider";
+import { ICodeActionParams } from "../paramsExtensions";
+
+const refactorName = "extract_type_alias";
+CodeActionProvider.registerRefactorAction(refactorName, {
+  getAvailableActions: (params: ICodeActionParams): IRefactorCodeAction[] => {
+    const result: IRefactorCodeAction[] = [];
+
+    const node = TreeUtils.getNamedDescendantForRange(
+      params.sourceFile,
+      params.range,
+    );
+
+    let canExtract =
+      node.type.includes("type") &&
+      node.startPosition.column === params.range.start.character &&
+      node.startPosition.row === params.range.start.line &&
+      node.endPosition.column === params.range.end.character &&
+      node.endPosition.row === params.range.end.line;
+
+    let actionName = "extract_type_alias";
+
+    const rootNode = params.sourceFile.tree.rootNode;
+    let startNode = node;
+    let endNode = node;
+    if (!canExtract) {
+      startNode = TreeUtils.getDescendantForPosition(
+        rootNode,
+        params.range.start,
+      );
+
+      const previousCharColumn =
+        params.range.end.character === 0 ? 0 : params.range.end.character - 1;
+      const charBeforeCursor = rootNode.text
+        .split("\n")
+        [params.range.end.line].substring(
+          previousCharColumn,
+          params.range.end.character,
+        );
+
+      if (charBeforeCursor === ")") {
+        const endNode = rootNode.descendantForPosition({
+          row: params.range.end.line,
+          column: previousCharColumn,
+        });
+
+        if (startNode.type === "(" && endNode.type === ")") {
+          const node = startNode.nextNamedSibling;
+          canExtract =
+            !!node &&
+            node.id == endNode.previousNamedSibling?.id &&
+            node.type.includes("type");
+          actionName = "extract_type_alias_parenthesized_expr";
+        }
+      }
+    }
+
+    if (!canExtract) {
+      endNode = TreeUtils.getDescendantForPosition(rootNode, params.range.end);
+
+      // Try to see if they are spanning multiple parameters of a function
+      const startTypeRef = TreeUtils.findParentOfType("type_ref", startNode);
+      const endTypeRef = TreeUtils.findParentOfType("type_ref", endNode);
+
+      if (startTypeRef && endTypeRef) {
+        const startTypeExpr = TreeUtils.findParentOfType(
+          "type_expression",
+          startTypeRef,
+        );
+        const endTypeExpr = TreeUtils.findParentOfType(
+          "type_expression",
+          endTypeRef,
+        );
+
+        // They must be from the same type expression
+        if (
+          startTypeExpr &&
+          endTypeExpr &&
+          startTypeExpr.id === endTypeExpr.id
+        ) {
+          canExtract =
+            startTypeRef.startPosition.column ===
+              params.range.start.character &&
+            startTypeRef.startPosition.row === params.range.start.line &&
+            endTypeRef.endPosition.column === params.range.end.character &&
+            endTypeRef.endPosition.row === params.range.end.line;
+          actionName = "extract_type_alias_partial_type_expr";
+        }
+      }
+    }
+
+    if (canExtract) {
+      result.push({
+        title: "Extract type alias",
+        kind: CodeActionKind.RefactorExtract,
+        data: {
+          actionName,
+          refactorName,
+          uri: params.sourceFile.uri,
+          range: params.range,
+        },
+      });
+    }
+
+    return result;
+  },
+  getEditsForAction: (
+    params: ICodeActionParams,
+    action: string,
+  ): IRefactorEdit => {
+    const edits: TextEdit[] = [];
+
+    const nodes: SyntaxNode[] = [];
+    if (action === "extract_type_alias_partial_type_expr") {
+      const startNode = TreeUtils.getNamedDescendantForPosition(
+        params.sourceFile.tree.rootNode,
+        params.range.start,
+      );
+
+      const endNode = TreeUtils.getNamedDescendantForPosition(
+        params.sourceFile.tree.rootNode,
+        params.range.end,
+      );
+
+      const typeExpression = TreeUtils.findParentOfType(
+        "type_expression",
+        startNode,
+      );
+
+      typeExpression?.namedChildren
+        .filter(
+          (n) =>
+            n.type === "type_ref" &&
+            n.startIndex >= startNode.startIndex &&
+            n.endIndex <= endNode.endIndex,
+        )
+        .forEach((n) => nodes.push(n));
+    } else if (action === "extract_type_alias_parenthesized_expr") {
+      const exprNode = TreeUtils.getDescendantForPosition(
+        params.sourceFile.tree.rootNode,
+        params.range.start,
+      ).nextNamedSibling;
+
+      if (!exprNode) {
+        throw new Error(
+          "Could not find expression node of parenthisized expression",
+        );
+      }
+
+      nodes.push(exprNode);
+    } else {
+      nodes.push(
+        TreeUtils.getDescendantForRange(params.sourceFile, params.range),
+      );
+    }
+
+    const rootNode = params.sourceFile.tree.rootNode;
+
+    const insertPosition: Position = {
+      line:
+        RefactorEditUtils.findLineNumberBeforeCurrentFunction(nodes[0]) ??
+        rootNode.endPosition.row,
+      character: 0,
+    };
+
+    const args: string[] = [];
+
+    nodes.forEach((node) =>
+      node.descendantsOfType(["type_variable"]).forEach((val) => {
+        if (!args.includes(val.text)) {
+          args.push(val.text);
+        }
+      }),
+    );
+
+    const typeText = nodes.map((n) => n.text).join(" -> ");
+
+    edits.push(
+      RefactorEditUtils.createTypeAlias(
+        insertPosition.line,
+        "NewType",
+        typeText,
+        args,
+      ),
+    );
+
+    let textToInsert =
+      args.length > 0 ? `NewType ${args.join(" ")}` : `NewType`;
+
+    const needsParenthesis =
+      action === "extract_type_alias_parenthesized_expr" && args.length > 0;
+    if (needsParenthesis) {
+      textToInsert = `(${textToInsert})`;
+    }
+
+    edits.push(TextEdit.replace(params.range, textToInsert));
+
+    // Check if we are adding the function before the current range and adjust the rename position
+    const linesAdded =
+      edits[0].range.start.line < params.range.start.line
+        ? edits[0].newText.split("\n").length - 1
+        : 0;
+
+    return {
+      edits,
+      renamePosition: {
+        line: params.range.start.line + linesAdded,
+        character: needsParenthesis
+          ? params.range.start.character + 1
+          : params.range.start.character,
+      },
+    };
+  },
+});
diff --git a/src/providers/codeAction/index.ts b/src/providers/codeAction/index.ts
index 930dbf1..2d0fb9a 100644
--- a/src/providers/codeAction/index.ts
+++ b/src/providers/codeAction/index.ts
@@ -3,6 +3,7 @@ import "./makeDeclarationFromUsageCodeAction";
 import "./makeExternalDeclarationFromUsageCodeAction";
 import "./addTypeAnnotationCodeAction";
 import "./extractFunctionCodeAction";
+import "./extractTypeAliasCodeAction";
 import "./exposeUnexposeCodeAction";
 import "./moveFunctionCodeAction";
 import "./installPackageCodeAction";
diff --git a/src/util/refactorEditUtils.ts b/src/util/refactorEditUtils.ts
index b3f50e5..28acbcc 100644
--- a/src/util/refactorEditUtils.ts
+++ b/src/util/refactorEditUtils.ts
@@ -16,6 +16,21 @@ export class RefactorEditUtils {
 
     return this.findLineNumberAfterCurrentFunction(nodeAtPosition.parent);
   }
+
+  public static findLineNumberBeforeCurrentFunction(
+    nodeAtPosition: SyntaxNode,
+  ): number | undefined {
+    if (!nodeAtPosition.parent) {
+      return undefined;
+    }
+
+    if (nodeAtPosition.parent?.type === "file") {
+      return nodeAtPosition.startPosition.row - 1;
+    }
+
+    return this.findLineNumberBeforeCurrentFunction(nodeAtPosition.parent);
+  }
+
   public static unexposedValueInModule(
     tree: Tree,
     valueName: string,
@@ -98,6 +113,20 @@ export class RefactorEditUtils {
     }
   }
 
+  public static createTypeAlias(
+    insertLineNumber: number,
+    aliasName: string,
+    typeString: string,
+    typeVariables: string[],
+  ): TextEdit {
+    const typeVariablesString =
+      typeVariables.length > 0 ? ` ${typeVariables.join(" ")}` : "";
+    return TextEdit.insert(
+      Position.create(insertLineNumber, 0),
+      `\ntype alias ${aliasName}${typeVariablesString} = ${typeString}\n\n`,
+    );
+  }
+
   private static argListFromArity(arity: number): string {
     return [...Array(arity).keys()].map((a) => `arg${a + 1}`).join(" ");
   }
diff --git a/src/util/treeUtils.ts b/src/util/treeUtils.ts
index e08e827..8e581a2 100644
--- a/src/util/treeUtils.ts
+++ b/src/util/treeUtils.ts
@@ -601,6 +601,35 @@ export class TreeUtils {
     }
   }
 
+  public static getDescendantForPosition(
+    node: SyntaxNode,
+    position: Position,
+  ): SyntaxNode {
+    const previousCharColumn =
+      position.character === 0 ? 0 : position.character - 1;
+    const charBeforeCursor = node.text
+      .split("\n")
+      [position.line].substring(previousCharColumn, position.character);
+
+    if (!functionNameRegex.test(charBeforeCursor)) {
+      return node.descendantForPosition({
+        column: position.character,
+        row: position.line,
+      });
+    } else {
+      return node.descendantForPosition(
+        {
+          column: previousCharColumn,
+          row: position.line,
+        },
+        {
+          column: position.character,
+          row: position.line,
+        },
+      );
+    }
+  }
+
   public static getNamedDescendantForRange(
     sourceFile: ISourceFile,
     range: Range,
@@ -624,6 +653,29 @@ export class TreeUtils {
     }
   }
 
+  public static getDescendantForRange(
+    sourceFile: ISourceFile,
+    range: Range,
+  ): SyntaxNode {
+    if (positionEquals(range.start, range.end)) {
+      return this.getDescendantForPosition(sourceFile.tree.rootNode, {
+        character: range.start.character,
+        line: range.start.line,
+      });
+    } else {
+      return sourceFile.tree.rootNode.descendantForPosition(
+        {
+          column: range.start.character,
+          row: range.start.line,
+        },
+        {
+          column: range.end.character,
+          row: range.end.line,
+        },
+      );
+    }
+  }
+
   public static findPreviousNode(
     node: SyntaxNode,
     position: Position,


Test patch:
diff --git a/test/codeActionTests/extractFunctionCodeAction.test.ts b/test/codeActionTests/extractFunctionCodeAction.test.ts
index de8fdba..6bd8387 100644
--- a/test/codeActionTests/extractFunctionCodeAction.test.ts
+++ b/test/codeActionTests/extractFunctionCodeAction.test.ts
@@ -19,7 +19,7 @@ foo val str =
 
                 Nothing ->
                     field2 + val2
-                               --^
+                              --^
 
         Nothing ->
             str + val2
@@ -73,7 +73,7 @@ foo val str =
           --^
                 prop1 + prop2 + val + field2)
             field1
-                --^
+               --^
 
         Nothing ->
             str + val2
@@ -128,7 +128,7 @@ foo val str =
           --^
                 prop1 + prop2 + val + field2)
             field1
-                --^
+               --^
 
         Nothing ->
             str + val2
@@ -186,7 +186,7 @@ foo val =
   --^
         + val2 
         + Foo.val2
-                --^
+               --^
   
 `;
 
@@ -237,7 +237,7 @@ foo val str =
 
                 Nothing ->
                     field2 + val2
-                               --^
+                              --^
 
         Nothing ->
             str + val2
@@ -301,7 +301,7 @@ foo val str =
 
                 Nothing ->
                     field2 + val2
-                               --^
+                              --^
 
         Nothing ->
             str + val2
@@ -361,7 +361,7 @@ foo val str =
 
                 Nothing ->
                     field2 + val2
-                               --^
+                              --^
 
         Nothing ->
             str + val2
diff --git a/test/codeActionTests/extractTypeAliasCodeAction.test.ts b/test/codeActionTests/extractTypeAliasCodeAction.test.ts
new file mode 100644
index 0000000..a6101f2
--- /dev/null
+++ b/test/codeActionTests/extractTypeAliasCodeAction.test.ts
@@ -0,0 +1,457 @@
+import { testCodeAction } from "./codeActionTestBase";
+
+describe("extract type alias code action", () => {
+  it("should extract type alias of a record", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : Int, b : Float }
+          --^                  --^  
+foo = 
+    Nothing
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = { a : Int, b : Float }
+
+
+foo : Maybe NewType
+foo = 
+    Nothing
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a type and record", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : Int, b : Float }
+    --^                        --^  
+foo = 
+    Nothing
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float }
+
+
+foo : NewType
+foo = 
+    Nothing
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a type with other function arguments", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : Int, b : Float } -> Int -> String
+    --^                        --^  
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float }
+
+
+foo : NewType -> Int -> String
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a function", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : Int, b : Float } -> Int -> String
+    --^                                         --^  
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int -> String
+
+
+foo : NewType
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a partial type expression", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : Int, b : Float } -> Int -> String
+    --^                               --^  
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int
+
+
+foo : NewType -> String
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a partial type expression spanning multiple lines", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : 
+  Maybe { a : Int, b : Float }
+--^ 
+  -> Int 
+     --^
+  -> String
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int
+
+
+foo : 
+  NewType 
+  -> String
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias of a type with parenthesis", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : (Maybe { a : Int, b : Float } -> Int) -> String
+    --^                                 --^  
+foo f = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int
+
+
+foo : NewType -> String
+foo f = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+
+    const source2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : (Maybe { a : Int, b : Float } -> Int) -> String
+     --^                               --^  
+foo f = 
+    ""
+`;
+
+    const expectedSource2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int
+
+
+foo : (NewType) -> String
+foo f = 
+    ""
+`;
+
+    await testCodeAction(
+      source2,
+      [{ title: "Extract type alias" }],
+      expectedSource2,
+    );
+  });
+
+  it("should extract type alias of a type with parenthesis spanning multiple lines", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : (
+    --^
+  Maybe { a : Int, b : Float } -> Int
+  ) -> String
+--^
+foo f = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } -> Int
+
+
+foo : NewType -> String
+foo f = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+
+    const source2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : (
+  Maybe { a : Int, b : Float } 
+--^
+  -> Int
+     --^
+  ) -> String
+foo f = 
+    ""
+`;
+
+    const expectedSource2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType = Maybe { a : Int, b : Float } 
+  -> Int
+
+
+foo : (
+  NewType
+  ) -> String
+foo f = 
+    ""
+`;
+
+    await testCodeAction(
+      source2,
+      [{ title: "Extract type alias" }],
+      expectedSource2,
+    );
+  });
+
+  it("should extract type alias with args", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : a, b : b } -> a -> String
+    --^                                 --^  
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType a b = Maybe { a : a, b : b } -> a -> String
+
+
+foo : NewType a b
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias with args with existing args", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+foo : Maybe { a : comparable, b : b } -> comparable -> String
+    --^                           --^  
+foo a b = 
+    ""
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType comparable b = Maybe { a : comparable, b : b }
+
+
+foo : NewType comparable b -> comparable -> String
+foo a b = 
+    ""
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+  });
+
+  it("should extract type alias and add parenthesis if there was parenthesis and there are args", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type Type1 a = 
+    Type1 (a -> String) String
+        --^         --^
+`;
+
+    const expectedSource = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType a = a -> String
+
+
+type Type1 a = 
+    Type1 (NewType a) String
+`;
+
+    await testCodeAction(
+      source,
+      [{ title: "Extract type alias" }],
+      expectedSource,
+    );
+
+    const source2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type Type1 a = 
+    Type1 (a -> String) String
+         --^       --^
+`;
+
+    const expectedSource2 = `
+--@ Test.elm
+module Test exposing (..)
+
+
+type alias NewType a = a -> String
+
+
+type Type1 a = 
+    Type1 (NewType a) String
+`;
+
+    await testCodeAction(
+      source2,
+      [{ title: "Extract type alias" }],
+      expectedSource2,
+    );
+  });
+});
diff --git a/test/utils/sourceParser.ts b/test/utils/sourceParser.ts
index 22a4b4e..5f78d41 100644
--- a/test/utils/sourceParser.ts
+++ b/test/utils/sourceParser.ts
@@ -194,23 +194,31 @@ export function getTargetPositionFromSource(
   let startPosition: Position | undefined;
   let endPosition: Position | undefined;
 
-  for (const fileName in sources) {
-    sources[fileName].split("\n").forEach((s, line) => {
-      const invokeCharacter = s.search(/--(\^)/);
+  const parseLine = (s: string, line: number, startCharOffset = 0): void => {
+    const invokeCharacter = s.slice(startCharOffset).search(/--(\^)/);
 
-      if (invokeCharacter >= 0) {
-        if (!startPosition) {
-          startPosition = {
-            line: line - 1,
-            character: invokeCharacter + 2,
-          };
-        } else if (!endPosition) {
-          endPosition = {
-            line: line - 2,
-            character: invokeCharacter + 2,
-          };
-        }
+    if (invokeCharacter >= 0) {
+      const character = startCharOffset + invokeCharacter + 2;
+      const lineIndex = line - 1;
+
+      if (!startPosition) {
+        startPosition = {
+          line: lineIndex,
+          character,
+        };
+
+        parseLine(s, line, character);
+      } else if (!endPosition) {
+        endPosition = {
+          line: startPosition.line === lineIndex ? lineIndex : lineIndex - 1,
+          character: character + 1,
+        };
       }
+    }
+  };
+  for (const fileName in sources) {
+    sources[fileName].split("\n").forEach((s, line) => {
+      parseLine(s, line);
     });
   }