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