From f2425bec7485dbc5196268839251b6801a81c90f Mon Sep 17 00:00:00 2001 From: Nikolay Chashnikov Date: Fri, 16 Jan 2026 16:54:56 +0100 Subject: [PATCH] IDEA-268316 Java: provide API in JvmBytecodeAnalysis to collect implicit superclass references Before, JvmBytecodeAnalysis processed only references explicitly mentioned in *.class files. However, javac requires having all superclasses of the classes being compiled and the classes methods from which are called in the classpath. In such cases, 'Extract Module' action and a quick-fix for extracting module in DevKit (IJPL-227635) didn't add required dependencies to the extracted module, so code fails to compile. Here a new API JvmBytecodeAnalysis.createReferenceAnalyzerWithImplicitSuperclassReferences is introduced which can be used to take into account such implicit references as well. GitOrigin-RevId: e4dec4e63d3a5ed70012747d386a48628275b6cb --- .../analysis/bytecode/JvmBytecodeAnalysis.kt | 8 ++ .../bytecode/JvmBytecodeReferenceProcessor.kt | 2 +- .../impl/bytecode/ClassAncestorResolver.kt | 94 +++++++++++++ .../bytecode/ClassFileAnalysisVisitor.java | 15 +- .../impl/bytecode/ClassFileAnalyzerImpl.kt | 11 +- .../impl/bytecode/JvmBytecodeAnalysisImpl.kt | 16 ++- .../JvmBytecodeReferenceAnalysisTest.kt | 130 +++++++++++++++++- 7 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassAncestorResolver.kt diff --git a/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeAnalysis.kt b/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeAnalysis.kt index 79119ffb8309..9aafda393ae1 100644 --- a/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeAnalysis.kt +++ b/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeAnalysis.kt @@ -24,6 +24,14 @@ public interface JvmBytecodeAnalysis { @Contract(pure = true) public fun createReferenceAnalyzer(processor: JvmBytecodeReferenceProcessor): ClassFileAnalyzer + /** + * Creates a new instance of [ClassFileAnalyzer] which will process references in *.class files. + * It also processes references to superclasses and superinterfaces which aren't mentioned in *.class files directly, but will be accessed + * by the compiler and therefore need to be available in the compilation classpath. + */ + @Contract(pure = true) + public fun createReferenceAnalyzerWithImplicitSuperclassReferences(processor: JvmBytecodeReferenceProcessor, classpath: List): ClassFileAnalyzer + /** * Creates a new instance of [ClassFileAnalyzer] which will process declarations in *.class files. */ diff --git a/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceProcessor.kt b/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceProcessor.kt index 5217d2bad5e4..53a87d9e1446 100644 --- a/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceProcessor.kt +++ b/java/java-analysis-api/src/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceProcessor.kt @@ -2,7 +2,7 @@ package com.intellij.java.analysis.bytecode /** - * Pass an instance of this interface to [JvmBytecodeAnalysis.createReferenceAnalyzer] or + * Pass an instance of this interface to [JvmBytecodeAnalysis.createReferenceAnalyzerWithImplicitSuperclassReferences] or * [JvmBytecodeAnalysis.createDeclarationAndReferencesAnalyzer] to process references in *.class files. */ public interface JvmBytecodeReferenceProcessor { diff --git a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassAncestorResolver.kt b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassAncestorResolver.kt new file mode 100644 index 000000000000..555f86731340 --- /dev/null +++ b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassAncestorResolver.kt @@ -0,0 +1,94 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.java.analysis.impl.bytecode + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.diagnostic.trace +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.Opcodes +import org.jetbrains.org.objectweb.asm.signature.SignatureReader +import org.jetbrains.org.objectweb.asm.signature.SignatureVisitor +import java.nio.file.Path +import java.util.* +import kotlin.io.path.exists +import kotlin.io.path.readBytes + +/** + * Provides a way to recursively find all superclasses and classes mentioned in their generic signatures for a given class + */ +internal class ClassAncestorResolver(private val classpath: List) { + private val packageToClasspathRoots by lazy { + collectPackagesInClasspath(classpath) + } + private val ancestorsCache = HashMap>() + + fun getAllAncestors(binaryClassName: String): List { + val cached = ancestorsCache[binaryClassName] + if (cached != null) return cached + ancestorsCache[binaryClassName] = emptyList() //this is needed to avoid StackOverflowError if the class refers to itself from generic parameters + val ancestors = collectAllAncestors(binaryClassName) + ancestorsCache[binaryClassName] = ancestors + return ancestors + } + + private fun collectAllAncestors(binaryClassName: String): List { + LOG.trace { "Collecting ancestors for $binaryClassName" } + val superclasses = collectSuperclasses(binaryClassName) + val result = HashSet() + for (superclass in superclasses) { + result.add(superclass) + result.addAll(getAllAncestors(superclass)) + } + return result.toList() + } + + private fun collectPackagesInClasspath(classpath: List): Map> { + return classpath + .asSequence() + .flatMap { root -> + withClassRootEntries(root) { + it.map { entry -> root to entry.entryName.substringBeforeLast(delimiter = '/', missingDelimiterValue = "") }.toSet() + } + } + .groupBy({ it.second }, { it.first }) + } + + private fun collectSuperclasses(binaryClassName: String): List { + val packageName = binaryClassName.substringBeforeLast(delimiter = '/', missingDelimiterValue = "") + val relativePath = "$binaryClassName.class" + val paths = packageToClasspathRoots[packageName] ?: return emptyList() + return paths + .firstNotNullOfOrNull { root -> + withClassRoot(root) { classRoot -> + val classFilePath = classRoot.resolve(relativePath) + if (classFilePath.exists()) loadSuperclasses(classFilePath) + else null + } + } ?: emptyList() + } + + private fun loadSuperclasses(path: Path): List { + val result = ArrayList() + val visitor = object : ClassVisitor(Opcodes.API_VERSION, ) { + override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array?) { + if (superName != null) { + result.add(superName) + } + if (interfaces != null) { + Collections.addAll(result, *interfaces) + } + if (signature != null) { + SignatureReader(signature).accept(object : SignatureVisitor(Opcodes.API_VERSION) { + override fun visitClassType(name: String) { + result.add(name) + } + }) + } + } + } + ClassReader(path.readBytes()).accept(visitor, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + return result + } +} + +private val LOG = logger() \ No newline at end of file diff --git a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalysisVisitor.java b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalysisVisitor.java index cb8f0e83bf6a..958aca4a0bfc 100644 --- a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalysisVisitor.java +++ b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalysisVisitor.java @@ -21,14 +21,17 @@ final class ClassFileAnalysisVisitor extends ClassVisitor { private final DependencyFieldVisitor myFieldVisitor = new DependencyFieldVisitor(); private final @Nullable JvmBytecodeDeclarationProcessor myDeclarationProcessor; private final @Nullable JvmBytecodeReferenceProcessor myReferenceProcessor; + private final @Nullable ClassAncestorResolver myImplicitAncestorReferencesResolver; private JvmClassBytecodeDeclaration myCurrentClass; ClassFileAnalysisVisitor(@Nullable JvmBytecodeDeclarationProcessor declarationProcessor, - @Nullable JvmBytecodeReferenceProcessor referenceProcessor) { + @Nullable JvmBytecodeReferenceProcessor referenceProcessor, + @Nullable ClassAncestorResolver implicitAncestorReferencesResolver) { super(Opcodes.API_VERSION); myDeclarationProcessor = declarationProcessor; myReferenceProcessor = referenceProcessor; + myImplicitAncestorReferencesResolver = implicitAncestorReferencesResolver; } void processFileContent(byte @NotNull [] classFileContent) { @@ -62,6 +65,15 @@ final class ClassFileAnalysisVisitor extends ClassVisitor { else { addSignature(signature); } + processImplicitSuperclassReferences(myCurrentClass); + } + + private void processImplicitSuperclassReferences(JvmClassBytecodeDeclaration referencedClass) { + if (myReferenceProcessor != null && myImplicitAncestorReferencesResolver != null) { + for (String superclass : myImplicitAncestorReferencesResolver.getAllAncestors(referencedClass.getBinaryClassName())) { + myReferenceProcessor.processClassReference(getOrCreateClassDeclaration(superclass), myCurrentClass); + } + } } private final Map myClassDeclarations = new HashMap<>(); @@ -186,6 +198,7 @@ final class ClassFileAnalysisVisitor extends ClassVisitor { JvmClassBytecodeDeclaration targetClass = addName(owner); addMethodDesc(desc); processMethodReference(targetClass, name, desc); + processImplicitSuperclassReferences(targetClass); } @Override diff --git a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalyzerImpl.kt b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalyzerImpl.kt index 6096d6df824b..6ee0351becfe 100644 --- a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalyzerImpl.kt +++ b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/ClassFileAnalyzerImpl.kt @@ -12,9 +12,10 @@ import kotlin.io.path.* internal class ClassFileAnalyzerImpl( declarationProcessor: JvmBytecodeDeclarationProcessor?, - referenceProcessor: JvmBytecodeReferenceProcessor? + referenceProcessor: JvmBytecodeReferenceProcessor?, + implicitAncestorReferencesResolver: ClassAncestorResolver? = null, ) : ClassFileAnalyzer { - private val visitor = ClassFileAnalysisVisitor(declarationProcessor, referenceProcessor) + private val visitor = ClassFileAnalysisVisitor(declarationProcessor, referenceProcessor, implicitAncestorReferencesResolver) override fun processFile(path: Path) { // ASM ClassReader in any case reads the whole file into memory @@ -43,10 +44,10 @@ internal class ClassFileAnalyzerImpl( } } -private data class ClassFileEntry(val entryName: String, val path: Path) +internal data class ClassFileEntry(val entryName: String, val path: Path) @OptIn(ExperimentalPathApi::class) -private fun withClassRootEntries(classRoot: Path, block: (entries: Sequence) -> R): R { +internal fun withClassRootEntries(classRoot: Path, block: (entries: Sequence) -> R): R { return withClassRoot(classRoot) { nioRoot -> val sequence = nioRoot .walk() @@ -58,7 +59,7 @@ private fun withClassRootEntries(classRoot: Path, block: (entries: Sequence< } } -private fun withClassRoot(classRoot: Path, block: (root: Path) -> R): R { +internal fun withClassRoot(classRoot: Path, block: (root: Path) -> R): R { return when { classRoot.isDirectory() -> block(classRoot) classRoot.isRegularFile() && classRoot.extension == "jar" -> { diff --git a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/JvmBytecodeAnalysisImpl.kt b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/JvmBytecodeAnalysisImpl.kt index 2a568064d004..f9b62cb81702 100644 --- a/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/JvmBytecodeAnalysisImpl.kt +++ b/java/java-analysis-impl/src/com/intellij/java/analysis/impl/bytecode/JvmBytecodeAnalysisImpl.kt @@ -5,14 +5,26 @@ import com.intellij.java.analysis.bytecode.ClassFileAnalyzer import com.intellij.java.analysis.bytecode.JvmBytecodeAnalysis import com.intellij.java.analysis.bytecode.JvmBytecodeDeclarationProcessor import com.intellij.java.analysis.bytecode.JvmBytecodeReferenceProcessor +import java.nio.file.Path public class JvmBytecodeAnalysisImpl : JvmBytecodeAnalysis { override fun createReferenceAnalyzer(processor: JvmBytecodeReferenceProcessor): ClassFileAnalyzer { - return ClassFileAnalyzerImpl(null, processor) + return ClassFileAnalyzerImpl(declarationProcessor = null, referenceProcessor = processor) + } + + override fun createReferenceAnalyzerWithImplicitSuperclassReferences( + processor: JvmBytecodeReferenceProcessor, + classpath: List, + ): ClassFileAnalyzer { + return ClassFileAnalyzerImpl( + declarationProcessor = null, + referenceProcessor = processor, + implicitAncestorReferencesResolver = ClassAncestorResolver(classpath) + ) } override fun createDeclarationAnalyzer(processor: JvmBytecodeDeclarationProcessor): ClassFileAnalyzer { - return ClassFileAnalyzerImpl(processor, null) + return ClassFileAnalyzerImpl(declarationProcessor = processor, referenceProcessor = null) } override fun createDeclarationAndReferencesAnalyzer( diff --git a/java/java-tests/testSrc/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceAnalysisTest.kt b/java/java-tests/testSrc/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceAnalysisTest.kt index 6bdeb668d509..06d7ceb2f961 100644 --- a/java/java-tests/testSrc/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceAnalysisTest.kt +++ b/java/java-tests/testSrc/com/intellij/java/analysis/bytecode/JvmBytecodeReferenceAnalysisTest.kt @@ -3,12 +3,21 @@ package com.intellij.java.analysis.bytecode import com.intellij.compiler.JavaInMemoryCompiler import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.rules.TempDirectoryExtension import org.assertj.core.api.Assertions.assertThat import org.intellij.lang.annotations.Language import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import java.nio.file.Path +import kotlin.io.path.createParentDirectories +import kotlin.io.path.writeBytes @TestApplication internal class JvmBytecodeReferenceAnalysisTest { + @JvmField + @RegisterExtension + val tempDirectory = TempDirectoryExtension() + @Test fun `test field reference`() { assertReference("public class Main { String field; }", "java/lang/String") @@ -52,6 +61,7 @@ internal class JvmBytecodeReferenceAnalysisTest { "A" to "public @interface A { Class value(); }", "Main" to "@A(String.class) public class Main { }" ), + emptyMap(), "A", "java/lang/String" ) } @@ -126,13 +136,113 @@ internal class JvmBytecodeReferenceAnalysisTest { "A") } - private fun assertReference(@Language("JAVA") source: String, vararg expectedTargets: String) { - assertReference(mapOf("Main" to source), *expectedTargets) + @Test + fun `test implicit references to superclasses from declaration`() { + assertReference( + sources = mapOf( + "Main" to "public class Main extends p.A {}" + ), + sourcesToSearchImplicitReferences = mapOf( + "p.A" to "package p; public class A extends Exception {}", + ), + "p/A", "java/lang/Exception", + ) } - private fun assertReference(sources: Map, vararg expectedTargets: String) { - val compiler = JavaInMemoryCompiler() + @Test + fun `test implicit references to superclasses from method call`() { + assertReference( + sources = mapOf( + "p1.A" to "package p1; public class A extends p2.B { public void foo() {} }", + "Main" to "public class Main { Main() { new p1.A().foo(); } }" + ), + sourcesToSearchImplicitReferences = mapOf( + "p2.B" to "package p2; public class B {}", + ), + "p1/A", "p2/B", + ) + } + + @Test + fun `test no implicit references to superclasses if instance is just passed around`() { + assertReference( + sources = mapOf( + "p1.A" to "package p1; public class A extends p2.B { public void foo() {} }", + "p1.AManager" to """ + |package p1; + |public class AManager { + | public A createInstance() { return new A(); } + | public void useInstance(A a) { a.foo(); } + |} + """.trimMargin(), + "Main" to """ + |import p1.*; + |public class Main { + | Main() { + | AManager manager = new AManager(); + | A a = manager.createInstance(); + | manager.useInstance(a); + | } + |} + """.trimMargin() + ), + sourcesToSearchImplicitReferences = mapOf( + "p2.B" to "package p2; public class B {}", + ), + "p1/A", "p1/AManager", + ) + } + + @Test + fun `test implicit references to superclasses in generic parameters from declaration`() { + assertReference( + sources = mapOf( + "Main" to "public abstract class Main extends p.A {}" + ), + sourcesToSearchImplicitReferences = mapOf( + "p.A" to "package p; public abstract class A extends B {}", + "p.B" to "package p; import java.util.*; public abstract class B implements Map> {}" + ), + "p/A", "p/B", "java/util/Map", "java/util/List", "java/lang/String", + ) + } + + @Test + fun `test avoid StackOverflowError when processing implicit references to superclasses`() { + assertReference( + sources = mapOf( + "Main" to "public abstract class Main extends p.A {}" + ), + sourcesToSearchImplicitReferences = mapOf( + "p.A" to "package p; public class A extends B {}", + "p.B" to "package p; public class B {}" + ), + "p/A", "p/B", + ) + } + + + private fun assertReference(@Language("JAVA") source: String, vararg expectedTargets: String) { + assertReference( + sources = mapOf("Main" to source), + sourcesToSearchImplicitReferences = emptyMap(), + expectedTargets = expectedTargets, + ) + } + + private fun assertReference(sources: Map, + sourcesToSearchImplicitReferences: Map = emptyMap(), + vararg expectedTargets: String) { + val classpathForImplicitReferences = ArrayList() + if (sourcesToSearchImplicitReferences.isNotEmpty()) { + saveClassFiles(JavaInMemoryCompiler().compile(sourcesToSearchImplicitReferences)) + classpathForImplicitReferences.add(tempDirectory.rootPath) + } + val compiler = JavaInMemoryCompiler(*classpathForImplicitReferences.map { it.toFile() }.toTypedArray()) val compiledClasses = compiler.compile(sources) + if (sourcesToSearchImplicitReferences.isNotEmpty()) { + saveClassFiles(compiledClasses) + } val mainClassBytes = compiledClasses["Main"] ?: throw IllegalStateException("Main class not found") val analysis = JvmBytecodeAnalysis.getInstance() @@ -143,9 +253,19 @@ internal class JvmBytecodeReferenceAnalysisTest { } } - val analyzer = analysis.createReferenceAnalyzer(referenceProcessor) + val analyzer = + if (classpathForImplicitReferences.isEmpty()) analysis.createReferenceAnalyzer(referenceProcessor) + else analysis.createReferenceAnalyzerWithImplicitSuperclassReferences(referenceProcessor, classpathForImplicitReferences) analyzer.processFileContent(mainClassBytes) references.remove("java/lang/Object") //always referenced as the superclass assertThat(references).containsExactlyInAnyOrder(*expectedTargets) } + + private fun saveClassFiles(classFiles: Map) { + for ((className, classFileContent) in classFiles) { + val classFilePath = tempDirectory.rootPath.resolve("${className.replace('.', '/')}.class") + classFilePath.createParentDirectories() + classFilePath.writeBytes(classFileContent) + } + } }