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

Issue:
Auto import completions
Following up from #230, there should also be completion suggestions for possible imports. On selected, it should automatically add the import. 


Pull request:
Add completions for possible imports
An WIP for adding completions for possible imports. 

### Update
This works fairly well now. By doing some custom filtering and limiting the completion items to 50 at a time and marking it as incomplete if there are still more, I was able to increase the performance and make it usable in a large workspace.

### Still to do:
- Add some tests

Closes #241 

Patch:
diff --git a/.prettierrc.yaml b/.prettierrc.yaml
index 1c999c2..9d4a7dc 100644
--- a/.prettierrc.yaml
+++ b/.prettierrc.yaml
@@ -1,1 +1,2 @@
-trailingComma: "all"
\ No newline at end of file
+trailingComma: "all"
+endOfLine: "auto"
diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts
index d9fb72c..3e47884 100644
--- a/src/providers/completionProvider.ts
+++ b/src/providers/completionProvider.ts
@@ -8,6 +8,7 @@ import {
   Position,
   Range,
   TextEdit,
+  CompletionList,
 } from "vscode-languageserver";
 import { URI } from "vscode-uri";
 import { SyntaxNode, Tree } from "web-tree-sitter";
@@ -19,8 +20,14 @@ import { ElmWorkspaceMatcher } from "../util/elmWorkspaceMatcher";
 import { HintHelper } from "../util/hintHelper";
 import { TreeUtils } from "../util/treeUtils";
 import RANKING_LIST from "./ranking";
+import { ImportUtils } from "../util/importUtils";
+import { RefactorEditUtils } from "../util/refactorEditUtils";
 
-export type CompletionResult = CompletionItem[] | null | undefined;
+export type CompletionResult =
+  | CompletionItem[]
+  | CompletionList
+  | null
+  | undefined;
 
 export class CompletionProvider {
   private qidRegex = /[a-zA-Z0-9.]+/;
@@ -221,7 +228,20 @@ export class CompletionProvider {
       completions.push(...this.createSnippets());
       completions.push(...this.getKeywords());
 
-      return completions;
+      const possibleImportCompletions = this.getPossibleImports(
+        replaceRange,
+        forest,
+        tree,
+        params.textDocument.uri,
+        nodeAtPosition.text,
+      );
+
+      completions.push(...possibleImportCompletions.list);
+
+      return {
+        items: completions,
+        isIncomplete: possibleImportCompletions.isIncomplete,
+      };
     }
   };
 
