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) + } + } }