feat(lsp/java): add 'Fix imports' actions

The 'Fix imports' action analyzes the file contents, walks through the diagnostic items looking for 'compiler.err.cant.resolve.location' errors, then performs an edit to
import all those classes at once. If multiple classes are found for a class name, the user is asked to choose the class that should be imported.
This commit is contained in:
Akash Yadav
2023-03-29 01:42:24 +05:30
parent 974a1ae208
commit 273e61daa8
12 changed files with 333 additions and 65 deletions
@@ -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")
}
Binary file not shown.
@@ -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)
}
}
@@ -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(),
@@ -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<CodeActionItem>()
val titles = result.first as List<String>
val rewrites = result.second as List<Rewrite>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, List<String>>()
// 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<String, List<String>>? = 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<String> {
val names = mutableListOf<String>()
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<String> {
return task.root(file).imports.map { it.qualifiedIdentifier }.map { it.toString() }.toSet()
}
inner class Result(val fileImports: Set<String>, val classes: MutableMap<String, List<String>>)
}
@@ -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<Path, TextEdit[]> 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<? extends ImportTree> 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);
}
}
@@ -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)
@@ -53,15 +53,7 @@ public class EditHelper {
public static List<TextEdit> addImportIfNeeded(CompilerProvider compiler, Path file,
Set<String> 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<String> 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
) {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
@@ -543,4 +543,7 @@
<string name="btn_sync">Sync</string>
<string name="btn_ignore_changes">Ignore these changes</string>
<string name="title_fix_imports">Fix imports</string>
<string name="msg_no_unresolved_classes">No unresolved class names found</string>
<string name="title_class_chooser">Which %1$s?</string>
</resources>
@@ -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)
}
}
/**