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