diff --git a/actions/src/main/java/com/itsaky/androidide/actions/actionUtils.kt b/actions/src/main/java/com/itsaky/androidide/actions/actionUtils.kt index d3d2afb4b..c29793cd4 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/actionUtils.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/actionUtils.kt @@ -24,6 +24,10 @@ import io.github.rosemoe.sora.widget.CodeEditor import java.io.File import java.nio.file.Path +fun ActionData.requireContext() : Context { + return get(Context::class.java) ?: throw IllegalArgumentException("No context instance provided") +} + fun ActionData.requireFile(): File { return get(File::class.java) ?: throw IllegalArgumentException("No file instance provided") } diff --git a/app/src/main/assets/data/common/tooling-api-all.jar b/app/src/main/assets/data/common/tooling-api-all.jar index 1a2184aa4..020e3d522 100644 Binary files a/app/src/main/assets/data/common/tooling-api-all.jar and b/app/src/main/assets/data/common/tooling-api-all.jar differ diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/BaseJavaCodeAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/BaseJavaCodeAction.kt index 8c18b0e21..e52d24d09 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/BaseJavaCodeAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/BaseJavaCodeAction.kt @@ -25,9 +25,11 @@ import com.itsaky.androidide.actions.EditorActionItem import com.itsaky.androidide.actions.hasRequiredData import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.java.JavaCompilerProvider import com.itsaky.androidide.lsp.java.JavaLanguageServer +import com.itsaky.androidide.lsp.java.compiler.JavaCompilerService import com.itsaky.androidide.lsp.java.rewrite.Rewrite import com.itsaky.androidide.projects.ProjectManager import com.itsaky.androidide.utils.DocumentUtils @@ -37,6 +39,7 @@ import java.io.File /** * Base class for java code actions + * * @author Akash Yadav */ abstract class BaseJavaCodeAction : EditorActionItem { @@ -47,7 +50,7 @@ abstract class BaseJavaCodeAction : EditorActionItem { override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS protected abstract val titleTextRes: Int - + override fun prepare(data: ActionData) { if ( !data.hasRequiredData(Context::class.java, JavaLanguageServer::class.java, File::class.java) @@ -67,20 +70,37 @@ abstract class BaseJavaCodeAction : EditorActionItem { fun performCodeAction(data: ActionData, result: Rewrite) { val server = ILanguageServerRegistry.getDefault().getServer(JavaLanguageServer.SERVER_ID)!! - val compiler = - JavaCompilerProvider.get(ProjectManager.findModuleForFile(data.requireFile()) ?: return) + val compiler = data.requireCompiler() val client = server.client!! val file = data.requireFile() - val actions = try { - result.asCodeActions(compiler, label) - } catch (e: Exception) { - flashError(e.cause?.message ?: e.message) - ILogger.instance().error(e) - return - } + val actions = + try { + result.asCodeActions(compiler, label) + } catch (e: Exception) { + flashError(e.cause?.message ?: e.message) + ILogger.instance().error(e) + return + } client.performCodeAction(file, actions) } + + protected fun ActionData.requireLanguageServer(): JavaLanguageServer { + return ILanguageServerRegistry.getDefault().getServer(JavaLanguageServer.SERVER_ID) + as JavaLanguageServer + } + + protected fun ActionData.getLanguageClient() : ILanguageClient? { + return requireLanguageServer().client + } + + protected fun ActionData.requireCompiler(): JavaCompilerService { + val module = ProjectManager.findModuleForFile(requireFile()) + requireNotNull(module) { + "Cannot get compiler instance. Unable to find module for file: ${requireFile().name}" + } + return JavaCompilerProvider.get(module) + } } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt index 379122806..8d106784c 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.lsp.java.actions.common.RemoveUnusedImportsAction import com.itsaky.androidide.lsp.java.actions.common.UncommentAction import com.itsaky.androidide.lsp.java.actions.diagnostics.AddImportAction import com.itsaky.androidide.lsp.java.actions.diagnostics.AddThrowsAction +import com.itsaky.androidide.lsp.java.actions.diagnostics.AutoFixImportsAction import com.itsaky.androidide.lsp.java.actions.diagnostics.CreateMissingMethodAction import com.itsaky.androidide.lsp.java.actions.diagnostics.FieldToBlockAction import com.itsaky.androidide.lsp.java.actions.diagnostics.ImplementAbstractMethodsAction @@ -54,6 +55,7 @@ object JavaCodeActionsMenu : IActionsMenuProvider { GoToDefinitionAction(), FindReferencesAction(), AddImportAction(), + AutoFixImportsAction(), ImplementAbstractMethodsAction(), VariableToStatementAction(), FieldToBlockAction(), diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt index 819cea646..c73210b86 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt @@ -24,9 +24,7 @@ import com.itsaky.androidide.actions.newDialogBuilder import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.actions.requirePath import com.itsaky.androidide.javac.services.util.JavaDiagnosticUtils -import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.JavaLanguageServer import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction import com.itsaky.androidide.lsp.java.models.DiagnosticCode import com.itsaky.androidide.lsp.java.rewrite.AddImport @@ -145,10 +143,7 @@ class AddImportAction : BaseJavaCodeAction() { } val compiler = JavaCompilerProvider.get(module) - val server = - ILanguageServerRegistry.getDefault().getServer(JavaLanguageServer.SERVER_ID) - as JavaLanguageServer - val client = server.client ?: return + val client = data.getLanguageClient() ?: return val actions = mutableListOf() val titles = result.first as List val rewrites = result.second as List diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt new file mode 100644 index 000000000..60332e539 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt @@ -0,0 +1,201 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.lsp.java.actions.diagnostics + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.lsp.java.R +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.utils.positionForImports +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.ILogger +import com.itsaky.androidide.utils.flashInfo +import java.nio.file.Path + +/** + * Analyzes the source file for unresolved names and tries to import all of them at once. + * + * @author Akash Yadav + */ +class AutoFixImportsAction : BaseJavaCodeAction() { + + private val log = ILogger.newInstance("AutoFixImportsAction") + override val titleTextRes: Int = R.string.title_fix_imports + override val id: String = "ide.editor.lsp.java.autoFixImports" + override var label: String = "" + + override fun execAction(data: ActionData): Result { + val path = data.requirePath() + val compiler = data.requireCompiler() + return compiler.compile(path).get { task -> + val classes = mutableMapOf>() + + // find all unresolved simple names + unresolvedNames(path, task).forEach { simpleName -> + + // if we have already looked for this simple name + // we do not need to look it up again + if (classes[simpleName] != null) return@forEach + + // find classes with those names + compiler.findQualifiedNames(simpleName).let { names -> + + // if we find classes with that specific simple name, map them to the simple name + if (names.isNotEmpty()) { + classes[simpleName] = names + } + } + } + + // return the result + Result(getFileImports(task, path), classes) + } + } + + override fun postExec(data: ActionData, result: Any) { + if (result !is Result) { + log.error("Invalid result returned from execAction", result) + return + } + + if (result.classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + // if there are multiple classes with same simple name + // ask the user to choose the appropriate class + if (result.classes.any { it.value.size > 1 }) { + finalizeClassNames(data, result) + } else { + performEdits(data, result) + } + } + + private fun finalizeClassNames(data: ActionData, result: Result) { + var e: Map.Entry>? = null + for (entry in result.classes) { + if (entry.value.size > 1) { + e = entry + break + } + } + + if (e == null) { + performEdits(data, result) + return + } + + val context = data.requireContext() + DialogUtils.newMaterialDialogBuilder(context) + .setCancelable(true) + .setItems(e.value.toTypedArray()) { dialog, which -> + dialog.dismiss() + result.classes[e.key] = listOf(e.value[which]) + + // once the user decides which class to import for this simple name, + // call this method again to see if there any other simple names with multiple options + finalizeClassNames(data, result) + } + .setTitle(context.getString(R.string.title_class_chooser, e.key)) + .show() + } + + private fun performEdits(data: ActionData, result: Result) { + val path = data.requirePath() + val compiler = data.requireCompiler() + val client = + data.getLanguageClient() + ?: run { + log.warn("No language client found. Cannot perform edits.") + return + } + + val classes = result.classes.mapNotNull { it.value.firstOrNull() } + + if (classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + val insertText = StringBuilder() + if (result.fileImports.isEmpty() && classes.isNotEmpty()) { + // if there are no file imports, the new imports will be added just after the package + // declaration. To avoid this, add a new line before the imports + insertText.append("\n") + } + + for (klass in classes) { + insertText.append("import ${klass};\n") + } + + val position = compiler.compile(path).get { positionForImports(classes[0], it) } + + val change = DocumentChange() + change.file = path + change.edits = listOf(TextEdit(Range.pointRange(position), insertText.toString())) + + val action = CodeActionItem() + action.title = data.requireContext().getString(R.string.title_fix_imports) + action.kind = CodeActionKind.QuickFix + action.changes = listOf(change) + client.performCodeAction(data.requireFile(), action) + } + + /** + * Walks through the diagnostics of the compilation task, looks for [DiagnosticCode.NOT_IMPORTED] + * errors and returns a list of simple names of all not imported classes. + */ + private fun unresolvedNames(file: Path, task: CompileTask): List { + val names = mutableListOf() + var docContents: CharSequence? = null + val diagnostics = + task.diagnostics.filter { + it.source.toUri() == file.toUri() && it.code == DiagnosticCode.NOT_IMPORTED.id + } + for (diagnostic in diagnostics) { + val content = + try { + docContents ?: diagnostic.source.getCharContent(true).also { docContents = it } + } catch (e: Exception) { + log.error("Failed to get contents of file $file", e) + continue + } + + val name = + content.subSequence(diagnostic.startPosition.toInt(), diagnostic.endPosition.toInt()) + names.add(name.toString()) + } + return names + } + + private fun getFileImports(task: CompileTask, file: Path): Set { + return task.root(file).imports.map { it.qualifiedIdentifier }.map { it.toString() }.toSet() + } + + inner class Result(val fileImports: Set, val classes: MutableMap>) +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/AddImport.java b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/AddImport.java index 4a7286717..e882f632a 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/AddImport.java +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/AddImport.java @@ -22,17 +22,13 @@ import androidx.annotation.RestrictTo; import com.itsaky.androidide.lsp.java.compiler.CompilerProvider; import com.itsaky.androidide.lsp.java.parser.ParseTask; +import com.itsaky.androidide.lsp.java.utils.InsertUtilsKt; import com.itsaky.androidide.models.Position; import com.itsaky.androidide.models.Range; import com.itsaky.androidide.lsp.models.TextEdit; -import openjdk.source.tree.ImportTree; -import openjdk.source.tree.Tree; -import openjdk.source.util.SourcePositions; -import openjdk.source.util.Trees; import java.nio.file.Path; import java.util.Collections; -import java.util.List; import java.util.Map; public class AddImport extends Rewrite { @@ -51,41 +47,9 @@ public class AddImport extends Rewrite { @Override public Map rewrite(@NonNull CompilerProvider compiler) { final ParseTask task = compiler.parse(file); - Position point = insertPosition(task); + Position point = InsertUtilsKt.positionForImports(className, task); String text = "import " + className + ";\n"; return Collections.singletonMap( file, new TextEdit[] {new TextEdit(new Range(point, point), text)}); } - - private Position insertPosition(@NonNull ParseTask task) { - List imports = task.root.getImports(); - for (ImportTree i : imports) { - String next = i.getQualifiedIdentifier().toString(); - if (className.compareTo(next) < 0) { - return insertBefore(task, i); - } - } - if (!imports.isEmpty()) { - Tree last = imports.get(imports.size() - 1); - return insertAfter(task, last); - } - if (task.root.getPackage() != null) { - return insertAfter(task, task.root.getPackage()); - } - return new Position(0, 0); - } - - private Position insertBefore(ParseTask task, Tree i) { - SourcePositions pos = Trees.instance(task.task).getSourcePositions(); - long offset = pos.getStartPosition(task.root, i); - int line = (int) task.root.getLineMap().getLineNumber(offset); - return new Position(line - 1, 0); - } - - private Position insertAfter(ParseTask task, Tree i) { - SourcePositions pos = Trees.instance(task.task).getSourcePositions(); - long offset = pos.getStartPosition(task.root, i); - int line = (int) task.root.getLineMap().getLineNumber(offset); - return new Position(line, 0); - } } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/Rewrite.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/Rewrite.kt index c066e248b..2326d622e 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/Rewrite.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/rewrite/Rewrite.kt @@ -37,7 +37,7 @@ abstract class Rewrite { * @param title The title for the code action. * @return The code action item. */ - fun asCodeActions(compiler: CompilerProvider, title: String?): CodeActionItem? { + fun asCodeActions(compiler: CompilerProvider, title: String): CodeActionItem? { val edits = rewrite(compiler) if (edits.isEmpty()) { return null @@ -52,7 +52,7 @@ abstract class Rewrite { changes.add(change) } val action = CodeActionItem() - action.title = title!! + action.title = title action.kind = CodeActionKind.QuickFix action.changes = changes finalizeCodeAction(action) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/EditHelper.java b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/EditHelper.java index fdb2de32b..e980dbb3c 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/EditHelper.java +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/EditHelper.java @@ -53,15 +53,7 @@ public class EditHelper { public static List addImportIfNeeded(CompilerProvider compiler, Path file, Set imports, String className ) { - final String pkgName = Extractors.packageName(className); - final String star = pkgName + ".*"; - if ("java.lang".equals(pkgName) || imports.contains(className) || imports.contains(star) || - file == null) { - return Collections.emptyList(); - } - - final var filePackage = StringSearch.packageName(file); - if (filePackage != null && filePackage.equals(pkgName)) { + if (file == null || containsImport(file, imports, className)) { return Collections.emptyList(); } @@ -69,6 +61,21 @@ public class EditHelper { return Arrays.asList(Objects.requireNonNull(addImport.rewrite(compiler).get(file))); } + public static boolean containsImport(@NonNull Path file, Set imports, String className) { + if (imports == null || imports.isEmpty()) { + return false; + } + + final String pkgName = Extractors.packageName(className); + final String star = pkgName + ".*"; + if ("java.lang".equals(pkgName) || imports.contains(className) || imports.contains(star)) { + return true; + } + + final var filePackage = StringSearch.packageName(file); + return filePackage != null && filePackage.equals(pkgName); + } + public static TextEdit removeTree(final JavacTask task, final CompilationUnitTree root, final Tree remove ) { diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/insertUtils.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/insertUtils.kt new file mode 100644 index 000000000..5375b05d0 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/utils/insertUtils.kt @@ -0,0 +1,67 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.lsp.java.utils + +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import com.itsaky.androidide.lsp.java.parser.ParseTask +import com.itsaky.androidide.models.Position +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.tree.Tree +import openjdk.source.util.JavacTask +import openjdk.source.util.Trees + +/** @author Akash Yadav */ +fun positionForImports(className: String, task: ParseTask): Position { + return positionForImports(className, task.task, task.root) +} + +fun positionForImports(className: String, task: CompileTask): Position { + return positionForImports(className, task.task, task.root()) +} + +fun positionForImports(className: String, task: JavacTask, root: CompilationUnitTree): Position { + val imports = root.imports + for (i in imports) { + val next = i.qualifiedIdentifier.toString() + if (className < next) { + return insertBefore(task, root, i) + } + } + if (imports.isNotEmpty()) { + val last = imports[imports.size - 1] + return insertAfter(task, root, last) + } + + return if (root.getPackage() != null) { + insertAfter(task, root, root.getPackage()) + } else Position(0, 0) +} + +fun insertBefore(task: JavacTask, root: CompilationUnitTree, tree: Tree): Position { + val pos = Trees.instance(task).sourcePositions + val offset = pos.getStartPosition(root, tree) + val line = root.lineMap.getLineNumber(offset).toInt() + return Position(line - 1, 0) +} + +fun insertAfter(task: JavacTask, root: CompilationUnitTree, tree: Tree): Position { + val pos = Trees.instance(task).sourcePositions + val offset = pos.getStartPosition(root, tree) + val line = root.lineMap.getLineNumber(offset).toInt() + return Position(line, 0) +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7723064d3..65e8ae106 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -543,4 +543,7 @@ Sync Ignore these changes + Fix imports + No unresolved class names found + Which %1$s? diff --git a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt index fc3126ca3..7b67165b7 100644 --- a/shared/src/main/java/com/itsaky/androidide/models/Locations.kt +++ b/shared/src/main/java/com/itsaky/androidide/models/Locations.kt @@ -92,6 +92,11 @@ constructor(var start: Position = Position(0, 0), var end: Position = Position(0 companion object { @JvmField val NONE = Range(Position.NONE, Position.NONE) + + @JvmStatic + fun pointRange(position: Position) : Range { + return Range(position, position) + } } /**