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

Issue:
Request: Offer alias imports in code actions for missing imports
When an import is missing, this plugin correctly flags the symbol that couldn't be resolved and when triggering a code action for the flagged error also correctly suggests to add a missing import or to add all missing imports.

## Expected Behavior
It would be very helpful if this tooltip could be extended to offer an option to import the missing module via an alias import.

## Current Behavior
The tooltip shown looks like the one in following image:
![grafik](https://user-images.githubusercontent.com/4807083/116428957-b2265b00-a845-11eb-9fda-79867902afa7.png)

## Possible Solution
Given an example like this (example includes highlighted errors):
```elm
module Test exposing (..)

value : Utils.Bem.Bem msg
--      ^^^^^^^^^^^^^
value = Utils.Bem.block "foo"
--      ^^^^^^^^^^^^^^^
```

the code action for the error in the last line should have an additional entry `Import module "Utils.Bem" as "Bem"`. When selected, the code action should alter the code in the following way:

```elm
module Test exposing (..)
import Utils.Bem as Bem

value : Bem.Bem msg     -- note that the previous full module name was replaced with the new alias
value = Bem.block "foo" -- note that the previous full module name was replaced with the new alias
```

## Context
The need to alias module names arises regularly when importing deeply nested modules.

## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
* Version used:
* Editor name and version (e.g. VSCode 1.36.1): 
* Environment name and version (e.g. node.js 5.4):
* Operating System and version:


Auto import qualified
## Expected Behavior
When activating quickfix through the vscode command "editor.action.quickFix",
while the cursor is hovering over a non-imported qualified import (such as `Attribute.none`),
I want to get auto-import suggestions for qualified imports (such as "Import Element.Attribute as Attribute").

## Current Behavior
No qualified import available on "quickFix"

## Possible Solution
1. Use pretty much the same logic as for unqualified imports to get the candidates for import
2. Give options to import with the "as [...]" postfix

## Context
Auto importa are nice, it would be lovely to have it for qualified imports as well


Pull request:
Auto imports and code actions for module aliases
Add auto import completions and code actions for module aliases. Closes #581, closes #663.

Patch:
diff --git a/src/providers/codeAction/importCodeAction.ts b/src/providers/codeAction/importCodeAction.ts
index 446baae..138db50 100644
--- a/src/providers/codeAction/importCodeAction.ts
+++ b/src/providers/codeAction/importCodeAction.ts
@@ -34,12 +34,14 @@ CodeActionProvider.registerCodeAction({
 
       const valueToImport = getValueToImport(valueNode, possibleImport);
 
+      const importAlias = edit?.importAlias ? ` as "${edit.importAlias}"` : "";
+
       return CodeActionProvider.getCodeAction(
         params,
         valueToImport
-          ? `Import '${valueToImport}' from module "${possibleImport.module}"`
-          : `Import module "${possibleImport.module}"`,
-        edit ? [edit] : [],
+          ? `Import '${valueToImport}' from module "${possibleImport.module}"${importAlias}`
+          : `Import module "${possibleImport.module}"${importAlias}`,
+        edit ? [edit.edit] : [],
       );
     });
   },
