From 86b5e1eea7ac61a3a1a3e10b0e1f5ce0e117f988 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sat, 5 Sep 2026 14:34:09 +0100 Subject: [PATCH] ADFA-5048: Add the region analysis primitives The three concerns extract method's analysis needs before it can derive a signature, each independent of the others and of the plan model: - AnchorMember.kt - the class member the new method becomes a sibling of. "Nearest ancestor that is a direct member of a ClassTree" covers method, constructor, initializer and field uniformly, and decides `static`. - RegionReferences.kt - what the region names: every identifier in source order, which of those are locals declared inside the anchor but outside the region (the future parameters), and whether the region reassigns one it does not declare. - RegionTypes.kt - rendering a type as source, shortened only where the file already resolves the short form, plus the two shapes that cannot be written out: a local or anonymous class, and a type variable owned by the anchor. Split into three files rather than one because they only meet in the entry point that follows, and 1000 lines in one file is not reviewable. No caller yet; the analysis that uses them lands next. --- .../lsp/java/refactor/AnchorMember.kt | 78 +++++++++ .../lsp/java/refactor/RegionReferences.kt | 157 ++++++++++++++++++ .../lsp/java/refactor/RegionTypes.kt | 157 ++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/AnchorMember.kt create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionReferences.kt create mode 100644 lsp/java/src/main/java/com/itsaky/androidide/lsp/java/refactor/RegionTypes.kt 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) + } +}