From 0667272e981230908bd789fb570cbaf7d57fd6fc Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sat, 5 Sep 2026 14:35:00 +0100 Subject: [PATCH] ADFA-5048: Add the Java extract-method signature analysis Derives everything the sheet renders and the edit builder emits for one region, or the typed reason it cannot be moved faithfully (ADR 0014). ThrownTypes.kt carries the one rule with no Kotlin analogue: the region's thrown checked types, from invocation and constructor thrownTypes, throw expressions and a try-with-resources close(), minus anything a try *inside* the region catches. Both halves matter -- under-declaring leaves the moved body uncompilable, over-declaring breaks the call site, which is only obliged to handle what the region actually threw. Copying the anchor's own throws clause instead is wrong in exactly the commonest case, a region inside a try whose method declares nothing. A generic `throws E` declines: getThrownTypes reports the callee's declared type variable and javac's public API does not expose what it was inferred to here. MethodSignature.kt is the entry point where the primitives meet, plus the two region-kind-specific parts: the single output the following code still needs, and the exits that decline (with the tail return as the one exception). ExtractMethodPlan.kt is the plain-data result, carrying a typed refusal rather than merely being empty, because "why not" is most of what this refactoring has to say. Java uses 8 of Kotlin's 13 reasons; OutputNotReturnable has no counterpart, since every Java local can be received back as `T x = extracted()`. Java has no local-method form, so unlike Kotlin there is no insert-before case and no "nowhere to anchor" refusal: a lambda in a field initializer anchors on the field. --- .../lsp/java/refactor/ExtractMethodPlan.kt | 186 +++++++ .../lsp/java/refactor/ExtractMethodPlanner.kt | 104 ++++ .../lsp/java/refactor/MethodSignature.kt | 459 ++++++++++++++++++ .../lsp/java/refactor/ThrownTypes.kt | 199 ++++++++ 4 files changed, 948 insertions(+) create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlan.kt create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlanner.kt create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/MethodSignature.kt create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ThrownTypes.kt diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlan.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlan.kt new file mode 100644 index 0000000000..ad82663a29 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlan.kt @@ -0,0 +1,186 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan + +/** One derived parameter of the new method. Names are the originals, unchanged (R5). */ +data class MethodParameter( + val name: String, + val typeText: String, +) + +/** What goes inside the new method's braces. */ +sealed interface ExtractedBody { + /** + * The region's expression text. [needsReturn] is false only for a `void`-typed expression, where + * the method returns `void` and a bare statement is all that would compile. + */ + data class ExpressionBody( + val needsReturn: Boolean, + ) : ExtractedBody + + /** + * The statements verbatim. [trailingReturn] is the `return ;` line appended for the + * single-output case, and null otherwise -- including the tail-return case, where the region already + * ends in a `return`. + */ + data class StatementBody( + val trailingReturn: String?, + ) : ExtractedBody +} + +/** How the region's own text is replaced (R6). */ +sealed interface CallSiteForm { + /** `extracted(args)` in an expression's place. */ + data object Call : CallSiteForm + + /** `extracted(args);` as a statement, for a statement range with no output. */ + data object CallStatement : CallSiteForm + + /** `int x = extracted(args);` for the single output [name]. */ + data class AssignOutput( + val typeText: String, + val name: String, + ) : CallSiteForm + + /** `return extracted(args);` for the tail-return case (R8). */ + data object Return : CallSiteForm +} + +/** + * One extractable region, fully derived: everything the sheet renders and the edit builder emits, with + * no trees left in it. + * + * [span] is what the call site replaces. [insertOffset] is the end of the anchor member -- the new + * method goes immediately after it (R4) -- and [insertIndent] is that member's own indentation, since + * nothing re-indents a code-action edit after it is applied. + * + * [returnTypeText] is spelled out rather than nullable: Java has no inferred method return type, so + * `void` is a type name like any other. + * + * [textBlockSpans] are the text block (`"""`) literals inside the region, in file offsets. Their + * interior whitespace is part of the literal's value, so re-indentation must leave those lines + * byte-for-byte (ADR 0014). + */ +data class ExtractMethodCandidate( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val modifiers: List, + val parameters: List, + val returnTypeText: String, + val thrownTypes: List, + val body: ExtractedBody, + val callSite: CallSiteForm, + val insertOffset: Int, + val insertIndent: String, + val textBlockSpans: List, +) + +/** + * Why a region could not be extracted. A refusal is a designed outcome, not an error (ADR 0014): each + * reason gets its own message naming the construct in the way, because a generic one reads as the + * feature being broken. + * + * Five of Kotlin's thirteen reasons have no Java counterpart and are deliberately absent: + * `OutputNotReturnable` (every Java local can be received back as `T x = extracted()`), + * `AnonymousExtensionFunction`, `InnerImplicitReceiver`, `UsesBackingField` and `SmartCastParameter` + * (no Java construct produces any of them). + */ +sealed interface ExtractionRefusal { + /** The selection is neither one expression nor whole statements inside one block (R2). */ + data object NotASingleRegion : ExtractionRefusal + + /** + * The analysis could not run at all -- no compiler, no attributed unit, or something threw. + * Deliberately neutral: the selection may have been perfectly good, so it must not be blamed the way + * [NotASingleRegion] blames it. + */ + data object CouldNotAnalyse : ExtractionRefusal + + /** + * The region declares two or more values the code after it still needs, and one return cannot carry + * them (R7). [names] is what is in the way, so the message can name them. + */ + data class MultipleOutputs( + val names: List, + ) : ExtractionRefusal + + /** + * A variable declared outside the region is assigned inside it (R7). Java has no out parameters, so + * the assignment would be lost. + */ + data class ReassignsOuterVar( + val name: String, + ) : ExtractionRefusal + + /** A `return`, `break`, `continue` or `yield` whose target is outside the region (R8). */ + data object ExitsRegion : ExtractionRefusal + + /** A type parameter declared on the anchor method (R10). */ + data class UsesTypeParameter( + val name: String, + ) : ExtractionRefusal + + /** A parameter, return or thrown type that cannot be written out as source (R5). */ + data object UnrenderableType : ExtractionRefusal + + /** + * A local class the region uses but does not contain, or a value whose type is one (R5). The value + * survives the move; the type name does not. + */ + data class CapturedLocalDeclaration( + val name: String, + ) : ExtractionRefusal +} + +/** + * The complete result of the background pass. + * + * Unlike extract variable's plan this carries a [refusal] rather than merely being empty, because "why + * not" is most of what this refactoring has to say (ADR 0014). [candidates] and [refusal] are mutually + * exclusive in practice: a non-empty candidate list means at least one region survived. + */ +data class ExtractMethodPlan( + val fileText: String, + val documentVersion: Int?, + val candidates: List, + val refusal: ExtractionRefusal?, +) { + val isEmpty: Boolean get() = candidates.isEmpty() + + companion object { + fun refused( + refusal: ExtractionRefusal, + fileText: String = "", + documentVersion: Int? = null, + ) = ExtractMethodPlan(fileText, documentVersion, emptyList(), refusal = refusal) + } +} + +/** + * Everything the signature says before the method's name: modifiers, then the return type. + * + * Split from [signatureSuffix] rather than rendered whole because the sheet's preview follows what the + * user types. Both halves compose through [signatureText], which the edit builder calls, so the preview + * cannot drift from the emitted declaration (R11). + */ +val ExtractMethodCandidate.signaturePrefix: String + get() = + buildString { + modifiers.forEach { append(it).append(' ') } + append(returnTypeText).append(' ') + } + +/** Everything the signature says after the method's name: parameters, then any `throws` clause. */ +val ExtractMethodCandidate.signatureSuffix: String + get() = + buildString { + append('(') + append(parameters.joinToString(", ") { "${it.typeText} ${it.name}" }) + append(')') + if (thrownTypes.isNotEmpty()) append(" throws ").append(thrownTypes.joinToString(", ")) + } + +/** The signature exactly as [buildExtractMethodRewrites] emits it. */ +fun ExtractMethodCandidate.signatureText(name: String): String = signaturePrefix + name + signatureSuffix diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlanner.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlanner.kt new file mode 100644 index 0000000000..c94de9f148 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ExtractMethodPlanner.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.java.compiler.CompileTask +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.util.JavacTask +import openjdk.source.util.Trees +import org.slf4j.LoggerFactory +import java.nio.file.Path +import kotlin.coroutines.cancellation.CancellationException + +private val logger = LoggerFactory.getLogger("JavaExtractMethodPlanner") + +/** + * The whole plan from one attributed compile. + * + * Degrades to `CouldNotAnalyse` whenever anything here throws: the action framework catches only + * `IllegalArgumentException` and this runs on a scope with no exception handler, so an uncaught throw + * would crash the app. The refusal is deliberately neutral -- the selection may have been perfectly + * good, and blaming it would teach the user the wrong thing (R14). + */ +fun buildExtractMethodPlan( + task: CompileTask, + file: Path, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int?, +): ExtractMethodPlan { + var fileText = "" + return runCatching { + val root = task.root(file) + fileText = root.sourceFile.getCharContent(true).toString() + planFor(task.task, root, fileText, selectionStart, selectionEnd, documentVersion) + }.getOrElse { error -> + // Cancellation is the coroutine's business, not a failure to degrade from: swallowing it would + // leave the action running after its scope was cancelled. + if (error is CancellationException) throw error + logger.warn("Failed to build a Java extract-method plan for {}", file, error) + ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse, fileText, documentVersion) + } +} + +/** + * The pass itself, over an already-attributed unit. + * + * Split from the [CompileTask] overload so it needs nothing but javac, which is what lets the analysis + * be tested against a source string with no project model and no tooling API. + * + * [fileText] must be the text [root]'s positions were computed against. + */ +fun buildExtractMethodPlan( + task: JavacTask, + root: CompilationUnitTree, + fileText: String, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int?, +): ExtractMethodPlan = + runCatching { + planFor(task, root, fileText, selectionStart, selectionEnd, documentVersion) + }.getOrElse { error -> + if (error is CancellationException) throw error + logger.warn("Failed to build a Java extract-method plan", error) + ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse, fileText, documentVersion) + } + +/** + * Every region the selection offers, analysed. + * + * When nothing survives, the **first** region's refusal is the one reported: regions arrive innermost + * first, so that is the reason for the thing closest to the cursor rather than for some ancestor the + * user was not pointing at. + */ +private fun planFor( + task: JavacTask, + root: CompilationUnitTree, + fileText: String, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int?, +): ExtractMethodPlan { + val trees = Trees.instance(task) + val positions = trees.sourcePositions + + val regions = resolveExtractionRegions(task, root, positions, fileText, selectionStart, selectionEnd) + if (regions.isEmpty()) { + return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion, fileText, documentVersion) + } + + val results = regions.map { analyseRegion(it, task, root, trees, positions, fileText) } + val candidates = results.filterIsInstance().map { it.candidate } + if (candidates.isEmpty()) { + val refusal = + results.filterIsInstance().firstOrNull()?.refusal + ?: ExtractionRefusal.NotASingleRegion + return ExtractMethodPlan.refused(refusal, fileText, documentVersion) + } + + return ExtractMethodPlan( + fileText = fileText, + documentVersion = documentVersion, + candidates = candidates, + refusal = null, + ) +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/MethodSignature.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/MethodSignature.kt new file mode 100644 index 0000000000..0dac84b494 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/MethodSignature.kt @@ -0,0 +1,459 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan +import com.itsaky.androidide.lsp.refactor.leadingIndentAt +import com.itsaky.androidide.lsp.refactor.uniqueName +import jdkx.lang.model.element.Element +import jdkx.lang.model.element.ElementKind +import jdkx.lang.model.element.ExecutableElement +import jdkx.lang.model.element.TypeElement +import jdkx.lang.model.element.VariableElement +import jdkx.lang.model.type.TypeKind +import jdkx.lang.model.util.Elements +import openjdk.source.tree.BlockTree +import openjdk.source.tree.BreakTree +import openjdk.source.tree.ClassTree +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.tree.ContinueTree +import openjdk.source.tree.DoWhileLoopTree +import openjdk.source.tree.EnhancedForLoopTree +import openjdk.source.tree.ForLoopTree +import openjdk.source.tree.IdentifierTree +import openjdk.source.tree.LabeledStatementTree +import openjdk.source.tree.LambdaExpressionTree +import openjdk.source.tree.LiteralTree +import openjdk.source.tree.MethodTree +import openjdk.source.tree.ReturnTree +import openjdk.source.tree.SwitchExpressionTree +import openjdk.source.tree.SwitchTree +import openjdk.source.tree.Tree +import openjdk.source.tree.VariableTree +import openjdk.source.tree.WhileLoopTree +import openjdk.source.tree.YieldTree +import openjdk.source.util.JavacTask +import openjdk.source.util.SourcePositions +import openjdk.source.util.TreePath +import openjdk.source.util.TreePathScanner +import openjdk.source.util.Trees + +/** Either a fully derived candidate or the reason the region could not become one. */ +internal sealed interface AnalysisResult { + data class Analysed( + val candidate: ExtractMethodCandidate, + ) : AnalysisResult + + data class Refused( + val refusal: ExtractionRefusal, + ) : AnalysisResult +} + +/** + * Derives everything the sheet renders and the edit builder emits for one region, or the reason it + * cannot be moved faithfully (ADR 0014). + * + * The order of the checks is the order of the refusals' specificity: a region that both uses a type + * parameter and exits itself reports the type parameter, which is the more actionable of the two. + */ +internal fun analyseRegion( + region: ExtractionRegion, + task: JavacTask, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, + fileText: String, +): AnalysisResult { + val anchor = anchorMemberFor(region.path, root, positions) ?: return refuse(ExtractionRefusal.NotASingleRegion) + val span = region.span + val names = TypeNames(root) + val elements = task.elements + val types = task.types + val regionPaths = regionPathsOf(region) + val anchorMethodElement = anchor.method?.let { runCatching { trees.getElement(anchor.path) }.getOrNull() } + + val references = collectReferences(regionPaths, root, positions, trees) + + references + .firstOrNull { isAnchorTypeParameter(it.element, anchorMethodElement) } + ?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it.element.simpleName.toString())) } + + references + .firstOrNull { isCapturedLocalType(it.element, span, root, trees, positions) } + ?.let { return refuse(ExtractionRefusal.CapturedLocalDeclaration(localTypeNameOf(it.element))) } + + outerReassignmentIn(regionPaths, span, anchor, root, trees, positions) + ?.let { return refuse(ExtractionRefusal.ReassignsOuterVar(it)) } + + val parameters = mutableListOf() + for (captured in capturedVariablesIn(references, span, anchor, root, trees, positions)) { + val type = captured.asType() + localTypeNameIn(type)?.let { return refuse(ExtractionRefusal.CapturedLocalDeclaration(captured.simpleName.toString())) } + anchorTypeVariableIn(type, anchorMethodElement)?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + val typeText = names.render(type) ?: return refuse(ExtractionRefusal.UnrenderableType) + parameters += MethodParameter(name = captured.simpleName.toString(), typeText = typeText) + } + + val referencedAfter = referencedAfterRegion(region, anchor, root, trees, positions) + + escapingLocalClassIn(region, referencedAfter, trees) + ?.let { return refuse(ExtractionRefusal.CapturedLocalDeclaration(it)) } + + val outputs = outputsOf(region, referencedAfter, trees) + if (outputs.size > 1) { + return refuse(ExtractionRefusal.MultipleOutputs(outputs.map { it.simpleName.toString() })) + } + + val exits = exitsIn(regionPaths, span, root, positions) + val tailReturn = tailReturnOf(region, anchor, exits, outputs) + if (exits.isNotEmpty() && !tailReturn) return refuse(ExtractionRefusal.ExitsRegion) + + val shape = + bodyAndCallSite(region, anchor, outputs.singleOrNull(), tailReturn, root, trees, names, anchorMethodElement) + ?: return refuse(ExtractionRefusal.UnrenderableType) + if (shape is Shape.Refusal) return refuse(shape.refusal) + val body = (shape as Shape.Derived) + + val thrown = + thrownCheckedTypesIn(regionPaths, span, root, trees, positions, types, elements, names) + ?: return refuse(ExtractionRefusal.UnrenderableType) + + val takenNames = methodNamesIn(anchor.classPath, trees, elements) + val insertOffset = absorbTrailingSemicolon(fileText, anchor.span.end) + + return AnalysisResult.Analysed( + ExtractMethodCandidate( + label = collapseForLabel(fileText.substring(span.start, span.end)), + span = span, + suggestedName = suggestedNameFor(region, body.returnTypeText, takenNames), + takenNames = takenNames, + modifiers = if (anchor.isStatic) listOf("private", "static") else listOf("private"), + parameters = parameters, + returnTypeText = body.returnTypeText, + thrownTypes = thrown, + body = body.body, + callSite = body.callSite, + insertOffset = insertOffset, + insertIndent = leadingIndentAt(fileText, anchor.span.start), + textBlockSpans = textBlockSpansIn(regionPaths, root, positions, fileText), + ), + ) +} + +private fun refuse(refusal: ExtractionRefusal): AnalysisResult = AnalysisResult.Refused(refusal) + +/** + * The locals the region declares that the code after it still needs (R7). + * + * Only declarations that are *direct* statements of the range can be outputs: one nested in an inner + * block is out of scope after the region whatever this refactoring does. + */ +private fun outputsOf( + region: ExtractionRegion, + referencedAfter: Set, + trees: Trees, +): List = declaredDirectlyIn(region, trees).filter { it in referencedAfter } + +/** + * A local class the region declares and the code after it still names (R7). + * + * A class is not a value, so unlike a local it cannot be handed back through a return: once the + * declaration moves into the new method, every mention of the name after the region stops resolving. + */ +private fun escapingLocalClassIn( + region: ExtractionRegion, + referencedAfter: Set, + trees: Trees, +): String? = + declaredDirectlyIn(region, trees) + .firstOrNull { it in referencedAfter } + ?.simpleName + ?.toString() + +/** The elements the region declares as *direct* statements: one nested deeper is out of scope anyway. */ +private inline fun declaredDirectlyIn( + region: ExtractionRegion, + trees: Trees, +): List { + if (region !is ExtractionRegion.Statements) return emptyList() + val blockPath = region.path.parentPath ?: return emptyList() + return region.statements + .filterIsInstance() + .mapNotNull { runCatching { trees.getElement(TreePath(blockPath, it)) }.getOrNull() } + .filterIsInstance() +} + +/** Every declaration the code after the region still names, within the anchor member. */ +private fun referencedAfterRegion( + region: ExtractionRegion, + anchor: AnchorMember, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, +): Set { + if (region !is ExtractionRegion.Statements) return emptySet() + val referenced = mutableSetOf() + val scanner = + object : TreePathScanner() { + override fun visitIdentifier( + node: IdentifierTree, + p: Unit?, + ): Unit? { + val span = spanOf(root, positions, node) + if (span != null && span.start >= region.span.end) { + runCatching { trees.getElement(currentPath) }.getOrNull()?.let { referenced += it } + } + return super.visitIdentifier(node, p) + } + } + scanner.scan(anchor.path, null) + return referenced +} + +private class Exit( + val tree: Tree, +) + +/** + * Every jump inside the region whose target lies outside it (R8). + * + * The scan stops at a lambda, a class body and a method: a `return` belonging to a declaration the + * region *contains* moves with that declaration and never crosses the boundary, so counting it would + * refuse a good extraction over something the user did not write. + */ +private fun exitsIn( + regionPaths: List, + span: TextSpan, + root: CompilationUnitTree, + positions: SourcePositions, +): List { + val exits = mutableListOf() + + fun consider(path: TreePath) { + val leaf = path.leaf + val target = + when (leaf) { + is ReturnTree -> null + is BreakTree -> jumpTargetOf(path, leaf.label?.toString(), breakable = true) + is ContinueTree -> jumpTargetOf(path, leaf.label?.toString(), breakable = false) + is YieldTree -> enclosingSwitchExpressionOf(path) + else -> return + } + + if (leaf is ReturnTree) { + exits += Exit(leaf) + return + } + val targetSpan = target?.let { spanOf(root, positions, it) } + if (targetSpan == null || !span.contains(targetSpan)) exits += Exit(leaf) + } + + val scanner = + object : TreePathScanner() { + override fun scan( + tree: Tree?, + p: Unit?, + ): Unit? { + if (tree == null) return null + consider(TreePath(currentPath, tree)) + return super.scan(tree, p) + } + + override fun visitLambdaExpression( + node: LambdaExpressionTree, + p: Unit?, + ): Unit? = null + + override fun visitClass( + node: ClassTree, + p: Unit?, + ): Unit? = null + + override fun visitMethod( + node: MethodTree, + p: Unit?, + ): Unit? = null + } + + regionPaths.forEach { path -> + consider(path) + scanner.scan(path, null) + } + return exits +} + +/** + * What an unlabelled `break`/`continue` jumps to, or the statement a labelled one names. + * + * `break` also targets a `switch`; `continue` only ever targets a loop. + */ +private fun jumpTargetOf( + sitePath: TreePath, + label: String?, + breakable: Boolean, +): Tree? { + var current: TreePath? = sitePath.parentPath + while (current != null) { + val leaf = current.leaf + if (label != null) { + if (leaf is LabeledStatementTree && leaf.label.toString() == label) return leaf + } else if (isLoop(leaf) || (breakable && (leaf is SwitchTree || leaf is SwitchExpressionTree))) { + return leaf + } + current = current.parentPath + } + return null +} + +private fun enclosingSwitchExpressionOf(sitePath: TreePath): Tree? { + var current: TreePath? = sitePath.parentPath + while (current != null) { + val leaf = current.leaf + if (leaf is SwitchExpressionTree) return leaf + current = current.parentPath + } + return null +} + +private fun isLoop(tree: Tree): Boolean = + tree is WhileLoopTree || tree is DoWhileLoopTree || tree is ForLoopTree || tree is EnhancedForLoopTree + +/** + * Whether the region ends in a `return` that can move with it (R8). + * + * The region's enclosing executable body has to be the anchor member's own: a `return` inside a lambda + * returns from the lambda, so taking the anchor's return type would emit a method returning something + * its body never returns. + */ +private fun tailReturnOf( + region: ExtractionRegion, + anchor: AnchorMember, + exits: List, + outputs: List, +): Boolean { + if (region !is ExtractionRegion.Statements) return false + if (anchor.method == null) return false + if (outputs.isNotEmpty()) return false + val last = region.statements.lastOrNull() as? ReturnTree ?: return false + if (exits.size != 1 || exits.single().tree !== last) return false + // enclosingExecutableBody answers the *declaration* that owns the body -- the MethodTree, the + // LambdaExpressionTree, or the initializer's own BlockTree -- so the anchor member's own tree is what + // it has to equal. A lambda in between means the `return` returns from the lambda, not the anchor. + return enclosingExecutableBody(region.path)?.leaf === anchor.path.leaf +} + +private sealed interface Shape { + data class Derived( + val returnTypeText: String, + val body: ExtractedBody, + val callSite: CallSiteForm, + ) : Shape + + data class Refusal( + val refusal: ExtractionRefusal, + ) : Shape +} + +/** The return type, the body form and the call-site form, which are one decision (R6). */ +private fun bodyAndCallSite( + region: ExtractionRegion, + anchor: AnchorMember, + output: VariableElement?, + tailReturn: Boolean, + root: CompilationUnitTree, + trees: Trees, + names: TypeNames, + anchorMethodElement: Element?, +): Shape? { + if (region is ExtractionRegion.Expression) { + val type = runCatching { trees.getTypeMirror(region.path) }.getOrNull() ?: return null + if (type.kind == TypeKind.VOID) { + return Shape.Derived("void", ExtractedBody.ExpressionBody(needsReturn = false), CallSiteForm.Call) + } + anchorTypeVariableIn(type, anchorMethodElement)?.let { return Shape.Refusal(ExtractionRefusal.UsesTypeParameter(it)) } + localTypeNameIn(type)?.let { return Shape.Refusal(ExtractionRefusal.CapturedLocalDeclaration(it)) } + // The same derivation extract variable uses, so a poly expression keeps javac's inference and a + // constant that only fits because it was narrowed in place is declined rather than widened. + val typeText = declaredTypeTextFor(region.path, trees, root) ?: return null + return Shape.Derived(typeText, ExtractedBody.ExpressionBody(needsReturn = true), CallSiteForm.Call) + } + + if (tailReturn) { + val element = anchorMethodElement as? ExecutableElement ?: return null + val returnType = element.returnType + if (returnType.kind == TypeKind.VOID) { + return Shape.Derived("void", ExtractedBody.StatementBody(trailingReturn = null), CallSiteForm.Return) + } + anchorTypeVariableIn(returnType, anchorMethodElement) + ?.let { return Shape.Refusal(ExtractionRefusal.UsesTypeParameter(it)) } + val typeText = names.render(returnType) ?: return null + return Shape.Derived(typeText, ExtractedBody.StatementBody(trailingReturn = null), CallSiteForm.Return) + } + + if (output != null) { + val type = output.asType() + anchorTypeVariableIn(type, anchorMethodElement)?.let { return Shape.Refusal(ExtractionRefusal.UsesTypeParameter(it)) } + localTypeNameIn(type)?.let { return Shape.Refusal(ExtractionRefusal.CapturedLocalDeclaration(it)) } + val typeText = names.render(type) ?: return null + val name = output.simpleName.toString() + return Shape.Derived( + typeText, + ExtractedBody.StatementBody(trailingReturn = "return $name;"), + CallSiteForm.AssignOutput(typeText, name), + ) + } + + return Shape.Derived("void", ExtractedBody.StatementBody(trailingReturn = null), CallSiteForm.CallStatement) +} + +/** Every method and constructor name visible in the insertion class, inherited ones included (R12). */ +internal fun methodNamesIn( + classPath: TreePath, + trees: Trees, + elements: Elements, +): Set { + val element = runCatching { trees.getElement(classPath) }.getOrNull() as? TypeElement ?: return emptySet() + return runCatching { elements.getAllMembers(element) } + .getOrNull() + .orEmpty() + .filter { it.kind == ElementKind.METHOD || it.kind == ElementKind.CONSTRUCTOR } + .map { it.simpleName.toString() } + .toSet() +} + +/** + * An expression region reads its name from its own shape and type, as extract variable does; a + * statement range has no expression to read, and inventing a verb from statement shapes is guesswork. + */ +private fun suggestedNameFor( + region: ExtractionRegion, + returnTypeText: String, + takenNames: Set, +): String = + when (region) { + is ExtractionRegion.Expression -> suggestVariableName(region.path.leaf, returnTypeText, takenNames) + is ExtractionRegion.Statements -> uniqueName("extracted", takenNames) + } + +/** The text block literals inside the region, whose interior must survive re-indentation verbatim. */ +private fun textBlockSpansIn( + regionPaths: List, + root: CompilationUnitTree, + positions: SourcePositions, + fileText: String, +): List { + val spans = mutableListOf() + val scanner = + object : TreePathScanner() { + override fun visitLiteral( + node: LiteralTree, + p: Unit?, + ): Unit? { + val span = spanOf(root, positions, node) + if (span != null && span.end <= fileText.length && fileText.startsWith("\"\"\"", span.start)) { + spans += span + } + return super.visitLiteral(node, p) + } + } + regionPaths.forEach { scanner.scan(it, null) } + return spans +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ThrownTypes.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ThrownTypes.kt new file mode 100644 index 0000000000..61b4d92299 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/ThrownTypes.kt @@ -0,0 +1,199 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan +import jdkx.lang.model.element.ExecutableElement +import jdkx.lang.model.element.TypeElement +import jdkx.lang.model.type.DeclaredType +import jdkx.lang.model.type.TypeKind +import jdkx.lang.model.type.TypeMirror +import jdkx.lang.model.type.UnionType +import jdkx.lang.model.util.Elements +import jdkx.lang.model.util.Types +import openjdk.source.tree.CatchTree +import openjdk.source.tree.ClassTree +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.tree.LambdaExpressionTree +import openjdk.source.tree.MethodInvocationTree +import openjdk.source.tree.MethodTree +import openjdk.source.tree.NewClassTree +import openjdk.source.tree.ThrowTree +import openjdk.source.tree.Tree +import openjdk.source.tree.TryTree +import openjdk.source.util.SourcePositions +import openjdk.source.util.TreePath +import openjdk.source.util.TreePathScanner +import openjdk.source.util.Trees + +/** + * The checked exception types the new method must declare (R10), or null when one cannot be written. + * + * Both halves matter: under-declaring leaves the moved body uncompilable, and over-declaring breaks the + * call site, which is only obliged to handle what the region actually threw. Nested lambdas, local + * classes and anonymous classes are not descended into -- a checked exception thrown there is + * constrained by that construct's own signature and never reaches the anchor member. + */ +internal fun thrownCheckedTypesIn( + regionPaths: List, + span: TextSpan, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, + types: Types, + elements: Elements, + names: TypeNames, +): List? { + val runtimeException = typeOf(elements, "java.lang.RuntimeException") + val error = typeOf(elements, "java.lang.Error") + val rendered = LinkedHashSet() + var unrenderable = false + + fun record( + type: TypeMirror, + sitePath: TreePath, + ) { + // A generic `throws E` is declared on the callee and only instantiated at the call site, which + // javac's public API does not hand back. Rendering `E` would emit a name nothing declares, and + // guessing its bound would over-declare, so the region is declined instead (ADR 0014). + if (type.kind == TypeKind.TYPEVAR) { + unrenderable = true + return + } + if (type.kind != TypeKind.DECLARED) return + if (runtimeException != null && types.isAssignable(type, runtimeException)) return + if (error != null && types.isAssignable(type, error)) return + if (isCaughtWithin(type, sitePath, span, root, trees, positions, types)) return + val text = names.render(type) + if (text == null) unrenderable = true else rendered += text + } + + fun consider(path: TreePath) { + when (val leaf = path.leaf) { + is MethodInvocationTree, is NewClassTree -> { + val element = runCatching { trees.getElement(path) }.getOrNull() as? ExecutableElement ?: return + element.thrownTypes.forEach { record(it, path) } + } + + is ThrowTree -> { + val type = + runCatching { trees.getTypeMirror(TreePath(path, leaf.expression)) }.getOrNull() ?: return + record(type, path) + } + + is TryTree -> { + // A resource's close() throws too, and there is no invocation node to find it on. + leaf.resources.forEach { resource -> + val type = runCatching { trees.getTypeMirror(TreePath(path, resource)) }.getOrNull() ?: return@forEach + closeThrownTypesOf(type, elements).forEach { record(it, path) } + } + } + + else -> { + Unit + } + } + } + + val scanner = + object : TreePathScanner() { + override fun scan( + tree: Tree?, + p: Unit?, + ): Unit? { + if (tree == null) return null + consider(TreePath(currentPath, tree)) + return super.scan(tree, p) + } + + override fun visitLambdaExpression( + node: LambdaExpressionTree, + p: Unit?, + ): Unit? = null + + override fun visitClass( + node: ClassTree, + p: Unit?, + ): Unit? = null + + override fun visitMethod( + node: MethodTree, + p: Unit?, + ): Unit? = null + } + + regionPaths.forEach { path -> + consider(path) + scanner.scan(path, null) + } + + return if (unrenderable) null else rendered.toList() +} + +/** + * Whether a `try` **inside the region** already handles [type] at [sitePath]. + * + * Only a `try` whose whole statement is inside the region counts: one that encloses the region handles + * the call site just as it handled the code, and declaring nothing there would leave the new method's + * body uncompilable. A site sitting in a `catch` or `finally` is not protected by that `try`'s own + * catches, which is why the block has to contain it. + */ +private fun isCaughtWithin( + type: TypeMirror, + sitePath: TreePath, + span: TextSpan, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, + types: Types, +): Boolean { + var child: Tree = sitePath.leaf + var current: TreePath? = sitePath.parentPath + while (current != null) { + val leaf = current.leaf + val leafSpan = spanOf(root, positions, leaf) + if (leafSpan != null && !span.contains(leafSpan)) return false + if (leaf is TryTree && (leaf.block === child || leaf.resources.any { it === child })) { + val tryPath = current + if (leaf.catches.any { catchesType(type, tryPath, it, trees, types) }) return true + } + child = leaf + current = current.parentPath + } + return false +} + +/** + * Whether one `catch` clause catches [thrown]. A multi-catch's alternatives are separate types, so each + * is asked in turn. + */ +private fun catchesType( + thrown: TypeMirror, + tryPath: TreePath, + catch: CatchTree, + trees: Trees, + types: Types, +): Boolean { + val parameterPath = TreePath(TreePath(tryPath, catch), catch.parameter) + val caught = runCatching { trees.getTypeMirror(parameterPath) }.getOrNull() ?: return false + if (caught is UnionType) return caught.alternatives.any { types.isAssignable(thrown, it) } + return types.isAssignable(thrown, caught) +} + +/** The thrown types of the `close()` a try-with-resources resource will call. */ +private fun closeThrownTypesOf( + resourceType: TypeMirror, + elements: Elements, +): List { + val element = runCatching { (resourceType as? DeclaredType)?.asElement() }.getOrNull() as? TypeElement ?: return emptyList() + return runCatching { elements.getAllMembers(element) } + .getOrNull() + .orEmpty() + .filterIsInstance() + .firstOrNull { it.simpleName.toString() == "close" && it.parameters.isEmpty() } + ?.thrownTypes + .orEmpty() +} + +private fun typeOf( + elements: Elements, + name: String, +): TypeMirror? = runCatching { elements.getTypeElement(name)?.asType() }.getOrNull()