diff --git a/jmh-fork/jmh-core/build.gradle.kts b/jmh-fork/jmh-core/build.gradle.kts index 0c32564..af53a72 100644 --- a/jmh-fork/jmh-core/build.gradle.kts +++ b/jmh-fork/jmh-core/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { api("net.sf.jopt-simple:jopt-simple:5.0.4") api("org.apache.commons:commons-math3:3.6.1") implementation("com.google.code.gson:gson:2.11.0") + implementation("org.ow2.asm:asm:9.9.1") testImplementation("junit:junit:4.13.2") } diff --git a/jmh-fork/jmh-core/pom.xml b/jmh-fork/jmh-core/pom.xml index 33f6a12..8213558 100644 --- a/jmh-fork/jmh-core/pom.xml +++ b/jmh-fork/jmh-core/pom.xml @@ -69,6 +69,10 @@ questions. gson 2.11.0 + + org.ow2.asm + asm + diff --git a/jmh-fork/jmh-core/src/main/java/io/codspeed/BenchmarkUri.java b/jmh-fork/jmh-core/src/main/java/io/codspeed/BenchmarkUri.java index e1cd307..d302830 100644 --- a/jmh-fork/jmh-core/src/main/java/io/codspeed/BenchmarkUri.java +++ b/jmh-fork/jmh-core/src/main/java/io/codspeed/BenchmarkUri.java @@ -7,13 +7,20 @@ import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.openjdk.jmh.infra.BenchmarkParams; /** Builds CodSpeed benchmark URIs in the format: {file_path}::{classQName}::{method}[{params}] */ public class BenchmarkUri { + static final String[] SOURCE_EXTENSIONS = {"java", "kt", "scala", "groovy"}; + private static volatile Path cachedGitRoot; private static final ConcurrentHashMap sourceFileCache = new ConcurrentHashMap<>(); @@ -61,7 +68,8 @@ static String buildBenchName(String method, BenchmarkParams params) { /** * Resolves the source file path relative to the git root for a given fully qualified class name. - * Searches from the git root for a .java file matching the class's package structure. + * Uses the class file's {@code SourceFile} attribute first, then searches from the git root for a + * matching source file across known extensions (java, kt, scala, groovy). * *

Falls back to the package-derived relative path if the file can't be found on disk. */ @@ -70,23 +78,38 @@ static String resolveSourceFile(String classQName) { } private static String resolveSourceFileUncached(String classQName) { - // Handle inner classes: com.example.Outer$Inner -> com.example.Outer - String outerClass = classQName; - int dollarIdx = outerClass.indexOf('$'); - if (dollarIdx != -1) { - outerClass = outerClass.substring(0, dollarIdx); - } - - String relativePath = outerClass.replace('.', '/') + ".java"; - Path gitRoot = findGitRoot(); - - Path found = findFile(gitRoot, relativePath); - if (found != null) { - // Normalize to forward slashes for consistent URIs across platforms - return gitRoot.relativize(found).toString().replace('\\', '/'); - } + return resolveSourceFile(findGitRoot(), classQName, ClassFileSourceName.read(classQName)); + } - return relativePath; + static String resolveSourceFile(Path root, String classQName, String sourceFileName) { + // Handle inner classes: com.example.Outer$Inner -> com.example.Outer + int dollarIdx = classQName.indexOf('$'); + String outerClass = dollarIdx == -1 ? classQName : classQName.substring(0, dollarIdx); + Path classPath = Paths.get(outerClass.replace('.', '/')); + Path pkgDir = classPath.getParent(); + String simpleName = classPath.getFileName().toString(); + + // The SourceFile attribute is authoritative when present; the extension search only covers + // classes compiled without it. + List names = + sourceFileName != null + ? Collections.singletonList(sourceFileName) + : Arrays.stream(SOURCE_EXTENSIONS) + .map(ext -> simpleName + "." + ext) + .collect(Collectors.toList()); + + Path found = + findFile( + root, + file -> + (pkgDir == null || file.getParent().endsWith(pkgDir)) + && names.contains(file.getFileName().toString())); + + Path result = + found != null + ? root.relativize(found) + : pkgDir == null ? Paths.get(names.get(0)) : pkgDir.resolve(names.get(0)); + return result.toString().replace('\\', '/'); } /** Walks up from the CWD to find the nearest .git directory, returns its parent. */ @@ -110,8 +133,7 @@ static Path findGitRoot() { return fallback; } - private static Path findFile(Path root, String relativeSuffix) { - String suffix = "/" + relativeSuffix; + private static Path findFile(Path root, Predicate matcher) { Path[] result = new Path[1]; try { @@ -120,7 +142,7 @@ private static Path findFile(Path root, String relativeSuffix) { new SimpleFileVisitor() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - if (file.toString().endsWith(suffix) || file.equals(root.resolve(relativeSuffix))) { + if (matcher.test(file)) { result[0] = file; return FileVisitResult.TERMINATE; } @@ -129,6 +151,9 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (dir.equals(root)) { + return FileVisitResult.CONTINUE; + } String dirName = dir.getFileName() != null ? dir.getFileName().toString() : ""; // Skip hidden dirs, build outputs, and VCS dirs if (dirName.startsWith(".") diff --git a/jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java b/jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java new file mode 100644 index 0000000..7ad1e08 --- /dev/null +++ b/jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java @@ -0,0 +1,59 @@ +package io.codspeed; + +import java.io.IOException; +import java.io.InputStream; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; + +/** + * Reads the {@code SourceFile} attribute of a compiled class, which names the file the class was + * compiled from ({@code MyBench.kt}, {@code Foo.scala}, {@code benchmarks.kt}, ...). + */ +final class ClassFileSourceName { + + private ClassFileSourceName() {} + + /** + * Reads the source file name declared by the given class. + * + * @param classQName fully qualified class name + * @return the declared source file name, or null if the class isn't on the classpath, can't be + * parsed, or carries no {@code SourceFile} attribute + */ + static String read(String classQName) { + InputStream stream = openClassFile(classQName.replace('.', '/') + ".class"); + if (stream == null) { + return null; + } + + try (InputStream in = stream) { + String[] source = {null}; + new ClassReader(in) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public void visitSource(String name, String debug) { + source[0] = name; + } + }, + ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES); + return source[0]; + } catch (IOException | RuntimeException e) { + return null; + } + } + + private static InputStream openClassFile(String resource) { + ClassLoader contextLoader = Thread.currentThread().getContextClassLoader(); + if (contextLoader != null) { + InputStream stream = contextLoader.getResourceAsStream(resource); + if (stream != null) { + return stream; + } + } + + ClassLoader ownLoader = ClassFileSourceName.class.getClassLoader(); + return ownLoader == null ? null : ownLoader.getResourceAsStream(resource); + } +} diff --git a/jmh-fork/jmh-core/src/test/java/io/codspeed/BenchmarkUriTest.java b/jmh-fork/jmh-core/src/test/java/io/codspeed/BenchmarkUriTest.java index c0e0c35..33b6d1e 100644 --- a/jmh-fork/jmh-core/src/test/java/io/codspeed/BenchmarkUriTest.java +++ b/jmh-fork/jmh-core/src/test/java/io/codspeed/BenchmarkUriTest.java @@ -1,9 +1,15 @@ package io.codspeed; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.infra.BenchmarkParams; import org.openjdk.jmh.infra.IterationParams; @@ -13,6 +19,15 @@ public class BenchmarkUriTest { + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private static Path createSource(Path root, String relativePath) throws IOException { + Path file = root.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.createFile(file); + return file; + } + private static BenchmarkParams makeParams(String benchmark, WorkloadParams workloadParams) { return new BenchmarkParams( benchmark, @@ -97,4 +112,77 @@ public void testFullUriWithParams() { assertEquals( "com/nonexistent/MyBenchmark.java::com.nonexistent.MyBenchmark::encode[65536]", uri); } + + @Test + public void testResolveSourceFileKotlinByExtension() throws IOException { + Path root = tempFolder.getRoot().toPath(); + createSource(root, "src/jmh/kotlin/com/example/KotlinBench.kt"); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.KotlinBench", null); + assertEquals("src/jmh/kotlin/com/example/KotlinBench.kt", path); + } + + @Test + public void testResolveSourceFileScalaByExtension() throws IOException { + Path root = tempFolder.getRoot().toPath(); + createSource(root, "src/jmh/scala/com/example/ScalaBench.scala"); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.ScalaBench", null); + assertEquals("src/jmh/scala/com/example/ScalaBench.scala", path); + } + + @Test + public void testResolveSourceFileKotlinFileNameDiffersFromClass() throws IOException { + Path root = tempFolder.getRoot().toPath(); + createSource(root, "src/main/kotlin/com/example/benchmarks.kt"); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.KotlinBench", "benchmarks.kt"); + assertEquals("src/main/kotlin/com/example/benchmarks.kt", path); + } + + @Test + public void testResolveSourceFileFallbackWithSourceFileName() { + Path root = tempFolder.getRoot().toPath(); + String path = + BenchmarkUri.resolveSourceFile(root, "com.example.KotlinBench", "KotlinBench.kt"); + assertEquals("com/example/KotlinBench.kt", path); + } + + @Test + public void testResolveSourceFileFallbackWithoutSourceFileName() { + Path root = tempFolder.getRoot().toPath(); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.KotlinBench", null); + assertEquals("com/example/KotlinBench.java", path); + } + + @Test + public void testResolveSourceFileSkipsBuildDirectory() throws IOException { + Path root = tempFolder.getRoot().toPath(); + createSource(root, "build/com/example/Skipped.kt"); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.Skipped", null); + assertEquals("com/example/Skipped.java", path); + } + + @Test + public void testResolveSourceFileInnerClassWithKotlinSource() throws IOException { + Path root = tempFolder.getRoot().toPath(); + createSource(root, "com/example/Outer.kt"); + String path = BenchmarkUri.resolveSourceFile(root, "com.example.Outer$Inner", null); + assertEquals("com/example/Outer.kt", path); + } + + @Test + public void testClassFileSourceNameReadsOwnSourceFile() { + String sourceFile = ClassFileSourceName.read("io.codspeed.BenchmarkUriTest"); + assertEquals("BenchmarkUriTest.java", sourceFile); + } + + @Test + public void testClassFileSourceNameReturnsNullForMissingClass() { + String sourceFile = ClassFileSourceName.read("com.nonexistent.Missing"); + assertNull(sourceFile); + } + + @Test + public void testResolveSourceFileEndToEndForThisTestClass() { + String path = BenchmarkUri.resolveSourceFile("io.codspeed.BenchmarkUriTest"); + assertEquals("jmh-fork/jmh-core/src/test/java/io/codspeed/BenchmarkUriTest.java", path); + } } diff --git a/jmh-fork/jmh-generator-asm/build.gradle.kts b/jmh-fork/jmh-generator-asm/build.gradle.kts index 0aace06..43f2905 100644 --- a/jmh-fork/jmh-generator-asm/build.gradle.kts +++ b/jmh-fork/jmh-generator-asm/build.gradle.kts @@ -1,5 +1,5 @@ dependencies { api(project(":jmh-core")) api(project(":jmh-generator-reflection")) - api("org.ow2.asm:asm:9.0") + api("org.ow2.asm:asm:9.9.1") } diff --git a/jmh-fork/pom.xml b/jmh-fork/pom.xml index 0ab7e98..5d4ad33 100644 --- a/jmh-fork/pom.xml +++ b/jmh-fork/pom.xml @@ -286,7 +286,7 @@ questions. org.ow2.asm asm - 9.0 + 9.9.1