From 4aacc1496c0ba8dc770a287cf45eb236ed4da64b 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. --- .../main/java/io/codspeed/BenchmarkUri.java | 73 ++++++-- .../java/io/codspeed/ClassFileSourceName.java | 162 ++++++++++++++++++ .../java/io/codspeed/BenchmarkUriTest.java | 88 ++++++++++ 3 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java 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..8e11767 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 @@ -9,11 +9,14 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.Collection; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; 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 +64,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,6 +74,10 @@ static String resolveSourceFile(String classQName) { } private static String resolveSourceFileUncached(String classQName) { + return resolveSourceFile(findGitRoot(), classQName, ClassFileSourceName.read(classQName)); + } + + static String resolveSourceFile(Path root, String classQName, String sourceFileName) { // Handle inner classes: com.example.Outer$Inner -> com.example.Outer String outerClass = classQName; int dollarIdx = outerClass.indexOf('$'); @@ -77,16 +85,59 @@ private static String resolveSourceFileUncached(String classQName) { outerClass = outerClass.substring(0, dollarIdx); } - String relativePath = outerClass.replace('.', '/') + ".java"; - Path gitRoot = findGitRoot(); + int lastDot = outerClass.lastIndexOf('.'); + String pkgPath = lastDot != -1 ? outerClass.substring(0, lastDot).replace('.', '/') : ""; + String simpleName = lastDot != -1 ? outerClass.substring(lastDot + 1) : outerClass; + + if (sourceFileName != null) { + String relativeSuffix = pkgPath.isEmpty() ? sourceFileName : pkgPath + "/" + sourceFileName; + String suffix = "/" + relativeSuffix; + Path exact = root.resolve(relativeSuffix); + Path found = + findFile( + root, + file -> file.toString().replace('\\', '/').endsWith(suffix) || file.equals(exact)); + if (found != null) { + return root.relativize(found).toString().replace('\\', '/'); + } + } - Path found = findFile(gitRoot, relativePath); + Path found = + findFile( + root, + file -> { + if (!parentEndsWith(file.getParent(), pkgPath)) { + return false; + } + Path fileName = file.getFileName(); + if (fileName == null) { + return false; + } + String name = fileName.toString(); + for (String ext : SOURCE_EXTENSIONS) { + if (name.equals(simpleName + "." + ext)) { + return true; + } + } + return false; + }); if (found != null) { - // Normalize to forward slashes for consistent URIs across platforms - return gitRoot.relativize(found).toString().replace('\\', '/'); + return root.relativize(found).toString().replace('\\', '/'); } - return relativePath; + String fallbackName = sourceFileName != null ? sourceFileName : simpleName + ".java"; + return pkgPath.isEmpty() ? fallbackName : pkgPath + "/" + fallbackName; + } + + private static boolean parentEndsWith(Path parent, String pkgPath) { + if (pkgPath.isEmpty()) { + return true; + } + if (parent == null) { + return false; + } + String parentStr = parent.toString().replace('\\', '/'); + return parentStr.endsWith("/" + pkgPath) || parentStr.equals(pkgPath); } /** Walks up from the CWD to find the nearest .git directory, returns its parent. */ @@ -110,8 +161,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 +170,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 +179,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..1d14968 --- /dev/null +++ b/jmh-fork/jmh-core/src/main/java/io/codspeed/ClassFileSourceName.java @@ -0,0 +1,162 @@ +package io.codspeed; + +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * 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 static final int MAGIC = 0xCAFEBABE; + + 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 (DataInputStream in = new DataInputStream(stream)) { + return parse(in); + } 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); + } + + /** Parses a class file as specified by JVMS ยง4.1, stopping at the class-level attributes. */ + private static String parse(DataInputStream in) throws IOException { + if (in.readInt() != MAGIC) { + return null; + } + in.readUnsignedShort(); // minor version + in.readUnsignedShort(); // major version + + String[] utf8 = readConstantPool(in); + if (utf8 == null) { + return null; + } + + in.readUnsignedShort(); // access flags + in.readUnsignedShort(); // this class + in.readUnsignedShort(); // super class + skipFully(in, 2 * in.readUnsignedShort()); // interfaces + skipMembers(in); // fields + skipMembers(in); // methods + + int attributeCount = in.readUnsignedShort(); + for (int i = 0; i < attributeCount; i++) { + int nameIndex = in.readUnsignedShort(); + int length = in.readInt(); + if ("SourceFile".equals(constant(utf8, nameIndex)) && length == 2) { + return constant(utf8, in.readUnsignedShort()); + } + skipFully(in, length); + } + return null; + } + + /** Collects the Utf8 entries of the constant pool, indexed by pool index. */ + private static String[] readConstantPool(DataInputStream in) throws IOException { + int count = in.readUnsignedShort(); + String[] utf8 = new String[Math.max(count, 1)]; + + for (int i = 1; i < count; i++) { + int tag = in.readUnsignedByte(); + switch (tag) { + case 1: // Utf8 + utf8[i] = in.readUTF(); + break; + case 7: // Class + case 8: // String + case 16: // MethodType + case 19: // Module + case 20: // Package + skipFully(in, 2); + break; + case 15: // MethodHandle + skipFully(in, 3); + break; + case 3: // Integer + case 4: // Float + case 9: // Fieldref + case 10: // Methodref + case 11: // InterfaceMethodref + case 12: // NameAndType + case 17: // Dynamic + case 18: // InvokeDynamic + skipFully(in, 4); + break; + case 5: // Long + case 6: // Double + skipFully(in, 8); + // Long and Double occupy two constant pool slots + i++; + break; + default: + // Unknown tag: entry size is unknown, so the rest of the pool can't be walked + return null; + } + } + return utf8; + } + + private static String constant(String[] utf8, int index) { + return index > 0 && index < utf8.length ? utf8[index] : null; + } + + private static void skipMembers(DataInputStream in) throws IOException { + int count = in.readUnsignedShort(); + for (int i = 0; i < count; i++) { + skipFully(in, 6); // access flags, name index, descriptor index + skipAttributes(in); + } + } + + private static void skipAttributes(DataInputStream in) throws IOException { + int count = in.readUnsignedShort(); + for (int i = 0; i < count; i++) { + in.readUnsignedShort(); // attribute name index + skipFully(in, in.readInt()); + } + } + + private static void skipFully(DataInputStream in, int count) throws IOException { + if (count < 0) { + throw new IOException("Invalid length: " + count); + } + + int remaining = count; + while (remaining > 0) { + int skipped = in.skipBytes(remaining); + if (skipped <= 0) { + throw new EOFException(); + } + remaining -= skipped; + } + } +} 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); + } }