diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
index 951d3593db..1af2e6f074 100644
--- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
+++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
@@ -79,7 +79,15 @@ object TooltipTag {
const val EDITOR_CODE_ACTIONS_GEN_TO_STRING_DIALOG = "editor.codeactions.gentostring.dialog"
const val EDITOR_CODE_ACTIONS_UNUSED_IMPORTS = "editor.codeactions.unusedimports"
const val EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS = "editor.codeactions.organizeimports"
- const val EDITOR_CODE_ACTIONS_KT_FIX_IMPORTS = "editor.codeactions.kotlin.fiximports"
+
+ // Kotlin code actions. Tags are per-language even where the action exists in both languages,
+ // so the tooltip can describe the Kotlin behaviour (see ADFA-4730).
+ const val EDITOR_CODE_ACTIONS_KT_COMMENT = "editor.codeactions.kotlin.comment"
+ const val EDITOR_CODE_ACTIONS_KT_UNCOMMENT = "editor.codeactions.kotlin.uncomment"
+ const val EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS = "editor.codeactions.kotlin.importclass"
+ const val EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS = "editor.codeactions.kotlin.organizeimports"
+ const val EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS = "editor.codeactions.kotlin.implementmembers"
+ const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX = "editor.codeactions.kotlin.nullsafetyfix"
const val EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH = "editor.codeactions.kotlin.trycatch"
const val EXIT_TO_MAIN = "exit.to.main"
diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt
index d95d804fd3..cd5b5eff56 100644
--- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt
+++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt
@@ -1,86 +1,94 @@
-/*
- * 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.actions
-
-import android.content.Context
-import android.graphics.drawable.Drawable
-import com.itsaky.androidide.actions.ActionData
-import com.itsaky.androidide.actions.ActionItem
-import com.itsaky.androidide.actions.EditorActionItem
-import com.itsaky.androidide.actions.hasRequiredData
-import com.itsaky.androidide.actions.markInvisible
-import com.itsaky.androidide.actions.requireContext
-import com.itsaky.androidide.actions.requireEditor
-import com.itsaky.androidide.actions.requireFile
-import com.itsaky.androidide.idetooltips.TooltipTag
-import com.itsaky.androidide.resources.R
-import io.github.rosemoe.sora.text.batchEdit
-import java.io.File
-
-/** @author Akash Yadav */
-class CommentLineAction(
- lang: String,
- private val targetFileExtensions: List,
- private val lineCommentToken: String,
-) : EditorActionItem {
-
- constructor(lang: String, extension: String, lineCommentToken: String) :
- this(lang, listOf(extension), lineCommentToken)
- override val id: String = "ide.editor.lsp.$lang.commentLine"
- override var label: String = ""
-
- override var visible = true
- override var enabled = true
- override var icon: Drawable? = null
- override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS
- override var requiresUIThread: Boolean = true
- override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_COMMENT
-
- override fun prepare(data: ActionData) {
- super.prepare(data)
-
- if (!data.hasRequiredData(Context::class.java, File::class.java)) {
- markInvisible()
- return
- }
-
- val context = data.requireContext()
- label = context.getString(R.string.action_comment_line)
-
- val file = data.requireFile()
- if (file.extension !in targetFileExtensions) {
- markInvisible()
- return
- }
- }
-
- override suspend fun execAction(data: ActionData): Boolean {
- val editor = data.requireEditor()
- val text = editor.text
- val cursor = editor.cursor
-
- text.batchEdit {
- for (line in cursor.leftLine..cursor.rightLine) {
- text.insert(line, 0, lineCommentToken)
- }
- }
-
- return true
- }
-
- override fun dismissOnAction() = true
-}
+/*
+ * 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.actions
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.itsaky.androidide.actions.ActionData
+import com.itsaky.androidide.actions.ActionItem
+import com.itsaky.androidide.actions.EditorActionItem
+import com.itsaky.androidide.actions.hasRequiredData
+import com.itsaky.androidide.actions.markInvisible
+import com.itsaky.androidide.actions.requireContext
+import com.itsaky.androidide.actions.requireEditor
+import com.itsaky.androidide.actions.requireFile
+import com.itsaky.androidide.resources.R
+import io.github.rosemoe.sora.text.batchEdit
+import java.io.File
+
+/** @author Akash Yadav */
+class CommentLineAction(
+ lang: String,
+ private val targetFileExtensions: List,
+ private val lineCommentToken: String,
+ tag: String,
+) : EditorActionItem {
+ companion object {
+ /** The id is per-language, since one instance is registered per language. */
+ fun idFor(lang: String) = "ide.editor.lsp.$lang.commentLine"
+ }
+
+ constructor(lang: String, extension: String, lineCommentToken: String, tag: String) :
+ this(lang, listOf(extension), lineCommentToken, tag)
+
+ override val id: String = idFor(lang)
+ override var label: String = ""
+
+ override var visible = true
+ override var enabled = true
+ override var icon: Drawable? = null
+ override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS
+ override var requiresUIThread: Boolean = true
+
+ // Required, not defaulted: one instance is registered per language, and a default would let a
+ // new language silently inherit another language's tooltip.
+ override var tooltipTag: String = tag
+
+ override fun prepare(data: ActionData) {
+ super.prepare(data)
+
+ if (!data.hasRequiredData(Context::class.java, File::class.java)) {
+ markInvisible()
+ return
+ }
+
+ val context = data.requireContext()
+ label = context.getString(R.string.action_comment_line)
+
+ val file = data.requireFile()
+ if (file.extension !in targetFileExtensions) {
+ markInvisible()
+ return
+ }
+ }
+
+ override suspend fun execAction(data: ActionData): Boolean {
+ val editor = data.requireEditor()
+ val text = editor.text
+ val cursor = editor.cursor
+
+ text.batchEdit {
+ for (line in cursor.leftLine..cursor.rightLine) {
+ text.insert(line, 0, lineCommentToken)
+ }
+ }
+
+ return true
+ }
+
+ override fun dismissOnAction() = true
+}
diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt
index 66cf2b9ffd..9a271012f6 100644
--- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt
+++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt
@@ -1,92 +1,98 @@
-/*
- * 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.actions
-
-import android.content.Context
-import android.graphics.drawable.Drawable
-import com.itsaky.androidide.actions.ActionData
-import com.itsaky.androidide.actions.ActionItem
-import com.itsaky.androidide.actions.EditorActionItem
-import com.itsaky.androidide.actions.hasRequiredData
-import com.itsaky.androidide.actions.markInvisible
-import com.itsaky.androidide.actions.requireContext
-import com.itsaky.androidide.actions.requireEditor
-import com.itsaky.androidide.actions.requireFile
-import com.itsaky.androidide.idetooltips.TooltipTag
-import com.itsaky.androidide.resources.R
-import io.github.rosemoe.sora.text.batchEdit
-import java.io.File
-
-/** @author Akash Yadav */
-class UncommentLineAction(
- lang: String,
- private val targetFileExtensions: List,
- private val lineCommentToken: String,
-) : EditorActionItem {
-
- constructor(lang: String, extension: String, lineCommentToken: String) :
- this(lang, listOf(extension), lineCommentToken)
-
- override val id: String = "ide.editor.lsp.$lang.uncommentLine"
- override var label: String = ""
-
- override var visible = true
- override var enabled = true
- override var icon: Drawable? = null
- override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS
- override var requiresUIThread: Boolean = true
- override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT
-
-
- override fun prepare(data: ActionData) {
- super.prepare(data)
-
- if (!data.hasRequiredData(Context::class.java, File::class.java)) {
- markInvisible()
- return
- }
-
- val context = data.requireContext()
- label = context.getString(R.string.action_uncomment_line)
-
- val file = data.requireFile()
- if (file.extension !in targetFileExtensions) {
- markInvisible()
- return
- }
- }
-
- override suspend fun execAction(data: ActionData): Boolean {
- val editor = data.requireEditor()
- val text = editor.text
- val cursor = editor.cursor
-
- text.batchEdit {
- for (line in cursor.leftLine..cursor.rightLine) {
- val l = text.getLineString(line)
- if (l.trim().startsWith(lineCommentToken)) {
- val i = l.indexOf(lineCommentToken)
- text.delete(line, i, line, i + 2)
- }
- }
- }
-
- return true
- }
-
- override fun dismissOnAction() = true
-}
+/*
+ * 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.actions
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import com.itsaky.androidide.actions.ActionData
+import com.itsaky.androidide.actions.ActionItem
+import com.itsaky.androidide.actions.EditorActionItem
+import com.itsaky.androidide.actions.hasRequiredData
+import com.itsaky.androidide.actions.markInvisible
+import com.itsaky.androidide.actions.requireContext
+import com.itsaky.androidide.actions.requireEditor
+import com.itsaky.androidide.actions.requireFile
+import com.itsaky.androidide.resources.R
+import io.github.rosemoe.sora.text.batchEdit
+import java.io.File
+
+/** @author Akash Yadav */
+class UncommentLineAction(
+ lang: String,
+ private val targetFileExtensions: List,
+ private val lineCommentToken: String,
+ tag: String,
+) : EditorActionItem {
+ companion object {
+ /** The id is per-language, since one instance is registered per language. */
+ fun idFor(lang: String) = "ide.editor.lsp.$lang.uncommentLine"
+ }
+
+ constructor(lang: String, extension: String, lineCommentToken: String, tag: String) :
+ this(lang, listOf(extension), lineCommentToken, tag)
+
+ override val id: String = idFor(lang)
+ override var label: String = ""
+
+ override var visible = true
+ override var enabled = true
+ override var icon: Drawable? = null
+ override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS
+ override var requiresUIThread: Boolean = true
+
+ // Required, not defaulted: one instance is registered per language, and a default would let a
+ // new language silently inherit another language's tooltip.
+ override var tooltipTag: String = tag
+
+ override fun prepare(data: ActionData) {
+ super.prepare(data)
+
+ if (!data.hasRequiredData(Context::class.java, File::class.java)) {
+ markInvisible()
+ return
+ }
+
+ val context = data.requireContext()
+ label = context.getString(R.string.action_uncomment_line)
+
+ val file = data.requireFile()
+ if (file.extension !in targetFileExtensions) {
+ markInvisible()
+ return
+ }
+ }
+
+ override suspend fun execAction(data: ActionData): Boolean {
+ val editor = data.requireEditor()
+ val text = editor.text
+ val cursor = editor.cursor
+
+ text.batchEdit {
+ for (line in cursor.leftLine..cursor.rightLine) {
+ val l = text.getLineString(line)
+ if (l.trim().startsWith(lineCommentToken)) {
+ val i = l.indexOf(lineCommentToken)
+ text.delete(line, i, line, i + 2)
+ }
+ }
+ }
+
+ return true
+ }
+
+ override fun dismissOnAction() = true
+}
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 1f1b3ecdfc..cfd4fd35e5 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
@@ -1,80 +1,85 @@
-/*
- * 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
-
-import com.itsaky.androidide.actions.ActionItem
-import com.itsaky.androidide.lsp.actions.IActionsMenuProvider
-import com.itsaky.androidide.lsp.actions.CommentLineAction
-import com.itsaky.androidide.lsp.java.actions.common.FindReferencesAction
-import com.itsaky.androidide.lsp.java.actions.common.GoToDefinitionAction
-import com.itsaky.androidide.lsp.java.actions.common.OrganizeImportsAction
-import com.itsaky.androidide.lsp.java.actions.common.RemoveUnusedImportsAction
-import com.itsaky.androidide.lsp.actions.UncommentLineAction
-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
-import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveClassAction
-import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveMethodAction
-import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveUnusedThrowsAction
-import com.itsaky.androidide.lsp.java.actions.diagnostics.SuppressUncheckedWarningAction
-import com.itsaky.androidide.lsp.java.actions.diagnostics.VariableToStatementAction
-import com.itsaky.androidide.lsp.java.actions.generators.GenerateConstructorAction
-import com.itsaky.androidide.lsp.java.actions.generators.GenerateMissingConstructorAction
-import com.itsaky.androidide.lsp.java.actions.generators.GenerateSettersAndGettersAction
-import com.itsaky.androidide.lsp.java.actions.generators.GenerateToStringMethodAction
-import com.itsaky.androidide.lsp.java.actions.generators.OverrideSuperclassMethodsAction
-
-/**
- * Java code actions.
- * @author Akash Yadav
- */
-object JavaCodeActionsMenu : IActionsMenuProvider {
-
- private const val LANG = "java"
- private const val EXT = "java"
- private const val LINE_COMMENT_TOKEN = "//"
-
- override val actions: List =
- listOf(
- CommentLineAction(LANG, EXT, LINE_COMMENT_TOKEN),
- UncommentLineAction(LANG, EXT, LINE_COMMENT_TOKEN),
- GoToDefinitionAction(),
- FindReferencesAction(),
- AddImportAction(),
- AutoFixImportsAction(),
- ImplementAbstractMethodsAction(),
- VariableToStatementAction(),
- FieldToBlockAction(),
- RemoveClassAction(),
- RemoveMethodAction(),
- RemoveUnusedThrowsAction(),
- CreateMissingMethodAction(),
- SuppressUncheckedWarningAction(),
- AddThrowsAction(),
- GenerateSettersAndGettersAction(),
- OverrideSuperclassMethodsAction(),
- GenerateMissingConstructorAction(),
- GenerateConstructorAction(),
- GenerateToStringMethodAction(),
- RemoveUnusedImportsAction(),
- OrganizeImportsAction()
- )
-}
+/*
+ * 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
+
+import com.itsaky.androidide.actions.ActionItem
+import com.itsaky.androidide.idetooltips.TooltipTag
+import com.itsaky.androidide.lsp.actions.CommentLineAction
+import com.itsaky.androidide.lsp.actions.IActionsMenuProvider
+import com.itsaky.androidide.lsp.actions.UncommentLineAction
+import com.itsaky.androidide.lsp.java.actions.common.FindReferencesAction
+import com.itsaky.androidide.lsp.java.actions.common.GoToDefinitionAction
+import com.itsaky.androidide.lsp.java.actions.common.OrganizeImportsAction
+import com.itsaky.androidide.lsp.java.actions.common.RemoveUnusedImportsAction
+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
+import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveClassAction
+import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveMethodAction
+import com.itsaky.androidide.lsp.java.actions.diagnostics.RemoveUnusedThrowsAction
+import com.itsaky.androidide.lsp.java.actions.diagnostics.SuppressUncheckedWarningAction
+import com.itsaky.androidide.lsp.java.actions.diagnostics.VariableToStatementAction
+import com.itsaky.androidide.lsp.java.actions.generators.GenerateConstructorAction
+import com.itsaky.androidide.lsp.java.actions.generators.GenerateMissingConstructorAction
+import com.itsaky.androidide.lsp.java.actions.generators.GenerateSettersAndGettersAction
+import com.itsaky.androidide.lsp.java.actions.generators.GenerateToStringMethodAction
+import com.itsaky.androidide.lsp.java.actions.generators.OverrideSuperclassMethodsAction
+
+/**
+ * Java code actions.
+ * @author Akash Yadav
+ */
+object JavaCodeActionsMenu : IActionsMenuProvider {
+ private const val LANG = "java"
+ private const val EXT = "java"
+ private const val LINE_COMMENT_TOKEN = "//"
+
+ override val actions: List =
+ listOf(
+ CommentLineAction(LANG, EXT, LINE_COMMENT_TOKEN, TooltipTag.EDITOR_CODE_ACTIONS_COMMENT),
+ UncommentLineAction(
+ LANG,
+ EXT,
+ LINE_COMMENT_TOKEN,
+ TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT,
+ ),
+ GoToDefinitionAction(),
+ FindReferencesAction(),
+ AddImportAction(),
+ AutoFixImportsAction(),
+ ImplementAbstractMethodsAction(),
+ VariableToStatementAction(),
+ FieldToBlockAction(),
+ RemoveClassAction(),
+ RemoveMethodAction(),
+ RemoveUnusedThrowsAction(),
+ CreateMissingMethodAction(),
+ SuppressUncheckedWarningAction(),
+ AddThrowsAction(),
+ GenerateSettersAndGettersAction(),
+ OverrideSuperclassMethodsAction(),
+ GenerateMissingConstructorAction(),
+ GenerateConstructorAction(),
+ GenerateToStringMethodAction(),
+ RemoveUnusedImportsAction(),
+ OrganizeImportsAction(),
+ )
+}
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt
index 561f957dfd..7acaabf9a1 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt
@@ -1,6 +1,7 @@
package com.itsaky.androidide.lsp.kotlin
import com.itsaky.androidide.actions.ActionItem
+import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.actions.CommentLineAction
import com.itsaky.androidide.lsp.actions.IActionsMenuProvider
import com.itsaky.androidide.lsp.actions.UncommentLineAction
@@ -11,14 +12,24 @@ import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction
import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction
object KotlinCodeActionsMenu : IActionsMenuProvider {
- private const val KT_LANG = "kt"
+ internal const val KT_LANG = "kt"
private val KT_EXTS = listOf("kt", "kts")
private const val KT_LINE_COMMENT_TOKEN = "//"
override val actions: List =
listOf(
- CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN),
- UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN),
+ CommentLineAction(
+ KT_LANG,
+ KT_EXTS,
+ KT_LINE_COMMENT_TOKEN,
+ TooltipTag.EDITOR_CODE_ACTIONS_KT_COMMENT,
+ ),
+ UncommentLineAction(
+ KT_LANG,
+ KT_EXTS,
+ KT_LINE_COMMENT_TOKEN,
+ TooltipTag.EDITOR_CODE_ACTIONS_KT_UNCOMMENT,
+ ),
AddImportAction(),
OrganizeImportsAction(),
SurroundWithTryCatchAction(),
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt
index 362c5effa4..ca820ac7ac 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt
@@ -6,6 +6,7 @@ import com.itsaky.androidide.actions.markInvisible
import com.itsaky.androidide.actions.newDialogBuilder
import com.itsaky.androidide.actions.requireFile
import com.itsaky.androidide.idetooltips.TooltipTag
+import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.index.findSymbolBySimpleName
import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction
import com.itsaky.androidide.lsp.kotlin.utils.insertImport
@@ -19,13 +20,17 @@ import com.itsaky.androidide.resources.R
import com.itsaky.androidide.utils.flashError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol
+import java.nio.file.Path
class AddImportAction : BaseKotlinCodeAction() {
+ companion object {
+ const val ID = "ide.editor.lsp.kt.diagnostics.addImport"
+ }
+
override var titleTextRes: Int = R.string.action_import_classes
- override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_FIX_IMPORTS
+ override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS
- override val id: String = "ide.editor.lsp.kt.diagnostics.addImport"
+ override val id: String = ID
override var label: String = ""
override fun prepare(data: ActionData) {
@@ -47,26 +52,41 @@ class AddImportAction : BaseKotlinCodeAction() {
}
}
- override suspend fun execAction(data: ActionData): Map> {
+ override suspend fun execAction(data: ActionData): Map> {
val (_, extra) =
data.findDiagnosticExtra()
?: return emptyMap()
val (env, action) = extra
- val file = data.requireFile()
- val nioPath = file.toPath()
+ val nioPath = data.requireFile().toPath()
+ return withContext(Dispatchers.IO) {
+ computeImportCandidates(env, nioPath, action.referenceName)
+ }
+ }
+
+ /**
+ * Resolves [referenceName] to the importable classifiers known to [env], each mapped to the edits
+ * that import it into the file at [nioPath]. Keyed by fully-qualified name, which is what
+ * [postExec] shows in the chooser -- so two index entries for the same class collapse into one
+ * entry instead of duplicating it.
+ *
+ * Blocking: does the `getCurrentKtFile` `.get()` and a SQLite-backed index query, so callers must
+ * stay off the main thread ([execAction] wraps it in [Dispatchers.IO]).
+ */
+ internal fun computeImportCandidates(
+ env: AbstractCompilationEnvironment,
+ nioPath: Path,
+ referenceName: String,
+ ): Map> {
val ktFile =
- withContext(Dispatchers.IO) {
- env.ktSymbolIndex
- .getCurrentKtFile(nioPath)
- .get()
- }
- ?: return emptyMap()
+ env.ktSymbolIndex
+ .getCurrentKtFile(nioPath)
+ .get() ?: return emptyMap()
return env.ktSymbolIndex
- .findSymbolBySimpleName(action.referenceName, limit = 0)
+ .findSymbolBySimpleName(referenceName, limit = 0)
.filter { it.kind.isClassifier }
- .associateWith { symbol -> insertImport(ktFile, symbol.fqName) }
+ .associate { it.fqName to insertImport(ktFile, it.fqName) }
}
override fun postExec(
@@ -80,7 +100,7 @@ class AddImportAction : BaseKotlinCodeAction() {
}
@Suppress("UNCHECKED_CAST")
- result as Map>
+ result as Map>
if (result.isEmpty()) {
logger.warn("No classifiers to import.")
@@ -99,9 +119,9 @@ class AddImportAction : BaseKotlinCodeAction() {
val nioPath = file.toPath()
val actions =
result
- .map { (symbol, edits) ->
+ .map { (fqName, edits) ->
CodeActionItem(
- title = symbol.fqName,
+ title = fqName,
changes = listOf(DocumentChange(file = nioPath, edits = edits)),
kind = CodeActionKind.QuickFix,
command = Command.CMD_FORMAT_CODE,
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt
index a432403754..bdc1eb2c07 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt
@@ -29,9 +29,13 @@ import org.jetbrains.kotlin.psi.KtFile
import java.nio.file.Path
class ImplementMembersAction : BaseKotlinCodeAction() {
+ companion object {
+ const val ID = "ide.editor.lsp.kt.implementMembers"
+ }
+
override var titleTextRes: Int = R.string.action_implement_members
- override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER
- override val id: String = "ide.editor.lsp.kt.implementMembers"
+ override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS
+ override val id: String = ID
override var label: String = ""
// Intentionally no prepare() visibility gate: the action is visible on any Kotlin file (BaseKotlinCodeAction
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt
index 3d8f21d435..7327aa9d2d 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt
@@ -5,6 +5,7 @@ import com.itsaky.androidide.actions.markInvisible
import com.itsaky.androidide.actions.newDialogBuilder
import com.itsaky.androidide.actions.requireContext
import com.itsaky.androidide.actions.requireFile
+import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction
import com.itsaky.androidide.lsp.kotlin.utils.NullSafetyKind
@@ -31,9 +32,14 @@ import kotlinx.coroutines.withContext
* are out of scope here.
*/
class NullSafetyAction : BaseKotlinCodeAction() {
+ companion object {
+ const val ID = "ide.editor.lsp.kt.diagnostics.nullSafety"
+ }
+
override var titleTextRes: Int = R.string.action_null_safety_fixes
- override val id: String = "ide.editor.lsp.kt.diagnostics.nullSafety"
+ override val id: String = ID
override var label: String = ""
+ override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX
override fun prepare(data: ActionData) {
super.prepare(data)
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt
index 603bfaf6cc..ce5dfafb97 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt
@@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.actions
import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.get
import com.itsaky.androidide.actions.requireFile
+import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer
import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
@@ -22,10 +23,13 @@ import java.nio.file.Path
class OrganizeImportsAction : BaseKotlinCodeAction() {
override var titleTextRes: Int = R.string.action_organize_imports
- override val id: String = "ide.editor.lsp.kt.organizeImports"
+ override val id: String = ID
override var label: String = ""
+ override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS
companion object {
+ const val ID = "ide.editor.lsp.kt.organizeImports"
+
private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java)
}
diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt
index d34d116892..b73eb23b6b 100644
--- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt
+++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/SurroundWithTryCatchAction.kt
@@ -14,10 +14,14 @@ import com.itsaky.androidide.lsp.models.TextEdit
import com.itsaky.androidide.resources.R
class SurroundWithTryCatchAction : BaseKotlinCodeAction() {
+ companion object {
+ const val ID = "ide.editor.lsp.kt.surroundWithTryCatch"
+ }
+
override var titleTextRes: Int = R.string.action_surround_with_try_catch
override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH
- override val id: String = "ide.editor.lsp.kt.surroundWithTryCatch"
+ override val id: String = ID
override var label: String = ""
// Reads the editor selection, so it must run on the UI thread (as CommentLineAction does).
diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt
new file mode 100644
index 0000000000..7111eb0103
--- /dev/null
+++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt
@@ -0,0 +1,58 @@
+package com.itsaky.androidide.lsp.kotlin
+
+import com.itsaky.androidide.idetooltips.TooltipTag
+import com.itsaky.androidide.lsp.actions.CommentLineAction
+import com.itsaky.androidide.lsp.actions.UncommentLineAction
+import com.itsaky.androidide.lsp.kotlin.KotlinCodeActionsMenu.KT_LANG
+import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction
+import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction
+import com.itsaky.androidide.lsp.kotlin.actions.NullSafetyAction
+import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction
+import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Pins each Kotlin code action to its tooltip tag. Tooltip content is authored per tag and looked up
+ * by that tag, so a wrong tag fails silently at runtime: the action either shows another action's
+ * tooltip or none at all (ADFA-4867).
+ *
+ * This catches an action pointing at the wrong constant, or a newly registered action carrying no
+ * tag. It cannot catch a tag's *value* being edited, since both sides read the same constant -- the
+ * prefix assertion below is the backstop for a Kotlin action drifting onto a Java tag.
+ */
+class KotlinCodeActionTooltipTagTest {
+ private val actualTags
+ get() = KotlinCodeActionsMenu.actions.associate { it.id to it.tooltipTag }
+
+ @Test
+ fun `every kotlin code action maps to its own tooltip tag`() {
+ val expected =
+ mapOf(
+ CommentLineAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_COMMENT,
+ UncommentLineAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_UNCOMMENT,
+ AddImportAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS,
+ OrganizeImportsAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS,
+ NullSafetyAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX,
+ ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS,
+ SurroundWithTryCatchAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH,
+ )
+ assertEquals(expected, actualTags)
+ }
+
+ /** Guards the specific regression: a Kotlin action reusing a Java tag, or carrying none. */
+ @Test
+ fun `no kotlin code action borrows a java tooltip tag`() {
+ actualTags.forEach { (id, tag) ->
+ assertTrue(
+ "$id has no tooltip tag",
+ tag.isNotEmpty(),
+ )
+ assertTrue(
+ "$id uses non-Kotlin tooltip tag '$tag'",
+ tag.startsWith("editor.codeactions.kotlin."),
+ )
+ }
+ }
+}