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()