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 2eed69f3d7..6af2438c87 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 @@ -5,6 +5,7 @@ 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.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction object KotlinCodeActionsMenu : IActionsMenuProvider { @@ -18,5 +19,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), AddImportAction(), OrganizeImportsAction(), + ImplementMembersAction(), ) } 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 new file mode 100644 index 0000000000..5c851f3c4a --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ImplementMembersAction.kt @@ -0,0 +1,222 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireEditor +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 +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.utils.membersToImplement +import com.itsaky.androidide.lsp.kotlin.utils.renderOverrideStub +import com.itsaky.androidide.lsp.kotlin.utils.toRange +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.models.Range +import com.itsaky.androidide.resources.R +import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolModality +import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtFile +import org.slf4j.LoggerFactory +import java.nio.file.Path + +class ImplementMembersAction : BaseKotlinCodeAction() { + 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 label: String = "" + + companion object { + private val logger = LoggerFactory.getLogger(ImplementMembersAction::class.java) + } + + // Intentionally no prepare() visibility gate: the action is visible on any Kotlin file (BaseKotlinCodeAction + // only checks the file type) and simply produces no edit when the enclosing class/object has nothing to + // implement. Deciding that up front needs a K2 analysis session, which is too costly for prepare() (UI + // thread). Matches OrganizeImportsAction. + + override suspend fun execAction(data: ActionData): List { + val server = data.get() ?: return emptyList() + val nioPath = data.requireFile().toPath() + val offset = data.requireEditor().cursor.left + val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() + return computeImplementMembersEdit(env, nioPath, offset) + } + + /** + * Computes the edit that inserts stubs for the abstract members left unimplemented by the class or + * object enclosing [offset] in the file at [nioPath]. The current [KtFile] is fetched BEFORE + * entering [read] (deadlock rule: never block on `getCurrentKtFile(...).get()` inside `project.read`). + * + * Returns an empty list when there is nothing to do (cursor not in a class/object, the declaration + * is abstract/an interface/enum, or every required member is already implemented) *and* whenever + * anything in this pipeline throws: the action framework only catches [IllegalArgumentException] and + * this runs on a scope with no exception handler, so an uncaught throw here would crash the app. + * Degrading to zero edits is always safe -- it inserts nothing rather than a partial rewrite. + */ + internal fun computeImplementMembersEdit( + env: AbstractCompilationEnvironment, + nioPath: Path, + offset: Int, + ): List = + runCatching { + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() + env.project.read { + val classOrObject = findEnclosingClassOrObject(ktFile, offset) ?: return@read emptyList() + analyzeMaybeDangling(ktFile) { + val classSymbol = classOrObject.symbol as? KaClassSymbol ?: return@analyzeMaybeDangling emptyList() + if (!isImplementable(classSymbol)) return@analyzeMaybeDangling emptyList() + + val classIndent = classIndentOf(ktFile, classOrObject) + val unit = detectIndentUnit(ktFile.text) + val memberIndent = memberIndentOf(ktFile, classOrObject, classIndent, unit) + val stubs = membersToImplement(classSymbol).mapNotNull { renderOverrideStub(it, memberIndent, unit) } + if (stubs.isEmpty()) return@analyzeMaybeDangling emptyList() + + buildInsertionEdit(ktFile, classOrObject, stubs, classIndent) + } + } + }.getOrElse { e -> + logger.warn("Failed to compute implement-members edit", e) + emptyList() + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is List<*> || result.isEmpty()) return + + @Suppress("UNCHECKED_CAST") + result as List + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot implement members.") + return + } + val file = data.requireFile() + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = file.toPath(), edits = result)), + kind = CodeActionKind.QuickFix, + command = Command("", ""), // stubs are emitted fully indented; no CMD_FORMAT_CODE (a Kotlin no-op) + ), + ) + } +} + +/** Innermost [KtClassOrObject] containing [offset], or null. Tries [offset] then [offset]-1 so a caret sitting just after a token still resolves. */ +private fun findEnclosingClassOrObject( + ktFile: KtFile, + offset: Int, +): KtClassOrObject? { + val element = ktFile.findElementAt(offset) ?: ktFile.findElementAt((offset - 1).coerceAtLeast(0)) + return element?.let { PsiTreeUtil.getParentOfType(it, KtClassOrObject::class.java, false) } +} + +/** Only concrete classes and objects are *required* to implement inherited members; abstract/sealed classes, interfaces, enums and annotations are not. */ +private fun isImplementable(classSymbol: KaClassSymbol): Boolean { + if (classSymbol.modality == KaSymbolModality.ABSTRACT || classSymbol.modality == KaSymbolModality.SEALED) return false + return classSymbol.classKind == KaClassKind.CLASS || classSymbol.classKind.isObject +} + +/** Leading whitespace of the line the declaration starts on (its base indentation). */ +private fun classIndentOf( + ktFile: KtFile, + classOrObject: KtClassOrObject, +): String = leadingIndentAt(ktFile.text, classOrObject.textRange.startOffset) + +/** The indentation each generated member should carry, matched to the surrounding code. */ +private fun memberIndentOf( + ktFile: KtFile, + classOrObject: KtClassOrObject, + classIndent: String, + unit: String, +): String { + // Prefer an existing member's exact indentation so stubs line up with hand-written code. + val firstDecl = classOrObject.body?.declarations?.firstOrNull() + if (firstDecl != null) return leadingIndentAt(ktFile.text, firstDecl.textRange.startOffset) + return classIndent + unit +} + +/** Leading run of spaces/tabs on the line containing [offset]. */ +private fun leadingIndentAt( + text: String, + offset: Int, +): String { + val lineStart = text.lastIndexOf('\n', offset - 1) + 1 + return text.substring(lineStart, offset).takeWhile { it == ' ' || it == '\t' } +} + +/** + * One indentation level for [text], inferred from its existing lines: a tab if any line is + * tab-indented, otherwise the smallest positive run of leading spaces, defaulting to a single tab + * when nothing is indented (the project convention). This keeps generated stubs consistent with the + * file's own style rather than assuming tabs -- TextEdits bypass the editor's reindent, so the text + * must be final. + */ +private fun detectIndentUnit(text: String): String { + var minSpaces = Int.MAX_VALUE + for (line in text.splitToSequence('\n')) { + if (line.isEmpty() || line[0] == '\t') { + if (line.isNotEmpty()) return "\t" + continue + } + if (line[0] != ' ') continue + val spaces = line.takeWhile { it == ' ' }.length + if (spaces in 1 until minSpaces) minSpaces = spaces + } + return if (minSpaces == Int.MAX_VALUE) "\t" else " ".repeat(minSpaces) +} + +/** + * A single [TextEdit] that drops [stubs] into [classOrObject]'s body: + * - no body -> append ` { ... }` after the declaration; + * - empty body -> replace the whitespace between the braces; + * - non-empty body -> insert after the last existing member (leaving existing code untouched). + */ +private fun buildInsertionEdit( + ktFile: KtFile, + classOrObject: KtClassOrObject, + stubs: List, + classIndent: String, +): List { + val memberBlock = stubs.joinToString("\n\n") + val body = classOrObject.body + + val edit = + when { + body == null -> { + val at = classOrObject.textRange.endOffset + TextEdit(TextRange(at, at).toRange(ktFile), " {\n$memberBlock\n$classIndent}") + } + + body.declarations.isNotEmpty() -> { + val at = + body.declarations + .last() + .textRange.endOffset + TextEdit(TextRange(at, at).toRange(ktFile), "\n\n$memberBlock") + } + + else -> { + val l = body.lBrace?.textRange?.endOffset ?: return emptyList() + val r = body.rBrace?.textRange?.startOffset ?: return emptyList() + TextEdit(TextRange(l, r).toRange(ktFile), "\n$memberBlock\n$classIndent") + } + } + + return if (edit.range == Range.NONE) emptyList() else listOf(edit) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubs.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubs.kt new file mode 100644 index 0000000000..66c2eb9520 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubs.kt @@ -0,0 +1,152 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolModality +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolVisibility +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.symbols.receiverType +import org.jetbrains.kotlin.analysis.api.symbols.typeParameters + +/** The body every generated stub gets; `TODO` returns `Nothing`, so it type-checks for any return type. */ +private const val STUB_BODY = "TODO(\"Not yet implemented\")" + +/** + * The abstract functions and properties [classSymbol] inherits but does not yet implement, in a + * stable (name-sorted) order. MUST be called inside an `analyze` block. + * + * A member's *effective* modality in [classSymbol]'s member scope is [KaSymbolModality.ABSTRACT] only + * when no supertype (and not the class itself) provides a concrete override. Overridden members show + * up non-abstract and are excluded. Members inherited unimplemented from several supertypes collapse + * to a single intersection symbol here, so each signature is rendered once. + */ +internal fun KaSession.membersToImplement(classSymbol: KaClassSymbol): List = + classSymbol.memberScope.callables + .filter { it.modality == KaSymbolModality.ABSTRACT } + .filter { it is KaNamedFunctionSymbol || it is KaPropertySymbol } + .sortedBy { (it as? KaNamedSymbol)?.name?.asString() ?: "" } + .toList() + +/** + * Renders [member] as a complete `override` declaration, ready to drop into a class body. Every line + * is prefixed with [indent] (the base indentation of a member in the target class); the body is + * indented one [unit] deeper. The result has no leading or trailing newline. Returns null for a + * member this renderer doesn't handle. + * + * LSP TextEdits bypass the editor's auto-indent, so the emitted text must already be final: nothing + * re-indents it after it is applied. [unit] is the surrounding file's own indentation step (a tab or + * N spaces), so stubs match the file's style rather than assuming tabs. + */ +@OptIn(KaExperimentalApi::class) +internal fun KaSession.renderOverrideStub( + member: KaCallableSymbol, + indent: String, + unit: String, +): String? = + when (member) { + is KaNamedFunctionSymbol -> renderFunctionStub(member, indent, unit) + is KaPropertySymbol -> renderPropertyStub(member, indent, unit) + else -> null + } + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderFunctionStub( + fn: KaNamedFunctionSymbol, + indent: String, + unit: String, +): String { + val modifiers = + buildString { + append(visibilityPrefix(fn.visibility)) + append("override ") + if (fn.isSuspend) append("suspend ") + if (fn.isOperator) append("operator ") + if (fn.isInfix) append("infix ") + } + val typeParams = renderTypeParams(fn.typeParameters) + val receiver = fn.receiverType?.let { "${renderName(it)}." } ?: "" + val params = + fn.valueParameters.joinToString(", ") { p -> + val vararg = if (p.isVararg) "vararg " else "" + "$vararg${p.name.asString()}: ${renderName(p.returnType)}" + } + val returnType = if (fn.returnType.isUnitType) "" else ": ${renderName(fn.returnType)}" + val where = renderWhereClause(fn.typeParameters) + + return buildString { + append(indent).append(modifiers).append("fun") + if (typeParams.isNotEmpty()) append(" ").append(typeParams) + append(" ") + .append(receiver) + .append(fn.name.asString()) + .append("(") + .append(params) + .append(")") + append(returnType) + append(where) + append(" {\n") + append(indent).append(unit).append(STUB_BODY).append("\n") + append(indent).append("}") + } +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderPropertyStub( + prop: KaPropertySymbol, + indent: String, + unit: String, +): String { + val modifiers = "${visibilityPrefix(prop.visibility)}override " + val keyword = if (prop.isVal) "val" else "var" + val receiver = prop.receiverType?.let { "${renderName(it)}." } ?: "" + val type = renderName(prop.returnType) + + return buildString { + append(indent).append(modifiers).append(keyword).append(" ") + append(receiver) + .append(prop.name.asString()) + .append(": ") + .append(type) + .append("\n") + append(indent).append(unit).append("get() = ").append(STUB_BODY) + if (!prop.isVal) { + append("\n").append(indent).append(unit).append("set(value) {}") + } + } +} + +private fun visibilityPrefix(visibility: KaSymbolVisibility): String = + when (visibility) { + KaSymbolVisibility.PROTECTED -> "protected " + KaSymbolVisibility.INTERNAL -> "internal " + else -> "" + } + +/** Renders `` (or empty). Multi-bound parameters are emitted via [renderWhereClause]. */ +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderTypeParams(typeParams: List): String { + if (typeParams.isEmpty()) return "" + return typeParams.joinToString(", ", "<", ">") { tp -> + val bounds = meaningfulBounds(tp) + if (bounds.size == 1) "${tp.name.asString()} : ${renderName(bounds.single())}" else tp.name.asString() + } +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderWhereClause(typeParams: List): String { + val clauses = + typeParams.flatMap { tp -> + val bounds = meaningfulBounds(tp) + if (bounds.size > 1) bounds.map { "${tp.name.asString()} : ${renderName(it)}" } else emptyList() + } + return if (clauses.isEmpty()) "" else " where ${clauses.joinToString(", ")}" +} + +/** Upper bounds excluding the implicit `Any?` that an unbounded type parameter carries. */ +@OptIn(KaExperimentalApi::class) +private fun KaSession.meaningfulBounds(tp: KaTypeParameterSymbol) = tp.upperBounds.filterNot { renderName(it) == "Any?" } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubsTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubsTest.kt new file mode 100644 index 0000000000..be83489ffe --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubsTest.kt @@ -0,0 +1,202 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AbstractMemberStubsTest : KtLspTest() { + /** Renders every unimplemented member of the class/object named [className] with a one-tab member indent. */ + private fun stubsFor( + content: String, + className: String, + ): List { + val ktFile = createSourceFile("Sample.kt", content) + return env.project.read { + analyzeMaybeDangling(ktFile) { + val decl = + PsiTreeUtil + .collectElementsOfType(ktFile, KtClassOrObject::class.java) + .first { it.name == className } + val symbol = decl.symbol as KaClassSymbol + membersToImplement(symbol).mapNotNull { renderOverrideStub(it, "\t", "\t") } + } + } + } + + @Test + fun `renders unit-returning function stub`() { + val stubs = + stubsFor( + """ + package p + interface I { fun foo() } + class C : I + """.trimIndent(), + "C", + ) + assertEquals(1, stubs.size) + assertEquals( + "\toverride fun foo() {\n\t\tTODO(\"Not yet implemented\")\n\t}", + stubs.single(), + ) + } + + @Test + fun `renders function stub with params and return type`() { + val stubs = + stubsFor( + """ + package p + interface I { fun bar(x: Int, s: String): Int } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride fun bar(x: Int, s: String): Int {\n\t\tTODO(\"Not yet implemented\")\n\t}", + stubs.single(), + ) + } + + @Test + fun `renders val property stub with a getter`() { + val stubs = + stubsFor( + """ + package p + interface I { val p: Int } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride val p: Int\n\t\tget() = TODO(\"Not yet implemented\")", + stubs.single(), + ) + } + + @Test + fun `renders var property stub with getter and setter`() { + val stubs = + stubsFor( + """ + package p + interface I { var q: String } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride var q: String\n\t\tget() = TODO(\"Not yet implemented\")\n\t\tset(value) {}", + stubs.single(), + ) + } + + @Test + fun `renders generic function stub`() { + val stubs = + stubsFor( + """ + package p + interface I { fun g(t: T): T } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride fun g(t: T): T {\n\t\tTODO(\"Not yet implemented\")\n\t}", + stubs.single(), + ) + } + + @Test + fun `renders bounded generic function stub`() { + val stubs = + stubsFor( + """ + package p + interface I { fun h(t: T) } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride fun h(t: T) {\n\t\tTODO(\"Not yet implemented\")\n\t}", + stubs.single(), + ) + } + + @Test + fun `renders suspend function stub`() { + val stubs = + stubsFor( + """ + package p + interface I { suspend fun s() } + class C : I + """.trimIndent(), + "C", + ) + assertEquals( + "\toverride suspend fun s() {\n\t\tTODO(\"Not yet implemented\")\n\t}", + stubs.single(), + ) + } + + @Test + fun `collects members from multiple supertypes`() { + val stubs = + stubsFor( + """ + package p + interface A { fun a() } + interface B { fun b() } + class C : A, B + """.trimIndent(), + "C", + ) + assertEquals(2, stubs.size) + assertTrue(stubs.any { it.contains("fun a()") }) + assertTrue(stubs.any { it.contains("fun b()") }) + } + + @Test + fun `no stubs when everything is implemented`() { + val stubs = + stubsFor( + """ + package p + interface I { fun foo() } + class C : I { + override fun foo() {} + } + """.trimIndent(), + "C", + ) + assertTrue("fully-implemented class needs no stubs", stubs.isEmpty()) + } + + @Test + fun `no stubs for concrete inherited members`() { + val stubs = + stubsFor( + """ + package p + interface I { + fun abstractOne() + fun defaulted() {} + } + class C : I + """.trimIndent(), + "C", + ) + assertEquals(1, stubs.size) + assertTrue("only the abstract member needs a stub", stubs.single().contains("fun abstractOne()")) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt new file mode 100644 index 0000000000..a067ab4236 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImplementMembersEndToEndTest.kt @@ -0,0 +1,208 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.lsp.models.TextEdit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImplementMembersEndToEndTest : KtLspTest() { + /** Writes [content] to Main.kt and runs the action with the caret at [caret]. */ + private fun edits( + content: String, + caret: Int, + ): List { + createSourceFile("Main.kt", content) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + return ImplementMembersAction().computeImplementMembersEdit(env, mainPath, caret) + } + + /** Applies a single edit's newText over its [TextEdit.range] index span, returning the resulting text. */ + private fun apply( + content: String, + edit: TextEdit, + ): String = content.substring(0, edit.range.start.index) + edit.newText + content.substring(edit.range.end.index) + + @Test + fun `adds body and stub to a class with no body`() { + val content = + """ + package p + interface I { fun foo() } + class C : I + """.trimIndent() + val result = edits(content, content.indexOf("class C") + 2) + assertEquals(1, result.size) + assertEquals( + """ + package p + interface I { fun foo() } + class C : I { + override fun foo() { + TODO("Not yet implemented") + } + } + """.trimIndent(), + apply(content, result.single()), + ) + } + + @Test + fun `adds body and stub to an object with no body`() { + val content = + """ + package p + interface I { fun foo() } + object O : I + """.trimIndent() + val result = edits(content, content.indexOf("object O") + 2) + assertEquals( + """ + package p + interface I { fun foo() } + object O : I { + override fun foo() { + TODO("Not yet implemented") + } + } + """.trimIndent(), + apply(content, result.single()), + ) + } + + @Test + fun `indents a nested class one level deeper`() { + val content = + """ + package p + interface I { fun foo() } + class Outer { + class C : I + } + """.trimIndent() + val result = edits(content, content.indexOf("class C") + 2) + assertEquals( + """ + package p + interface I { fun foo() } + class Outer { + class C : I { + override fun foo() { + TODO("Not yet implemented") + } + } + } + """.trimIndent(), + apply(content, result.single()), + ) + } + + @Test + fun `matches the file's space indentation`() { + // Explicit \n strings so the 4-space indentation is unambiguous (the test file itself uses tabs). + val content = + "package p\n" + + "interface I {\n" + + " fun foo()\n" + + "}\n" + + "class C : I" + val result = edits(content, content.indexOf("class C") + 2) + val expected = + "package p\n" + + "interface I {\n" + + " fun foo()\n" + + "}\n" + + "class C : I {\n" + + " override fun foo() {\n" + + " TODO(\"Not yet implemented\")\n" + + " }\n" + + "}" + assertEquals(expected, apply(content, result.single())) + } + + @Test + fun `inserts after existing members`() { + val content = + """ + package p + interface I { fun foo() } + class C : I { + val x = 1 + } + """.trimIndent() + val result = edits(content, content.indexOf("val x")) + assertEquals( + """ + package p + interface I { fun foo() } + class C : I { + val x = 1 + + override fun foo() { + TODO("Not yet implemented") + } + } + """.trimIndent(), + apply(content, result.single()), + ) + } + + @Test + fun `fills an empty body`() { + val content = + """ + package p + interface I { fun foo() } + class C : I {} + """.trimIndent() + val result = edits(content, content.indexOf("class C") + 2) + assertEquals( + """ + package p + interface I { fun foo() } + class C : I { + override fun foo() { + TODO("Not yet implemented") + } + } + """.trimIndent(), + apply(content, result.single()), + ) + } + + @Test + fun `no edit when caret is not in a class`() { + val content = + """ + package p + interface I { fun foo() } + class C : I + """.trimIndent() + assertTrue(edits(content, content.indexOf("package")).isEmpty()) + } + + @Test + fun `no edit when class already implements everything`() { + val content = + """ + package p + interface I { fun foo() } + class C : I { + override fun foo() {} + } + """.trimIndent() + assertTrue(edits(content, content.indexOf("class C") + 2).isEmpty()) + } + + @Test + fun `no edit for an abstract class`() { + val content = + """ + package p + interface I { fun foo() } + abstract class C : I + """.trimIndent() + assertTrue(edits(content, content.indexOf("class C") + 2).isEmpty()) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index fa547343a5..d64ef58a3a 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -535,6 +535,7 @@ Generate toString() Remove unused imports Organize imports + Implement members Unable to generate toString() implementation toString() is already overridden Generate missing constructor