diff --git a/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTable.java b/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTable.java
index 253ca48971..10e4f971d0 100644
--- a/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTable.java
+++ b/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTable.java
@@ -15,9 +15,7 @@
*/
package org.openrewrite.java.internal.parser;
-import lombok.RequiredArgsConstructor;
-import lombok.ToString;
-import lombok.Value;
+import lombok.*;
import lombok.experimental.NonFinal;
import org.jspecify.annotations.Nullable;
import org.objectweb.asm.*;
@@ -34,6 +32,8 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
@@ -43,10 +43,12 @@
import java.util.zip.ZipException;
import static java.util.Collections.emptyList;
+import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toSet;
-import static org.objectweb.asm.ClassReader.SKIP_CODE;
import static org.objectweb.asm.ClassWriter.COMPUTE_MAXS;
import static org.objectweb.asm.Opcodes.V1_8;
+import static org.openrewrite.java.internal.parser.AnnotationSerializer.convertAnnotationValueToString;
+import static org.openrewrite.java.internal.parser.AnnotationSerializer.serializeArray;
import static org.openrewrite.java.internal.parser.JavaParserCaller.findCaller;
/**
@@ -66,6 +68,10 @@
*
signature
* parameterNames
* exceptions[]
+ * elementAnnotations
+ * parameterAnnotations[]
+ * typeAnnotations[]
+ * constantValue
*
*
* Descriptor and signature are in JVMS 4.3 format.
@@ -76,7 +82,7 @@
* the disk impact of that and the value is an overall single table representation.
*
* To read a compressed type table file (which is compressed with gzip), the following command can be used:
- * gzcat types.tsv.zip.
+ * gzcat types.tsv.gz.
*/
@Incubating(since = "8.44.0")
@Value
@@ -88,15 +94,24 @@ public class TypeTable implements JavaParserClasspathLoader {
*/
public static final String VERIFY_CLASS_WRITING = "org.openrewrite.java.TypeTableClassWritingVerification";
- public static final String DEFAULT_RESOURCE_PATH = "META-INF/rewrite/classpath.tsv.zip";
+ public static final String DEFAULT_RESOURCE_PATH = "META-INF/rewrite/classpath.tsv.gz";
private static final Map> classesDirByArtifact = new ConcurrentHashMap<>();
public static @Nullable TypeTable fromClasspath(ExecutionContext ctx, Collection artifactNames) {
try {
- Enumeration resources = findCaller().getClassLoader().getResources(DEFAULT_RESOURCE_PATH);
- if (resources.hasMoreElements()) {
- return new TypeTable(ctx, resources, artifactNames);
+ ClassLoader classLoader = findCaller().getClassLoader();
+ Vector combinedResources = new Vector<>();
+ for (Enumeration e = classLoader.getResources(DEFAULT_RESOURCE_PATH); e.hasMoreElements(); ) {
+ combinedResources.add(e.nextElement());
+ }
+ // TO-BE-REMOVED(2025-10-31) In the future we only want to support the `.gz` extension
+ for (Enumeration e = classLoader.getResources(DEFAULT_RESOURCE_PATH.replace(".gz", ".zip")); e.hasMoreElements(); ) {
+ combinedResources.add(e.nextElement());
+ }
+
+ if (!combinedResources.isEmpty()) {
+ return new TypeTable(ctx, combinedResources.elements(), artifactNames);
}
return null;
} catch (IOException e) {
@@ -121,12 +136,13 @@ private static void read(URL url, Collection artifactNames, ExecutionCon
return;
}
+ Reader.Options options = Reader.Options.builder().artifactPrefixes(artifactNames).build();
try (InputStream is = url.openStream(); InputStream inflate = new GZIPInputStream(is)) {
- new Reader(ctx).read(inflate, missingArtifacts);
+ new Reader(ctx).read(inflate, options);
} catch (ZipException e) {
// Fallback to `InflaterInputStream` for older files created as raw zlib data using DeflaterOutputStream
try (InputStream is = url.openStream(); InputStream inflate = new InflaterInputStream(is)) {
- new Reader(ctx).read(inflate, missingArtifacts);
+ new Reader(ctx).read(inflate, options);
} catch (IOException e1) {
throw new UncheckedIOException(e1);
}
@@ -154,7 +170,48 @@ private static Collection artifactsNotYetWritten(Collection arti
* Reads a type table from the classpath, and writes classes directories to disk for matching artifact names.
*/
@RequiredArgsConstructor
- static class Reader {
+ public static class Reader {
+
+ /**
+ * Options for controlling how type tables are read.
+ * This allows for flexible filtering and processing of type table entries.
+ *
+ * Uses a builder pattern for future extensibility without breaking changes.
+ */
+ @lombok.Builder(builderClassName = "Builder")
+ @lombok.AllArgsConstructor(access = lombok.AccessLevel.PRIVATE)
+ public static class Options {
+ @lombok.Builder.Default
+ @Getter(AccessLevel.PACKAGE)
+ private final Predicate artifactMatcher = gav -> true;
+
+ /**
+ * Creates options that match all artifacts.
+ */
+ public static Options matchAll() {
+ return builder().build();
+ }
+
+ /**
+ * Enhanced builder with convenience methods.
+ */
+ public static class Builder {
+
+ /**
+ * Matches artifacts whose names start with any of the given prefixes.
+ * @param artifactPrefixes Collection of artifact name prefixes to match
+ */
+ public Builder artifactPrefixes(Collection artifactPrefixes) {
+ Set patterns = artifactPrefixes.stream()
+ .map(prefix -> Pattern.compile(prefix + ".*"))
+ .collect(toSet());
+ this.artifactMatcher(artifactVersion -> patterns.stream()
+ .anyMatch(pattern -> pattern.matcher(artifactVersion).matches()));
+ return this;
+ }
+ }
+ }
+
private static final int NESTED_TYPE_ACCESS_MASK = Opcodes.ACC_PUBLIC | Opcodes.ACC_PRIVATE | Opcodes.ACC_PROTECTED |
Opcodes.ACC_STATIC | Opcodes.ACC_FINAL | Opcodes.ACC_INTERFACE |
Opcodes.ACC_ABSTRACT | Opcodes.ACC_SYNTHETIC | Opcodes.ACC_ANNOTATION |
@@ -162,16 +219,32 @@ static class Reader {
private final ExecutionContext ctx;
- public void read(InputStream is, Collection artifactNames) throws IOException {
- if (artifactNames.isEmpty()) {
- // could be empty due to the filtering in `artifactsNotYetWritten()`
- return;
- }
+ public void read(InputStream is, Options options) throws IOException {
+ parseTsvAndProcess(is, options, this::writeClassesDir);
+ }
- Set artifactNamePatterns = artifactNames.stream()
- .map(name -> Pattern.compile(name + ".*"))
- .collect(toSet());
+ /**
+ * Read a type table and process classes with custom ClassVisitors instead of writing to disk.
+ *
+ * @param is The input stream containing the TSV data
+ * @param options Options controlling how the type table is read
+ * @param visitorSupplier Supplier to create a ClassVisitor for each class
+ */
+ public void read(InputStream is, Options options, Supplier visitorSupplier) throws IOException {
+ parseTsvAndProcess(is, options,
+ (gav, classes, nestedTypes) -> {
+ for (ClassDefinition classDef : classes.values()) {
+ processClass(classDef, nestedTypes.getOrDefault(classDef.getName(), emptyList()), visitorSupplier.get());
+ }
+ });
+ }
+ /**
+ * Common TSV parsing logic used by both read() and readWithVisitors().
+ * Parses the TSV and calls the processor for each GAV's classes.
+ */
+ private void parseTsvAndProcess(InputStream is, Options options,
+ ClassesProcessor processor) throws IOException {
AtomicReference<@Nullable GroupArtifactVersion> matchedGav = new AtomicReference<>();
Map classesByName = new HashMap<>();
// nested types appear first in type tables and therefore not stored in a `ClassDefinition` field
@@ -184,18 +257,18 @@ public void read(InputStream is, Collection artifactNames) throws IOExce
GroupArtifactVersion rowGav = new GroupArtifactVersion(fields[0], fields[1], fields[2]);
if (!Objects.equals(rowGav, lastGav.get())) {
- writeClassesDir(matchedGav.get(), classesByName, nestedTypesByOwner);
+ if (matchedGav.get() != null) {
+ processor.accept(matchedGav.get(), classesByName, nestedTypesByOwner);
+ }
matchedGav.set(null);
classesByName.clear();
nestedTypesByOwner.clear();
String artifactVersion = fields[1] + "-" + fields[2];
- for (Pattern artifactNamePattern : artifactNamePatterns) {
- if (artifactNamePattern.matcher(artifactVersion).matches()) {
- matchedGav.set(rowGav);
- break;
- }
+ // Check if this artifact matches our predicate
+ if (options.getArtifactMatcher().test(artifactVersion)) {
+ matchedGav.set(rowGav);
}
}
lastGav.set(rowGav);
@@ -208,7 +281,9 @@ public void read(InputStream is, Collection artifactNames) throws IOExce
name,
fields[5].isEmpty() ? null : fields[5],
fields[6].isEmpty() ? null : fields[6],
- fields[7].isEmpty() ? null : fields[7].split("\\|")
+ fields[7].isEmpty() ? null : fields[7].split("\\|"),
+ fields.length > 14 && !fields[14].isEmpty() ? fields[14] : null, // elementAnnotations - raw string (may have | delimiters)
+ fields.length > 17 && !fields[17].isEmpty() ? fields[17] : null // constantValue moved to column 17
));
int lastIndexOf$ = className.lastIndexOf('$');
if (lastIndexOf$ != -1) {
@@ -225,14 +300,27 @@ public void read(InputStream is, Collection artifactNames) throws IOExce
fields[10],
fields[11].isEmpty() ? null : fields[11],
fields[12].isEmpty() ? null : fields[12].split("\\|"),
- fields[13].isEmpty() ? null : fields[13].split("\\|")
+ fields[13].isEmpty() ? null : fields[13].split("\\|"),
+ fields.length > 14 && !fields[14].isEmpty() ? fields[14] : null, // elementAnnotations - raw string
+ fields.length > 15 && !fields[15].isEmpty() ? fields[15] : null,
+ fields.length > 16 && !fields[16].isEmpty() ? fields[16].split("\\|") : null, // typeAnnotations - keep | delimiter between different type contexts
+ fields.length > 17 && !fields[17].isEmpty() ? fields[17] : null
));
}
}
});
}
- writeClassesDir(matchedGav.get(), classesByName, nestedTypesByOwner);
+ // Process final GAV if any
+ if (matchedGav.get() != null) {
+ processor.accept(matchedGav.get(), classesByName, nestedTypesByOwner);
+ }
+ }
+
+ @FunctionalInterface
+ private interface ClassesProcessor {
+ void accept(@Nullable GroupArtifactVersion gav, Map classes,
+ Map> nestedTypes);
}
private void writeClassesDir(@Nullable GroupArtifactVersion gav, Map classes, Map> nestedTypesByOwner) {
@@ -258,54 +346,7 @@ private void writeClassesDir(@Nullable GroupArtifactVersion gav, Map nestedTypes, ClassVisitor classVisitor) {
+ classVisitor.visit(
+ V1_8,
+ classDef.getAccess(),
+ classDef.getName(),
+ classDef.getSignature(),
+ classDef.getSuperclassSignature(),
+ classDef.getSuperinterfaceSignatures()
+ );
+
+ // Apply annotations to the class
+ if (classDef.getAnnotations() != null) {
+ AnnotationApplier.applyAnnotations(classDef.getAnnotations(), classVisitor::visitAnnotation);
+ }
+
+ for (ClassDefinition innerClassDef : nestedTypes) {
+ classVisitor.visitInnerClass(
+ innerClassDef.getName(),
+ classDef.getName(),
+ innerClassDef.getName().substring(classDef.getName().length() + 1),
+ innerClassDef.getAccess() & NESTED_TYPE_ACCESS_MASK
+ );
+ }
+
+ for (Member member : classDef.getMembers()) {
+ if (member.getDescriptor().startsWith("(")) {
+ MethodVisitor mv = classVisitor
+ .visitMethod(
+ member.getAccess(),
+ member.getName(),
+ member.getDescriptor(),
+ member.getSignature(),
+ member.getExceptions()
+ );
+
+ if (mv != null) {
+ // Apply element annotations to the method
+ if (member.getAnnotations() != null) {
+ AnnotationApplier.applyAnnotations(member.getAnnotations(), mv::visitAnnotation);
+ }
+
+ // Apply parameter annotations
+ if (member.getParameterAnnotations() != null && !member.getParameterAnnotations().isEmpty()) {
+ // Parse dense format: "[annotations]||[annotations]|" where each position represents a parameter
+ // Empty positions mean no annotations for that parameter
+ String[] paramAnnotations = TsvEscapeUtils.splitAnnotationList(member.getParameterAnnotations(), '|');
+ for (int i = 0; i < paramAnnotations.length; i++) {
+ final int paramIndex = i;
+ String annotationsPart = paramAnnotations[i];
+ if (!annotationsPart.isEmpty()) {
+ // Parse and apply the annotation sequence (no delimiters needed within)
+ AnnotationApplier.applyAnnotations(annotationsPart,
+ (descriptor, visible) -> mv.visitParameterAnnotation(paramIndex, descriptor, visible));
+ }
+ }
+ }
+
+ // Apply type annotations
+ if (member.getTypeAnnotations() != null) {
+ for (String typeAnnotation : member.getTypeAnnotations()) {
+ TypeAnnotationSupport.TypeAnnotationInfo info =
+ TypeAnnotationSupport.TypeAnnotationInfo.parse(typeAnnotation);
+ AnnotationApplier.applyAnnotation(info.annotation,
+ (descriptor, visible) -> mv.visitTypeAnnotation(info.typeRef, info.typePath, descriptor, visible));
+ }
+ }
+
+ String[] parameterNames = member.getParameterNames();
+ if (parameterNames != null) {
+ for (String parameterName : parameterNames) {
+ mv.visitParameter(parameterName, 0);
+ }
+ }
+
+ if (member.getConstantValue() != null) {
+ AnnotationVisitor annotationDefaultVisitor = mv.visitAnnotationDefault();
+ AnnotationSerializer.processAnnotationDefaultValue(
+ annotationDefaultVisitor,
+ AnnotationDeserializer.parseValue(member.getConstantValue())
+ );
+ annotationDefaultVisitor.visitEnd();
+ }
+
+ writeMethodBody(member, mv);
+ mv.visitEnd();
+ }
+ } else {
+ // Determine the constant value for static final fields
+ // Only set constantValue for bytecode if it's a valid ConstantValue attribute type
+ Object constantValue = null;
+ if ((member.getAccess() & (Opcodes.ACC_STATIC | Opcodes.ACC_FINAL)) == (Opcodes.ACC_STATIC | Opcodes.ACC_FINAL) &&
+ member.getConstantValue() != null) {
+ Object parsedValue = AnnotationDeserializer.parseValue(member.getConstantValue());
+ // Only primitive types and strings can be ConstantValue attributes
+ if (isValidConstantValueType(parsedValue)) {
+ constantValue = parsedValue;
+ }
+ }
+
+ FieldVisitor fv = classVisitor
+ .visitField(
+ member.getAccess(),
+ member.getName(),
+ member.getDescriptor(),
+ member.getSignature(),
+ constantValue
+ );
+
+ if (fv != null) {
+ // Apply element annotations to the field
+ if (member.getAnnotations() != null) {
+ AnnotationApplier.applyAnnotations(member.getAnnotations(), fv::visitAnnotation);
+ }
+
+ // Apply type annotations for fields
+ if (member.getTypeAnnotations() != null) {
+ for (String typeAnnotation : member.getTypeAnnotations()) {
+ TypeAnnotationSupport.TypeAnnotationInfo info =
+ TypeAnnotationSupport.TypeAnnotationInfo.parse(typeAnnotation);
+ AnnotationApplier.applyAnnotation(info.annotation,
+ (descriptor, visible) -> fv.visitTypeAnnotation(info.typeRef, info.typePath, descriptor, visible));
+ }
+ }
+
+ fv.visitEnd();
+ }
+ }
+ }
+
+ classVisitor.visitEnd();
+ }
+
private void writeMethodBody(Member member, MethodVisitor mv) {
if ((member.getAccess() & Opcodes.ACC_ABSTRACT) == 0) {
mv.visitCode();
@@ -375,6 +551,18 @@ private void writeMethodBody(Member member, MethodVisitor mv) {
}
}
+ private static boolean isValidConstantValueType(@Nullable Object value) {
+ if (value == null) {
+ return false; // null values cannot be ConstantValue attributes
+ }
+ return value instanceof String ||
+ value instanceof Integer || value instanceof Long ||
+ value instanceof Float || value instanceof Double ||
+ value instanceof Boolean || value instanceof Character ||
+ value instanceof Byte || value instanceof Short;
+ }
+
+
private static Path getClassesDir(ExecutionContext ctx, GroupArtifactVersion gav) {
Path jarsFolder = JavaParserExecutionContextView.view(ctx)
.getParserClasspathDownloadTarget().toPath().resolve(".tt");
@@ -423,7 +611,7 @@ public static class Writer implements AutoCloseable {
public Writer(OutputStream out) throws IOException {
this.deflater = new GZIPOutputStream(out);
this.out = new PrintStream(deflater);
- this.out.println("groupId\tartifactId\tversion\tclassAccess\tclassName\tclassSignature\tclassSuperclassSignature\tclassSuperinterfaceSignatures\taccess\tname\tdescriptor\tsignature\tparameterNames\texceptions");
+ this.out.println("groupId\tartifactId\tversion\tclassAccess\tclassName\tclassSignature\tclassSuperclassSignature\tclassSuperinterfaceSignatures\taccess\tname\tdescriptor\tsignature\tparameterNames\texceptions\telementAnnotations\tparameterAnnotations\ttypeAnnotations\tconstantValue");
}
public Jar jar(String groupId, String artifactId, String version) {
@@ -449,76 +637,240 @@ public void write(Path jar) {
JarEntry entry = entries.nextElement();
if (entry.getName().endsWith(".class")) {
try (InputStream inputStream = jarFile.getInputStream(entry)) {
- new ClassReader(inputStream).accept(new ClassVisitor(Opcodes.ASM9) {
- @Nullable
- ClassDefinition classDefinition;
-
- boolean wroteFieldOrMethod;
- final List collectedParameterNames = new ArrayList<>();
-
- @Override
- public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
- int lastIndexOf$ = name.lastIndexOf('$');
- if (lastIndexOf$ != -1 && lastIndexOf$ < name.length() - 1 && !Character.isJavaIdentifierStart(name.charAt(lastIndexOf$ + 1))) {
- // skip anonymous subclasses
- classDefinition = null;
- } else {
- classDefinition = new ClassDefinition(Jar.this, access, name, signature, superName, interfaces);
- wroteFieldOrMethod = false;
- super.visit(version, access, name, signature, superName, interfaces);
- if (!wroteFieldOrMethod && !"module-info".equals(name)) {
- // No fields or methods, which can happen for marker annotations for example
- classDefinition.writeClass();
+ writeClass(inputStream);
+ }
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Write a single class from an InputStream containing bytecode.
+ * This can be used for processing individual class files.
+ *
+ * @param classInputStream InputStream containing the class bytecode
+ * @throws IOException if reading the class fails
+ */
+ public void writeClass(InputStream classInputStream) throws IOException {
+ new ClassReader(classInputStream).accept(new ClassVisitor(Opcodes.ASM9) {
+ @Nullable
+ ClassDefinition classDefinition;
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ int lastIndexOf$ = name.lastIndexOf('$');
+ if (lastIndexOf$ != -1 && lastIndexOf$ < name.length() - 1 && !Character.isJavaIdentifierStart(name.charAt(lastIndexOf$ + 1))) {
+ // skip anonymous subclasses
+ classDefinition = null;
+ } else {
+ classDefinition = new ClassDefinition(
+ Jar.this,
+ access,
+ name,
+ signature,
+ superName,
+ interfaces
+ );
+ super.visit(version, access, name, signature, superName, interfaces);
+ }
+ }
+
+ @Override
+ public @Nullable AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (classDefinition != null) {
+ return AnnotationCollectorHelper.createCollector(descriptor, requireNonNull(classDefinition).classAnnotations);
+ }
+ return null;
+ }
+
+ @Override
+ public @Nullable AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) {
+ if (classDefinition != null) {
+ List tempCollector = new ArrayList<>();
+ AnnotationVisitor collector = AnnotationCollectorHelper.createCollector(descriptor, tempCollector);
+ return new AnnotationVisitor(Opcodes.ASM9, collector) {
+ @Override
+ public void visitEnd() {
+ super.visitEnd();
+ if (!tempCollector.isEmpty()) {
+ String annotation = tempCollector.get(0);
+ String formatted = TypeAnnotationSupport.formatTypeAnnotation(typeRef, typePath, annotation);
+ classDefinition.classTypeAnnotations.add(formatted);
+ }
+ }
+ };
+ }
+ return null;
+ }
+
+ @Override
+ public @Nullable FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
+ if (classDefinition != null) {
+ Writer.Member member = new Writer.Member(access, name, descriptor, signature, null, null);
+
+ // Only store constant values that can be ConstantValue attributes in bytecode
+ if ((access & (Opcodes.ACC_STATIC | Opcodes.ACC_FINAL)) == (Opcodes.ACC_STATIC | Opcodes.ACC_FINAL) &&
+ isValidConstantValueType(value)) {
+ member.constantValue = AnnotationSerializer.convertConstantValueWithType(value, descriptor);
+ }
+
+ return new FieldVisitor(Opcodes.ASM9) {
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ return AnnotationCollectorHelper.createCollector(descriptor, member.elementAnnotations);
+ }
+
+ @Override
+ public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) {
+ List tempCollector = new ArrayList<>();
+ AnnotationVisitor collector = AnnotationCollectorHelper.createCollector(descriptor, tempCollector);
+ return new AnnotationVisitor(Opcodes.ASM9, collector) {
+ @Override
+ public void visitEnd() {
+ super.visitEnd();
+ if (!tempCollector.isEmpty()) {
+ String annotation = tempCollector.get(0);
+ String formatted = TypeAnnotationSupport.formatTypeAnnotation(typeRef, typePath, annotation);
+ member.typeAnnotations.add(formatted);
}
}
+ };
+ }
+
+ @Override
+ public void visitEnd() {
+ classDefinition.addField(member);
+ }
+ };
+ }
+
+ return null;
+ }
+
+ @Override
+ public @Nullable MethodVisitor visitMethod(int access, @Nullable String name, String descriptor,
+ @Nullable String signature, String @Nullable [] exceptions) {
+ // Repeating check from `writeMethod()` for performance reasons
+ if (classDefinition != null && ((Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC) & access) == 0 &&
+ name != null && !"".equals(name)) {
+ Writer.Member member = new Writer.Member(access, name, descriptor, signature, exceptions, null);
+ return new MethodVisitor(Opcodes.ASM9) {
+ @Override
+ public void visitParameter(@Nullable String name, int access) {
+ if (name != null) {
+ member.parameterNames.add(name);
}
+ }
- @Override
- public @Nullable FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
- if (classDefinition != null) {
- wroteFieldOrMethod |= classDefinition
- .writeField(access, name, descriptor, signature);
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ return AnnotationCollectorHelper.createCollector(descriptor, member.elementAnnotations);
+ }
+
+ @Override
+ public AnnotationVisitor visitParameterAnnotation(int parameter, String descriptor, boolean visible) {
+ List tempCollector = new ArrayList<>();
+ AnnotationVisitor collector = AnnotationCollectorHelper.createCollector(descriptor, tempCollector);
+ // After collection, add to parameter annotations with parameter index
+ return new AnnotationVisitor(Opcodes.ASM9, collector) {
+ @Override
+ public void visitEnd() {
+ super.visitEnd();
+ if (!tempCollector.isEmpty()) {
+ // Format: "paramIndex:annotation"
+ member.parameterAnnotations.add(parameter + ":" + tempCollector.get(0));
+ }
}
+ };
+ }
- return null;
- }
+ @Override
+ public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) {
+ List tempCollector = new ArrayList<>();
+ AnnotationVisitor collector = AnnotationCollectorHelper.createCollector(descriptor, tempCollector);
+ return new AnnotationVisitor(Opcodes.ASM9, collector) {
+ @Override
+ public void visitEnd() {
+ super.visitEnd();
+ if (!tempCollector.isEmpty()) {
+ String annotation = tempCollector.get(0);
+ String formatted = TypeAnnotationSupport.formatTypeAnnotation(typeRef, typePath, annotation);
+ member.typeAnnotations.add(formatted);
+ }
+ }
+ };
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotationDefault() {
+ List nested = new ArrayList<>();
+ // Collect default values for annotation methods
+ return new AnnotationVisitor(Opcodes.ASM9) {
+ @Override
+ public void visit(String name, Object value) {
+ member.constantValue = convertAnnotationValueToString(value);
+ }
+
+ @Override
+ public AnnotationVisitor visitArray(String name) {
+ return new AnnotationVisitor(Opcodes.ASM9) {
+ final List arrayValues = new ArrayList<>();
- @Override
- public @Nullable MethodVisitor visitMethod(int access, @Nullable String name, String descriptor,
- String signature, String[] exceptions) {
- // Repeating check from `writeMethod()` for performance reasons
- if (classDefinition != null && ((Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC) & access) == 0 &&
- name != null && !"".equals(name)) {
- return new MethodVisitor(Opcodes.ASM9) {
@Override
- public void visitParameter(@Nullable String name, int access) {
- if (name != null) {
- collectedParameterNames.add(name);
- }
+ public void visit(String name, Object value) {
+ arrayValues.add(convertAnnotationValueToString(value));
}
@Override
public void visitEnd() {
- wroteFieldOrMethod |= classDefinition
- .writeMethod(access, name, descriptor, signature, collectedParameterNames.isEmpty() ? null : collectedParameterNames, exceptions);
- collectedParameterNames.clear();
+ member.constantValue = "[" + String.join(",", arrayValues) + "]";
}
};
}
- return null;
- }
- }, SKIP_CODE);
- }
+
+ @Override
+ public void visitEnum(String name, String descriptor, String value) {
+ member.constantValue = "e" + descriptor + "." + value;
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String name, String descriptor) {
+ return AnnotationCollectorHelper.createCollector(descriptor, nested);
+ }
+
+ @Override
+ public void visitEnd() {
+ if (!nested.isEmpty()) {
+ member.constantValue = serializeArray(nested.toArray(new String[0]));
+ }
+ }
+ };
+ }
+
+ @Override
+ public void visitEnd() {
+ classDefinition.addMethod(member);
+ }
+ };
}
+ return null;
}
- } catch (IOException e) {
- throw new UncheckedIOException(e);
- }
+
+ @Override
+ public void visitEnd() {
+ if (classDefinition != null && !"module-info".equals(classDefinition.className)) {
+ // No fields or methods, which can happen for marker annotations for example
+ classDefinition.writeClass();
+ }
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES);
}
}
@Value
- public class ClassDefinition {
+ class ClassDefinition {
Jar jar;
int classAccess;
String className;
@@ -526,47 +878,83 @@ public class ClassDefinition {
@Nullable
String classSignature;
+ @Nullable
String classSuperclassName;
+
String @Nullable [] classSuperinterfaceSignatures;
+ List classAnnotations = new ArrayList<>(4);
+ List classTypeAnnotations = new ArrayList<>(4);
+ List members = new ArrayList<>();
+
public void writeClass() {
if (((Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC) & classAccess) == 0) {
out.printf(
- "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s%n",
+ "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s%n",
jar.groupId, jar.artifactId, jar.version,
classAccess, className,
classSignature == null ? "" : classSignature,
classSuperclassName,
classSuperinterfaceSignatures == null ? "" : String.join("|", classSuperinterfaceSignatures),
- -1, "", "", "", "", "");
+ -1, "", "", "", "", "",
+ classAnnotations.isEmpty() ? "" : String.join("", classAnnotations),
+ "", // Empty parameter annotations for class row
+ classTypeAnnotations.isEmpty() ? "" : PipeDelimitedJoiner.joinWithPipes(classTypeAnnotations),
+ ""); // Empty constant value for class row
+
+ for (Writer.Member member : members) {
+ member.writeMember(jar, this);
+ }
}
}
- public boolean writeMethod(int access, @Nullable String name, String descriptor,
- @Nullable String signature,
- @Nullable List parameterNames,
- String @Nullable [] exceptions) {
- if (((Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC) & access) == 0 && name != null && !"".equals(name)) {
+ void addMethod(Writer.Member member) {
+ members.add(member);
+ }
+
+ void addField(Writer.Member member) {
+ members.add(member);
+ }
+ }
+
+ @Value
+ private class Member {
+ int access;
+ String name;
+ String descriptor;
+
+ @Nullable
+ String signature;
+
+ String @Nullable [] exceptions;
+ List parameterNames = new ArrayList<>(4);
+ List elementAnnotations = new ArrayList<>(4);
+ List parameterAnnotations = new ArrayList<>(4);
+ List typeAnnotations = new ArrayList<>(4);
+
+ @Nullable
+ @NonFinal
+ String constantValue;
+
+ private void writeMember(Jar jar, ClassDefinition classDefinition) {
+ if (((Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC) & access) == 0) {
out.printf(
- "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s%n",
+ "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s%n",
jar.groupId, jar.artifactId, jar.version,
- classAccess, className,
- classSignature == null ? "" : classSignature,
- classSuperclassName,
- classSuperinterfaceSignatures == null ? "" : String.join("|", classSuperinterfaceSignatures),
+ classDefinition.classAccess, classDefinition.className,
+ classDefinition.classSignature == null ? "" : classDefinition.classSignature,
+ classDefinition.classSuperclassName,
+ classDefinition.classSuperinterfaceSignatures == null ? "" : String.join("|", classDefinition.classSuperinterfaceSignatures),
access, name, descriptor,
signature == null ? "" : signature,
- parameterNames == null ? "" : String.join("|", parameterNames),
- exceptions == null ? "" : String.join("|", exceptions)
+ parameterNames.isEmpty() ? "" : String.join("|", parameterNames),
+ exceptions == null ? "" : String.join("|", exceptions),
+ elementAnnotations.isEmpty() ? "" : String.join("", elementAnnotations),
+ serializeParameterAnnotations(parameterAnnotations, descriptor),
+ typeAnnotations.isEmpty() ? "" : PipeDelimitedJoiner.joinWithPipes(typeAnnotations),
+ constantValue == null ? "" : constantValue
);
- return true;
}
- return false;
- }
-
- public boolean writeField(int access, String name, String descriptor, @Nullable String signature) {
- // Fits into the same table structure
- return writeMethod(access, name, descriptor, signature, null, null);
}
}
}
@@ -592,6 +980,12 @@ private static class ClassDefinition {
String @Nullable [] superinterfaceSignatures;
+ @Nullable
+ String annotations;
+
+ @Nullable
+ String constantValue;
+
@NonFinal
@Nullable
@ToString.Exclude
@@ -621,5 +1015,91 @@ private static class Member {
String @Nullable [] parameterNames;
String @Nullable [] exceptions;
+
+ @Nullable
+ String annotations;
+
+ @Nullable
+ String parameterAnnotations;
+
+ String @Nullable [] typeAnnotations;
+
+ @Nullable
+ String constantValue;
+ }
+
+ /**
+ * Serializes parameter annotations from a list of "paramIndex:annotation" strings
+ * into a dense TSV format where each parameter position is represented.
+ * For a method with 4 parameters where only parameters 0 and 2 have annotations,
+ * the format would be: "[annotations]||[annotations]|"
+ * Returns empty string if no parameters have annotations.
+ */
+ private static String serializeParameterAnnotations(List parameterAnnotations, String descriptor) {
+ if (parameterAnnotations.isEmpty()) {
+ return "";
+ }
+
+ // Parse the method descriptor to get parameter count
+ Type methodType = Type.getMethodType(descriptor);
+ int paramCount = methodType.getArgumentTypes().length;
+
+ // Group annotations by parameter index
+ Map> annotationsByParam = new TreeMap<>();
+ for (String paramAnnotation : parameterAnnotations) {
+ int colonIdx = paramAnnotation.indexOf(':');
+ if (colonIdx > 0) {
+ int paramIndex = Integer.parseInt(paramAnnotation.substring(0, colonIdx));
+ String annotation = paramAnnotation.substring(colonIdx + 1);
+ annotationsByParam.computeIfAbsent(paramIndex, k -> new ArrayList<>()).add(annotation);
+ }
+ }
+
+ // If no parameters have annotations, return empty string
+ if (annotationsByParam.isEmpty()) {
+ return "";
+ }
+
+ // Build the dense representation
+ StringBuilder result = new StringBuilder();
+ for (int i = 0; i < paramCount; i++) {
+ if (i > 0) {
+ result.append('|');
+ }
+ List annotations = annotationsByParam.get(i);
+ if (annotations != null && !annotations.isEmpty()) {
+ // Escape pipes in annotation values since we use pipes to separate parameters
+ for (String annotation : annotations) {
+ result.append(PipeDelimitedJoiner.escapePipes(annotation));
+ }
+ }
+ }
+ return result.toString();
+ }
+
+ private static class PipeDelimitedJoiner {
+ static String joinWithPipes(List items) {
+ if (items.isEmpty()) {
+ return "";
+ }
+ StringBuilder result = new StringBuilder();
+ boolean first = true;
+ for (String item : items) {
+ if (!first) {
+ result.append('|');
+ }
+ first = false;
+ // Escape any pipes in the item
+ result.append(escapePipes(item));
+ }
+ return result.toString();
+ }
+
+ private static String escapePipes(String str) {
+ if (!str.contains("|")) {
+ return str;
+ }
+ return str.replace("|", "\\|");
+ }
}
}
diff --git a/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSupport.java b/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSupport.java
new file mode 100644
index 0000000000..aaf658d595
--- /dev/null
+++ b/rewrite-java/src/main/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSupport.java
@@ -0,0 +1,1193 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.internal.parser;
+
+import org.jetbrains.annotations.VisibleForTesting;
+import org.jspecify.annotations.Nullable;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.TypePath;
+
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Functional interface for creating an AnnotationVisitor.
+ */
+@FunctionalInterface
+interface AnnotationVisitorCreator {
+ @Nullable
+ AnnotationVisitor create(String descriptor, boolean visible);
+}
+
+/**
+ * Helper class for applying annotations to classes, methods, and fields using ASM.
+ */
+class AnnotationApplier {
+
+ /**
+ * Applies a sequence of annotations to a class, method, or field.
+ * Annotations can be separated by pipes or concatenated without delimiters.
+ *
+ * @param annotationsStr The serialized annotations string (may contain multiple annotations)
+ * @param visitAnnotation A function that creates an AnnotationVisitor
+ */
+ public static void applyAnnotations(String annotationsStr, AnnotationVisitorCreator visitAnnotation) {
+ if (annotationsStr.isEmpty()) {
+ return;
+ }
+
+ List annotations = AnnotationDeserializer.parseAnnotations(annotationsStr);
+ for (AnnotationDeserializer.AnnotationInfo annotationInfo : annotations) {
+ applyAnnotation(annotationInfo, visitAnnotation);
+ }
+ }
+
+ /**
+ * Applies a single annotation string to a class, method, or field.
+ *
+ * @param annotationStr The serialized annotation string
+ * @param visitAnnotation A function that creates an AnnotationVisitor
+ */
+ public static void applyAnnotation(String annotationStr, AnnotationVisitorCreator visitAnnotation) {
+ if (!annotationStr.startsWith("@")) {
+ return;
+ }
+
+ AnnotationDeserializer.AnnotationInfo annotationInfo = AnnotationDeserializer.parseAnnotation(annotationStr);
+ applyAnnotation(annotationInfo, visitAnnotation);
+ }
+
+ /**
+ * Applies a parsed annotation info to a class, method, or field.
+ */
+ private static void applyAnnotation(AnnotationDeserializer.AnnotationInfo annotationInfo, AnnotationVisitorCreator visitAnnotation) {
+ AnnotationVisitor av = visitAnnotation.create(annotationInfo.getDescriptor(), true);
+ if (av != null) {
+ AnnotationAttributeApplier.applyAttributes(av, annotationInfo.getAttributes());
+ av.visitEnd();
+ }
+ }
+}
+
+/**
+ * Handles the application of annotation attributes to ASM AnnotationVisitors.
+ * Centralizes the value handling logic that was previously duplicated.
+ */
+class AnnotationAttributeApplier {
+
+ /**
+ * Applies a list of attributes to an annotation visitor.
+ */
+ public static void applyAttributes(AnnotationVisitor av, @Nullable List attributes) {
+ if (attributes == null || attributes.isEmpty()) {
+ return;
+ }
+
+ for (AnnotationDeserializer.AttributeInfo attribute : attributes) {
+ applyParsedValue(av, attribute.getName(), attribute.getValue());
+ }
+ }
+
+
+ /**
+ * Applies a parsed value to an annotation visitor.
+ * This method centralizes all the value handling logic that was previously duplicated.
+ */
+ public static void applyParsedValue(AnnotationVisitor av, @Nullable String attributeName, Object parsedValue) {
+ if (parsedValue instanceof Boolean || parsedValue instanceof Character || parsedValue instanceof Number) {
+ av.visit(attributeName, parsedValue);
+ } else if (parsedValue instanceof String) {
+ String stringValue = (String) parsedValue;
+ if (stringValue.startsWith("\"") && stringValue.endsWith("\"")) {
+ stringValue = stringValue.substring(1, stringValue.length() - 1);
+ }
+ av.visit(attributeName, stringValue);
+ } else if (parsedValue instanceof AnnotationDeserializer.ClassConstant) {
+ String classDescriptor = ((AnnotationDeserializer.ClassConstant) parsedValue).getDescriptor();
+ av.visit(attributeName, Type.getType(classDescriptor));
+ } else if (parsedValue instanceof AnnotationDeserializer.EnumConstant) {
+ AnnotationDeserializer.EnumConstant enumConstant = (AnnotationDeserializer.EnumConstant) parsedValue;
+ av.visitEnum(attributeName, enumConstant.getEnumDescriptor(), enumConstant.getConstantName());
+ } else if (parsedValue instanceof AnnotationDeserializer.AnnotationInfo) {
+ applyNestedAnnotation(av, attributeName, (AnnotationDeserializer.AnnotationInfo) parsedValue);
+ } else if (parsedValue instanceof Object[]) {
+ applyArrayAttribute(av, attributeName, (Object[]) parsedValue);
+ }
+ }
+
+ /**
+ * Applies a nested annotation to an annotation visitor.
+ */
+ private static void applyNestedAnnotation(AnnotationVisitor av, @Nullable String attributeName,
+ AnnotationDeserializer.AnnotationInfo nestedAnnotationInfo) {
+ AnnotationVisitor nestedAv = av.visitAnnotation(attributeName, nestedAnnotationInfo.getDescriptor());
+
+ if (nestedAv != null) {
+ applyAttributes(nestedAv, nestedAnnotationInfo.getAttributes());
+ nestedAv.visitEnd();
+ }
+ }
+
+ /**
+ * Applies an array attribute to an annotation visitor.
+ */
+ private static void applyArrayAttribute(AnnotationVisitor av, @Nullable String attributeName, Object[] arrayValues) {
+ AnnotationVisitor arrayVisitor = av.visitArray(attributeName);
+
+ if (arrayVisitor != null) {
+ for (Object arrayValue : arrayValues) {
+ applyParsedValue(arrayVisitor, null, arrayValue);
+ }
+ arrayVisitor.visitEnd();
+ }
+ }
+}
+
+/**
+ * Helper class to create reusable annotation visitors for collecting annotation data.
+ */
+class AnnotationCollectorHelper {
+
+ /**
+ * Creates an annotation visitor that collects annotation values into a serialized string format.
+ */
+ static AnnotationVisitor createCollector(String annotationDescriptor, List collectedAnnotations) {
+ return new AnnotationValueCollector(result -> {
+ String serializedAnnotation = result.isEmpty() ?
+ AnnotationSerializer.serializeSimpleAnnotation(annotationDescriptor) :
+ AnnotationSerializer.serializeAnnotationWithAttributes(annotationDescriptor, result.toArray(new String[0]));
+ collectedAnnotations.add(serializedAnnotation);
+ });
+ }
+
+ /**
+ * A reusable annotation visitor that collects annotation values.
+ */
+ static class AnnotationValueCollector extends AnnotationVisitor {
+ private final ResultCallback callback;
+ private final List collectedValues = new ArrayList<>();
+
+ AnnotationValueCollector(ResultCallback callback) {
+ super(Opcodes.ASM9);
+ this.callback = callback;
+ }
+
+ @Override
+ public void visit(@Nullable String name, Object value) {
+ String serializedValue = AnnotationSerializer.serializeValue(value);
+ addCollectedValue(name, serializedValue);
+ }
+
+ @Override
+ public void visitEnum(@Nullable String name, String descriptor, String value) {
+ String serializedValue = AnnotationSerializer.serializeEnumConstant(descriptor, value);
+ addCollectedValue(name, serializedValue);
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(@Nullable String name, String nestedAnnotationDescriptor) {
+ return new AnnotationValueCollector(result -> {
+ String nestedAnnotation = result.isEmpty() ?
+ AnnotationSerializer.serializeSimpleAnnotation(nestedAnnotationDescriptor) :
+ AnnotationSerializer.serializeAnnotationWithAttributes(nestedAnnotationDescriptor, result.toArray(new String[0]));
+ addCollectedValue(name, nestedAnnotation);
+ });
+ }
+
+ @Override
+ public AnnotationVisitor visitArray(@Nullable String name) {
+ return new ArrayValueCollector(arrayValues -> {
+ String arrayValue = AnnotationSerializer.serializeArray(arrayValues.toArray(new String[0]));
+ addCollectedValue(name, arrayValue);
+ });
+ }
+
+ @Override
+ public void visitEnd() {
+ callback.onResult(collectedValues);
+ }
+
+ private void addCollectedValue(@Nullable String name, String value) {
+ if (name == null) {
+ collectedValues.add(value);
+ } else {
+ collectedValues.add(AnnotationSerializer.serializeAttribute(name, value));
+ }
+ }
+ }
+
+ /**
+ * Specialized collector for array values.
+ */
+ static class ArrayValueCollector extends AnnotationVisitor {
+ private final List arrayValues = new ArrayList<>();
+ private final ResultCallback callback;
+
+ ArrayValueCollector(ResultCallback callback) {
+ super(Opcodes.ASM9);
+ this.callback = callback;
+ }
+
+ @Override
+ public void visit(@Nullable String name, Object value) {
+ arrayValues.add(AnnotationSerializer.serializeValue(value));
+ }
+
+ @Override
+ public void visitEnum(@Nullable String name, String descriptor, String value) {
+ arrayValues.add(AnnotationSerializer.serializeEnumConstant(descriptor, value));
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(@Nullable String name, String descriptor) {
+ return new AnnotationValueCollector(result -> {
+ String annotation = result.isEmpty() ?
+ AnnotationSerializer.serializeSimpleAnnotation(descriptor) :
+ AnnotationSerializer.serializeAnnotationWithAttributes(descriptor, result.toArray(new String[0]));
+ arrayValues.add(annotation);
+ });
+ }
+
+ @Override
+ public void visitEnd() {
+ callback.onResult(arrayValues);
+ }
+ }
+
+ /**
+ * Callback interface for receiving collection results.
+ */
+ @FunctionalInterface
+ interface ResultCallback {
+ void onResult(List result);
+ }
+}
+
+/**
+ * Deserializes annotation strings from the TypeTable format back into Java objects.
+ */
+class AnnotationDeserializer {
+
+ private static final int MAX_NESTING_DEPTH = 32;
+
+ // JVM type descriptors: V=void, Z=boolean, C=char, B=byte, S=short, I=int, F=float, J=long, D=double
+ private static final String JVM_PRIMITIVE_DESCRIPTORS = "VZCBSIFJD";
+
+ /**
+ * Cursor-based parser for efficient annotation string parsing.
+ */
+ private static class Parser {
+ private final String input;
+ private int pos = 0;
+
+ Parser(String input) {
+ this.input = input;
+ }
+
+ private char peek() {
+ return pos < input.length() ? input.charAt(pos) : '\0';
+ }
+
+ private char consume() {
+ return pos < input.length() ? input.charAt(pos++) : '\0';
+ }
+
+ private void expect(char expected) {
+ int errorPos = pos; // Save position before consuming
+ char actual = consume();
+ if (actual != expected) {
+ // Create a helpful error message with context
+ StringBuilder errorMsg = new StringBuilder();
+ errorMsg.append("Expected '").append(expected).append("' at position ").append(errorPos);
+ if (actual == '\0') {
+ errorMsg.append(", but reached end of input");
+ } else {
+ errorMsg.append(", but found '").append(actual).append("'");
+ }
+ errorMsg.append(inputWithErrorIndicator(errorPos));
+
+ throw new IllegalArgumentException(errorMsg.toString());
+ }
+ }
+
+ private String inputWithErrorIndicator(int errorPos) {
+ StringBuilder errorMsg = new StringBuilder();
+ errorMsg.append("\nInput string: ").append(input);
+
+ // Add a visual indicator of the error position
+ errorMsg.append("\nPosition indicator: ");
+ for (int i = 0; i < errorPos - 6; i++) {
+ errorMsg.append(' ');
+ }
+ errorMsg.append('^');
+
+ // Add some context around the error position
+ int contextStart = Math.max(0, errorPos - 20);
+ int contextEnd = Math.min(input.length(), errorPos + 20);
+ if (contextStart > 0 || contextEnd < input.length()) {
+ errorMsg.append("\nContext: ");
+ if (contextStart > 0) errorMsg.append("...");
+ errorMsg.append(input, contextStart, contextEnd);
+ if (contextEnd < input.length()) errorMsg.append("...");
+ }
+ return errorMsg.toString();
+ }
+
+ private List parseAttributes() {
+ List attributes = new ArrayList<>();
+
+ while (pos < input.length()) {
+
+ // Parse attribute name (identifier)
+ String attributeName = parseIdentifier();
+ if (attributeName.isEmpty()) break;
+
+ // Check for '=' - if not found, this is a value-only attribute
+ expect('=');
+ Object attributeValue = parseValue(0);
+
+ attributes.add(new AttributeInfo(attributeName, attributeValue));
+
+ // Check for comma separator
+ if (peek() == ',') {
+ consume(); // consume ','
+ } else {
+ // No comma means we're done with attributes
+ break;
+ }
+ }
+
+ return attributes;
+ }
+
+ private String parseIdentifier() {
+ int start = pos;
+
+ // Parse a simple identifier (letters, digits, underscores)
+ if (!Character.isJavaIdentifierStart(peek())) {
+ throw new IllegalArgumentException("Expected identifier at position " + pos + " but found '" + peek() + "'" + inputWithErrorIndicator(pos));
+ }
+ while (pos < input.length()) {
+ char c = peek();
+ if (Character.isJavaIdentifierPart(c)) {
+ consume();
+ } else {
+ break;
+ }
+ }
+
+ return input.substring(start, pos);
+ }
+
+ private Object[] parseArrayValue(int depth) {
+ // Parse array elements directly without creating new parser instances
+ List elements = new ArrayList<>();
+
+ expect('[');
+
+ while (pos < input.length() && peek() != ']') {
+ // Parse the next array element value directly
+ Object element = parseValue(depth);
+ elements.add(element);
+
+ // Check if we have more elements (comma) or end of array
+ if (peek() == ',') {
+ consume(); // consume comma
+ } else {
+ // No comma means end of array
+ break;
+ }
+ }
+ expect(']');
+ return elements.toArray();
+ }
+
+ private AnnotationInfo parseNestedAnnotationValue(int depth) {
+ if (depth > MAX_NESTING_DEPTH) {
+ throw new IllegalArgumentException("Maximum nesting depth of " + MAX_NESTING_DEPTH + " exceeded while parsing nested annotation: " + input);
+ }
+ return parseSingleAnnotation();
+ }
+
+ private AnnotationInfo parseSingleAnnotation() {
+ expect('@');
+ ClassConstant annotationName = parseClassConstantValue();
+ if (peek() != '(') {
+ // No attributes
+ return new AnnotationInfo(annotationName.getDescriptor(), null);
+ }
+
+ expect('(');
+ // Parse attributes directly without extracting substring first
+ List attributes = parseAttributes();
+ expect(')');
+
+ return new AnnotationInfo(annotationName.getDescriptor(), attributes);
+ }
+
+ private Object parseValue(int depth) {
+ if (depth > MAX_NESTING_DEPTH) {
+ throw new IllegalArgumentException("Maximum nesting depth of " + MAX_NESTING_DEPTH + " exceeded while parsing: " + input);
+ }
+
+ // Check for type-prefixed values (javap style)
+ char typePrefix = peek();
+
+ // Handle javap-style type prefixes
+ switch (typePrefix) {
+ case 'Z': // boolean
+ consume();
+ return parseBooleanValue();
+ case 'B': // byte
+ consume();
+ return Byte.parseByte(parseNumericValue());
+ case 'C': // char
+ consume();
+ return parseCharValue();
+ case 'S': // short
+ consume();
+ return Short.parseShort(parseNumericValue());
+ case 'I': // int
+ consume();
+ return Integer.parseInt(parseNumericValue());
+ case 'J': // long
+ consume();
+ return Long.parseLong(parseNumericValue());
+ case 'F': // float
+ consume();
+ return Float.parseFloat(parseNumericValue());
+ case 'D': // double
+ consume();
+ return Double.parseDouble(parseNumericValue());
+ case 's': // string
+ consume();
+ return parseStringValue();
+ case 'c': // class
+ consume();
+ return parseClassConstantValue();
+ case 'e': // enum
+ consume();
+ return parseEnumConstantValue();
+ case '[': // array
+ return parseArrayValue(depth);
+ case '@': // annotation
+ return parseNestedAnnotationValue(depth + 1);
+ }
+
+ // If no type prefix matched, this is an error
+ throw new IllegalArgumentException("Unknown value format at position " + pos + ": " + peek() + inputWithErrorIndicator(pos));
+ }
+
+ private Object parseBooleanValue() {
+ if (matchesAtPosition("true")) {
+ pos += 4; // consume "true"
+ return Boolean.TRUE;
+ } else if (matchesAtPosition("false")) {
+ pos += 5; // consume "false"
+ return Boolean.FALSE;
+ }
+ throw new IllegalArgumentException("Expected boolean value at position " + pos);
+ }
+
+ private Object parseCharValue() {
+ // Parse numeric value and cast to char
+ String numStr = parseNumericValue();
+ return (char) Integer.parseInt(numStr);
+ }
+
+ private Object parseStringValue() {
+ expect('"');
+ StringBuilder sb = new StringBuilder();
+ boolean escaped = false;
+
+ while (pos < input.length()) {
+ char c = peek();
+ if (escaped) {
+ // Process escape sequences directly
+ switch (c) {
+ case '"':
+ sb.append('"');
+ break;
+ case '\\':
+ sb.append('\\');
+ break;
+ case 'n':
+ sb.append('\n');
+ break;
+ case 'r':
+ sb.append('\r');
+ break;
+ case 't':
+ sb.append('\t');
+ break;
+ case '|':
+ sb.append('|');
+ break; // TypeTable-specific escape
+ default:
+ sb.append(c); // Pass through unknown escapes
+ }
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else if (c == '"') {
+ consume(); // consume closing quote
+ return sb.toString();
+ } else {
+ sb.append(c);
+ }
+ consume();
+ }
+
+ throw new IllegalArgumentException("Unterminated string at position " + (pos - sb.length()));
+ }
+
+ private ClassConstant parseClassConstantValue() {
+ int start = pos;
+ // Parse class descriptor: L...;, [L...;, [I, etc.
+ if (peek() == '[') {
+ // Array type descriptor
+ while (pos < input.length() && peek() == '[') {
+ consume();
+ }
+ }
+ if (peek() == 'L') {
+ consume(); // consume 'L'
+ while (pos < input.length() && peek() != ';') {
+ consume();
+ }
+ if (pos < input.length()) {
+ consume(); // consume ';'
+ }
+ } else if (isPrimitiveTypeDescriptor()) {
+ consume(); // consume primitive type character
+ }
+
+ String descriptor = input.substring(start, pos);
+ return new ClassConstant(descriptor);
+ }
+
+ private Object parseEnumConstantValue() {
+ // Parse enum constant with dot notation: L...;.CONSTANT_NAME
+ int start = pos;
+ expect('L');
+ while (pos < input.length() && peek() != ';') {
+ consume();
+ }
+ expect(';');
+ int semicolonPos = pos - 1;
+ expect('.');
+
+ return new EnumConstant(input.substring(start, semicolonPos + 1), parseIdentifier());
+ }
+
+ private String parseNumericValue() {
+ int start = pos;
+ boolean hasSign = false;
+
+ // Check for sign
+ if (peek() == '-' || peek() == '+') {
+ consume();
+ hasSign = true;
+ }
+
+ // Parse digits and decimal point
+ while (pos < input.length()) {
+ char c = peek();
+ if (!Character.isDigit(c) && c != '.') {
+ break;
+ }
+ consume();
+ }
+
+ if (pos == start || (hasSign && pos == start + 1)) {
+ throw new IllegalArgumentException("Expected numeric value at position " + start + inputWithErrorIndicator(start));
+ }
+
+ return input.substring(start, pos);
+ }
+
+ private boolean isPrimitiveTypeDescriptor() {
+ char c = peek();
+ return JVM_PRIMITIVE_DESCRIPTORS.indexOf(c) != -1;
+ }
+
+ private boolean matchesAtPosition(String text) {
+ if (pos + text.length() > input.length()) {
+ return false;
+ }
+ return input.startsWith(text, pos);
+ }
+ }
+
+ /**
+ * Parses a serialized annotation string.
+ */
+ public static AnnotationInfo parseAnnotation(String annotationStr) {
+ if (!annotationStr.startsWith("@")) {
+ throw new IllegalArgumentException("Invalid annotation format: " + annotationStr);
+ }
+
+ Parser parser = new Parser(annotationStr);
+ return parser.parseSingleAnnotation();
+ }
+
+ /**
+ * Parses multiple annotations from a string that may contain:
+ * - Multiple annotations separated by pipes (backward compatibility)
+ * - Multiple annotations concatenated without delimiters (new format)
+ * - Single annotation
+ *
+ * @param annotationsStr The serialized annotations string
+ * @return List of parsed annotation info objects
+ */
+ public static List parseAnnotations(String annotationsStr) {
+ if (annotationsStr.isEmpty()) {
+ return new ArrayList<>();
+ }
+
+ List annotations = new ArrayList<>();
+
+ Parser parser = new Parser(annotationsStr);
+ while (parser.pos < parser.input.length()) {
+ if (parser.peek() != '@') {
+ break; // No more annotations
+ }
+ annotations.add(parser.parseSingleAnnotation());
+ }
+
+ return annotations;
+ }
+
+
+ /**
+ * Determines the type of a serialized value and returns it in the appropriate format.
+ * This is a public API method that creates a new parser for the complete value string.
+ */
+ public static Object parseValue(String value) {
+ Parser parser = new Parser(value);
+ return parser.parseValue(0);
+ }
+
+
+ // Value classes for different types of constants
+ public static class AnnotationInfo {
+ private final String descriptor;
+ private final @Nullable List attributes;
+
+ public AnnotationInfo(String descriptor, @Nullable List attributes) {
+ this.descriptor = descriptor;
+ this.attributes = attributes;
+ }
+
+ public String getDescriptor() {
+ return descriptor;
+ }
+
+ public @Nullable List getAttributes() {
+ return attributes;
+ }
+ }
+
+ public static class AttributeInfo {
+ private final String name;
+ private final Object value;
+
+ public AttributeInfo(String name, Object value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+ }
+
+ public static class ClassConstant {
+ private final String descriptor;
+
+ public ClassConstant(String descriptor) {
+ this.descriptor = descriptor;
+ }
+
+ public String getDescriptor() {
+ return descriptor;
+ }
+ }
+
+ public static class EnumConstant {
+ private final String enumDescriptor;
+ private final String constantName;
+
+ public EnumConstant(String enumDescriptor, String constantName) {
+ this.enumDescriptor = enumDescriptor;
+ this.constantName = constantName;
+ }
+
+ public String getEnumDescriptor() {
+ return enumDescriptor;
+ }
+
+ public String getConstantName() {
+ return constantName;
+ }
+ }
+
+ public static class FieldConstant {
+ private final String className;
+ private final String fieldName;
+
+ public FieldConstant(String className, String fieldName) {
+ this.className = className;
+ this.fieldName = fieldName;
+ }
+
+ public String getClassName() {
+ return className;
+ }
+
+ public String getFieldName() {
+ return fieldName;
+ }
+ }
+}
+
+/**
+ * Utility methods for handling TSV escaping in annotation contexts.
+ */
+class TsvEscapeUtils {
+
+ /**
+ * Splits a string by the given delimiter, respecting escape sequences and only unescaping \| sequences.
+ * Other escape sequences are preserved as-is since they're part of the content format.
+ *
+ * @param input the input string to split
+ * @param delimiter the delimiter character to split on
+ * @return array of string segments with \| unescaped to |
+ */
+ @VisibleForTesting
+ static String[] splitAnnotationList(String input, char delimiter) {
+ if (input.isEmpty()) return new String[0];
+
+ List result = new ArrayList<>();
+ StringBuilder current = null; // Lazy allocation
+ boolean escaped = false;
+ int segmentStart = 0;
+
+ for (int i = 0; i < input.length(); i++) {
+ char c = input.charAt(i);
+
+ if (escaped) {
+ // Previous character was backslash, so this character is escaped
+ if (current == null) {
+ // First escape found - allocate StringBuilder and copy previous content
+ current = new StringBuilder();
+ current.append(input, segmentStart, i - 1); // Exclude the backslash from previous content
+ }
+
+ if (c == '|') {
+ // Unescape \| to just |
+ current.append('|');
+ } else {
+ // For all other escapes, preserve the backslash and character
+ current.append('\\').append(c);
+ }
+ escaped = false;
+ } else if (c == '\\') {
+ // This is an escape character - set flag but don't append yet
+ escaped = true;
+ } else if (c == delimiter) {
+ // Unescaped delimiter - split here
+ if (current == null) {
+ // No escapes in this segment - use substring for efficiency
+ result.add(input.substring(segmentStart, i));
+ } else {
+ result.add(current.toString());
+ current = null;
+ }
+ segmentStart = i + 1;
+ } else if (current != null) {
+ // Regular character and we're building escaped content
+ current.append(c);
+ }
+ // If current == null and it's a regular character, do nothing (will use substring later)
+ }
+
+ // Handle trailing backslash (malformed escape)
+ if (escaped) {
+ if (current == null) {
+ current = new StringBuilder();
+ current.append(input, segmentStart, input.length() - 1);
+ }
+ current.append('\\');
+ }
+
+ // Add the last segment
+ if (current == null) {
+ // No escapes in final segment - use substring
+ result.add(input.substring(segmentStart));
+ } else {
+ result.add(current.toString());
+ }
+
+ return result.toArray(new String[0]);
+ }
+}
+
+/**
+ * Serializes Java annotations to a string format for storage in the TypeTable.
+ */
+class AnnotationSerializer {
+
+ public static String serializeSimpleAnnotation(String annotationDescriptor) {
+ return "@" + annotationDescriptor;
+ }
+
+ public static String serializeBoolean(boolean value) {
+ return "Z" + value;
+ }
+
+ public static String serializeChar(char value) {
+ // Use numeric format for consistency with convertConstantValueWithType
+ return "C" + (int) value;
+ }
+
+ public static String serializeNumber(Number value) {
+ if (value instanceof Byte) {
+ return "B" + value;
+ } else if (value instanceof Short) {
+ return "S" + value;
+ } else if (value instanceof Integer) {
+ return "I" + value;
+ } else if (value instanceof Long) {
+ return "J" + value;
+ } else if (value instanceof Float) {
+ return "F" + value;
+ } else if (value instanceof Double) {
+ return "D" + value;
+ }
+ return "I" + value; // Default to integer
+ }
+
+ public static String serializeLong(long value) {
+ return "J" + value;
+ }
+
+ public static String serializeFloat(float value) {
+ return "F" + value;
+ }
+
+ public static String serializeDouble(double value) {
+ return "D" + value;
+ }
+
+ public static String serializeClassConstant(Type type) {
+ return "c" + type.getDescriptor();
+ }
+
+ public static String serializeEnumConstant(String enumDescriptor, String enumConstant) {
+ return "e" + enumDescriptor + "." + enumConstant;
+ }
+
+ public static String serializeArray(String[] values) {
+ if (values.length == 0) {
+ return "[]";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("[");
+ for (int i = 0; i < values.length; i++) {
+ if (i > 0) {
+ sb.append(",");
+ }
+ sb.append(values[i]);
+ }
+ sb.append("]");
+ return sb.toString();
+ }
+
+ public static String serializeAnnotationWithAttributes(String annotationDescriptor, String[] attributes) {
+ if (attributes.length == 0) {
+ return serializeSimpleAnnotation(annotationDescriptor);
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("@").append(annotationDescriptor).append("(");
+ for (int i = 0; i < attributes.length; i++) {
+ if (i > 0) {
+ sb.append(",");
+ }
+ sb.append(attributes[i]);
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ public static String serializeAttribute(String name, String value) {
+ return name + "=" + value;
+ }
+
+ static String serializeValue(Object value) {
+ return serializeValueInternal(value);
+ }
+
+ public static String convertAnnotationValueToString(Object value) {
+ return serializeValueInternal(value);
+ }
+
+ /**
+ * Converts a constant value to its string representation with type prefix,
+ * specifically handling primitive types that are stored as integers in the JVM.
+ * This is used for field constants in TypeTable serialization.
+ *
+ * @param value the constant value from the bytecode
+ * @param descriptor the JVM type descriptor for the field (e.g., "Z" for boolean, "C" for char)
+ * @return the serialized string with appropriate type prefix
+ */
+ public static String convertConstantValueWithType(Object value, String descriptor) {
+ // For primitive types that are stored as integers in the constant pool,
+ // use the descriptor to determine the correct type prefix
+ if (value instanceof Integer) {
+ int intValue = (Integer) value;
+ switch (descriptor) {
+ case "Z": // boolean
+ return "Z" + (intValue != 0 ? "true" : "false");
+ case "C": // char
+ return "C" + intValue; // Could format as C'A' but numeric is simpler
+ case "B": // byte
+ return "B" + intValue;
+ case "S": // short
+ return "S" + intValue;
+ case "I": // int
+ return "I" + intValue;
+ default:
+ // Shouldn't happen, but fall back to regular formatting
+ return convertAnnotationValueToString(value);
+ }
+ }
+ // For other types, use the regular conversion
+ return convertAnnotationValueToString(value);
+ }
+
+ private static String serializeValueInternal(@Nullable Object value) {
+ if (value == null) {
+ return "null";
+ } else if (value instanceof String) {
+ return "s\"" + escapeStringContentForTsv((String) value) + "\"";
+ } else if (value instanceof Type) {
+ return serializeClassConstant((Type) value);
+ } else if (value.getClass().isArray()) {
+ return serializeArrayValue(value);
+ } else if (value instanceof Boolean) {
+ return serializeBoolean((Boolean) value);
+ } else if (value instanceof Character) {
+ return serializeChar((Character) value);
+ } else if (value instanceof Number) {
+ return serializeNumericValue((Number) value);
+ } else {
+ return String.valueOf(value);
+ }
+ }
+
+ /**
+ * Escapes string content for TSV storage.
+ * It escapes backslashes, control characters, and quotes using Java source code escape sequences.
+ * Note: Pipe characters are NOT escaped here. Escaping pipes is only needed when the string
+ * will be part of a pipe-delimited list, and that escaping is done at the point of joining.
+ */
+ private static String escapeStringContentForTsv(String content) {
+ StringBuilder sb = null; // Lazy allocation
+ int start = 0;
+
+ for (int i = 0; i < content.length(); i++) {
+ char c = content.charAt(i);
+ String replacement = null;
+
+ switch (c) {
+ case '\\':
+ replacement = "\\\\";
+ break;
+ // Pipes are NOT escaped here - only when joining with pipes as delimiters
+ case '\n':
+ replacement = "\\n";
+ break;
+ case '\r':
+ replacement = "\\r";
+ break;
+ case '\t':
+ replacement = "\\t";
+ break;
+ case '\b':
+ replacement = "\\b";
+ break;
+ case '\f':
+ replacement = "\\f";
+ break;
+ case '"':
+ replacement = "\\\"";
+ break;
+ }
+
+ if (replacement != null) {
+ if (sb == null) {
+ // First escape found - allocate StringBuilder and copy previous content
+ sb = new StringBuilder();
+ sb.append(content, start, i);
+ }
+ sb.append(replacement);
+ } else if (sb != null) {
+ // We're building escaped content and this is a regular character
+ sb.append(c);
+ }
+ }
+
+ // If no escaping was needed, return the original string
+ return sb == null ? content : sb.toString();
+ }
+
+ private static String serializeArrayValue(Object value) {
+ StringBuilder elements = new StringBuilder();
+ elements.append('[');
+
+ if (value instanceof Object[]) {
+ Object[] array = (Object[]) value;
+ for (int i = 0; i < array.length; i++) {
+ if (i > 0) {
+ elements.append(',');
+ }
+ elements.append(serializeValueInternal(array[i]));
+ }
+ } else {
+ int length = Array.getLength(value);
+ for (int i = 0; i < length; i++) {
+ if (i > 0) {
+ elements.append(',');
+ }
+ elements.append(serializeValueInternal(Array.get(value, i)));
+ }
+ }
+
+ elements.append(']');
+ return elements.toString();
+ }
+
+ private static String serializeNumericValue(Number value) {
+ if (value instanceof Long) {
+ return serializeLong((Long) value);
+ } else if (value instanceof Float) {
+ return serializeFloat((Float) value);
+ } else if (value instanceof Double) {
+ return serializeDouble((Double) value);
+ } else {
+ return serializeNumber(value);
+ }
+ }
+
+ public static void processAnnotationDefaultValue(AnnotationVisitor annotationDefaultVisitor, Object value) {
+ if (value.getClass().isArray()) {
+ AnnotationVisitor arrayVisitor = annotationDefaultVisitor.visitArray(null);
+ for (Object v : ((Object[]) value)) {
+ processAnnotationDefaultValue(arrayVisitor, v);
+ }
+ arrayVisitor.visitEnd();
+ } else if (value instanceof AnnotationDeserializer.ClassConstant) {
+ annotationDefaultVisitor.visit(null, Type.getType(((AnnotationDeserializer.ClassConstant) value).getDescriptor()));
+ } else if (value instanceof AnnotationDeserializer.EnumConstant) {
+ AnnotationDeserializer.EnumConstant enumConstant = (AnnotationDeserializer.EnumConstant) value;
+ annotationDefaultVisitor.visitEnum(null, enumConstant.getEnumDescriptor(), enumConstant.getConstantName());
+ } else if (value instanceof AnnotationDeserializer.FieldConstant) {
+ AnnotationDeserializer.FieldConstant fieldConstant = (AnnotationDeserializer.FieldConstant) value;
+ annotationDefaultVisitor.visitEnum(null, fieldConstant.getClassName(), fieldConstant.getFieldName());
+ } else if (value instanceof AnnotationDeserializer.AnnotationInfo) {
+ AnnotationDeserializer.AnnotationInfo annotationInfo = (AnnotationDeserializer.AnnotationInfo) value;
+ AnnotationVisitor nestedVisitor = annotationDefaultVisitor.visitAnnotation(null, annotationInfo.getDescriptor());
+ AnnotationAttributeApplier.applyAttributes(nestedVisitor, annotationInfo.getAttributes());
+ nestedVisitor.visitEnd();
+ } else {
+ annotationDefaultVisitor.visit(null, value);
+ }
+ }
+}
+
+/**
+ * Support for type annotations and parameter annotations in TypeTable TSV format.
+ * Uses abbreviated codes for compact representation while maintaining javap compatibility.
+ */
+class TypeAnnotationSupport {
+
+ /**
+ * Format a complete type annotation for TSV.
+ * Format: typeRefHex:pathString:annotation
+ * Where:
+ * - typeRefHex is the full typeRef value in hex (8 hex digits)
+ * - pathString is the TypePath.toString() representation (empty if no path)
+ * - annotation is the full JVM descriptor with values
+ */
+ public static String formatTypeAnnotation(int typeRef, @Nullable TypePath typePath, String annotation) {
+ // Format typeRef as 8 hex digits
+ String typeRefHex = String.format("%08x", typeRef);
+
+ // Use TypePath.toString() if present, empty string otherwise
+ String pathString = (typePath != null) ? typePath.toString() : "";
+
+ return typeRefHex + ":" + pathString + ":" + annotation;
+ }
+
+ /**
+ * Parse and reconstruct a type annotation from TSV format.
+ */
+ public static class TypeAnnotationInfo {
+ public final int typeRef;
+ public final @Nullable TypePath typePath;
+ public final String annotation;
+
+ private TypeAnnotationInfo(int typeRef, @Nullable TypePath typePath, String annotation) {
+ this.typeRef = typeRef;
+ this.typePath = typePath;
+ this.annotation = annotation;
+ }
+
+ public static TypeAnnotationInfo parse(String serialized) {
+ // Type annotation format: "typeRefHex:pathString:annotation"
+ final int EXPECTED_PARTS = 3;
+ String[] parts = serialized.split(":", EXPECTED_PARTS);
+ if (parts.length != EXPECTED_PARTS) {
+ throw new IllegalArgumentException("Invalid type annotation format: " + serialized);
+ }
+
+ String typeRefHex = parts[0];
+ String pathString = parts[1];
+ String annotation = parts[2];
+
+ // Parse typeRef from hex (8 hex digits)
+ int typeRef = (int) Long.parseLong(typeRefHex, 16);
+
+ // Reconstruct TypePath if present
+ TypePath typePath = null;
+ if (!pathString.isEmpty()) {
+ // Use TypePath.fromString() to parse the string representation
+ typePath = TypePath.fromString(pathString);
+ }
+
+ return new TypeAnnotationInfo(typeRef, typePath, annotation);
+ }
+ }
+
+}
diff --git a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSerializationTest.java b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSerializationTest.java
new file mode 100644
index 0000000000..8128447582
--- /dev/null
+++ b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableAnnotationSerializationTest.java
@@ -0,0 +1,508 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.internal.parser;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Type;
+import org.openrewrite.java.internal.parser.AnnotationDeserializer.AnnotationInfo;
+import org.openrewrite.java.internal.parser.AnnotationDeserializer.AttributeInfo;
+import org.openrewrite.java.internal.parser.AnnotationDeserializer.ClassConstant;
+import org.openrewrite.java.internal.parser.AnnotationDeserializer.EnumConstant;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for the serialization and deserialization of annotations in TypeTable TSV format.
+ */
+class TypeTableAnnotationSerializationTest {
+
+ @Test
+ void simpleAnnotation() {
+ // Test serialization
+ String serialized = AnnotationSerializer.serializeSimpleAnnotation("Lorg/junit/jupiter/api/Test;");
+ assertThat(serialized).isEqualTo("@Lorg/junit/jupiter/api/Test;");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/junit/jupiter/api/Test;");
+ assertThat(info.getAttributes()).isNull();
+ }
+
+ @Test
+ void annotationWithBooleanAttribute() {
+ // Test serialization
+ String attributeValue = AnnotationSerializer.serializeBoolean(true);
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/BooleanAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/BooleanAnnotation;(value=Ztrue)");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/BooleanAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo(true);
+ }
+
+ @Test
+ void annotationWithCharAttribute() {
+ // Test serialization
+ String attributeValue = AnnotationSerializer.serializeChar('c');
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/CharAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/CharAnnotation;(value=C99)");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/CharAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo('c');
+ }
+
+ @Test
+ void annotationWithCharConstant38() {
+ // Test the specific case that was failing: C38 which is '&'
+ String serialized = "@Lorg/example/CharAnnotation;(value=C38)";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/CharAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo('&');
+
+ // Verify serialization produces the same format
+ char ampersand = '&';
+ assertThat(AnnotationSerializer.serializeChar(ampersand)).isEqualTo("C38");
+ }
+
+ @Test
+ void annotationWithStringAttribute() {
+ // Test serialization
+ String attributeValue = AnnotationSerializer.serializeValue("Hello, World!");
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/StringAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/StringAnnotation;(value=s\"Hello, World!\")");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/StringAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo("Hello, World!");
+ }
+
+ @Test
+ void annotationWithClassAttribute() {
+ // Test serialization
+ String attributeValue = AnnotationSerializer.serializeClassConstant(Type.getType("Ljava/lang/String;"));
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/ClassAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/ClassAnnotation;(value=cLjava/lang/String;)");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/ClassAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(((AnnotationDeserializer.ClassConstant) (info.getAttributes().getFirst().getValue())).getDescriptor()).isEqualTo("Ljava/lang/String;");
+
+ // Parse the value
+ Object value = info.getAttributes().getFirst().getValue();
+ assertThat(value).isInstanceOf(ClassConstant.class);
+ assertThat(((ClassConstant) value).getDescriptor()).isEqualTo("Ljava/lang/String;");
+ }
+
+ @Test
+ void annotationWithEnumAttribute() {
+ // Test serialization
+ String attributeValue = AnnotationSerializer.serializeEnumConstant("Ljava/time/DayOfWeek;", "MONDAY");
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/EnumAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/EnumAnnotation;(value=eLjava/time/DayOfWeek;.MONDAY)");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/EnumAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+
+ // Parse the value
+ Object value = info.getAttributes().getFirst().getValue();
+ assertThat(value).isInstanceOf(EnumConstant.class);
+ assertThat(((EnumConstant) value).getEnumDescriptor()).isEqualTo("Ljava/time/DayOfWeek;");
+ assertThat(((EnumConstant) value).getConstantName()).isEqualTo("MONDAY");
+ }
+
+ @Test
+ void annotationWithArrayAttribute() {
+ // Test serialization
+ String[] arrayValues = new String[]{
+ AnnotationSerializer.serializeNumber(1),
+ AnnotationSerializer.serializeNumber(2),
+ AnnotationSerializer.serializeNumber(3)
+ };
+ String attributeValue = AnnotationSerializer.serializeArray(arrayValues);
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/ArrayAnnotation;",
+ new String[]{attribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/ArrayAnnotation;(value=[I1,I2,I3])");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/ArrayAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo(new Object[]{1, 2, 3});
+
+ // Parse the value
+ Object value = info.getAttributes().getFirst().getValue();
+ assertThat(value).isInstanceOf(Object[].class);
+ Object[] array = (Object[]) value;
+ assertThat(array).hasSize(3);
+ assertThat(array[0]).isEqualTo(1);
+ assertThat(array[1]).isEqualTo(2);
+ assertThat(array[2]).isEqualTo(3);
+ }
+
+ @Test
+ void annotationWithMultipleAttributes() {
+ // Test serialization
+ String nameAttribute = AnnotationSerializer.serializeAttribute("name", AnnotationSerializer.serializeValue("test"));
+ String valueAttribute = AnnotationSerializer.serializeAttribute("value", AnnotationSerializer.serializeNumber(42));
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/MultiAttributeAnnotation;",
+ new String[]{nameAttribute, valueAttribute}
+ );
+ assertThat(serialized).isEqualTo("@Lorg/example/MultiAttributeAnnotation;(name=s\"test\",value=I42)");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/MultiAttributeAnnotation;");
+ assertThat(info.getAttributes()).hasSize(2);
+
+ // Find attributes by name
+ AttributeInfo nameAttr = findAttributeByName(info.getAttributes(), "name");
+ AttributeInfo valueAttr = findAttributeByName(info.getAttributes(), "value");
+
+ assertThat(nameAttr).isNotNull();
+ assertThat(nameAttr.getValue()).isEqualTo("test");
+
+ assertThat(valueAttr).isNotNull();
+ assertThat(valueAttr.getValue()).isEqualTo(42);
+ }
+
+ @Test
+ void nestedAnnotation() {
+ // Test serialization
+ String innerAttributeValue = AnnotationSerializer.serializeNumber(42);
+ String innerAttribute = AnnotationSerializer.serializeAttribute("value", innerAttributeValue);
+ String innerAnnotation = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/InnerAnnotation;",
+ new String[]{innerAttribute}
+ );
+
+ String outerAttribute = AnnotationSerializer.serializeAttribute("nested", innerAnnotation);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/OuterAnnotation;",
+ new String[]{outerAttribute}
+ );
+
+ assertThat(serialized).isEqualTo("@Lorg/example/OuterAnnotation;(nested=@Lorg/example/InnerAnnotation;(value=I42))");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/OuterAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("nested");
+
+ // Parse the nested annotation
+ Object value = info.getAttributes().getFirst().getValue();
+ assertThat(value).isInstanceOf(AnnotationInfo.class);
+ AnnotationInfo nestedInfo = (AnnotationInfo) value;
+ assertThat(nestedInfo.getDescriptor()).isEqualTo("Lorg/example/InnerAnnotation;");
+ assertThat(nestedInfo.getAttributes()).hasSize(1);
+ assertThat(nestedInfo.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(nestedInfo.getAttributes().getFirst().getValue()).isEqualTo(42);
+ }
+
+ @Nested
+ class SpecialCharacters {
+
+ @Test
+ void pipe() {
+ // Test serialization with pipe character
+ String attributeValue = AnnotationSerializer.serializeValue("Hello|World");
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/SpecialCharAnnotation;",
+ new String[]{attribute}
+ );
+
+ // The pipe character is not escaped in elementAnnotations (only in pipe-delimited fields)
+ assertThat(serialized).isEqualTo("@Lorg/example/SpecialCharAnnotation;(value=s\"Hello|World\")");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/SpecialCharAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo("Hello|World");
+
+ // Parse the value and verify the pipe character is preserved
+ String value = (String) info.getAttributes().getFirst().getValue();
+ assertThat(value).isEqualTo("Hello|World");
+ }
+
+ @Test
+ void tab() {
+ // Test serialization with pipe character
+ String attributeValue = AnnotationSerializer.serializeValue("Hello\tWorld");
+ String attribute = AnnotationSerializer.serializeAttribute("value", attributeValue);
+ String serialized = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/SpecialCharAnnotation;",
+ new String[]{attribute}
+ );
+
+ // The pipe character should be escaped
+ assertThat(serialized).isEqualTo("@Lorg/example/SpecialCharAnnotation;(value=s\"Hello\\tWorld\")");
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(serialized);
+ assertThat(info.getDescriptor()).isEqualTo("Lorg/example/SpecialCharAnnotation;");
+ assertThat(info.getAttributes()).hasSize(1);
+ assertThat(info.getAttributes().getFirst().getName()).isEqualTo("value");
+ assertThat(info.getAttributes().getFirst().getValue()).isEqualTo("Hello\tWorld");
+
+ // Parse the value and verify the pipe character is preserved
+ String value = (String) info.getAttributes().getFirst().getValue();
+ assertThat(value).isEqualTo("Hello\tWorld");
+ }
+ }
+
+ private @Nullable AttributeInfo findAttributeByName(List attributes, String name) {
+ return attributes.stream()
+ .filter(attr -> attr.getName().equals(name))
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Test
+ void testAnnotationValueCollector() {
+ // Create a list to collect the annotations
+ List collectedAnnotations = new ArrayList<>();
+
+ // Create an AnnotationValueCollector
+ AnnotationCollectorHelper.AnnotationValueCollector collector =
+ new AnnotationCollectorHelper.AnnotationValueCollector(
+ result -> {
+ if (result.isEmpty()) {
+ collectedAnnotations.add("@org/junit/jupiter/api/Test");
+ } else {
+ String annotationWithAttributes = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "org.junit.jupiter.api.Test",
+ result.toArray(new String[0])
+ );
+ collectedAnnotations.add(annotationWithAttributes);
+ }
+ }
+ );
+
+ // Call visitEnd to trigger the callback
+ collector.visitEnd();
+
+ // Verify that the annotation was collected correctly
+ assertThat(collectedAnnotations).hasSize(1);
+ assertThat(collectedAnnotations.getFirst()).isEqualTo("@org/junit/jupiter/api/Test");
+ }
+
+ @Test
+ void testAnnotationValueCollectorWithAttributes() {
+ // Create a list to collect the annotations
+ List collectedAnnotations = new ArrayList<>();
+
+ // Create an AnnotationValueCollector
+ AnnotationCollectorHelper.AnnotationValueCollector collector =
+ new AnnotationCollectorHelper.AnnotationValueCollector(
+ result -> {
+ if (result.isEmpty()) {
+ collectedAnnotations.add("@Lorg/junit/jupiter/api/Test;");
+ } else {
+ String annotationWithAttributes = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/junit/jupiter/api/Test;",
+ result.toArray(new String[0])
+ );
+ collectedAnnotations.add(annotationWithAttributes);
+ }
+ }
+ );
+
+ // Add an attribute
+ collector.visit("timeout", 1000L);
+
+ // Call visitEnd to trigger the callback
+ collector.visitEnd();
+
+ // Verify that the annotation was collected correctly
+ assertThat(collectedAnnotations).hasSize(1);
+ // With our test setup, the actual output is:
+ assertThat(collectedAnnotations.getFirst()).isEqualTo("@Lorg/junit/jupiter/api/Test;(timeout=J1000)");
+
+ // Note: The actual output is "@org/junit/jupiter/api/Test(1000L)" because the name parameter
+ // is only used if attributeName is not null, and we're creating the collector with attributeName=null.
+ // In a real-world scenario, the AnnotationCollectorHelper.createCollector() method would set
+ // attributeName to null, and the name parameter would be used in the visit() method.
+ }
+
+ @Test
+ void testAnnotationValueCollectorWithNestedAnnotation() {
+ // Create a list to collect the annotations
+ List collectedAnnotations = new ArrayList<>();
+
+ // Create an AnnotationValueCollector
+ AnnotationCollectorHelper.AnnotationValueCollector collector =
+ new AnnotationCollectorHelper.AnnotationValueCollector(
+ result -> {
+ if (result.isEmpty()) {
+ collectedAnnotations.add("@Lorg/example/OuterAnnotation;");
+ } else {
+ String annotationWithAttributes = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/OuterAnnotation;",
+ result.toArray(new String[0])
+ );
+ collectedAnnotations.add(annotationWithAttributes);
+ }
+ }
+ );
+
+ // Create a nested annotation visitor
+ AnnotationVisitor nestedVisitor = collector.visitAnnotation("nested", "Lorg/example/InnerAnnotation;");
+
+ // Add an attribute to the nested annotation
+ nestedVisitor.visit("value", 42);
+
+ // End the nested annotation
+ nestedVisitor.visitEnd();
+
+ // Call visitEnd to trigger the callback
+ collector.visitEnd();
+
+ // Verify that the annotation was collected correctly
+ assertThat(collectedAnnotations).hasSize(1);
+ // With our test setup, the actual output is:
+ assertThat(collectedAnnotations.getFirst()).isEqualTo("@Lorg/example/OuterAnnotation;(nested=@Lorg/example/InnerAnnotation;(value=I42))");
+
+ // Note: The actual output is missing the attribute name "nested=" because the name parameter
+ // is only used if attributeName is not null, and we're creating the collector with attributeName=null.
+ }
+
+ @Test
+ void testAnnotationValueCollectorWithArray() {
+ // Create a list to collect the annotations
+ List collectedAnnotations = new ArrayList<>();
+
+ // Create an AnnotationValueCollector
+ AnnotationCollectorHelper.AnnotationValueCollector collector =
+ new AnnotationCollectorHelper.AnnotationValueCollector(
+ result -> {
+ if (result.isEmpty()) {
+ collectedAnnotations.add("@Lorg/example/ArrayAnnotation;");
+ } else {
+ String annotationWithAttributes = AnnotationSerializer.serializeAnnotationWithAttributes(
+ "Lorg/example/ArrayAnnotation;",
+ result.toArray(new String[0])
+ );
+ collectedAnnotations.add(annotationWithAttributes);
+ }
+ }
+ );
+
+ // Create an array visitor
+ AnnotationVisitor arrayVisitor = collector.visitArray("value");
+
+ // Add elements to the array
+ arrayVisitor.visit(null, 1);
+ arrayVisitor.visit(null, 2);
+ arrayVisitor.visit(null, 3);
+
+ // End the array
+ arrayVisitor.visitEnd();
+
+ // Call visitEnd to trigger the callback
+ collector.visitEnd();
+
+ // Verify that the annotation was collected correctly
+ assertThat(collectedAnnotations).hasSize(1);
+ assertThat(collectedAnnotations.getFirst()).isEqualTo("@Lorg/example/ArrayAnnotation;(value=[I1,I2,I3])");
+ }
+
+ @Test
+ void testParserErrorPositioning() {
+ // Test malformed annotation - missing closing parenthesis
+ assertThatThrownBy(() -> AnnotationDeserializer.parseAnnotation("@Lorg/springframework/retry/annotation/Backoff;("))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at position 48, but reached end of input")
+ .hasMessageContaining("Input string: @Lorg/springframework/retry/annotation/Backoff;(")
+ .hasMessageContaining("Position indicator: ^");
+
+ // Test malformed annotation - invalid character at specific position
+ assertThatThrownBy(() -> AnnotationDeserializer.parseAnnotation("@Lorg/example/Test;(value=I123$)"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at position 30")
+ .hasMessageContaining("but found '$'")
+ .hasMessageContaining("Input string: @Lorg/example/Test;(value=I123$)")
+ .hasMessageContaining("Position indicator: ^");
+
+ // Test missing attribute value
+ assertThatThrownBy(() -> AnnotationDeserializer.parseAnnotation("@Lorg/example/Test;(name=)"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown value format at position 25")
+ .hasMessageContaining("Input string: @Lorg/example/Test;(name=)")
+ .hasMessageContaining("Position indicator: ^");
+
+ // Test missing attribute value
+ assertThatThrownBy(() -> AnnotationDeserializer.parseAnnotation("@Lorg/example/Test;(0name=)"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Expected identifier at position 20 but found '0'")
+ .hasMessageContaining("Input string: @Lorg/example/Test;(0name=)")
+ .hasMessageContaining("Position indicator: ^");
+ }
+}
diff --git a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableInvisibleAnnotationsTest.java b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableInvisibleAnnotationsTest.java
new file mode 100644
index 0000000000..63572fce6d
--- /dev/null
+++ b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableInvisibleAnnotationsTest.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.internal.parser;
+
+import org.intellij.lang.annotations.Language;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.InMemoryExecutionContext;
+import org.openrewrite.java.JavaParserExecutionContextView;
+
+import javax.tools.JavaCompiler;
+import javax.tools.ToolProvider;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.zip.GZIPInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.internal.parser.TypeTable.VERIFY_CLASS_WRITING;
+
+/**
+ * Tests that TypeTable correctly collects and stores both visible (runtime-retained)
+ * and invisible (class/source-retained) annotations.
+ */
+class TypeTableInvisibleAnnotationsTest {
+
+ @TempDir
+ Path tempDir;
+
+ ExecutionContext ctx;
+ JavaCompiler compiler;
+ Path tsv;
+
+ @BeforeEach
+ void setUp() {
+ ctx = new InMemoryExecutionContext();
+ ctx.putMessage(VERIFY_CLASS_WRITING, true);
+ JavaParserExecutionContextView.view(ctx).setParserClasspathDownloadTarget(tempDir.toFile());
+ compiler = ToolProvider.getSystemJavaCompiler();
+ tsv = tempDir.resolve("types.tsv.gz");
+ }
+
+ @Test
+ void collectsRuntimeRetainedAnnotations() throws Exception {
+ // Create test sources with RUNTIME retention (visible annotations)
+ @Language("java")
+ String annotationSource = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
+ public @interface RuntimeAnnotation {
+ String value() default "runtime";
+ }
+ """;
+
+ @Language("java")
+ String classSource = """
+ package test;
+
+ import test.annotations.RuntimeAnnotation;
+
+ @RuntimeAnnotation("class-level")
+ public class TestClass {
+
+ @RuntimeAnnotation("field-level")
+ public String field;
+
+ @RuntimeAnnotation("method-level")
+ public void method() {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(annotationSource, "test.annotations.RuntimeAnnotation",
+ classSource, "test.TestClass");
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Verify runtime annotations are collected
+ assertThat(tsvContent)
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"class-level\")")
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"field-level\")")
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"method-level\")");
+ }
+
+ @Test
+ void collectsClassRetainedAnnotations() throws Exception {
+ // Create test sources with CLASS retention (invisible annotations)
+ @Language("java")
+ String annotationSource = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.CLASS)
+ @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
+ public @interface ClassAnnotation {
+ String value() default "class";
+ }
+ """;
+
+ @Language("java")
+ String classSource = """
+ package test;
+
+ import test.annotations.ClassAnnotation;
+
+ @ClassAnnotation("class-level")
+ public class TestClass {
+
+ @ClassAnnotation("field-level")
+ public String field;
+
+ @ClassAnnotation("method-level")
+ public void method() {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(annotationSource, "test.annotations.ClassAnnotation",
+ classSource, "test.TestClass");
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Verify CLASS retention annotations are now collected (these are invisible annotations)
+ assertThat(tsvContent)
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"class-level\")")
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"field-level\")")
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"method-level\")");
+ }
+
+ @Test
+ void collectsBothVisibleAndInvisibleAnnotations() throws Exception {
+ // Create test with both runtime and class retained annotations
+ @Language("java")
+ String runtimeAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
+ public @interface RuntimeAnnotation {
+ String value();
+ }
+ """;
+
+ @Language("java")
+ String classAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.CLASS)
+ @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
+ public @interface ClassAnnotation {
+ String value();
+ }
+ """;
+
+ @Language("java")
+ String testClass = """
+ package test;
+
+ import test.annotations.*;
+
+ @RuntimeAnnotation("runtime-class")
+ @ClassAnnotation("class-class")
+ public class TestClass {
+
+ @RuntimeAnnotation("runtime-field")
+ @ClassAnnotation("class-field")
+ public String field;
+
+ @RuntimeAnnotation("runtime-method")
+ @ClassAnnotation("class-method")
+ public void method() {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(
+ runtimeAnnotation, "test.annotations.RuntimeAnnotation",
+ classAnnotation, "test.annotations.ClassAnnotation",
+ testClass, "test.TestClass"
+ );
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Verify both visible (runtime) and invisible (class) annotations are collected
+ assertThat(tsvContent)
+ // Class-level annotations
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"runtime-class\")")
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"class-class\")")
+ // Field-level annotations
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"runtime-field\")")
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"class-field\")")
+ // Method-level annotations
+ .contains("@Ltest/annotations/RuntimeAnnotation;(value=s\"runtime-method\")")
+ .contains("@Ltest/annotations/ClassAnnotation;(value=s\"class-method\")");
+ }
+
+ @Test
+ void preservesAnnotationOrderInTSV() throws Exception {
+ // Test that multiple annotations on the same element are preserved in order
+ @Language("java")
+ String ann1 = """
+ package test.annotations;
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.CLASS)
+ @Target(ElementType.TYPE)
+ public @interface First {
+ String value();
+ }
+ """;
+
+ @Language("java")
+ String ann2 = """
+ package test.annotations;
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ public @interface Second {
+ int value();
+ }
+ """;
+
+ @Language("java")
+ String ann3 = """
+ package test.annotations;
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.CLASS)
+ @Target(ElementType.TYPE)
+ public @interface Third {
+ boolean value();
+ }
+ """;
+
+ @Language("java")
+ String testClass = """
+ package test;
+
+ import test.annotations.*;
+
+ @First("first")
+ @Second(42)
+ @Third(true)
+ public class TestClass {
+ }
+ """;
+
+ Path jarFile = compileAndPackage(
+ ann1, "test.annotations.First",
+ ann2, "test.annotations.Second",
+ ann3, "test.annotations.Third",
+ testClass, "test.TestClass"
+ );
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Find the line with TestClass annotations
+ String[] lines = tsvContent.split("\n");
+ String classLine = null;
+ for (String line : lines) {
+ if (line.contains("\ttest/TestClass\t") && line.contains("@Ltest/annotations/")) {
+ classLine = line;
+ break;
+ }
+ }
+
+ assertThat(classLine).isNotNull();
+
+ // Extract the annotations column (15th column, 0-indexed as 14)
+ String[] columns = classLine.split("\t");
+ assertThat(columns.length).isGreaterThanOrEqualTo(15);
+ String annotationsColumn = columns[14];
+
+ // Verify all three annotations are present, concatenated without delimiters
+ // The annotations are self-delimiting (each starts with @ and has clear boundaries)
+ assertThat(annotationsColumn)
+ .contains("@Ltest/annotations/First;(value=s\"first\")")
+ .contains("@Ltest/annotations/Second;(value=I42)")
+ .contains("@Ltest/annotations/Third;(value=Ztrue)");
+ }
+
+ /**
+ * Helper to compile sources and create a JAR
+ */
+ private Path compileAndPackage(String... sourceAndClassPairs) throws Exception {
+ if (sourceAndClassPairs.length % 2 != 0) {
+ throw new IllegalArgumentException("Must provide source,className pairs");
+ }
+
+ Path srcDir = tempDir.resolve("src");
+ Files.createDirectories(srcDir);
+
+ // Write all source files
+ for (int i = 0; i < sourceAndClassPairs.length; i += 2) {
+ String source = sourceAndClassPairs[i];
+ String className = sourceAndClassPairs[i + 1];
+
+ // Create package directories
+ String packagePath = className.substring(0, className.lastIndexOf('.')).replace('.', '/');
+ Path packageDir = srcDir.resolve(packagePath);
+ Files.createDirectories(packageDir);
+
+ String simpleClassName = className.substring(className.lastIndexOf('.') + 1);
+ Path sourceFile = packageDir.resolve(simpleClassName + ".java");
+ Files.writeString(sourceFile, source);
+ }
+
+ // Compile all sources
+ List allSources = Files.walk(srcDir)
+ .filter(p -> p.toString().endsWith(".java"))
+ .toList();
+
+ String[] compilerArgs = new String[allSources.size() + 2];
+ compilerArgs[0] = "-d";
+ compilerArgs[1] = tempDir.toString();
+ for (int i = 0; i < allSources.size(); i++) {
+ compilerArgs[i + 2] = allSources.get(i).toString();
+ }
+
+ int result = compiler.run(null, null, null, compilerArgs);
+ assertThat(result).isEqualTo(0);
+
+ // Create JAR from compiled classes
+ Path jarFile = tempDir.resolve("test.jar");
+ try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarFile))) {
+ Files.walk(tempDir)
+ .filter(p -> p.toString().endsWith(".class"))
+ .forEach(classFile -> {
+ try {
+ String entryName = tempDir.relativize(classFile).toString();
+ JarEntry entry = new JarEntry(entryName);
+ jos.putNextEntry(entry);
+ jos.write(Files.readAllBytes(classFile));
+ jos.closeEntry();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ return jarFile;
+ }
+
+ /**
+ * Process a JAR through TypeTable and return the TSV content
+ */
+ private String processJarThroughTypeTable(Path jarFile) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (TypeTable.Writer writer = TypeTable.newWriter(baos)) {
+ writer.jar("test.group", "test-artifact", "1.0").write(jarFile);
+ }
+
+ // Decompress and return TSV content
+ try (InputStream is = new ByteArrayInputStream(baos.toByteArray());
+ InputStream gzis = new GZIPInputStream(is);
+ java.util.Scanner scanner = new java.util.Scanner(gzis)) {
+ return scanner.useDelimiter("\\A").next();
+ }
+ }
+}
diff --git a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableKotlinMetadataTest.java b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableKotlinMetadataTest.java
new file mode 100644
index 0000000000..19e8af7947
--- /dev/null
+++ b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableKotlinMetadataTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.internal.parser;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.java.internal.parser.AnnotationDeserializer.AnnotationInfo;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Test for Kotlin metadata annotation parsing which contains binary-like data that needs proper escaping.
+ */
+class TypeTableKotlinMetadataTest {
+
+ @Test
+ void simpleKotlinMetadataAnnotation() {
+ // Create a simplified Kotlin metadata annotation similar to what we might find
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d1=[s\"\\u0000\\u001e\\n\\u0002\\u0018\\u0002\\n\\u0000\"],d2=[s\"Lkotlin/Metadata\",s\"kotlin-stdlib\"])";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(3);
+ }
+
+ @Test
+ void kotlinMetadataWithComplexBinaryData() {
+ // Create a more complex case with longer binary-like strings
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d1=[s\"\\u0000\\u001e\\n\\u0002\\u0018\\u0002\\n\\u0000\\n\\u0002\\u0010\\u000e\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\u0002\"],d2=[s\"Collection\",s\"ExecutableStream\",s\"kotlin-stdlib\"])";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(3);
+
+ // Verify attribute parsing
+ assertThat(info.getAttributes().get(0).getName()).isEqualTo("k");
+ assertThat(info.getAttributes().get(0).getValue()).isEqualTo(1);
+
+ assertThat(info.getAttributes().get(1).getName()).isEqualTo("d1");
+ Object[] d1Array = (Object[]) info.getAttributes().get(1).getValue();
+ assertThat(d1Array).isNotEmpty();
+
+ assertThat(info.getAttributes().get(2).getName()).isEqualTo("d2");
+ Object[] d2Array = (Object[]) info.getAttributes().get(2).getValue();
+ assertThat(d2Array).hasSize(3);
+ assertThat(d2Array[0]).isEqualTo("Collection");
+ assertThat(d2Array[1]).isEqualTo("ExecutableStream");
+ assertThat(d2Array[2]).isEqualTo("kotlin-stdlib");
+ }
+
+ @Test
+ void kotlinMetadataArrayParsingWithSpace() {
+ // Test the specific case where we have space before closing paren (from the error message)
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d2=[s\"Collection\",s\"ExecutableStream\",s\"kotlin-stdlib\"])";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(2);
+ }
+
+ @Test
+ void veryLongKotlinMetadataString() {
+ // Create a test case with a very long string that might reproduce the position 3517 error
+ StringBuilder longBinaryData = new StringBuilder();
+ for (int i = 0; i < 100; i++) {
+ longBinaryData.append("\\u").append(String.format("%04x", i % 65536));
+ }
+
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d1=[s\"" + longBinaryData + "\"],d2=[s\"Collection\",s\"ExecutableStream\",s\"kotlin-stdlib\"])";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(3);
+ }
+
+ @Test
+ void extremelyLongKotlinMetadataString() {
+ // Create a test case with an extremely long string to reach ~3500 characters like the failing case
+ StringBuilder longBinaryData = new StringBuilder();
+ // Generate enough data to reach position ~3500
+ for (int i = 0; i < 600; i++) {
+ longBinaryData.append("\\u").append(String.format("%04x", i % 65536));
+ }
+
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d1=[s\"" + longBinaryData + "\"],d2=[s\"Collection\",s\"ExecutableStream\",s\"kotlin-stdlib\"])";
+
+ System.out.println("Annotation length: " + metadataAnnotation.length());
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(3);
+ }
+
+ @Test
+ void kotlinMetadataWithSpecialSequences() {
+ // Test with some specific sequences that might cause parsing issues
+ String problematicData = "\\u0000\\u001e\\n\\u0002\\u0018\\u0002\\n\\u0000\\n\\u0002\\u0010\\u000e\\n\\u0002\\b\\u0002\\n\\u0002\\u0010\\u0002";
+ String metadataAnnotation = "@Lkotlin/Metadata;(k=I1,d1=[s\"" + problematicData + "\"],d2=[s\"Collection\",s\"ExecutableStream\",s\"junit-jupiter-api\"])";
+
+ // Test deserialization
+ AnnotationInfo info = AnnotationDeserializer.parseAnnotation(metadataAnnotation);
+ assertThat(info.getDescriptor()).isEqualTo("Lkotlin/Metadata;");
+ assertThat(info.getAttributes()).hasSize(3);
+ }
+}
diff --git a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTest.java b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTest.java
index 47ad8a7399..7c9582dfa6 100644
--- a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTest.java
+++ b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTest.java
@@ -18,131 +18,819 @@
import lombok.SneakyThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Opcodes;
import org.openrewrite.ExecutionContext;
import org.openrewrite.InMemoryExecutionContext;
+import org.openrewrite.Parser;
import org.openrewrite.java.JavaParser;
import org.openrewrite.java.JavaParserExecutionContextView;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.java.tree.JavaType;
import org.openrewrite.test.RewriteTest;
-import java.io.IOException;
+import javax.tools.JavaCompiler;
+import javax.tools.ToolProvider;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.zip.GZIPInputStream;
import static io.micrometer.core.instrument.util.DoubleFormat.decimalOrNan;
import static java.util.Objects.requireNonNull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.openrewrite.java.Assertions.java;
+import static org.openrewrite.java.internal.parser.TypeTable.VERIFY_CLASS_WRITING;
+/**
+ * Consolidated test suite for TypeTable functionality including annotation escaping,
+ * enum constant handling, constant field processing, and integration tests.
+ */
+@SuppressWarnings("SameParameterValue")
class TypeTableTest implements RewriteTest {
- Path tsv;
- ExecutionContext ctx = new InMemoryExecutionContext();
@TempDir
- Path temp;
+ Path tempDir;
+
+ ExecutionContext ctx;
+ JavaCompiler compiler;
+ Path tsv;
@BeforeEach
- void before() {
- ctx.putMessage(TypeTable.VERIFY_CLASS_WRITING, true);
- JavaParserExecutionContextView.view(ctx).setParserClasspathDownloadTarget(temp.toFile());
- tsv = temp.resolve("types.tsv.zip");
- System.out.println(tsv);
+ void setUp() {
+ ctx = new InMemoryExecutionContext();
+ ctx.putMessage(VERIFY_CLASS_WRITING, true);
+ JavaParserExecutionContextView.view(ctx).setParserClasspathDownloadTarget(tempDir.toFile());
+ compiler = ToolProvider.getSystemJavaCompiler();
+ tsv = tempDir.resolve("types.tsv.gz");
}
/**
- * Snappy isn't optimal for compression, but is excellent for portability since it
- * requires no native libraries or JNI.
- *
- * @throws IOException If unable to write.
+ * Helper method to compile Java source to a class file
*/
- @Test
- void writeAllRuntimeClasspathJars() throws Exception {
- try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
- long jarsSize = 0;
- for (Path classpath : JavaParser.runtimeClasspath()) {
- jarsSize += writeJar(classpath, writer);
+ Path compileToClassFile(String source, String className) throws Exception {
+ Path srcDir = tempDir.resolve("src");
+ Files.createDirectories(srcDir);
+
+ // Extract simple class name for file
+ String simpleClassName = className.contains(".") ?
+ className.substring(className.lastIndexOf('.') + 1) : className;
+ Path sourceFile = srcDir.resolve(simpleClassName + ".java");
+ Files.writeString(sourceFile, source);
+
+ int result = compiler.run(null, null, null, "-d", tempDir.toString(), sourceFile.toString());
+ assertThat(result).isEqualTo(0);
+
+ return tempDir.resolve(className.replace('.', '/') + ".class");
+ }
+
+ /**
+ * Helper method to compile multiple Java sources and return their class files
+ */
+ Path[] compileToClassFiles(String... sourceAndClassPairs) throws Exception {
+ if (sourceAndClassPairs.length % 2 != 0) {
+ throw new IllegalArgumentException("Must provide source,className pairs");
+ }
+
+ Path srcDir = tempDir.resolve("src");
+ Files.createDirectories(srcDir);
+
+ // Write all source files first
+ for (int i = 0; i < sourceAndClassPairs.length; i += 2) {
+ String source = sourceAndClassPairs[i];
+ String className = sourceAndClassPairs[i + 1];
+ String simpleClassName = className.contains(".") ?
+ className.substring(className.lastIndexOf('.') + 1) : className;
+ Path sourceFile = srcDir.resolve(simpleClassName + ".java");
+ Files.writeString(sourceFile, source);
+ }
+
+ // Compile all sources together so they can reference each other
+ Path[] sourceFiles = new Path[sourceAndClassPairs.length / 2];
+ for (int i = 0; i < sourceAndClassPairs.length; i += 2) {
+ String className = sourceAndClassPairs[i + 1];
+ String simpleClassName = className.contains(".") ?
+ className.substring(className.lastIndexOf('.') + 1) : className;
+ sourceFiles[i / 2] = srcDir.resolve(simpleClassName + ".java");
+ }
+
+ String[] compilerArgs = new String[sourceFiles.length + 2];
+ compilerArgs[0] = "-d";
+ compilerArgs[1] = tempDir.toString();
+ for (int i = 0; i < sourceFiles.length; i++) {
+ compilerArgs[i + 2] = sourceFiles[i].toString();
+ }
+
+ int result = compiler.run(null, null, null, compilerArgs);
+ assertThat(result).isEqualTo(0);
+
+ // Return paths to the compiled class files
+ Path[] classFiles = new Path[sourceAndClassPairs.length / 2];
+ for (int i = 0; i < sourceAndClassPairs.length; i += 2) {
+ String className = sourceAndClassPairs[i + 1];
+ classFiles[i / 2] = tempDir.resolve(className.replace('.', '/') + ".class");
+ }
+ return classFiles;
+ }
+
+ /**
+ * Helper method to create a JAR file from class files
+ */
+ Path createJarFromClasses(String jarName, Path... classFiles) throws Exception {
+ Path jarFile = tempDir.resolve(jarName);
+ try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarFile))) {
+ for (Path classFile : classFiles) {
+ String relativePath = tempDir.relativize(classFile).toString();
+ JarEntry entry = new JarEntry(relativePath);
+ jos.putNextEntry(entry);
+ jos.write(Files.readAllBytes(classFile));
+ jos.closeEntry();
}
- System.out.println("Total size of table " + humanReadableByteCount(Files.size(tsv)));
- System.out.println("Total size of jars " + humanReadableByteCount(jarsSize));
+ }
+ return jarFile;
+ }
+
+ /**
+ * Helper method to process a JAR through TypeTable and return the TSV content
+ */
+ String processJarThroughTypeTable(Path jarFile, String groupId, String artifactId, String version) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (TypeTable.Writer writer = TypeTable.newWriter(baos)) {
+ writer.jar(groupId, artifactId, version).write(jarFile);
+ }
+
+ // Decompress and return TSV content
+ try (InputStream is = new ByteArrayInputStream(baos.toByteArray());
+ InputStream gzis = new GZIPInputStream(is);
+ java.util.Scanner scanner = new java.util.Scanner(gzis)) {
+ return scanner.useDelimiter("\\A").next();
+ }
+ }
+
+ @Nested
+ class AnnotationEscapingTests {
+
+ @Test
+ void annotationArraysAreNotDoubleEscaped() throws Exception {
+ //language=java
+ String annotationSource = """
+ package com.example;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface TestAnnotation {
+ String[] values();
+ }
+ """;
+
+ //language=java
+ String classSource = """
+ package com.example;
+
+ @TestAnnotation(values = {"text/plain;charset=UTF-8", "application/json"})
+ public class AnnotatedClass {
+ @TestAnnotation(values = {"field", "annotation"})
+ public String field = "test";
+ }
+ """;
+
+ Path[] classFiles = compileToClassFiles(
+ annotationSource, "com.example.TestAnnotation",
+ classSource, "com.example.AnnotatedClass"
+ );
+ Path jarFile = createJarFromClasses("test.jar", classFiles);
+ String tsvContent = processJarThroughTypeTable(jarFile, "com.example", "test", "1.0");
+
+ // Verify that annotation arrays contain unescaped quotes
+ assertThat(tsvContent).contains("@Lcom/example/TestAnnotation;(values=[s\"text/plain;charset=UTF-8\",s\"application/json\"])");
+ assertThat(tsvContent).contains("@Lcom/example/TestAnnotation;(values=[s\"field\",s\"annotation\"])");
+ }
+
+ @Test
+ void controlCharactersAreEscapedInAnnotations() throws Exception {
+ //language=java
+ String annotationSource = """
+ package com.example;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface TestAnnotation {
+ String value();
+ }
+ """;
+
+ //language=java
+ String classSource = """
+ package com.example;
+
+ @TestAnnotation("line1\\nline2\\ttab\\rcarriage")
+ public class ControlCharsClass {}
+ """;
+
+ Path[] classFiles = compileToClassFiles(
+ annotationSource, "com.example.TestAnnotation",
+ classSource, "com.example.ControlCharsClass"
+ );
+ Path jarFile = createJarFromClasses("test.jar", classFiles);
+ String tsvContent = processJarThroughTypeTable(jarFile, "com.example", "test", "1.0");
+
+ // Verify control characters are properly escaped
+ assertThat(tsvContent).contains("@Lcom/example/TestAnnotation;(value=s\"line1\\nline2\\ttab\\rcarriage\")");
+ }
+ }
+
+ @Nested
+ class EnumConstantTests {
+
+ @Test
+ void enumConstantsHaveNoConstantValues() throws Exception {
+ //language=java
+ String enumSource = """
+ package com.example;
+
+ public enum TestEnum {
+ VALUE1("First"),
+ VALUE2("Second");
+
+ private final String description;
+
+ TestEnum(String description) {
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ // Regular constants should still have values
+ public static final String CONSTANT = "test";
+ public static final int NUMBER = 42;
+ }
+ """;
+
+ Path classFile = compileToClassFile(enumSource, "com.example.TestEnum");
+ Path jarFile = createJarFromClasses("test.jar", classFile);
+ String tsvContent = processJarThroughTypeTable(jarFile, "com.example", "test", "1.0");
+
+ // Verify enum constants don't have constant values (empty last column, now position 17)
+ assertThat(tsvContent).contains("VALUE1\tLcom/example/TestEnum;\t\t\t\t\t\t\t");
+ assertThat(tsvContent).contains("VALUE2\tLcom/example/TestEnum;\t\t\t\t\t\t\t");
+
+ // Verify regular constants do have values (constantValue now at position 17)
+ assertThat(tsvContent).contains("CONSTANT\tLjava/lang/String;\t\t\t\t\t\t\ts\"test\"");
+ assertThat(tsvContent).contains("NUMBER\tI\t\t\t\t\t\t\tI42");
+ }
+
+ @Test
+ void canCompileAgainstTypeTableGeneratedEnum() throws Exception {
+ // Create a simple enum that can be compiled against
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER | Opcodes.ACC_ENUM,
+ "TestEnum", "Ljava/lang/Enum;", "java/lang/Enum", null);
+
+ // Enum constant without ConstantValue (this is the fix!)
+ FieldVisitor fv = cw.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL | Opcodes.ACC_ENUM,
+ "VALUE1", "LTestEnum;", null, null);
+ fv.visitEnd();
+
+ // Regular static final field with ConstantValue
+ fv = cw.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
+ "CONSTANT", "Ljava/lang/String;", null, "test");
+ fv.visitEnd();
+
+ cw.visitEnd();
+
+ Path enumClassFile = tempDir.resolve("TestEnum.class");
+ Files.write(enumClassFile, cw.toByteArray());
+
+ // Test compilation against this enum
+ //language=java
+ String testCode = """
+ public class TestUseEnum {
+ public void test() {
+ TestEnum e = TestEnum.VALUE1;
+ String constant = TestEnum.CONSTANT;
+ System.out.println(e + " " + constant);
+ }
+ }
+ """;
+
+ Path testFile = tempDir.resolve("TestUseEnum.java");
+ Files.writeString(testFile, testCode);
+
+ // This should compile successfully without "cannot have a constant value" errors
+ int compileResult = compiler.run(null, null, null,
+ "-cp", tempDir.toString(),
+ testFile.toString());
+
+ assertThat(compileResult)
+ .as("Compilation against properly generated enum should succeed")
+ .isEqualTo(0);
}
}
- @Disabled
- @Test
- void writeAllMavenLocal() throws Exception {
- Path m2Repo = Paths.get(System.getProperty("user.home"), ".m2", "repository");
- try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
- AtomicLong jarsSize = new AtomicLong();
- AtomicLong jarCount = new AtomicLong();
- Files.walkFileTree(m2Repo, new SimpleFileVisitor<>() {
- @Override
- @SneakyThrows
- public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
- if (file.toString().endsWith(".jar")) {
- jarsSize.addAndGet(writeJar(file, writer));
- if (jarCount.incrementAndGet() > 500) {
- return FileVisitResult.TERMINATE;
+ @Nested
+ class ConstantFieldTests {
+
+ @Test
+ void primitiveConstantsAreStored() throws Exception {
+ //language=java
+ String source = """
+ package com.example;
+
+ public class Constants {
+ public static final int INT_CONST = 42;
+ public static final long LONG_CONST = 123L;
+ public static final float FLOAT_CONST = 3.14f;
+ public static final double DOUBLE_CONST = 2.718;
+ public static final boolean BOOL_CONST = true;
+ public static final char CHAR_CONST = 'A';
+ public static final byte BYTE_CONST = 1;
+ public static final short SHORT_CONST = 100;
+ public static final String STRING_CONST = "Hello";
+
+ // Non-constant static final fields should not have values
+ public static final Object OBJECT_CONST = new Object();
+ public static final int[] ARRAY_CONST = {1, 2, 3};
+ }
+ """;
+
+ Path classFile = compileToClassFile(source, "com.example.Constants");
+ Path jarFile = createJarFromClasses("test.jar", classFile);
+ String tsvContent = processJarThroughTypeTable(jarFile, "com.example", "test", "1.0");
+
+ // Verify primitive constants have values (constantValue is now at column 17, with two new columns before it)
+ assertThat(tsvContent).contains("INT_CONST\tI\t\t\t\t\t\t\tI42");
+ assertThat(tsvContent).contains("LONG_CONST\tJ\t\t\t\t\t\t\tJ123");
+ assertThat(tsvContent).contains("FLOAT_CONST\tF\t\t\t\t\t\t\tF3.14");
+ assertThat(tsvContent).contains("DOUBLE_CONST\tD\t\t\t\t\t\t\tD2.718");
+ assertThat(tsvContent).contains("BOOL_CONST\tZ\t\t\t\t\t\t\tZtrue");
+ assertThat(tsvContent).contains("CHAR_CONST\tC\t\t\t\t\t\t\tC65");
+ assertThat(tsvContent).contains("BYTE_CONST\tB\t\t\t\t\t\t\tB1");
+ assertThat(tsvContent).contains("SHORT_CONST\tS\t\t\t\t\t\t\tS100");
+ assertThat(tsvContent).contains("STRING_CONST\tLjava/lang/String;\t\t\t\t\t\t\ts\"Hello\"");
+
+ // Verify non-constant fields don't have values
+ assertThat(tsvContent).contains("OBJECT_CONST\tLjava/lang/Object;\t\t\t\t\t\t\t");
+ assertThat(tsvContent).contains("ARRAY_CONST\t[I\t\t\t\t\t\t\t");
+ }
+ }
+
+ @Nested
+ class IntegrationTests {
+
+ /**
+ * Snappy isn't optimal for compression, but is excellent for portability since it
+ * requires no native libraries or JNI.
+ */
+ @Test
+ void writeAllRuntimeClasspathJars() throws Exception {
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ long jarsSize = 0;
+ for (Path classpath : JavaParser.runtimeClasspath()) {
+ jarsSize += writeJar(classpath, writer);
+ }
+ System.out.println("Total size of table " + humanReadableByteCount(Files.size(tsv)));
+ System.out.println("Total size of jars " + humanReadableByteCount(jarsSize));
+ }
+ }
+
+ @Disabled
+ @Test
+ void writeAllMavenLocal() throws Exception {
+ Path m2Repo = Paths.get(System.getProperty("user.home"), ".m2", "repository");
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ AtomicLong jarsSize = new AtomicLong();
+ AtomicLong jarCount = new AtomicLong();
+ Files.walkFileTree(m2Repo, new SimpleFileVisitor<>() {
+ @Override
+ @SneakyThrows
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
+ if (file.toString().endsWith(".jar")) {
+ jarsSize.addAndGet(writeJar(file, writer));
+ if (jarCount.incrementAndGet() > 500) {
+ return FileVisitResult.TERMINATE;
+ }
}
+ return FileVisitResult.CONTINUE;
}
- return FileVisitResult.CONTINUE;
- }
- });
- System.out.println("Total size of table " + humanReadableByteCount(Files.size(tsv)));
- System.out.println("Total size of jars " + humanReadableByteCount(jarsSize.get()));
+ });
+ System.out.println("Total size of table " + humanReadableByteCount(Files.size(tsv)));
+ System.out.println("Total size of jars " + humanReadableByteCount(jarsSize.get()));
+ }
}
- }
- @Test
- void writeReadJunitJupiterApi() throws Exception {
- try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
- for (Path classpath : JavaParser.runtimeClasspath()) {
- if (classpath.toFile().getName().contains("junit-jupiter-api")) {
- writeJar(classpath, writer);
+ @Test
+ void writeReadJunitJupiterApi() throws Exception {
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ for (Path classpath : JavaParser.runtimeClasspath()) {
+ if (classpath.toFile().getName().contains("junit-jupiter-api")) {
+ writeJar(classpath, writer);
+ }
}
}
+
+ TypeTable table = new TypeTable(ctx, tsv.toUri().toURL(), List.of("junit-jupiter-api"));
+ Path classesDir = table.load("junit-jupiter-api");
+ assertThat(Files.walk(requireNonNull(classesDir))).noneMatch(p -> p.getFileName().toString().endsWith("$1.class"));
+
+ assertThat(classesDir)
+ .isNotNull()
+ .isDirectoryRecursivelyContaining("glob:**/Assertions.class")
+ .isDirectoryRecursivelyContaining("glob:**/BeforeEach.class"); // No fields or methods
+
+ // Demonstrate that the bytecode we wrote for the classes in this
+ // JAR is sufficient for the compiler to type attribute code that depends
+ // on them.
+ rewriteRun(
+ spec -> spec.parser(JavaParser.fromJavaVersion()
+ .classpath(List.of(classesDir))),
+ java(
+ """
+ import org.junit.jupiter.api.Assertions;
+ import org.junit.jupiter.api.BeforeEach;
+ import org.junit.jupiter.api.Test;
+
+ class Test {
+
+ @BeforeEach
+ void before() {
+ }
+
+ @Test
+ void foo() {
+ Assertions.assertTrue(true);
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void writeReadWithAnnotations() throws Exception {
+ // Create our own test annotation JAR
+ //language=java
+ String annotationSource = """
+ package test.validation;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.FIELD)
+ @interface TestValidation {
+ String message() default "{test.validation.TestValidation.message}";
+ String[] groups() default {};
+ }
+ """;
+
+ Path[] classFiles = compileToClassFiles(
+ annotationSource, "test.validation.TestValidation"
+ );
+ Path testJar = createJarFromClasses("test-validation.jar", classFiles);
+
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ writer.jar("test.group", "test-validation", "1.0").write(testJar);
+ }
+
+ TypeTable table = new TypeTable(ctx, tsv.toUri().toURL(), List.of("test-validation"));
+ Path classesDir = table.load("test-validation");
+
+ // Verify that TypeTable can successfully load classes from our JAR
+ assertThat(classesDir).isNotNull();
+ assertThat(classesDir)
+ .isDirectoryRecursivelyContaining("glob:**/TestValidation.class");
}
- TypeTable table = new TypeTable(ctx, tsv.toUri().toURL(), List.of("junit-jupiter-api"));
- Path classesDir = table.load("junit-jupiter-api");
- assertThat(Files.walk(requireNonNull(classesDir))).noneMatch(p -> p.getFileName().toString().endsWith("$1.class"));
+ @Test
+ void annotationAttributeValuesPreservedThroughTypeTableRoundtrip() throws Exception {
+ // Create annotation with various default values to test escaping and preservation
+ //language=java
+ String annotationSource = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target({ElementType.TYPE, ElementType.FIELD})
+ public @interface ValidationRule {
+ String message() default "{validation.rule.message}";
+ String[] values() default {"text/plain;charset=UTF-8", "application/json"};
+ String specialChars() default "line1\\nline2\\ttab\\rcarriage";
+ int priority() default 100;
+ boolean enabled() default true;
+ }
+ """;
- assertThat(classesDir)
- .isNotNull()
- .isDirectoryRecursivelyContaining("glob:**/Assertions.class")
- .isDirectoryRecursivelyContaining("glob:**/BeforeEach.class"); // No fields or methods
+ Path[] classFiles = compileToClassFiles(
+ annotationSource, "test.annotations.ValidationRule"
+ );
+ Path testJar = createJarFromClasses("validation-rules.jar", classFiles);
- // Demonstrate that the bytecode we wrote for the classes in this
- // JAR is sufficient for the compiler to type attribute code that depends
- // on them.
- rewriteRun(
- spec -> spec.parser(JavaParser.fromJavaVersion()
- .classpath(List.of(classesDir))),
- java(
- """
- import org.junit.jupiter.api.Assertions;
- import org.junit.jupiter.api.BeforeEach;
- import org.junit.jupiter.api.Test;
+ // Write through TypeTable
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ writer.jar("test.group", "validation-rules", "1.0").write(testJar);
+ }
- class Test {
+ // Load back via TypeTable
+ TypeTable table = new TypeTable(ctx, tsv.toUri().toURL(), List.of("validation-rules"));
+ Path classesDir = table.load("validation-rules");
+ assertThat(classesDir).isNotNull();
- @BeforeEach
- void before() {
+ // Test that JavaParser can parse code using the annotation
+ // This validates that TypeTable preserved the annotation structure correctly
+ rewriteRun(
+ spec -> spec.parser((Parser.Builder) JavaParser.fromJavaVersion()
+ .classpath(List.of(classesDir)).logCompilationWarningsAndErrors(true)),
+ java(
+ """
+ import test.annotations.ValidationRule;
+
+ @ValidationRule
+ class TestClass {
+ @ValidationRule(message = "custom message")
+ private String field;
}
+ """,
+ spec -> spec.afterRecipe(cu -> {
+ // Verify that the annotation types are properly resolved
+ J.ClassDeclaration clazz = cu.getClasses().getFirst();
+ J.Annotation classAnnotation = clazz.getLeadingAnnotations().getFirst();
+ JavaType.Class annotationType = (JavaType.Class) classAnnotation.getType();
+
+ assertThat(annotationType).isNotNull();
+ assertThat(annotationType.getFullyQualifiedName()).isEqualTo("test.annotations.ValidationRule");
+
+ // Verify the annotation has the expected methods (proving structure is preserved)
+ assertThat(annotationType.getMethods()).hasSize(5);
+ assertThat(annotationType.getMethods().stream().map(JavaType.Method::getName))
+ .containsExactlyInAnyOrder("message", "values", "specialChars", "priority", "enabled");
+
+ // Verify all default values are preserved through the TypeTable roundtrip
+ assertThat(annotationType.getMethods().stream()
+ .filter(m -> "message".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("{validation.rule.message}")
+ );
+
+ assertThat(annotationType.getMethods().stream()
+ .filter(m -> "values".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("text/plain;charset=UTF-8", "application/json")
+ );
+
+ assertThat(annotationType.getMethods().stream()
+ .filter(m -> "specialChars".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("line1\nline2\ttab\rcarriage")
+ );
+
+ assertThat(annotationType.getMethods().stream()
+ .filter(m -> "priority".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("100")
+ );
+
+ assertThat(annotationType.getMethods().stream()
+ .filter(m -> "enabled".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("true")
+ );
+
+ // Verify field-level annotation is also properly typed with same default values
+ J.VariableDeclarations field = (J.VariableDeclarations) clazz.getBody().getStatements().getFirst();
+ J.Annotation fieldAnnotation = field.getLeadingAnnotations().getFirst();
+ JavaType.Class fieldAnnotationType = (JavaType.Class) fieldAnnotation.getType();
+
+ assertThat(fieldAnnotationType).isNotNull();
+ assertThat(fieldAnnotationType.getFullyQualifiedName()).isEqualTo("test.annotations.ValidationRule");
+
+ // Field annotation should have the same type information (including default values)
+ // even when explicitly overriding some attributes
+ assertThat(fieldAnnotationType.getMethods().stream()
+ .filter(m -> "message".equals(m.getName()))
+ .findFirst())
+ .isPresent()
+ .get()
+ .satisfies(method ->
+ assertThat(method.getDefaultValue()).containsExactly("{validation.rule.message}")
+ );
+ })
+ )
+ );
+ }
+
+ @Test
+ void typeAndParameterAnnotationsThroughTypeTableRoundtrip() throws Exception {
+ // Create a comprehensive set of annotations to test all three annotation types
+ //language=java
+ String methodAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.METHOD)
+ public @interface Transactional {
+ boolean readOnly() default false;
+ }
+ """;
+
+ //language=java
+ String paramAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.PARAMETER)
+ public @interface NotNull {
+ String message() default "must not be null";
+ }
+ """;
- @Test
- void foo() {
- Assertions.assertTrue(true);
+ //language=java
+ String typeUseAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target({ElementType.TYPE_USE, ElementType.TYPE_PARAMETER})
+ public @interface Nullable {}
+ """;
+
+ //language=java
+ String anotherTypeUseAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE_USE)
+ public @interface NonNull {}
+ """;
+
+ //language=java
+ String libraryClass = """
+ package test.library;
+
+ import test.annotations.*;
+ import java.util.List;
+
+ public class AnnotatedLibrary {
+ // Field with type annotation
+ private @Nullable String nullableField;
+
+ // Array field with type annotation
+ private String @NonNull [] nonNullArray;
+
+ // Method with all three annotation types
+ @Transactional(readOnly = true)
+ public @Nullable String processData(
+ @NotNull @NonNull String required,
+ @Nullable List<@NonNull String> items) {
+ return nullableField;
}
+
+ // Method with type annotation on generic return type
+ public List<@Nullable String> getNullableStrings() {
+ return null;
+ }
+
+ // Method with complex nested type annotations
+ public void processWildcard(List extends @NonNull Number> numbers) {}
}
- """
- )
- );
+ """;
+
+ Path[] classFiles = compileToClassFiles(
+ methodAnnotation, "test.annotations.Transactional",
+ paramAnnotation, "test.annotations.NotNull",
+ typeUseAnnotation, "test.annotations.Nullable",
+ anotherTypeUseAnnotation, "test.annotations.NonNull",
+ libraryClass, "test.library.AnnotatedLibrary"
+ );
+ Path testJar = createJarFromClasses("annotated-library.jar", classFiles);
+
+ // Write through TypeTable
+ try (TypeTable.Writer writer = TypeTable.newWriter(Files.newOutputStream(tsv))) {
+ writer.jar("test.group", "annotated-library", "1.0").write(testJar);
+ }
+
+ // Load back via TypeTable
+ TypeTable table = new TypeTable(ctx, tsv.toUri().toURL(), List.of("annotated-library"));
+ Path classesDir = table.load("annotated-library");
+ assertThat(classesDir).isNotNull();
+
+ // Verify the generated classes exist
+ assertThat(classesDir)
+ .isDirectoryRecursivelyContaining("glob:**/Transactional.class")
+ .isDirectoryRecursivelyContaining("glob:**/NotNull.class")
+ .isDirectoryRecursivelyContaining("glob:**/Nullable.class")
+ .isDirectoryRecursivelyContaining("glob:**/NonNull.class")
+ .isDirectoryRecursivelyContaining("glob:**/AnnotatedLibrary.class");
+
+ // Test that JavaParser can parse code using the library with all annotation types
+ // This validates that TypeTable preserved all annotation information correctly
+ rewriteRun(
+ spec -> spec.parser((Parser.Builder) JavaParser.fromJavaVersion()
+ .classpath(List.of(classesDir)).logCompilationWarningsAndErrors(true)),
+ java(
+ """
+ import test.library.AnnotatedLibrary;
+ import test.annotations.*;
+ import java.util.List;
+ import java.util.ArrayList;
+
+ class TestClient {
+ void useLibrary() {
+ AnnotatedLibrary lib = new AnnotatedLibrary();
+
+ // Call method with all three annotation types
+ String result = lib.processData("required", new ArrayList<>());
+
+ // Call method with type annotations on return type
+ List nullables = lib.getNullableStrings();
+
+ // Call method with complex type annotations
+ List numbers = new ArrayList<>();
+ lib.processWildcard(numbers);
+ }
+
+ // Use the annotations in our own code to verify they work
+ @Transactional
+ public void transactionalMethod(@NotNull String param) {}
+
+ private @Nullable String nullableField;
+
+ public List<@NonNull String> getRequiredStrings() {
+ return new ArrayList<>();
+ }
+ }
+ """,
+ spec -> spec.afterRecipe(cu -> {
+ // Basic verification that the compilation succeeded
+ J.ClassDeclaration clazz = cu.getClasses().getFirst();
+ assertThat(clazz.getSimpleName()).isEqualTo("TestClient");
+
+ // Find the transactionalMethod
+ J.MethodDeclaration transactionalMethod = clazz.getBody().getStatements().stream()
+ .filter(J.MethodDeclaration.class::isInstance)
+ .map(J.MethodDeclaration.class::cast)
+ .filter(m -> "transactionalMethod".equals(m.getSimpleName()))
+ .findFirst()
+ .orElseThrow();
+
+ // Verify the method has the @Transactional annotation
+ assertThat(transactionalMethod.getLeadingAnnotations()).hasSize(1);
+ J.Annotation transactionalAnn = transactionalMethod.getLeadingAnnotations().getFirst();
+ assertThat(transactionalAnn.getSimpleName()).isEqualTo("Transactional");
+
+ // Verify the annotation type is resolved
+ JavaType.Class annotationType = (JavaType.Class) transactionalAnn.getType();
+ assertThat(annotationType).isNotNull();
+ assertThat(annotationType.getFullyQualifiedName()).isEqualTo("test.annotations.Transactional");
+
+ // Note: As you mentioned, JavaType model might not yet fully support
+ // parameter annotations and type annotations, so we can't verify those
+ // in detail. But the fact that compilation succeeds proves that
+ // TypeTable preserved enough information for the compiler to work.
+ })
+ )
+ );
+ }
+
}
- private static long writeJar(Path classpath, TypeTable.Writer writer) throws IOException {
+ // Helper methods for integration tests
+ private static long writeJar(Path classpath, TypeTable.Writer writer) throws Exception {
String fileName = classpath.toFile().getName();
if (fileName.endsWith(".jar")) {
String[] artifactVersion = fileName.replaceAll(".jar$", "")
diff --git a/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTypeAnnotationsTest.java b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTypeAnnotationsTest.java
new file mode 100644
index 0000000000..0ceda6f1cd
--- /dev/null
+++ b/rewrite-java/src/test/java/org/openrewrite/java/internal/parser/TypeTableTypeAnnotationsTest.java
@@ -0,0 +1,587 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.internal.parser;
+
+import org.intellij.lang.annotations.Language;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.InMemoryExecutionContext;
+import org.openrewrite.java.JavaParserExecutionContextView;
+
+import javax.tools.JavaCompiler;
+import javax.tools.ToolProvider;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.zip.GZIPInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.internal.parser.TypeTable.VERIFY_CLASS_WRITING;
+
+/**
+ * Tests for the new TSV format with three annotation columns:
+ * elementAnnotations, parameterAnnotations, and typeAnnotations
+ */
+@SuppressWarnings("resource")
+class TypeTableTypeAnnotationsTest {
+
+ @TempDir
+ Path tempDir;
+
+ ExecutionContext ctx;
+ JavaCompiler compiler;
+ Path tsv;
+
+ @BeforeEach
+ void setUp() {
+ ctx = new InMemoryExecutionContext();
+ ctx.putMessage(VERIFY_CLASS_WRITING, true);
+ JavaParserExecutionContextView.view(ctx).setParserClasspathDownloadTarget(tempDir.toFile());
+ compiler = ToolProvider.getSystemJavaCompiler();
+ tsv = tempDir.resolve("types.tsv.gz");
+ }
+
+ @Test
+ void capturesParameterAnnotations() throws Exception {
+ @Language("java")
+ String paramAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Target(ElementType.PARAMETER)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface NotNull {}
+ """;
+
+ @Language("java")
+ String validAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Target(ElementType.PARAMETER)
+ @Retention(RetentionPolicy.CLASS)
+ public @interface Valid {}
+ """;
+
+ @Language("java")
+ String testClass = """
+ package test;
+
+ import test.annotations.*;
+
+ public class TestClass {
+ public void singleParam(@NotNull String param) {}
+
+ public void multipleParams(@NotNull String first, @Valid @NotNull Object second) {}
+
+ public void mixedParams(String plain, @Valid Object annotated) {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(
+ paramAnnotation, "test.annotations.NotNull",
+ validAnnotation, "test.annotations.Valid",
+ testClass, "test.TestClass"
+ );
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Parse TSV to check parameter annotations column (column 16)
+ String[] lines = tsvContent.split("\n");
+
+ // Find method rows
+ for (String line : lines) {
+ if (line.contains("\tsingleParam\t")) {
+ String[] cols = line.split("\t", -1);
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[15]).as("parameterAnnotations column for singleParam")
+ .isEqualTo("@Ltest/annotations/NotNull;");
+ }
+
+ if (line.contains("\tmultipleParams\t")) {
+ String[] cols = line.split("\t", -1);
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[15]).as("parameterAnnotations column for multipleParams")
+ .isEqualTo("@Ltest/annotations/NotNull;|@Ltest/annotations/NotNull;@Ltest/annotations/Valid;");
+ }
+
+ if (line.contains("\tmixedParams\t")) {
+ String[] cols = line.split("\t", -1);
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[15]).as("parameterAnnotations column for mixedParams")
+ .isEqualTo("|@Ltest/annotations/Valid;");
+ }
+ }
+ }
+
+ @Test
+ void capturesTypeAnnotations() throws Exception {
+ @Language("java")
+ String nullableAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Target({ElementType.TYPE_USE, ElementType.TYPE_PARAMETER})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface Nullable {}
+ """;
+
+ @Language("java")
+ String nonNullAnnotation = """
+ package test.annotations;
+
+ import java.lang.annotation.*;
+
+ @Target(ElementType.TYPE_USE)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface NonNull {}
+ """;
+
+ @Language("java")
+ String testClass = """
+ package test;
+
+ import test.annotations.*;
+ import java.util.List;
+
+ public class TestClass {
+ // Type annotation on field
+ @Nullable String field;
+
+ // Type annotations on array
+ String @NonNull [] arrayField;
+
+ // Type annotation on method return
+ @Nullable String getField() { return field; }
+
+ // Type annotation on method parameter type
+ void setField(@NonNull String value) { this.field = value; }
+
+ // Complex: generic with type annotations
+ void processList(List<@Nullable String> list) {}
+
+ // Complex: wildcard with type annotation
+ void processWildcard(List extends @NonNull Number> numbers) {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(
+ nullableAnnotation, "test.annotations.Nullable",
+ nonNullAnnotation, "test.annotations.NonNull",
+ testClass, "test.TestClass"
+ );
+
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Parse TSV to check type annotations column (column 17)
+ String[] lines = tsvContent.split("\n");
+
+ for (String line : lines) {
+ String[] cols = line.split("\t", -1);
+
+ if (line.contains("\tfield\t") && line.contains("Ljava/lang/String;")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for field")
+ .contains("13000000::@Ltest/annotations/Nullable;");
+ }
+
+ if (line.contains("\tarrayField\t")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for arrayField")
+ .contains("13000000::@Ltest/annotations/NonNull;");
+ }
+
+ if (line.contains("\tgetField\t")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for getField")
+ .contains("14000000::@Ltest/annotations/Nullable;");
+ }
+
+ if (line.contains("\tsetField\t")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for setField")
+ .contains("16000000::@Ltest/annotations/NonNull;");
+ }
+
+ if (line.contains("\tprocessList\t")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for processList")
+ .contains("16000000:0;:@Ltest/annotations/Nullable;");
+ }
+
+ if (line.contains("\tprocessWildcard\t")) {
+ assertThat(cols.length).isGreaterThanOrEqualTo(18);
+ assertThat(cols[16]).as("typeAnnotations for processWildcard")
+ .contains("16000000:0;*:@Ltest/annotations/NonNull;");
+ }
+ }
+ }
+
+ @Test
+ void columnOrderCorrect() throws Exception {
+ // Simple test to verify column order is as expected
+ @Language("java")
+ String testClass = """
+ package test;
+
+ @Deprecated
+ public class TestClass {
+ @Deprecated
+ public static final String CONSTANT = "value";
+
+ @Deprecated
+ public void method() {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(testClass, "test.TestClass");
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ String[] lines = tsvContent.split("\n");
+ String header = lines[0];
+
+ // Verify header has the right columns in order
+ assertThat(header).isEqualTo(
+ "groupId\tartifactId\tversion\tclassAccess\tclassName\tclassSignature\t" +
+ "classSuperclassSignature\tclassSuperinterfaceSignatures\taccess\tname\t" +
+ "descriptor\tsignature\tparameterNames\texceptions\telementAnnotations\t" +
+ "parameterAnnotations\ttypeAnnotations\tconstantValue"
+ );
+
+ // Find the field row and check constantValue is in the right place
+ for (String line : lines) {
+ if (line.contains("\tCONSTANT\t")) {
+ String[] cols = line.split("\t", -1);
+ assertThat(cols.length).isEqualTo(18);
+ assertThat(cols[14]).as("elementAnnotations").contains("@Ljava/lang/Deprecated;");
+ assertThat(cols[15]).as("parameterAnnotations").isEmpty();
+ assertThat(cols[16]).as("typeAnnotations").isEmpty();
+ assertThat(cols[17]).as("constantValue").isEqualTo("s\"value\"");
+ }
+ }
+ }
+
+ @Test
+ void annotationsWithSpecialCharactersInValues() throws Exception {
+ // Test that annotation values with special characters are properly escaped
+ // and don't break the TSV format
+ @Language("java")
+ String sources = """
+ package test;
+
+ import java.lang.annotation.*;
+
+ @Target({ElementType.PARAMETER, ElementType.TYPE_USE})
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Message {
+ String value();
+ String[] tags() default {};
+ }
+
+ @Target(ElementType.TYPE_USE)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Format {
+ String pattern();
+ }
+
+ public class TestClass {
+ // Test string with quotes that need escaping
+ public void quotes(@Message(value = "He said \\"Hello\\"") String param) {}
+
+ // Test string with commas (delimiter in arrays)
+ public void commas(@Message(value = "a,b,c", tags = {"tag1,tag2", "tag3|tag4"}) String param) {}
+
+ // Test string with pipe characters (delimiter between annotations)
+ public void pipes(@Message(value = "value|with|pipes") String param) {}
+
+ // Test string with newlines and tabs
+ public void whitespace(@Message(value = "line1\\nline2\\ttab") String param) {}
+
+ // Test string with backslashes
+ public void backslashes(@Message(value = "C:\\\\Users\\\\file.txt") String param) {}
+
+ // Type annotation with special characters
+ public @Format(pattern = "\\\\d{2,4}-\\\\d{2}-\\\\d{2}|\\\\d{4}/\\\\d{2}/\\\\d{2}") String getDate() {
+ return null;
+ }
+
+ // Multiple annotations with complex values
+ public void complex(
+ @Message(value = "Complex: \\"a,b|c\\\\d\\n\\"", tags = {"t1|t2", "t3,t4"})
+ @Format(pattern = "[a-z]+|[A-Z]+")
+ String param
+ ) {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(sources, "test.TestClass");
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Parse the TSV content to verify it's valid
+ String[] lines = tsvContent.split("\n");
+ for (String line : lines) {
+ String[] cols = line.split("\t", -1);
+
+ if (line.contains("\tquotes\t")) {
+ // Check that the escaped quotes are properly handled
+ assertThat(cols[15]).as("parameterAnnotations for quotes method")
+ .contains("@Ltest/Message;")
+ .contains("value=s\"He said \\\"Hello\\\"\"");
+ }
+
+ if (line.contains("\tcommas\t")) {
+ // Check that commas in values don't break the format
+ // Note: pipes are escaped in TSV format since they're used as delimiters
+ assertThat(cols[15]).as("parameterAnnotations for commas method")
+ .contains("@Ltest/Message;")
+ .contains("value=s\"a,b,c\"")
+ .contains("tags=[s\"tag1,tag2\",s\"tag3\\|tag4\"]");
+ }
+
+ if (line.contains("\tpipes\t")) {
+ // Check that pipes in values are escaped (pipes are TSV delimiters)
+ assertThat(cols[15]).as("parameterAnnotations for pipes method")
+ .contains("value=s\"value\\|with\\|pipes\"");
+ // Also verify that the pipe delimiter between parameters is handled correctly
+ // if there were multiple parameters with annotations
+ }
+
+ if (line.contains("\twhitespace\t")) {
+ // Check that newlines and tabs are escaped
+ assertThat(cols[15]).as("parameterAnnotations for whitespace method")
+ .contains("value=s\"line1\\nline2\\ttab\"");
+ }
+
+ if (line.contains("\tbackslashes\t")) {
+ // Check that backslashes are properly escaped
+ assertThat(cols[15]).as("parameterAnnotations for backslashes method")
+ .contains("value=s\"C:\\\\Users\\\\file.txt\"");
+ }
+
+ if (line.contains("\tgetDate\t")) {
+ // Check type annotation with regex pattern (pipes and backslashes are escaped in TSV)
+ assertThat(cols[16]).as("typeAnnotations for getDate method")
+ .contains("14000000::@Ltest/Format;")
+ .contains("pattern=s\"\\\\d{2,4}-\\\\d{2}-\\\\d{2}\\|\\\\d{4}/\\\\d{2}/\\\\d{2}\"");
+ }
+
+ if (line.contains("\tcomplex\t")) {
+ // Check multiple annotations with complex values
+ // @Message is a parameter annotation, @Format is a type annotation
+ assertThat(cols[15]).as("parameterAnnotations for complex method")
+ .contains("@Ltest/Message;");
+ assertThat(cols[16]).as("typeAnnotations for complex method")
+ .contains("16000000::@Ltest/Format;");
+ }
+ }
+ }
+
+ @Test
+ void parameterAnnotationsWithPipeDelimiters() throws Exception {
+ // Test that pipe characters in parameter annotation values don't break
+ // the pipe delimiter between different parameters
+ @Language("java")
+ String sources = """
+ package test;
+
+ import java.lang.annotation.*;
+
+ @Target(ElementType.PARAMETER)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Pattern {
+ String value();
+ }
+
+ @Target(ElementType.PARAMETER)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Description {
+ String text();
+ }
+
+ public class TestClass {
+ // Multiple parameters with annotations containing pipe characters
+ public void validate(
+ @Pattern("\\\\d+|\\\\w+") String first,
+ @Description(text = "Options: A|B|C") String second,
+ @Pattern("[a-z]+|[A-Z]+") @Description(text = "Letters|Digits") String third
+ ) {}
+ }
+ """;
+
+ Path jarFile = compileAndPackage(sources, "test.TestClass");
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Find the validate method row
+ for (String line : tsvContent.split("\n")) {
+ if (line.contains("\tvalidate\t")) {
+ String[] cols = line.split("\t", -1);
+ String paramAnnotations = cols[15];
+
+ // Verify the parameter annotations are properly formatted
+ // The pipe delimiter between parameters should work correctly
+ // even though the annotation values contain escaped pipes
+ assertThat(paramAnnotations).as("parameterAnnotations for validate method")
+ .contains("@Ltest/Pattern;(value=s\"\\\\d+\\|\\\\w+\")")
+ .contains("@Ltest/Description;(text=s\"Options: A\\|B\\|C\")")
+ .contains("@Ltest/Pattern;(value=s\"[a-z]+\\|[A-Z]+\")@Ltest/Description;(text=s\"Letters\\|Digits\")");
+
+ break;
+ }
+ }
+ }
+
+ @Test
+ void allThreeAnnotationTypesOnMethod() throws Exception {
+ // Test a method that has element annotations, parameter annotations, and type annotations
+ @Language("java")
+ String sources = """
+ package test;
+
+ import java.lang.annotation.*;
+
+ @Target(ElementType.METHOD)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface MethodAnnotation {}
+
+ @Target(ElementType.PARAMETER)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface ParamAnnotation {}
+
+ @Target(ElementType.TYPE_USE)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface TypeAnnotation {}
+
+ public class TestClass {
+ @MethodAnnotation
+ public @TypeAnnotation String process(@ParamAnnotation @TypeAnnotation String input) {
+ return input;
+ }
+ }
+ """;
+
+ Path jarFile = compileAndPackage(sources, "test.TestClass");
+ String tsvContent = processJarThroughTypeTable(jarFile);
+
+ // Find the process method row
+ for (String line : tsvContent.split("\n")) {
+ if (line.contains("\tprocess\t")) {
+ String[] cols = line.split("\t", -1);
+ assertThat(cols[14]).as("elementAnnotations")
+ .contains("@Ltest/MethodAnnotation;");
+ assertThat(cols[15]).as("parameterAnnotations")
+ .contains("@Ltest/ParamAnnotation;");
+ assertThat(cols[16]).as("typeAnnotations")
+ .contains("14000000::@Ltest/TypeAnnotation;")
+ .contains("16000000::@Ltest/TypeAnnotation;");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper to compile sources and create a JAR
+ */
+ private Path compileAndPackage(String... sourceAndClassPairs) throws Exception {
+ if (sourceAndClassPairs.length % 2 != 0) {
+ throw new IllegalArgumentException("Must provide source,className pairs");
+ }
+
+ Path srcDir = tempDir.resolve("src");
+ Files.createDirectories(srcDir);
+
+ // Write all source files
+ for (int i = 0; i < sourceAndClassPairs.length; i += 2) {
+ String source = sourceAndClassPairs[i];
+ String className = sourceAndClassPairs[i + 1];
+
+ // Create package directories
+ String packagePath = className.contains(".") ?
+ className.substring(0, className.lastIndexOf('.')).replace('.', '/') : "";
+ if (!packagePath.isEmpty()) {
+ Path packageDir = srcDir.resolve(packagePath);
+ Files.createDirectories(packageDir);
+ }
+
+ String simpleClassName = className.substring(className.lastIndexOf('.') + 1);
+ Path sourceFile = packagePath.isEmpty() ?
+ srcDir.resolve(simpleClassName + ".java") :
+ srcDir.resolve(packagePath).resolve(simpleClassName + ".java");
+ Files.writeString(sourceFile, source);
+ }
+
+ // Compile all sources
+ List allSources = Files.walk(srcDir)
+ .filter(p -> p.toString().endsWith(".java"))
+ .toList();
+
+ String[] compilerArgs = new String[allSources.size() + 2];
+ compilerArgs[0] = "-d";
+ compilerArgs[1] = tempDir.toString();
+ for (int i = 0; i < allSources.size(); i++) {
+ compilerArgs[i + 2] = allSources.get(i).toString();
+ }
+
+ int result = compiler.run(null, null, null, compilerArgs);
+ assertThat(result).isEqualTo(0);
+
+ // Create JAR from compiled classes
+ Path jarFile = tempDir.resolve("test.jar");
+ try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarFile))) {
+ Files.walk(tempDir)
+ .filter(p -> p.toString().endsWith(".class"))
+ .forEach(classFile -> {
+ try {
+ String entryName = tempDir.relativize(classFile).toString();
+ JarEntry entry = new JarEntry(entryName);
+ jos.putNextEntry(entry);
+ jos.write(Files.readAllBytes(classFile));
+ jos.closeEntry();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ return jarFile;
+ }
+
+ /**
+ * Process a JAR through TypeTable and return the TSV content
+ */
+ private String processJarThroughTypeTable(Path jarFile) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (TypeTable.Writer writer = TypeTable.newWriter(baos)) {
+ writer.jar("test.group", "test-artifact", "1.0").write(jarFile);
+ }
+
+ // Decompress and return TSV content
+ try (InputStream is = new ByteArrayInputStream(baos.toByteArray());
+ InputStream gzis = new GZIPInputStream(is);
+ java.util.Scanner scanner = new java.util.Scanner(gzis)) {
+ return scanner.useDelimiter("\\A").next();
+ }
+ }
+}