diff --git a/src/providers/codeAction/importCodeAction.ts b/src/providers/codeAction/importCodeAction.ts index 446baae8..138db500 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 0d795c56..ec335967 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 a620a6fb..b3f50e53 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`, ); } diff --git a/test/codeActionTests/importCodeAction.test.ts b/test/codeActionTests/importCodeAction.test.ts index 0c1edbc6..42b1088f 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 58beb662..b52c6608 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