@@ -63,7 +65,7 @@ CodeActionProvider.registerCodeAction({
             params.sourceFile,
             diagnostic.range,
             firstPossibleImport,
-          );
+          )?.edit;
 
           if (edit && !edits.find((e) => e.newText === edit.newText)) {
             edits.push(edit);
@@ -93,19 +95,30 @@ function getPossibleImports(
 
   // Add import quick fixes
   if (valueNode) {
-    return possibleImports.filter(
-      (exposed) =>
-        exposed.value === valueNode.text ||
-        ((valueNode.type === "upper_case_qid" ||
-          valueNode.type === "value_qid") &&
-          exposed.value ===
-            valueNode.namedChildren[valueNode.namedChildren.length - 1].text &&
-          exposed.module ===
-            valueNode.namedChildren
-              .slice(0, valueNode.namedChildren.length - 2) // Dots are also namedNodes
-              .map((a) => a.text)
-              .join("")),
-    );
+    return possibleImports.filter((exposed) => {
+      if (exposed.value === valueNode.text) {
+        return true;
+      }
+
+      if (
+        valueNode.type === "upper_case_qid" ||
+        valueNode.type === "value_qid"
+      ) {
+        const targetValue =
+          valueNode.namedChildren[valueNode.namedChildren.length - 1].text;
+
+        const targetModule = getTargetModule(valueNode);
+
+        return (
+          exposed.value === targetValue &&
+          (targetModule.includes(".")
+            ? exposed.module === targetModule
+            : exposed.module.endsWith(targetModule))
+        );
+      }
+
+      return false;
+    });
   }
 
   return [];
@@ -115,14 +128,24 @@ function getEditFromPossibleImport(
   sourceFile: ISourceFile,
   range: Range,
   possibleImport: IPossibleImport,
-): TextEdit | undefined {
+): { edit: TextEdit; importAlias: string | undefined } | undefined {
   const valueNode = TreeUtils.getNamedDescendantForRange(sourceFile, range);
 
-  return RefactorEditUtils.addImport(
+  const targetModule = getTargetModule(valueNode);
+  const edit = RefactorEditUtils.addImport(
     sourceFile.tree,
     possibleImport.module,
     getValueToImport(valueNode, possibleImport),
+    targetModule,
   );
+
+  if (edit) {
+    return {
+      edit,
+      importAlias:
+        possibleImport.module !== targetModule ? targetModule : undefined,
+    };
+  }
 }
 
 function getValueToImport(
@@ -133,3 +156,10 @@ function getValueToImport(
     ? possibleImport.valueToImport ?? possibleImport.value
     : undefined;
 }
+
+function getTargetModule(valueNode: SyntaxNode): string {
+  return valueNode.namedChildren
+    .slice(0, valueNode.namedChildren.length - 2) // Dots are also namedNodes
+    .map((a) => a.text)
+    .join("");
+}
diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts
index 0d795c5..ec33596 100644
--- a/src/providers/completionProvider.ts
+++ b/src/providers/completionProvider.ts
@@ -1209,9 +1209,7 @@ export class CompletionProvider {
       return result;
     }
 
-    let alreadyImported = false;
-
-    const matchedSourceFiles: ISourceFile[] = [];
+    const matchedSourceFiles: [ISourceFile, boolean][] = [];
 
     const imports =
       sourceFile.symbolLinks
@@ -1226,32 +1224,57 @@ export class CompletionProvider {
             const moduleName =
               imp.node.childForFieldName("moduleName")?.text ?? "";
 
-            return program.getSourceFileOfImportableModule(
+            const importSourceFile = program.getSourceFileOfImportableModule(
               sourceFile,
               moduleName,
             );
+
+            if (importSourceFile) {
+              return [importSourceFile, true] as [ISourceFile, boolean];
+            }
+
+            return undefined;
           })
           .filter(Utils.notUndefined),
       );
-
-      alreadyImported = true;
     } else if (!checker.getAllImports(sourceFile).getModule(targetModule)) {
       // Try to find a module that may not be imported
-      const moduleSourceFile = program.getSourceFileOfImportableModule(
-        sourceFile,
-        targetModule,
-      );
+      // If it incudes a dot then we don't look for an alias, only an exact module
+      if (targetModule.includes(".")) {
+        const moduleSourceFile = program.getSourceFileOfImportableModule(
+          sourceFile,
+          targetModule,
+        );
 
-      if (moduleSourceFile) {
-        matchedSourceFiles.push(moduleSourceFile);
-        alreadyImported = false;
+        if (moduleSourceFile) {
+          matchedSourceFiles.push([moduleSourceFile, false]);
+        }
+      } else {
+        program
+          .getImportableModules(sourceFile)
+          .filter(
+            ({ moduleName }) =>
+              moduleName === targetModule ||
+              moduleName.endsWith(`.${targetModule}`),
+          )
+          .forEach((module) => {
+            const moduleSourceFile = program.getSourceFile(module.uri);
+
+            if (moduleSourceFile) {
+              matchedSourceFiles.push([moduleSourceFile, false]);
+            }
+          });
       }
     }
 
     // Get exposed values
     matchedSourceFiles
-      .flatMap(ImportUtils.getPossibleImportsOfTree.bind(this))
-      .forEach((value) => {
+      .flatMap(([importSourceFile, alreadyImported]) =>
+        ImportUtils.getPossibleImportsOfTree(importSourceFile).map(
+          (value) => [value, alreadyImported] as [IPossibleImport, boolean],
+        ),
+      )
+      .forEach(([value, alreadyImported]) => {
         const type = checker.findType(value.node);
         const typeString = checker.typeToString(type, sourceFile);
 
@@ -1264,11 +1287,18 @@ export class CompletionProvider {
 
         // Add the import text edit if not imported
         if (!alreadyImported) {
-          const importEdit = RefactorEditUtils.addImport(tree, targetModule);
+          const importEdit = RefactorEditUtils.addImport(
+            tree,
+            value.module,
+            undefined,
+            targetModule,
+          );
 
           if (importEdit) {
+            const aliasDetail =
+              targetModule !== value.module ? ` as '${targetModule}'` : "";
             additionalTextEdits = [importEdit];
-            detail = `Auto import module '${targetModule}'`;
+            detail = `Auto import module '${value.module}'${aliasDetail}`;
           }
         }
 
diff --git a/src/util/refactorEditUtils.ts b/src/util/refactorEditUtils.ts
index a620a6f..b3f50e5 100644
--- a/src/util/refactorEditUtils.ts
+++ b/src/util/refactorEditUtils.ts
@@ -206,12 +206,16 @@ export class RefactorEditUtils {
     tree: Tree,
     moduleName: string,
     valueName?: string,
+    moduleAlias?: string,
   ): TextEdit | undefined {
     const lastImportNode =
       TreeUtils.getLastImportNode(tree) ??
       TreeUtils.getModuleNameCommentNode(tree) ??
       TreeUtils.getModuleNameNode(tree)?.parent;
 
+    const aliasText =
+      moduleAlias && moduleAlias !== moduleName ? ` as ${moduleAlias}` : "";
+
     return TextEdit.insert(
       Position.create(
         lastImportNode?.endPosition.row
@@ -220,8 +224,8 @@ export class RefactorEditUtils {
         0,
       ),
       valueName
-        ? `import ${moduleName} exposing (${valueName})\n`
-        : `import ${moduleName}\n`,
+        ? `import ${moduleName}${aliasText} exposing (${valueName})\n`
+        : `import ${moduleName}${aliasText}\n`,
     );
   }
 


Test patch:
diff --git a/test/codeActionTests/importCodeAction.test.ts b/test/codeActionTests/importCodeAction.test.ts
index 0c1edbc..42b1088 100644
--- a/test/codeActionTests/importCodeAction.test.ts
+++ b/test/codeActionTests/importCodeAction.test.ts
@@ -47,6 +47,54 @@ foo = ""
     await testCodeAction(source2, [{ title: `Import module "App"` }]);
   });
 
+  test("add import alias of qualified value", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+func = Foo.foo
+          --^
+
+--@ App/Foo.elm
+module App.Foo exposing (foo)
+
+foo = ""
+`;
+    await testCodeAction(source, [
+      { title: `Import module "App.Foo" as "Foo"` },
+    ]);
+
+    const source2 = `
+--@ Test.elm
+module Test exposing (..)
+
+func = Bar.foo
+      --^
+
+--@ App/Foo/Bar.elm
+module App.Foo.Bar exposing (foo)
+
+foo = ""
+`;
+    await testCodeAction(source2, [
+      { title: `Import module "App.Foo.Bar" as "Bar"` },
+    ]);
+
+    const source3 = `
+--@ Test.elm
+module Test exposing (..)
+
+func = Foo.foo
+      --^
+
+--@ App/Foo/Bar.elm
+module App.Foo.Bar exposing (foo)
+
+foo = ""
+    `;
+    await testCodeAction(source3, []);
+  });
+
   test("add all missing imports", async () => {
     const source = `
 --@ Test.elm
diff --git a/test/completionProvider.test.ts b/test/completionProvider.test.ts
index 58beb66..b52c660 100644
--- a/test/completionProvider.test.ts
+++ b/test/completionProvider.test.ts
@@ -1234,6 +1234,168 @@ test = div [] [ Module.{-caret-} ]
     );
   });
 
+  it("Non imported qualified modules with aliases should have value completions with auto imports", async () => {
+    const source = `
+--@ Module/Foo.elm
+module Module.Foo exposing (..)
+testFunc = ""
+
+type Msg = Msg1 | Msg2
+
+type alias Model = { field : String }
+
+--@ Test.elm
+module Test exposing (..)
+test = div [] [ Foo.{-caret-} ]
+`;
+
+    await testCompletions(
+      source,
+      [
+        {
+          label: "Model",
+          detail: "Auto import module 'Module.Foo' as 'Foo'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo as Foo\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg",
+          detail: "Auto import module 'Module.Foo' as 'Foo'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo as Foo\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg1",
+          detail: "Auto import module 'Module.Foo' as 'Foo'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo as Foo\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg2",
+          detail: "Auto import module 'Module.Foo' as 'Foo'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo as Foo\n",
+            ),
+          ],
+        },
+        {
+          label: "testFunc",
+          detail: "Auto import module 'Module.Foo' as 'Foo'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo as Foo\n",
+            ),
+          ],
+        },
+      ],
+      "exactMatch",
+      "triggeredByDot",
+    );
+
+    const source2 = `
+--@ Module/Foo/Bar.elm
+module Module.Foo.Bar exposing (..)
+testFunc = ""
+
+type Msg = Msg1 | Msg2
+
+type alias Model = { field : String }
+
+--@ Test.elm
+module Test exposing (..)
+test = div [] [ Bar.{-caret-} ]
+`;
+
+    await testCompletions(
+      source2,
+      [
+        {
+          label: "Model",
+          detail: "Auto import module 'Module.Foo.Bar' as 'Bar'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo.Bar as Bar\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg",
+          detail: "Auto import module 'Module.Foo.Bar' as 'Bar'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo.Bar as Bar\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg1",
+          detail: "Auto import module 'Module.Foo.Bar' as 'Bar'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo.Bar as Bar\n",
+            ),
+          ],
+        },
+        {
+          label: "Msg2",
+          detail: "Auto import module 'Module.Foo.Bar' as 'Bar'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo.Bar as Bar\n",
+            ),
+          ],
+        },
+        {
+          label: "testFunc",
+          detail: "Auto import module 'Module.Foo.Bar' as 'Bar'",
+          additionalTextEdits: [
+            TextEdit.insert(
+              Position.create(1, 0),
+              "import Module.Foo.Bar as Bar\n",
+            ),
+          ],
+        },
+      ],
+      "exactMatch",
+      "triggeredByDot",
+    );
+
+    const source3 = `
+--@ Module/Foo/Bar.elm
+module Module.Foo.Bar exposing (..)
+testFunc = ""
+
+type Msg = Msg1 | Msg2
+
+type alias Model = { field : String }
+
+--@ Test.elm
+module Test exposing (..)
+test = div [] [ Foo.{-caret-} ]
+`;
+
+    await testCompletions(source3, [], "exactMatch", "triggeredByDot");
+  });
+
   it("Non imported qualified modules should have value completions with auto imports after module docs", async () => {
     const source = `
 --@ Module.elm