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 @@ -80,6 +80,7 @@ object TooltipTag {
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"
const val EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH = "editor.codeactions.kotlin.trycatch"

const val EXIT_TO_MAIN = "exit.to.main"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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

object KotlinCodeActionsMenu : IActionsMenuProvider {
private const val KT_LANG = "kt"
Expand All @@ -20,6 +21,7 @@ object KotlinCodeActionsMenu : IActionsMenuProvider {
UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN),
AddImportAction(),
OrganizeImportsAction(),
SurroundWithTryCatchAction(),
NullSafetyAction(),
ImplementMembersAction(),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.itsaky.androidide.lsp.kotlin.actions

import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.requireEditor
import com.itsaky.androidide.actions.requireFile
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.kotlin.utils.computeSurroundWithTryCatchEdit
import com.itsaky.androidide.lsp.kotlin.utils.resolveSurroundLines
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

class SurroundWithTryCatchAction : BaseKotlinCodeAction() {
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 var label: String = ""

// Reads the editor selection, so it must run on the UI thread (as CommentLineAction does).
override var requiresUIThread: Boolean = true

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,
) ?: 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 =
data.languageClient
?: 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
@@ -0,0 +1,97 @@
package com.itsaky.androidide.lsp.kotlin.utils

import com.itsaky.androidide.lsp.models.TextEdit
import com.itsaky.androidide.models.Position
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.
*/
fun resolveSurroundLines(
leftLine: Int,
leftColumn: Int,
rightLine: Int,
rightColumn: Int,
): Pair<Int, Int> {
val endLine =
if (rightColumn == 0 && rightLine > leftLine) rightLine - 1 else rightLine
return leftLine to endLine
}

/**
* 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.
*/
fun computeSurroundWithTryCatchEdit(
text: String,
startLine: Int,
endLine: Int,
): 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) {
return null
}

val selected = lines.subList(startLine, endLine + 1)
if (selected.all(String::isBlank)) {
return null
}

val baseIndent =
selected.first(String::isNotBlank).takeWhile { it == ' ' || it == '\t' }
val indentUnit = detectIndentUnit(lines)
val body =
selected.joinToString(nl) { if (it.isBlank()) it else "$indentUnit$it" }

val newText =
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("}")
}

val startIndex = lineStartIndex(lines, startLine, nl)
val endCol = lines[endLine].length
val endIndex = lineStartIndex(lines, endLine, nl) + endCol

return TextEdit(
range =
Range(
Position(startLine, 0, startIndex),
Position(endLine, endCol, endIndex),
),
newText = newText,
)
}

/** 4 spaces if the file's first indented line starts with a space, else a tab. */
private fun detectIndentUnit(lines: List<String>): String {
val firstIndented =
lines.firstOrNull { it.isNotBlank() && (it[0] == ' ' || it[0] == '\t') }
return if (firstIndented != null && firstIndented[0] == ' ') " " else "\t"
}

private fun lineStartIndex(
lines: List<String>,
line: Int,
nl: String,
): Int {
var index = 0
for (i in 0 until line) {
index += lines[i].length + nl.length
}
return index
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.itsaky.androidide.lsp.kotlin.utils

import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class SurroundWithTryCatchTest {
@Test
fun `single unindented line is wrapped`() {
val edit = computeSurroundWithTryCatchEdit("foo()", 0, 0)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
"try {\n\tfoo()\n} catch (e: Exception) {\n\te.printStackTrace()\n}",
)
assertThat(edit.range).isEqualTo(
Range(Position(0, 0, 0), Position(0, 5, 5)),
)
assertThat(edit.range.start.index).isEqualTo(0)
assertThat(edit.range.end.index).isEqualTo(5)
}

@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)
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}",
)
assertThat(edit.range).isEqualTo(
Range(Position(1, 0, 10), Position(2, 11, 37)),
)
assertThat(edit.range.start.index).isEqualTo(10)
assertThat(edit.range.end.index).isEqualTo(37)
}

@Test
fun `blank lines inside the span are not indented`() {
val edit = computeSurroundWithTryCatchEdit("a()\n\nb()", 0, 2)
assertThat(edit!!.newText).isEqualTo(
"try {\n\ta()\n\n\tb()\n} catch (e: Exception) {\n\te.printStackTrace()\n}",
)
}

@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)
assertThat(edit).isNotNull()
assertThat(edit!!.newText).isEqualTo(
" try {\n val a = read()\n process(a)\n" +
" } catch (e: Exception) {\n e.printStackTrace()\n }",
)
assertThat(edit.newText).doesNotContain("\t")
assertThat(edit.range).isEqualTo(
Range(Position(1, 0, 10), Position(2, 14, 43)),
)
}

@Test
fun `whitespace-only span returns null`() {
assertThat(computeSurroundWithTryCatchEdit("\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()
}

@Test
fun `trailing column-0 selection drops the unselected last line`() {
assertThat(resolveSurroundLines(1, 2, 3, 0)).isEqualTo(1 to 2)
}

@Test
fun `column-0 on a single-line selection is not trimmed`() {
assertThat(resolveSurroundLines(2, 0, 2, 0)).isEqualTo(2 to 2)
}

@Test
fun `non-zero end column keeps the last line`() {
assertThat(resolveSurroundLines(1, 4, 3, 7)).isEqualTo(1 to 3)
}

@Test
fun `partial mid-line columns still select whole lines`() {
assertThat(resolveSurroundLines(0, 4, 0, 12)).isEqualTo(0 to 0)
assertThat(resolveSurroundLines(0, 4, 2, 9)).isEqualTo(0 to 2)
}

@Test
fun `CRLF file preserves carriage returns and replace indices`() {
val edit = computeSurroundWithTryCatchEdit("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}",
)
assertThat(edit.range).isEqualTo(
Range(Position(0, 0, 0), Position(1, 3, 8)),
)
}

@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)
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}",
)
assertThat(edit.newText).doesNotContain(" ")
}
}
1 change: 1 addition & 0 deletions resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@
<string name="action_generate_setters_getters">Generate setters/getters</string>
<string name="action_add_throws">Add \'throws\'</string>
<string name="action_comment_line">Comment line</string>
<string name="action_surround_with_try_catch">Surround with try/catch</string>
<string name="action_create_missing_method">Create missing method</string>
<string name="action_convert_to_block">Convert to block</string>
<string name="action_generate_constructor">Generate constructor</string>
Expand Down
Loading