From 51d1287cbb8990656e26e2aef0ecf83049a5ca84 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 18 Sep 2026 10:56:22 +0200 Subject: [PATCH] fix(uri): make benchmark source file detection language-agnostic Resolve the source path from the class file's SourceFile attribute first, then fall back to searching known source extensions (java, kt, scala, groovy) instead of hardcoding .java. Kotlin and Scala benchmarks now get the correct file_path in their CodSpeed URI. --- jmh-fork/jmh-core/build.gradle.kts | 1 + jmh-fork/jmh-core/pom.xml | 4 + .../main/java/io/codspeed/BenchmarkUri.java | 65 +++++++++----- .../java/io/codspeed/ClassFileSourceName.java | 59 +++++++++++++ .../java/io/codspeed/BenchmarkUriTest.java | 88 +++++++++++++++++++ jmh-fork/jmh-generator-asm/build.gradle.kts | 2 +- jmh-fork/pom.xml | 2 +- 7 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java 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