@@ -383,7 +403,7 @@ export class CompletionProvider {
   private getSameFileTopLevelCompletions(
     tree: Tree,
     range: Range,
-    moduleDefinition: boolean = false,
+    moduleDefinition = false,
   ): CompletionItem[] {
     const completions: CompletionItem[] = [];
     const topLevelFunctions = TreeUtils.findAllTopLeverFunctionDeclarations(
@@ -542,6 +562,8 @@ export class CompletionProvider {
     label: string,
     range: Range,
     sortPrefix: string,
+    detail?: string,
+    addImportTextEdit?: TextEdit,
   ): CompletionItem {
     return this.createCompletion(
       markdownDocumentation,
@@ -549,6 +571,8 @@ export class CompletionProvider {
       label,
       range,
       sortPrefix,
+      detail,
+      addImportTextEdit ? [addImportTextEdit] : undefined,
     );
   }
 
@@ -570,6 +594,8 @@ export class CompletionProvider {
     label: string,
     range: Range,
     sortPrefix: string,
+    detail?: string,
+    addImportTextEdit?: TextEdit,
   ): CompletionItem {
     return this.createCompletion(
       markdownDocumentation,
@@ -577,6 +603,8 @@ export class CompletionProvider {
       label,
       range,
       sortPrefix,
+      detail,
+      addImportTextEdit ? [addImportTextEdit] : undefined,
     );
   }
 
@@ -585,6 +613,8 @@ export class CompletionProvider {
     label: string,
     range: Range,
     sortPrefix: string,
+    detail?: string,
+    addImportTextEdit?: TextEdit,
   ): CompletionItem {
     return this.createCompletion(
       markdownDocumentation,
@@ -592,6 +622,8 @@ export class CompletionProvider {
       label,
       range,
       sortPrefix,
+      detail,
+      addImportTextEdit ? [addImportTextEdit] : undefined,
     );
   }
 
@@ -644,6 +676,8 @@ export class CompletionProvider {
     label: string,
     range: Range,
     sortPrefix: string,
+    detail?: string,
+    additionalTextEdits?: TextEdit[],
   ): CompletionItem {
     return {
       documentation: markdownDocumentation
@@ -656,6 +690,8 @@ export class CompletionProvider {
       label,
       sortText: `${sortPrefix}_${label}`,
       textEdit: TextEdit.replace(range, label),
+      detail,
+      additionalTextEdits,
     };
   }
 
@@ -794,6 +830,81 @@ export class CompletionProvider {
     return result;
   }
 
+  private getPossibleImports(
+    range: Range,
+    forest: IForest,
+    tree: Tree,
+    uri: string,
+    filterText: string,
+  ): { list: CompletionItem[]; isIncomplete: boolean } {
+    const result: CompletionItem[] = [];
+    const possibleImports = ImportUtils.getPossibleImportsFiltered(
+      forest,
+      uri,
+      filterText,
+    ).filter(
+      (imp) =>
+        imp.value !== "view" &&
+        imp.value !== "init" &&
+        imp.value !== "update" &&
+        imp.value !== "subscriptions" &&
+        imp.value !== "Model" &&
+        imp.value !== "Msg",
+    );
+
+    let isIncomplete = false;
+    if (possibleImports.length > 50) {
+      isIncomplete = true;
+    }
+
+    possibleImports.splice(0, 49).forEach((possibleImport, i) => {
+      const documentation = HintHelper.createHint(possibleImport.node);
+      const detail = `Auto import from module '${possibleImport.module}'`;
+      const importTextEdit = RefactorEditUtils.addImport(
+        tree,
+        possibleImport.module,
+        possibleImport.valueToImport ?? possibleImport.value,
+      );
+
+      if (possibleImport.type === "Function") {
+        result.push(
+          this.createFunctionCompletion(
+            documentation,
+            possibleImport.value,
+            range,
+            `e${i}`,
+            detail,
+            importTextEdit,
+          ),
+        );
+      } else if (possibleImport.type === "TypeAlias") {
+        result.push(
+          this.createTypeAliasCompletion(
+            documentation,
+            possibleImport.value,
+            range,
+            `e${i}`,
+            detail,
+            importTextEdit,
+          ),
+        );
+      } else if (possibleImport.type === "Type") {
+        result.push(
+          this.createTypeCompletion(
+            documentation,
+            possibleImport.value,
+            range,
+            `e${i}`,
+            detail,
+            importTextEdit,
+          ),
+        );
+      }
+    });
+
+    return { list: result, isIncomplete };
+  }
+
   private createSnippet(
     label: string,
     snippetText: string | string[],
diff --git a/src/util/importUtils.ts b/src/util/importUtils.ts
index 4f4820e..10834b9 100644
--- a/src/util/importUtils.ts
+++ b/src/util/importUtils.ts
@@ -1,58 +1,127 @@
 import { IForest } from "../forest";
 import RANKING_LIST from "../providers/ranking";
-import { TreeUtils } from "./treeUtils";
+import { TreeUtils, NodeType } from "./treeUtils";
+import { SyntaxNode } from "web-tree-sitter";
+
+interface IPossibleImport {
+  module: string;
+  value: string;
+  type: NodeType;
+  node: SyntaxNode;
+  valueToImport?: string;
+  package?: string;
+}
 
 export class ImportUtils {
+  public static getPossibleImportsFiltered(
+    forest: IForest,
+    uri: string,
+    filterText: string,
+  ): IPossibleImport[] {
+    const currentTree = forest.getTree(uri);
+
+    if (currentTree) {
+      const allImportedValues = TreeUtils.getAllImportedValues(
+        forest,
+        currentTree,
+      );
+
+      // Filter out already imported values
+      // Then sort by startsWith filter text, then matches filter text
+      return this.getPossibleImports(forest, uri)
+        .filter(
+          (possibleImport) =>
+            !allImportedValues.find(
+              (importedValue) =>
+                importedValue.module === possibleImport.module &&
+                importedValue.value ===
+                  (possibleImport.valueToImport ?? possibleImport.value),
+            ),
+        )
+        .sort((a, b) => {
+          const aValue = (a.valueToImport ?? a.value).toLowerCase();
+          const bValue = (b.valueToImport ?? b.value).toLowerCase();
+
+          filterText = filterText.toLowerCase();
+
+          const aStartsWith = aValue.startsWith(filterText);
+          const bStartsWith = bValue.startsWith(filterText);
+
+          if (aStartsWith && !bStartsWith) {
+            return -1;
+          } else if (!aStartsWith && bStartsWith) {
+            return 1;
+          } else {
+            const regex = new RegExp(filterText);
+            const aMatches = regex.exec(aValue);
+            const bMatches = regex.exec(bValue);
+
+            if (aMatches && !bMatches) {
+              return -1;
+            } else if (!aMatches && bMatches) {
+              return 1;
+            } else {
+              return 0;
+            }
+          }
+        });
+    }
+
+    return [];
+  }
+
   public static getPossibleImports(
     forest: IForest,
     uri: string,
-  ): {
-    module: string;
-    value: string;
-    valueToImport?: string;
-    package?: string;
-  }[] {
+  ): IPossibleImport[] {
     const currentModule = forest.getByUri(uri)?.moduleName;
 
-    const exposedValues: {
-      module: string;
-      value: string;
-      valueToImport?: string;
-      package?: string;
-    }[] = [];
+    const exposedValues: IPossibleImport[] = [];
 
     // Find all exposed values that could be imported
     if (forest) {
-      forest.treeIndex.forEach((tree) => {
-        tree.exposing?.forEach((exposed) => {
-          const module = tree.moduleName;
-          if (module) {
-            exposedValues.push({
-              module,
-              value: exposed.name,
-              package: tree.maintainerAndPackageName,
-            });
-
-            exposed.exposedUnionConstructors?.forEach((exp) => {
-              if (exp.syntaxNode.parent) {
-                const value = TreeUtils.findFirstNamedChildOfType(
-                  "upper_case_identifier",
-                  exp.syntaxNode.parent,
-                )?.text;
-
-                if (value) {
-                  exposedValues.push({
-                    module,
-                    value: exp.name,
-                    valueToImport: `${value}(..)`,
-                    package: tree.maintainerAndPackageName,
-                  });
+      forest.treeIndex
+        .filter(
+          (tree) =>
+            tree.moduleName !== "Basics" &&
+            tree.moduleName !== "Debug" &&
+            tree.moduleName !== "Tuple" &&
+            tree.uri !== uri,
+        )
+        .forEach((tree) => {
+          tree.exposing?.forEach((exposed) => {
+            const module = tree.moduleName;
+            if (module) {
+              exposedValues.push({
+                module,
+                value: exposed.name,
+                package: tree.maintainerAndPackageName,
+                type: exposed.type,
+                node: exposed.syntaxNode,
+              });
+
+              exposed.exposedUnionConstructors?.forEach((exp) => {
+                if (exp.syntaxNode.parent) {
+                  const value = TreeUtils.findFirstNamedChildOfType(
+                    "upper_case_identifier",
+                    exp.syntaxNode.parent,
+                  )?.text;
+
+                  if (value) {
+                    exposedValues.push({
+                      module,
+                      value: exp.name,
+                      valueToImport: `${value}(..)`,
+                      package: tree.maintainerAndPackageName,
+                      type: "UnionConstructor",
+                      node: exp.syntaxNode,
+                    });
+                  }
                 }
-              }
-            });
-          }
+              });
+            }
+          });
         });
-      });
     }
 
     const ranking = RANKING_LIST as {
diff --git a/src/util/treeUtils.ts b/src/util/treeUtils.ts
index 70350f2..0ba4441 100644
--- a/src/util/treeUtils.ts
+++ b/src/util/treeUtils.ts
@@ -1222,6 +1222,65 @@ export class TreeUtils {
     }
   }
 
+  public static getAllImportedValues(
+    forest: IForest,
+    tree: Tree,
+  ): { module: string; value: string }[] {
+    const allImports = this.findAllImportNameNodes(tree);
+
+    const allImportedValues: { module: string; value: string }[] = [];
+
+    if (allImports) {
+      allImports.forEach((importClause) => {
+        const exposingList = TreeUtils.findFirstNamedChildOfType(
+          "exposing_list",
+          importClause,
+        );
+
+        const moduleName = TreeUtils.findFirstNamedChildOfType(
+          "upper_case_qid",
+          importClause,
+        )?.text;
+
+        if (exposingList && moduleName) {
+          TreeUtils.findAllNamedChildrenOfType(
+            ["exposed_value", "exposed_type"],
+            exposingList,
+          )?.forEach((node) => {
+            allImportedValues.push({
+              module: moduleName,
+              value: node.text,
+            });
+            // Todo: Add exposing union constructors
+          });
+
+          // Handle all imports
+          if (exposingList.text === "exposing (..)") {
+            const moduleTree = forest.treeIndex.find(
+              (tree) => tree.moduleName === moduleName,
+            );
+
+            moduleTree?.exposing?.forEach((exposed) => {
+              allImportedValues.push({
+                module: moduleName,
+                value: exposed.name,
+              });
+
+              exposed.exposedUnionConstructors?.forEach((exposedUnion) => {
+                allImportedValues.push({
+                  module: moduleName,
+                  value: exposedUnion.name,
+                });
+              });
+            });
+          }
+        }
+      });
+    }
+
+    return allImportedValues;
+  }
+
   public static findImportNameNode(
     tree: Tree,
     moduleName: string,


Test patch:
diff --git a/src/test/completionProvider.test.ts b/src/test/completionProvider.test.ts
index 3978329..93fda2d 100644
--- a/src/test/completionProvider.test.ts
+++ b/src/test/completionProvider.test.ts
@@ -3,6 +3,9 @@ import {
   CompletionParams,
   CompletionContext,
   IConnection,
+  CompletionItem,
+  Position,
+  TextEdit,
 } from "vscode-languageserver";
 import { IElmWorkspace } from "../elmWorkspace";
 import { SourceTreeParser } from "./utils/sourceTreeParser";
@@ -36,7 +39,7 @@ describe("CompletionProvider", () => {
    */
   async function testCompletions(
     source: string,
-    expectedCompletions: string[],
+    expectedCompletions: (string | CompletionItem)[],
     testExactCompletions?: boolean,
     testDotCompletion?: boolean,
   ) {
@@ -61,16 +64,36 @@ describe("CompletionProvider", () => {
           treeParser.getWorkspace(newSources),
         ) ?? [];
 
+      const completionsList = Array.isArray(completions)
+        ? completions
+        : completions.items;
+
       if (testExactCompletions) {
-        expect(completions.length).toBe(expectedCompletions.length);
+        expect(completionsList.length).toBe(expectedCompletions.length);
       } else {
-        expect(completions.length).toBeGreaterThanOrEqual(
+        expect(completionsList.length).toBeGreaterThanOrEqual(
           expectedCompletions.length,
         );
       }
 
       expectedCompletions.forEach((completion) => {
-        expect(completions.find((c) => c.label === completion)).toBeTruthy();
+        expect(
+          completionsList.find((c) => {
+            if (typeof completion === "string") {
+              return c.label === completion;
+            } else {
+              // Compare label, detail, and text edit text
+              return (
+                c.label === completion.label &&
+                c.detail === completion.detail &&
+                c.additionalTextEdits &&
+                completion.additionalTextEdits &&
+                c.additionalTextEdits[0].newText ===
+                  completion.additionalTextEdits[0].newText
+              );
+            }
+          }),
+        ).toBeTruthy();
       });
     }
 
@@ -456,4 +479,94 @@ func msg =
 
     await testCompletions(source, ["str"]);
   });
+
+  it("There should be auto import completions from other modules", async () => {
+    const source = `
+--@ OtherModule.elm
+module OtherModule exposing (testFunction, TestType)
+
+testFunction : String
+testFunction =
+  ""
+
+type alias TestType = {prop : String}
+
+--@ Test.elm
+module Test exposing (..)
+
+func : String
+func = 
+  {-caret-}
+`;
+
+    await testCompletions(source, [
+      {
+        label: "testFunction",
+        detail: "Auto import from module 'OtherModule'",
+        additionalTextEdits: [
+          TextEdit.insert(
+            Position.create(2, 0),
+            "import OtherModule exposing (testFunction)\n",
+          ),
+        ],
+      },
+      {
+        label: "TestType",
+        detail: "Auto import from module 'OtherModule'",
+        additionalTextEdits: [
+          TextEdit.insert(
+            Position.create(2, 0),
+            "import OtherModule exposing (TestType)\n",
+          ),
+        ],
+      },
+    ]);
+
+    const source2 = `
+--@ OtherModule.elm
+module OtherModule exposing (..)
+
+type Msg = Msg1 | Msg2
+
+--@ Test.elm
+module Test exposing (..)
+
+func : String
+func = 
+  {-caret-}
+`;
+
+    // await testCompletions(source2, [
+    //   {
+    //     label: "Msg",
+    //     detail: "Auto import from module 'OtherModule'",
+    //     additionalTextEdits: [
+    //       TextEdit.insert(
+    //         Position.create(2, 0),
+    //         "import OtherModule exposing (Msg)\n",
+    //       ),
+    //     ],
+    //   },
+    //   {
+    //     label: "Msg1",
+    //     detail: "Auto import from module 'OtherModule'",
+    //     additionalTextEdits: [
+    //       TextEdit.insert(
+    //         Position.create(2, 0),
+    //         "import OtherModule exposing (Msg(..))\n",
+    //       ),
+    //     ],
+    //   },
+    //   {
+    //     label: "Msg2",
+    //     detail: "Auto import from module 'OtherModule'",
+    //     additionalTextEdits: [
+    //       TextEdit.insert(
+    //         Position.create(2, 0),
+    //         "import OtherModule exposing (Msg(..))\n",
+    //       ),
+    //     ],
+    //   },
+    // ]);
+  });
 });