diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/AnchorMember.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/AnchorMember.kt new file mode 100644 index 0000000000..a6a787c29f --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/AnchorMember.kt @@ -0,0 +1,78 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan +import jdkx.lang.model.element.Modifier +import openjdk.source.tree.BlockTree +import openjdk.source.tree.ClassTree +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.tree.MethodTree +import openjdk.source.tree.Tree +import openjdk.source.tree.VariableTree +import openjdk.source.util.SourcePositions +import openjdk.source.util.TreePath + +/** + * The class member the new method becomes a sibling of (R4). + * + * "Direct member of a `ClassTree`" covers every anchor uniformly: a method, a constructor, an + * initializer block, and a field whose initializer holds a lambda. Java has no local-method form, so + * unlike Kotlin there is no insert-before case and no "nowhere to anchor" refusal. + */ +internal class AnchorMember( + val path: TreePath, + val classPath: TreePath, + val span: TextSpan, + val isStatic: Boolean, + val method: MethodTree?, +) + +/** + * The nearest ancestor that is a direct member of a class. + * + * A nested class is never climbed past: a region inside a member of an inner, local or anonymous class + * anchors on that member, so the new method lands in the class whose members the region reads. + */ +internal fun anchorMemberFor( + regionPath: TreePath, + root: CompilationUnitTree, + positions: SourcePositions, +): AnchorMember? { + var current: TreePath = regionPath + while (true) { + val parent = current.parentPath ?: return null + if (parent.leaf is ClassTree) { + val member = current.leaf + val span = spanOf(root, positions, member) ?: return null + return AnchorMember( + path = current, + classPath = parent, + span = span, + isStatic = isStaticMember(member), + method = member as? MethodTree, + ) + } + current = parent + } +} + +/** A static anchor forces a `static` method: no instance to resolve `this` or an instance member on. */ +private fun isStaticMember(member: Tree): Boolean = + when (member) { + is MethodTree -> Modifier.STATIC in member.modifiers.flags + is VariableTree -> Modifier.STATIC in member.modifiers.flags + is BlockTree -> member.isStatic + else -> false + } + +/** The trees the region actually covers: one expression, or each statement of the range. */ +internal fun regionPathsOf(region: ExtractionRegion): List = + when (region) { + is ExtractionRegion.Expression -> { + listOf(region.path) + } + + is ExtractionRegion.Statements -> { + val blockPath = region.path.parentPath + if (blockPath == null) listOf(region.path) else region.statements.map { TreePath(blockPath, it) } + } + } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionReferences.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionReferences.kt new file mode 100644 index 0000000000..d32efc5cc4 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionReferences.kt @@ -0,0 +1,157 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan +import jdkx.lang.model.element.Element +import jdkx.lang.model.element.VariableElement +import openjdk.source.tree.AssignmentTree +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.tree.CompoundAssignmentTree +import openjdk.source.tree.IdentifierTree +import openjdk.source.tree.Tree +import openjdk.source.tree.UnaryTree +import openjdk.source.util.SourcePositions +import openjdk.source.util.TreePath +import openjdk.source.util.TreePathScanner +import openjdk.source.util.Trees + +internal class Reference( + val element: Element, + val offset: Int, +) + +/** + * Every named reference the region makes, in source order. + * + * Identifiers only: a member select's own selector resolves to a field or method, which needs nothing, + * and its base is an identifier this already sees. Nested lambdas and local classes **are** descended + * into, since a local they capture is a local the new method must be handed. + */ +internal fun collectReferences( + regionPaths: List, + root: CompilationUnitTree, + positions: SourcePositions, + trees: Trees, +): List { + val references = mutableListOf() + + fun consider(path: TreePath) { + val leaf = path.leaf + if (leaf !is IdentifierTree) return + val element = runCatching { trees.getElement(path) }.getOrNull() ?: return + val span = spanOf(root, positions, leaf) ?: return + references += Reference(element, span.start) + } + + 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) + } + } + + regionPaths.forEach { path -> + consider(path) + scanner.scan(path, null) + } + return references.sortedBy { it.offset } +} + +/** + * The name of the variable the region reassigns but does not declare, or null when there is none (R7). + * + * Only a reassignment of the variable *itself* counts. An element write through a captured reference + * (`arr[i] = x`) mutates what the caller can already see, so it needs no rule -- the same distinction + * `writeOffsetsFor` draws for extract variable. + */ +internal fun outerReassignmentIn( + regionPaths: List, + span: TextSpan, + anchor: AnchorMember, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, +): String? { + var found: String? = null + + fun consider(path: TreePath) { + if (found != null) return + val target = + when (val leaf = path.leaf) { + is AssignmentTree -> leaf.variable + is CompoundAssignmentTree -> leaf.variable + is UnaryTree -> if (leaf.kind in INCREMENT_KINDS) leaf.expression else null + else -> null + } as? IdentifierTree ?: return + + val element = runCatching { trees.getElement(TreePath(path, target)) }.getOrNull() ?: return + if (element.kind !in LOCAL_KINDS) return + val declaration = declarationSpanOf(element, root, trees, positions) ?: return + if (span.contains(declaration)) return + if (!anchor.span.contains(declaration)) return + found = element.simpleName.toString() + } + + 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) + } + } + + regionPaths.forEach { path -> + consider(path) + scanner.scan(path, null) + } + return found +} + +/** + * The variables that become parameters: referenced, declared inside the anchor member, declared outside + * the region. In first textual appearance order, so the signature reads in the order the body uses it. + * + * A field needs nothing -- the new method is a member of the same class -- and a declaration in another + * file cannot be a local at all. + */ +internal fun capturedVariablesIn( + references: List, + span: TextSpan, + anchor: AnchorMember, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, +): List { + val captured = LinkedHashMap() + for (reference in references) { + val element = reference.element as? VariableElement ?: continue + if (element.kind !in LOCAL_KINDS) continue + val declaration = declarationSpanOf(element, root, trees, positions) ?: continue + if (span.contains(declaration)) continue + if (!anchor.span.contains(declaration)) continue + captured[element] = Unit + } + return captured.keys.toList() +} + +internal fun declarationSpanOf( + element: Element, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, +): TextSpan? { + val path = runCatching { trees.getPath(element) }.getOrNull() ?: return null + if (path.compilationUnit !== root) return null + return spanOf(root, positions, path.leaf) +} + +/** Whether [other] lies entirely inside this span. */ +internal fun TextSpan.contains(other: TextSpan): Boolean = start <= other.start && other.end <= end diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionTypes.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionTypes.kt new file mode 100644 index 0000000000..7441ec18d3 --- /dev/null +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionTypes.kt @@ -0,0 +1,157 @@ +package com.itsaky.androidide.lsp.java.refactor + +import com.itsaky.androidide.lsp.refactor.TextSpan +import jdkx.lang.model.element.Element +import jdkx.lang.model.element.NestingKind +import jdkx.lang.model.element.TypeElement +import jdkx.lang.model.element.TypeParameterElement +import jdkx.lang.model.type.ArrayType +import jdkx.lang.model.type.DeclaredType +import jdkx.lang.model.type.TypeMirror +import jdkx.lang.model.type.TypeVariable +import jdkx.lang.model.type.UnionType +import jdkx.lang.model.type.WildcardType +import openjdk.source.tree.CompilationUnitTree +import openjdk.source.util.SourcePositions +import openjdk.source.util.Trees + +/** Whether [element] is a type parameter declared on the anchor method itself (R10). */ +internal fun isAnchorTypeParameter( + element: Element, + anchorMethodElement: Element?, +): Boolean = + element is TypeParameterElement && + anchorMethodElement != null && + runCatching { element.genericElement == anchorMethodElement }.getOrDefault(false) + +/** + * Whether [element] is a local or anonymous class the region uses but does not contain. Its name is + * reachable only from inside the anchor member, so it stops resolving once the body moves. + */ +internal fun isCapturedLocalType( + element: Element, + span: TextSpan, + root: CompilationUnitTree, + trees: Trees, + positions: SourcePositions, +): Boolean { + // A reference to the class *itself* is only one of the shapes: `new Helper()` resolves to Helper's + // constructor and `Helper.CONST` to a field, so a member's own declaring class is asked about too. + val type = + element as? TypeElement + ?: element.enclosingElement as? TypeElement + ?: return false + if (type.nestingKind != NestingKind.LOCAL && type.nestingKind != NestingKind.ANONYMOUS) return false + val declaration = declarationSpanOf(type, root, trees, positions) ?: return false + return !span.contains(declaration) +} + +/** The local class a reference names, whether directly or through one of its members. */ +internal fun localTypeNameOf(element: Element): String { + val type = element as? TypeElement ?: element.enclosingElement as? TypeElement ?: return element.simpleName.toString() + return type.simpleName.toString().ifEmpty { element.simpleName.toString() } +} + +/** + * The name of a local or anonymous class appearing anywhere in [type], or null when there is none. + * + * A depth limit rather than a visited set: `Enum>` is a real shape, and comparing + * `TypeMirror`s for identity is not reliable enough to terminate on. + */ +internal fun localTypeNameIn( + type: TypeMirror, + depth: Int = 0, +): String? { + if (depth > MAX_TYPE_DEPTH) return null + return when (type) { + is DeclaredType -> { + val element = runCatching { type.asElement() }.getOrNull() as? TypeElement + if (element != null && + (element.nestingKind == NestingKind.LOCAL || element.nestingKind == NestingKind.ANONYMOUS) + ) { + element.simpleName.toString().ifEmpty { "anonymous class" } + } else { + type.typeArguments.firstNotNullOfOrNull { localTypeNameIn(it, depth + 1) } + } + } + + is ArrayType -> { + localTypeNameIn(type.componentType, depth + 1) + } + + is WildcardType -> { + type.extendsBound?.let { localTypeNameIn(it, depth + 1) } + ?: type.superBound?.let { localTypeNameIn(it, depth + 1) } + } + + is UnionType -> { + type.alternatives.firstNotNullOfOrNull { localTypeNameIn(it, depth + 1) } + } + + else -> { + null + } + } +} + +/** The name of a type variable declared on the anchor method appearing anywhere in [type] (R10). */ +internal fun anchorTypeVariableIn( + type: TypeMirror, + anchorMethodElement: Element?, + depth: Int = 0, +): String? { + if (anchorMethodElement == null || depth > MAX_TYPE_DEPTH) return null + return when (type) { + is TypeVariable -> { + val element = runCatching { type.asElement() }.getOrNull() + if (isAnchorTypeParameter(element ?: return null, anchorMethodElement)) { + element.simpleName.toString() + } else { + null + } + } + + is DeclaredType -> { + type.typeArguments.firstNotNullOfOrNull { anchorTypeVariableIn(it, anchorMethodElement, depth + 1) } + } + + is ArrayType -> { + anchorTypeVariableIn(type.componentType, anchorMethodElement, depth + 1) + } + + is WildcardType -> { + type.extendsBound?.let { anchorTypeVariableIn(it, anchorMethodElement, depth + 1) } + ?: type.superBound?.let { anchorTypeVariableIn(it, anchorMethodElement, depth + 1) } + } + + is UnionType -> { + type.alternatives.firstNotNullOfOrNull { anchorTypeVariableIn(it, anchorMethodElement, depth + 1) } + } + + else -> { + null + } + } +} + +private const val MAX_TYPE_DEPTH = 8 + +/** + * Renders a type as source, shortened only where the file already resolves the short form. + * + * The import sets are read once per plan rather than per type: every candidate in one plan renders + * against the same file. + */ +internal class TypeNames( + root: CompilationUnitTree, +) { + private val imported = importedNamesOf(root) + private val starred = starImportedPackagesOf(root) + + fun render(type: TypeMirror): String? { + val text = runCatching { type.toString() }.getOrNull() ?: return null + if (isUnrenderableTypeText(text)) return null + if (isValuelessKind(type.kind)) return null + return shortenTypeText(text, imported, starred) + } +}