Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ 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_TRY_CATCH = "editor.codeactions.trycatch"

// 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).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsaky.androidide.lsp.kotlin.utils
package com.itsaky.androidide.lsp.actions

import com.itsaky.androidide.lsp.models.TextEdit
import com.itsaky.androidide.models.Position
Expand All @@ -7,12 +7,12 @@ import com.itsaky.androidide.models.Range
/**
* Resolves an editor selection (cursor left/right line+column) to the whole-line
* span the surround action wraps. Whole-line based by design: mid-line columns
* still select the entire line -- statement-boundary snapping needs PSI, which is
* out of scope, so a wrapped `val x = ...` on a multi-statement line stays scoped
* inside the try. A selection whose end handle sits at column 0 of the line after
* the last selected line (the common "drag to select whole lines" gesture) would
* otherwise wrap that trailing, visually-unselected line, so it is trimmed.
* Returns (startLine, endLine), 0-based inclusive.
* still select the entire line -- statement-boundary snapping needs a syntax
* tree, which is out of scope, so a wrapped declaration on a multi-statement
* line stays scoped inside the try. A selection whose end handle sits at column
* 0 of the line after the last selected line (the common "drag to select whole
* lines" gesture) would otherwise wrap that trailing, visually-unselected line,
* so it is trimmed. Returns (startLine, endLine), 0-based inclusive.
*/
fun resolveSurroundLines(
leftLine: Int,
Expand All @@ -27,19 +27,24 @@ fun resolveSurroundLines(

/**
* Wraps lines [startLine]..[endLine] (0-based, inclusive) of [text] in a
* try/catch block. Whole-line based: columns are ignored and full lines are
* replaced. Indentation is computed here so the result is correct even without a
* follow-up formatter. Returns null when the span is blank (a whitespace-only
* selection is an intended silent no-op) or out of range.
* try/catch block. The catch syntax is language-specific and provided by the
* caller: [catchClause] is the clause without braces (e.g. `catch (Exception e)`)
* and [catchBody] the single handler statement. Whole-line based: columns are
* ignored and full lines are replaced. Indentation is computed here so the
* result is correct even without a follow-up formatter. Returns null when the
* span is blank (a whitespace-only selection is an intended silent no-op) or
* out of range.
*/
fun computeSurroundWithTryCatchEdit(
text: String,
startLine: Int,
endLine: Int,
catchClause: String,
catchBody: String,
): TextEdit? {
val nl = if (text.contains("\r\n")) "\r\n" else "\n"
val lines = text.split(nl)
if (startLine < 0 || startLine > endLine || endLine >= lines.size) {
if (startLine !in 0..endLine || endLine >= lines.size) {
return null
}

Expand All @@ -58,8 +63,12 @@ fun computeSurroundWithTryCatchEdit(
buildString {
append(baseIndent).append("try {").append(nl)
append(body).append(nl)
append(baseIndent).append("} catch (e: Exception) {").append(nl)
append(baseIndent).append(indentUnit).append("e.printStackTrace()").append(nl)
append(baseIndent)
.append("} ")
.append(catchClause)
.append(" {")
.append(nl)
append(baseIndent).append(indentUnit).append(catchBody).append(nl)
append(baseIndent).append("}")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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.lsp.api.ILanguageServerRegistry
import com.itsaky.androidide.lsp.models.CodeActionItem
import com.itsaky.androidide.lsp.models.CodeActionKind
import com.itsaky.androidide.lsp.models.Command
import com.itsaky.androidide.lsp.models.DocumentChange
import com.itsaky.androidide.lsp.models.TextEdit
import com.itsaky.androidide.resources.R
import org.slf4j.LoggerFactory
import java.io.File

class SurroundWithTryCatchAction(
lang: String,
private val targetFileExtensions: List<String>,
private val serverId: String,
private val catchClause: String,
private val catchBody: 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.surroundWithTryCatch"

private val logger = LoggerFactory.getLogger(SurroundWithTryCatchAction::class.java)
}

constructor(
lang: String,
extension: String,
serverId: String,
catchClause: String,
catchBody: String,
tag: String,
) : this(lang, listOf(extension), serverId, catchClause, catchBody, tag)
Comment thread
Daniel-ADFA marked this conversation as resolved.

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

// Reads the editor selection, so it must run on the UI thread (as CommentLineAction does).
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_surround_with_try_catch)

val file = data.requireFile()
if (file.extension !in targetFileExtensions) {
markInvisible()
return
}
}

override suspend fun execAction(data: ActionData): List<TextEdit> {
val editor = data.requireEditor()
val cursor = editor.cursor
val (startLine, endLine) =
resolveSurroundLines(
cursor.leftLine,
cursor.leftColumn,
cursor.rightLine,
cursor.rightColumn,
)
val edit =
computeSurroundWithTryCatchEdit(
editor.text.toString(),
startLine,
endLine,
catchClause,
catchBody,
) ?: return emptyList()
return listOf(edit)
}

override fun postExec(
data: ActionData,
result: Any,
) {
super.postExec(data, result)

if (result !is List<*> || result.isEmpty()) {
return
}

@Suppress("UNCHECKED_CAST")
val edits = result as List<TextEdit>

val client =
ILanguageServerRegistry.default.getServer(serverId)?.client
?: run {
logger.warn("No language client set. Cannot complete action.")
return
}

val file = data.requireFile()
client.performCodeAction(
CodeActionItem(
title = label,
changes = listOf(DocumentChange(file = file.toPath(), edits = edits)),
kind = CodeActionKind.QuickFix,
command = Command.CMD_FORMAT_CODE,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsaky.androidide.lsp.kotlin.utils
package com.itsaky.androidide.lsp.actions

import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.models.Position
Expand All @@ -9,9 +9,28 @@ import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class SurroundWithTryCatchTest {
private companion object {
const val KT_CATCH_CLAUSE = "catch (e: Exception)"
const val KT_CATCH_BODY = "e.printStackTrace()"
const val JAVA_CATCH_CLAUSE = "catch (Exception e)"
const val JAVA_CATCH_BODY = "e.printStackTrace();"
}

private fun kotlinEdit(
text: String,
startLine: Int,
endLine: Int,
) = computeSurroundWithTryCatchEdit(text, startLine, endLine, KT_CATCH_CLAUSE, KT_CATCH_BODY)

private fun javaEdit(
text: String,
startLine: Int,
endLine: Int,
) = computeSurroundWithTryCatchEdit(text, startLine, endLine, JAVA_CATCH_CLAUSE, JAVA_CATCH_BODY)

@Test
fun `single unindented line is wrapped`() {
val edit = computeSurroundWithTryCatchEdit("foo()", 0, 0)
val edit = kotlinEdit("foo()", 0, 0)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"try {\n\tfoo()\n} catch (e: Exception) {\n\te.printStackTrace()\n}",
Expand All @@ -26,7 +45,7 @@ class SurroundWithTryCatchTest {
@Test
fun `indented multi-line block preserves and deepens indentation`() {
val text = "fun f() {\n\tval a = read()\n\tprocess(a)\n}"
val edit = computeSurroundWithTryCatchEdit(text, 1, 2)
val edit = kotlinEdit(text, 1, 2)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"\ttry {\n\t\tval a = read()\n\t\tprocess(a)\n\t} catch (e: Exception) {\n\t\te.printStackTrace()\n\t}",
Expand All @@ -40,7 +59,7 @@ class SurroundWithTryCatchTest {

@Test
fun `blank lines inside the span are not indented`() {
val edit = computeSurroundWithTryCatchEdit("a()\n\nb()", 0, 2)
val edit = kotlinEdit("a()\n\nb()", 0, 2)
assertThat(edit!!.newText).isEqualTo(
"try {\n\ta()\n\n\tb()\n} catch (e: Exception) {\n\te.printStackTrace()\n}",
)
Expand All @@ -49,7 +68,7 @@ class SurroundWithTryCatchTest {
@Test
fun `space-indented file produces a spaces-only body`() {
val text = "fun f() {\n val a = read()\n process(a)\n}"
val edit = computeSurroundWithTryCatchEdit(text, 1, 2)
val edit = kotlinEdit(text, 1, 2)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
" try {\n val a = read()\n process(a)\n" +
Expand All @@ -61,16 +80,41 @@ class SurroundWithTryCatchTest {
)
}

@Test
fun `java catch clause and semicolon body are emitted`() {
val edit = javaEdit("foo();", 0, 0)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"try {\n\tfoo();\n} catch (Exception e) {\n\te.printStackTrace();\n}",
)
assertThat(edit.range).isEqualTo(
Range(Position(0, 0, 0), Position(0, 6, 6)),
)
}

@Test
fun `java indented multi-line block preserves and deepens indentation`() {
val text = "void f() {\n\tint a = read();\n\tprocess(a);\n}"
val edit = javaEdit(text, 1, 2)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"\ttry {\n\t\tint a = read();\n\t\tprocess(a);\n\t} catch (Exception e) {\n\t\te.printStackTrace();\n\t}",
)
assertThat(edit.range).isEqualTo(
Range(Position(1, 0, 11), Position(2, 12, 39)),
)
}

@Test
fun `whitespace-only span returns null`() {
assertThat(computeSurroundWithTryCatchEdit("\n \n", 0, 1)).isNull()
assertThat(kotlinEdit("\n \n", 0, 1)).isNull()
}

@Test
fun `out-of-range span returns null`() {
assertThat(computeSurroundWithTryCatchEdit("foo()", 0, 5)).isNull()
assertThat(computeSurroundWithTryCatchEdit("foo()", -1, 0)).isNull()
assertThat(computeSurroundWithTryCatchEdit("foo()", 2, 1)).isNull()
assertThat(kotlinEdit("foo()", 0, 5)).isNull()
assertThat(kotlinEdit("foo()", -1, 0)).isNull()
assertThat(kotlinEdit("foo()", 2, 1)).isNull()
Comment thread
Daniel-ADFA marked this conversation as resolved.
}

@Test
Expand All @@ -96,7 +140,7 @@ class SurroundWithTryCatchTest {

@Test
fun `CRLF file preserves carriage returns and replace indices`() {
val edit = computeSurroundWithTryCatchEdit("a()\r\nb()", 0, 1)
val edit = kotlinEdit("a()\r\nb()", 0, 1)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"try {\r\n\ta()\r\n\tb()\r\n} catch (e: Exception) {\r\n\te.printStackTrace()\r\n}",
Expand All @@ -109,7 +153,7 @@ class SurroundWithTryCatchTest {
@Test
fun `stray whitespace-only line does not switch a tab file to spaces`() {
val text = "fun f() {\n \n\tval a = read()\n\tprocess(a)\n}"
val edit = computeSurroundWithTryCatchEdit(text, 2, 3)
val edit = kotlinEdit(text, 2, 3)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"\ttry {\n\t\tval a = read()\n\t\tprocess(a)\n\t} catch (e: Exception) {\n\t\te.printStackTrace()\n\t}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ 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.SurroundWithTryCatchAction
import com.itsaky.androidide.lsp.actions.UncommentLineAction
import com.itsaky.androidide.lsp.java.JavaLanguageServer
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
Expand Down Expand Up @@ -51,6 +53,8 @@ object JavaCodeActionsMenu : IActionsMenuProvider {
private const val LANG = "java"
private const val EXT = "java"
private const val LINE_COMMENT_TOKEN = "//"
private const val CATCH_CLAUSE = "catch (Exception e)"
private const val CATCH_BODY = "e.printStackTrace();"

override val actions: List<ActionItem> =
listOf(
Expand Down Expand Up @@ -81,5 +85,13 @@ object JavaCodeActionsMenu : IActionsMenuProvider {
GenerateToStringMethodAction(),
RemoveUnusedImportsAction(),
OrganizeImportsAction(),
SurroundWithTryCatchAction(
LANG,
EXT,
JavaLanguageServer.SERVER_ID,
CATCH_CLAUSE,
CATCH_BODY,
TooltipTag.EDITOR_CODE_ACTIONS_TRY_CATCH,
),
)
}
Loading
Loading