diff --git a/cli/build.gradle b/cli/build.gradle index efb6d92..d2f2787 100644 --- a/cli/build.gradle +++ b/cli/build.gradle @@ -33,6 +33,7 @@ dependencies { include project(":parchment") include project(":accesstransformers") + include project(":enumextensions") include project(':interfaceinjection') include project(':unpick') diff --git a/enumextensions/build.gradle b/enumextensions/build.gradle new file mode 100644 index 0000000..58f5f88 --- /dev/null +++ b/enumextensions/build.gradle @@ -0,0 +1,8 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':api') + implementation "com.google.code.gson:gson:${project.gson_version}" +} diff --git a/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionPlugin.java b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionPlugin.java new file mode 100644 index 0000000..cfbf117 --- /dev/null +++ b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionPlugin.java @@ -0,0 +1,16 @@ +package net.neoforged.jst.enumextensions; + +import net.neoforged.jst.api.SourceTransformer; +import net.neoforged.jst.api.SourceTransformerPlugin; + +public class EnumExtensionPlugin implements SourceTransformerPlugin { + @Override + public String getName() { + return "enum-extensions"; + } + + @Override + public SourceTransformer createTransformer() { + return new EnumExtensionTransformer(); + } +} diff --git a/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionTransformer.java b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionTransformer.java new file mode 100644 index 0000000..d6062cf --- /dev/null +++ b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionTransformer.java @@ -0,0 +1,173 @@ +package net.neoforged.jst.enumextensions; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.intellij.psi.PsiFile; +import com.intellij.util.containers.MultiMap; +import net.neoforged.jst.api.Replacements; +import net.neoforged.jst.api.SourceTransformer; +import net.neoforged.jst.api.TransformContext; +import org.jetbrains.annotations.Nullable; +import picocli.CommandLine; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class EnumExtensionTransformer implements SourceTransformer { + private static final Gson GSON = new Gson(); + + @Nullable + @CommandLine.Option(names = "--enum-extensions-stubs", description = "The path to a zip to save reference stubs in") + public Path stubOut; + + @CommandLine.Option(names = "--enum-extensions-data", description = "The paths to read enum extension JSON files from") + public List paths = new ArrayList<>(); + + @Nullable + @CommandLine.Option(names = "--enum-extensions-marker", description = "The name (binary representation) of an annotation to use as a marker for extended enum entries") + public String annotationMarker; + + @Nullable + @CommandLine.Option(names = "--enum-extensions-required-interface", description = "The name (binary representation) of an interface to enforce that extendeable enums implement") + public String requiredInterface; + + private MultiMap extensions; + private StubStore stubs; + private String marker; + private String requiredInterfaceFqn; + + @Override + public void beforeRun(TransformContext context) { + extensions = new MultiMap<>(); + stubs = new StubStore(context.environment().getPsiFacade()); + + if (annotationMarker != null) { + marker = annotationMarker.replace('/', '.').replace('$', '.'); + } + + if (requiredInterface != null) { + requiredInterfaceFqn = requiredInterface.replace('/', '.').replace('$', '.'); + } + + for (Path path : paths) { + try { + var json = GSON.fromJson(Files.readString(path), JsonObject.class); + JsonArray entries = json.getAsJsonArray("entries"); + for (JsonElement entry : entries) { + JsonObject entryObj = entry.getAsJsonObject(); + + String enumName = entryObj.get("enum").getAsString(); + String fieldName = entryObj.get("name").getAsString(); + String ctorDesc = entryObj.get("constructor").getAsString(); + JsonElement paramElem = entryObj.get("parameters"); + ExtensionPrototype.EnumParameters parameters = null; + if (paramElem.isJsonArray()) { + parameters = loadConstantParameters(context, enumName, fieldName, ctorDesc, paramElem.getAsJsonArray()); + } else if (paramElem.isJsonObject()) { + JsonObject obj = paramElem.getAsJsonObject(); + String className = obj.get("class").getAsString(); + if (obj.has("method")) { + String srcMethodName = obj.get("method").getAsString(); + parameters = new ExtensionPrototype.EnumParameters.MethodReference(className, srcMethodName); + } else if (obj.has("field")) { + String srcFieldName = obj.get("field").getAsString(); + parameters = new ExtensionPrototype.EnumParameters.FieldReference(className, srcFieldName); + } + } + extensions.putValue(enumName, new ExtensionPrototype( + fieldName, + ctorDesc, + parameters + )); + if (parameters == null) { + context.logger().error("Failed to read parameters for enum extension entry: %s", entryObj); + throw new IllegalArgumentException("Invalid parameters for enum extension entry"); + } + } + } catch (IOException exception) { + context.logger().error("Failed to read interface injection data file: %s", exception.getMessage()); + throw new UncheckedIOException(exception); + } + } + } + + private static ExtensionPrototype.EnumParameters loadConstantParameters(TransformContext context, String enumName, String fieldName, String ctorDesc, JsonArray obj) { + List params = new ArrayList<>(obj.size()); + var argTypes = MethodTypeDesc.ofDescriptor(ctorDesc).parameterArray(); + if (argTypes.length != obj.size()) { + var message = String.format( + "Parameter count %s does not match argument count %s of constructor %s for field %s in enum %s", + obj.size(), argTypes.length, ctorDesc, fieldName, enumName + ); + context.logger().error(message); + throw new IllegalArgumentException(message); + } + + int idx = 0; + for (JsonElement element : obj) { + ClassDesc argType = argTypes[idx]; + switch (argType.descriptorString()) { + case "Z" -> params.add(element.getAsBoolean()); + case "C" -> { + String param = element.getAsString(); + if (param.length() != 1) { + var message = String.format( + "Invalid character %s at parameter index %s for field %s in enum %s", + param, idx, fieldName, enumName + ); + context.logger().error(message); + throw new IllegalArgumentException(message); + } + params.add(param.charAt(0)); + } + case "B" -> params.add(element.getAsByte()); + case "S" -> params.add(element.getAsShort()); + case "I" -> params.add(element.getAsInt()); + case "F" -> params.add(element.getAsFloat()); + case "J" -> params.add(element.getAsLong()); + case "D" -> params.add(element.getAsDouble()); + case "Ljava/lang/String;" -> params.add(element.isJsonNull() ? null : element.getAsString()); + default -> { + if (!element.isJsonNull()) { + var message = String.format( + "Unsupported immediate argument type %s at parameter index %s for field %s in enum %s", + argType, idx, fieldName, enumName + ); + context.logger().error(message); + throw new IllegalArgumentException(message); + } + params.add(null); + } + } + idx++; + } + return new ExtensionPrototype.EnumParameters.Constant(params); + } + + @Override + public boolean afterRun(TransformContext context) { + if (stubOut != null) { + try { + stubs.save(stubOut); + } catch (IOException e) { + context.logger().error("Failed to save stubs: %s", e.getMessage()); + throw new UncheckedIOException(e); + } + } + + return true; + } + + @Override + public void visitFile(PsiFile psiFile, Replacements replacements) { + new EnumExtensionVisitor(replacements, extensions, stubs, marker, requiredInterfaceFqn).visitFile(psiFile); + } +} diff --git a/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionVisitor.java b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionVisitor.java new file mode 100644 index 0000000..4b3186d --- /dev/null +++ b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/EnumExtensionVisitor.java @@ -0,0 +1,225 @@ +package net.neoforged.jst.enumextensions; + +import com.intellij.lang.jvm.JvmModifier; +import com.intellij.psi.PsiArrayType; +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiEnumConstant; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiJavaCodeReferenceElement; +import com.intellij.psi.PsiPrimitiveType; +import com.intellij.psi.PsiRecursiveElementVisitor; +import com.intellij.psi.PsiReference; +import com.intellij.psi.PsiTypeVisitor; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.util.ClassUtil; +import com.intellij.psi.util.TypeConversionUtil; +import com.intellij.util.containers.MultiMap; +import net.neoforged.jst.api.ImportHelper; +import net.neoforged.jst.api.PsiHelper; +import net.neoforged.jst.api.Replacements; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +class EnumExtensionVisitor extends PsiRecursiveElementVisitor { + private final Replacements replacements; + private final MultiMap extensions; + private final StubStore stubs; + + @Nullable + private final String marker; + + @Nullable + private final String requiredInterface; + + EnumExtensionVisitor(Replacements replacements, MultiMap extensions, StubStore stubs, @Nullable String marker, @Nullable String requiredInterface) { + this.replacements = replacements; + this.extensions = extensions; + this.stubs = stubs; + this.marker = marker; + this.requiredInterface = requiredInterface; + } + + @Override + public void visitElement(@NotNull PsiElement element) { + if (element instanceof PsiClass psiClass) { + if (psiClass.getQualifiedName() == null) { + return; + } + + String className = ClassUtil.getJVMClassName(psiClass); + inject(psiClass, extensions.get(className.replace('.', '/'))); + + for (PsiClass innerClass : psiClass.getInnerClasses()) { + visitElement(innerClass); + } + } + } + + @Override + public void visitFile(@NotNull PsiFile file) { + file.acceptChildren(this); + } + + private void inject(PsiClass psiClass, Collection targets) { + // We cannot enum-extend things that aren't enums + if (targets.isEmpty() || !psiClass.isEnum()) { + return; + } + + if (psiClass.hasModifier(JvmModifier.ABSTRACT)) { + throw new IllegalArgumentException("Cannot extend abstract enum " + psiClass.getQualifiedName()); + } + + if (requiredInterface != null) { + // We check the implements list too in case the required interface isn't present on the classpath + if (Stream.concat( + Arrays.stream(psiClass.getInterfaces()).map(PsiClass::getQualifiedName), + Arrays.stream(psiClass.getImplementsList().getReferenceElements()).map(PsiJavaCodeReferenceElement::getQualifiedName) + ).noneMatch(requiredInterface::equals)) { + throw new IllegalArgumentException("Enum " + psiClass.getQualifiedName() + " must implement " + requiredInterface + " to be extended"); + } + } + + var imports = ImportHelper.get(psiClass.getContainingFile()); + + var fields = psiClass.getFields(); + AtomicBoolean insertingAfterConstant = new AtomicBoolean(false); + var toInsertAfter = IntStream.range(0, fields.length) + .mapToObj(i -> fields[fields.length - (1 + i)]) + .filter(f -> f instanceof PsiEnumConstant) + // If there's args, we want to insert after the entire enum entry, not just the constant name + .map(f -> ((PsiEnumConstant)f).getArgumentList() instanceof PsiElement args ? args : f) + .findFirst() + .map(e -> { + insertingAfterConstant.set(true); + return e; + }) + .orElse(Objects.requireNonNull(psiClass.getLBrace())); // If there's no existing enum entries, insert after the opening brace + + // Add 4 spaces of indent to indent the enum entry inside the class + int indent; + // If the class is preceded by whitespace, use the last line of that whitespace as the base indent + if (psiClass.getPrevSibling() instanceof PsiWhiteSpace psiWhiteSpace) { + indent = 4 + PsiHelper.getLastLineLength(psiWhiteSpace); + } else { + indent = 4; + } + + var ctor = psiClass.getConstructors().length > 0 ? psiClass.getConstructors()[0] : null; + + replacements.insertAfter( + toInsertAfter, + targets.stream() + .sorted(Comparator.comparing(ExtensionPrototype::name)) + .map(extension -> { + var entry = new StringBuilder(); + if (insertingAfterConstant.get()) { + entry.append(','); + } + entry.append("\n").append(" ".repeat(indent)).append(decorate(imports, extension.name())); + if (ctor != null && ctor.getParameterList().getParametersCount() > 0) { + entry.append('('); + switch (extension.parameters()) { + case ExtensionPrototype.EnumParameters.Constant(var params) -> { + if (params.size() != ctor.getParameterList().getParametersCount()) { + throw new IllegalArgumentException("Parameter count mismatch for extension " + extension.name() + ": expected " + ctor.getParameterList().getParametersCount() + " but got " + params.size()); + } + for (var param : params) { + switch (param) { + case String s -> entry.append('"').append(escape(s)).append('"'); + case Character c -> entry.append('\'').append(escape(c.toString())).append('\''); + case null, default -> entry.append(param); + } + entry.append(", "); + } + if (!params.isEmpty()) { + entry.setLength(entry.length() - 2); // Remove trailing comma and space + } + } + case ExtensionPrototype.EnumParameters.FieldReference(var owner, var fieldName) -> { + var className = possiblyImport(imports, owner, fieldName, false); + for (int i = 0; i < ctor.getParameterList().getParametersCount(); i++) { + if (i > 0) { + entry.append(", "); + } + entry.append(className).append('.').append(fieldName).append(".getParameter(").append(i).append(')'); + } + } + case ExtensionPrototype.EnumParameters.MethodReference(var owner, var methodName) -> { + var className = possiblyImport(imports, owner, methodName, true); + for (int i = 0; i < ctor.getParameterList().getParametersCount(); i++) { + if (i > 0) { + entry.append(", "); + } + var parameterType = TypeConversionUtil.erasure( + ctor.getParameterList().getParameters()[i].getType() + ); + String typeText = parameterType.accept(new PsiTypeVisitor<>() { + @Override + public String visitPrimitiveType(@NotNull PsiPrimitiveType primitiveType) { + return primitiveType.getCanonicalText(); + } + + @Override + public String visitClassType(@NotNull PsiClassType classType) { + PsiClass aClass = classType.resolve(); + if (aClass == null) { + throw new IllegalArgumentException("Cannot find fully qualified name for type: " + classType.getCanonicalText()); + } + return possiblyImport(imports, aClass.getQualifiedName()); + } + + @Override + public String visitArrayType(@NotNull PsiArrayType arrayType) { + return arrayType.getComponentType().accept(this) + "[]"; + } + }); + entry.append("(").append(typeText).append(") ").append(className).append('.').append(methodName).append("(").append(i).append(", ").append(typeText).append(".class)"); + } + } + } + entry.append(')'); + } + return entry.toString(); + }) + .collect(Collectors.joining()) + ); + } + + private static String escape(String s){ + // Every unicode character except LF, CR, \, or " should be valid within a Java string literal + // See https://docs.oracle.com/javase/specs/jls/se25/html/jls-3.html#jls-3.10.5 + return s.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\'", "\\'") // Since we'll run characters through this too, and it works fine + .replace("\"", "\\\""); + } + + private String possiblyImport(@Nullable ImportHelper helper, String fqn) { + return helper == null ? fqn : helper.importClass(fqn); + } + + private String possiblyImport(@Nullable ImportHelper helper, String toImport, String member, boolean isMethod) { + var fqn = stubs.createStub(toImport, member, isMethod); + return helper == null ? fqn : helper.importClass(fqn); + } + + private String decorate(@Nullable ImportHelper helper, String entry) { + if (marker == null) { + return entry; + } + return "@" + (helper == null ? marker : helper.importClass(marker)) + " " + entry; + } +} diff --git a/enumextensions/src/main/java/net/neoforged/jst/enumextensions/ExtensionPrototype.java b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/ExtensionPrototype.java new file mode 100644 index 0000000..bd3faa0 --- /dev/null +++ b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/ExtensionPrototype.java @@ -0,0 +1,17 @@ +package net.neoforged.jst.enumextensions; + +import java.util.List; + +public record ExtensionPrototype( + String name, + String ctorDescriptor, + EnumParameters parameters +) { + sealed interface EnumParameters { + record Constant(List params) implements EnumParameters {} + + record FieldReference(String owner, String fieldName) implements EnumParameters {} + + record MethodReference(String owner, String methodName) implements EnumParameters {} + } +} diff --git a/enumextensions/src/main/java/net/neoforged/jst/enumextensions/StubStore.java b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/StubStore.java new file mode 100644 index 0000000..e1b4568 --- /dev/null +++ b/enumextensions/src/main/java/net/neoforged/jst/enumextensions/StubStore.java @@ -0,0 +1,123 @@ +package net.neoforged.jst.enumextensions; + +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.search.GlobalSearchScope; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * When adding enum entries, they may reference methods or fields in mod code. We create stubs to recompile against to + * avoid circular dependencies + */ +class StubStore { + private final JavaPsiFacade facade; + private final Map jvmToFqn = new HashMap<>(); + private final Map stubsByFqn = new HashMap<>(); + private final Map> stubs = new HashMap<>(); + + StubStore(JavaPsiFacade facade) { + this.facade = facade; + } + + public synchronized String createStub(String jvm, String member, boolean isMethod) { + var fqn = jvmToFqn.get(jvm); + if (fqn != null) { + var stub = stubsByFqn.get(fqn); + if (stub != null) { + addToStub(member, isMethod, stub); + } + return fqn; + } + + var splitName = new ArrayList<>(Arrays.asList(jvm.split("/"))); + var name = splitName.removeLast(); + var packageName = String.join(".", splitName); + var byInner = name.split("\\$"); + + fqn = packageName; + if (!fqn.isBlank()) fqn += "."; + fqn += String.join(".", byInner); + jvmToFqn.put(jvm, fqn); + + // Skip creating a stub if the class is visible to JST already + if (facade.findClass(fqn, GlobalSearchScope.everythingScope(facade.getProject())) != null) { + return fqn; + } + + StubClass stub = stubs.computeIfAbsent(packageName, $ -> new HashMap<>()).computeIfAbsent(byInner[0], $ -> new StubClass(byInner[0])); + for (int i = 1; i < byInner.length; i++) { + stub = stub.getChildren(byInner[i]); + } + addToStub(member, isMethod, stub); + stubsByFqn.put(fqn, stub); + + return fqn; + } + + private static void addToStub(String member, boolean isMethod, StubClass stub) { + if (isMethod) { + stub.methodNames().add(member); + } else { + stub.fieldNames().add(member); + } + } + + public synchronized void save(Path path) throws IOException { + if (path.getParent() != null && !Files.isDirectory(path.getParent())) { + Files.createDirectories(path.getParent()); + } + + try (var zos = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(path)))) { + for (var entry : this.stubs.entrySet()) { + var pkg = entry.getKey(); + var stubs = entry.getValue(); + String baseDeclaration = pkg.isBlank() ? "" : ("package " + pkg + ";\n\n"); + String baseFileName = pkg.isBlank() ? "" : (pkg.replace('.', '/') + "/"); + for (StubClass stub : stubs.values()) { + var builder = new StringBuilder(baseDeclaration); + builder.append("import net.neoforged.fml.common.asm.enumextension.EnumProxy;\n\n"); + stub.save(s -> builder.append(s).append('\n')); + + zos.putNextEntry(new ZipEntry(baseFileName + stub.name() + ".java")); + zos.write(builder.toString().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + } + } + + public record StubClass(String name, Set fieldNames, Set methodNames, Map children) { + public StubClass(String name) { + this(name, new HashSet<>(), new HashSet<>(), new HashMap<>()); + } + + public StubClass getChildren(String name) { + return children.computeIfAbsent(name, StubClass::new); + } + + public void save(Consumer consumer) { + consumer.accept("public class " + name + " {"); + fieldNames.stream().sorted() + .forEach(field -> consumer.accept(" public static final EnumProxy " + field + " = null;")); + methodNames.stream().sorted() + .forEach(method -> consumer.accept(" public static Object " + method + "(int idx, Class type) { return null; }")); + for (StubClass child : children.values()) { + child.save(str -> consumer.accept(" " + str)); + } + consumer.accept("}"); + } + } +} diff --git a/enumextensions/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin b/enumextensions/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin new file mode 100644 index 0000000..1c21ecd --- /dev/null +++ b/enumextensions/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin @@ -0,0 +1 @@ +net.neoforged.jst.enumextensions.EnumExtensionPlugin diff --git a/settings.gradle b/settings.gradle index a773252..4990f66 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,5 +41,6 @@ include 'parchment' include 'tests' include 'accesstransformers' include 'interfaceinjection' +include 'enumextensions' include 'unpick' include 'sourcetest' \ No newline at end of file diff --git a/tests/data/enumextension/constant_parameters/enumextensions.json b/tests/data/enumextension/constant_parameters/enumextensions.json new file mode 100644 index 0000000..f0c066a --- /dev/null +++ b/tests/data/enumextension/constant_parameters/enumextensions.json @@ -0,0 +1,12 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "EXTENSION", + "constructor": "(BSIJFDCZLjava/lang/String;Ljava/lang/Object;)V", + "parameters": [ + 0, 0, 0, 0, 0, 0, "c", false, "string", null + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/constant_parameters/expected/Example.java b/tests/data/enumextension/constant_parameters/expected/Example.java new file mode 100644 index 0000000..f9631f3 --- /dev/null +++ b/tests/data/enumextension/constant_parameters/expected/Example.java @@ -0,0 +1,4 @@ +public enum Example { + EXTENSION(0, 0, 0, 0, 0.0, 0.0, 'c', false, "string", null); + Example(byte p0, short p1, int p2, long p3, float p4, double p5, char p6, boolean p7, String p8, Object p9) {} +} diff --git a/tests/data/enumextension/constant_parameters/source/Example.java b/tests/data/enumextension/constant_parameters/source/Example.java new file mode 100644 index 0000000..297a0db --- /dev/null +++ b/tests/data/enumextension/constant_parameters/source/Example.java @@ -0,0 +1,3 @@ +public enum Example {; + Example(byte p0, short p1, int p2, long p3, float p4, double p5, char p6, boolean p7, String p8, Object p9) {} +} diff --git a/tests/data/enumextension/extension_marker/enumextensions.json b/tests/data/enumextension/extension_marker/enumextensions.json new file mode 100644 index 0000000..9dfe7f3 --- /dev/null +++ b/tests/data/enumextension/extension_marker/enumextensions.json @@ -0,0 +1,10 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "EXTENSION", + "constructor": "()V", + "parameters": [] + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/extension_marker/expected/Example.java b/tests/data/enumextension/extension_marker/expected/Example.java new file mode 100644 index 0000000..6d25f5e --- /dev/null +++ b/tests/data/enumextension/extension_marker/expected/Example.java @@ -0,0 +1,5 @@ +import com.markers.ExtensionMarker; + +public enum Example { + @ExtensionMarker EXTENSION +} diff --git a/tests/data/enumextension/extension_marker/source/Example.java b/tests/data/enumextension/extension_marker/source/Example.java new file mode 100644 index 0000000..1e87ea4 --- /dev/null +++ b/tests/data/enumextension/extension_marker/source/Example.java @@ -0,0 +1,2 @@ +public enum Example { +} diff --git a/tests/data/enumextension/inner_stubs/enumextensions.json b/tests/data/enumextension/inner_stubs/enumextensions.json new file mode 100644 index 0000000..289dfac --- /dev/null +++ b/tests/data/enumextension/inner_stubs/enumextensions.json @@ -0,0 +1,31 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "A", + "constructor": "(I)V", + "parameters": { + "class": "com/example/Stubs$Inner", + "field": "A" + } + }, + { + "enum": "Example", + "name": "B", + "constructor": "(I)V", + "parameters": { + "class": "com/example/Stubs$Inner", + "field": "B" + } + }, + { + "enum": "Example", + "name": "C", + "constructor": "(I)V", + "parameters": { + "class": "com/example/Stubs$Inner$SubInner", + "field": "C" + } + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/inner_stubs/expected/Example.java b/tests/data/enumextension/inner_stubs/expected/Example.java new file mode 100644 index 0000000..804ac9d --- /dev/null +++ b/tests/data/enumextension/inner_stubs/expected/Example.java @@ -0,0 +1,10 @@ +import com.example.Stubs.Inner; +import com.example.Stubs.Inner.SubInner; + +public enum Example { + EXISTING(0), + A(Inner.A.getParameter(0)), + B(Inner.B.getParameter(0)), + C(SubInner.C.getParameter(0)); + Example(int parameter) {} +} diff --git a/tests/data/enumextension/inner_stubs/expected_stub/com/example/Stubs.java b/tests/data/enumextension/inner_stubs/expected_stub/com/example/Stubs.java new file mode 100644 index 0000000..7955c59 --- /dev/null +++ b/tests/data/enumextension/inner_stubs/expected_stub/com/example/Stubs.java @@ -0,0 +1,13 @@ +package com.example; + +import net.neoforged.fml.common.asm.enumextension.EnumProxy; + +public class Stubs { + public class Inner { + public static final EnumProxy A = null; + public static final EnumProxy B = null; + public class SubInner { + public static final EnumProxy C = null; + } + } +} diff --git a/tests/data/enumextension/inner_stubs/source/Example.java b/tests/data/enumextension/inner_stubs/source/Example.java new file mode 100644 index 0000000..87f3446 --- /dev/null +++ b/tests/data/enumextension/inner_stubs/source/Example.java @@ -0,0 +1,4 @@ +public enum Example { + EXISTING(0); + Example(int parameter) {} +} diff --git a/tests/data/enumextension/method_parameters/enumextensions.json b/tests/data/enumextension/method_parameters/enumextensions.json new file mode 100644 index 0000000..640d7b2 --- /dev/null +++ b/tests/data/enumextension/method_parameters/enumextensions.json @@ -0,0 +1,13 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "EXTENSION", + "constructor": "(I[ILjava/lang/Object;[Ljava/lang/Object;[[Ljava/lang/Object;)V", + "parameters": { + "class": "com/example/Stub", + "method": "getParameter" + } + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/method_parameters/expected/Example.java b/tests/data/enumextension/method_parameters/expected/Example.java new file mode 100644 index 0000000..f545320 --- /dev/null +++ b/tests/data/enumextension/method_parameters/expected/Example.java @@ -0,0 +1,6 @@ +import com.example.Stub; + +public enum Example { + EXTENSION((int) Stub.getParameter(0, int.class), (int[]) Stub.getParameter(1, int[].class), (Object) Stub.getParameter(2, Object.class), (Object[]) Stub.getParameter(3, Object[].class), (Object[][]) Stub.getParameter(4, Object[][].class)); + Example(int p0, int[] p1, Object p2, Object[] p3, Object[][] p4) {} +} diff --git a/tests/data/enumextension/method_parameters/expected_stub/com/example/Stub.java b/tests/data/enumextension/method_parameters/expected_stub/com/example/Stub.java new file mode 100644 index 0000000..9f8c517 --- /dev/null +++ b/tests/data/enumextension/method_parameters/expected_stub/com/example/Stub.java @@ -0,0 +1,7 @@ +package com.example; + +import net.neoforged.fml.common.asm.enumextension.EnumProxy; + +public class Stub { + public static Object getParameter(int idx, Class type) { return null; } +} diff --git a/tests/data/enumextension/method_parameters/source/Example.java b/tests/data/enumextension/method_parameters/source/Example.java new file mode 100644 index 0000000..c4dcc2a --- /dev/null +++ b/tests/data/enumextension/method_parameters/source/Example.java @@ -0,0 +1,3 @@ +public enum Example {; + Example(int p0, int[] p1, Object p2, Object[] p3, Object[][] p4) {} +} diff --git a/tests/data/enumextension/required_interface/enumextensions.json b/tests/data/enumextension/required_interface/enumextensions.json new file mode 100644 index 0000000..9dfe7f3 --- /dev/null +++ b/tests/data/enumextension/required_interface/enumextensions.json @@ -0,0 +1,10 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "EXTENSION", + "constructor": "()V", + "parameters": [] + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/required_interface/expected/Example.java b/tests/data/enumextension/required_interface/expected/Example.java new file mode 100644 index 0000000..2582117 --- /dev/null +++ b/tests/data/enumextension/required_interface/expected/Example.java @@ -0,0 +1,3 @@ +public enum Example implements com.example.IExtensibleEnum { + EXTENSION +} diff --git a/tests/data/enumextension/required_interface/source/Example.java b/tests/data/enumextension/required_interface/source/Example.java new file mode 100644 index 0000000..41da0e9 --- /dev/null +++ b/tests/data/enumextension/required_interface/source/Example.java @@ -0,0 +1,2 @@ +public enum Example implements com.example.IExtensibleEnum { +} diff --git a/tests/data/enumextension/simple_extension/enumextensions.json b/tests/data/enumextension/simple_extension/enumextensions.json new file mode 100644 index 0000000..9f4979a --- /dev/null +++ b/tests/data/enumextension/simple_extension/enumextensions.json @@ -0,0 +1,16 @@ +{ + "entries": [ + { + "enum": "net/me/Example", + "name": "EXTENSION", + "constructor": "()V", + "parameters": [] + }, + { + "enum": "net/me/Example2", + "name": "EXTENSION", + "constructor": "()V", + "parameters": [] + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/simple_extension/expected/net/me/Example.java b/tests/data/enumextension/simple_extension/expected/net/me/Example.java new file mode 100644 index 0000000..95522e1 --- /dev/null +++ b/tests/data/enumextension/simple_extension/expected/net/me/Example.java @@ -0,0 +1,5 @@ +package net.me; + +public enum Example { + EXTENSION +} diff --git a/tests/data/enumextension/simple_extension/expected/net/me/Example2.java b/tests/data/enumextension/simple_extension/expected/net/me/Example2.java new file mode 100644 index 0000000..a447b7a --- /dev/null +++ b/tests/data/enumextension/simple_extension/expected/net/me/Example2.java @@ -0,0 +1,6 @@ +package net.me; + +public enum Example2 { + EXISTING, + EXTENSION +} diff --git a/tests/data/enumextension/simple_extension/source/net/me/Example.java b/tests/data/enumextension/simple_extension/source/net/me/Example.java new file mode 100644 index 0000000..6433652 --- /dev/null +++ b/tests/data/enumextension/simple_extension/source/net/me/Example.java @@ -0,0 +1,4 @@ +package net.me; + +public enum Example { +} diff --git a/tests/data/enumextension/simple_extension/source/net/me/Example2.java b/tests/data/enumextension/simple_extension/source/net/me/Example2.java new file mode 100644 index 0000000..386f36f --- /dev/null +++ b/tests/data/enumextension/simple_extension/source/net/me/Example2.java @@ -0,0 +1,5 @@ +package net.me; + +public enum Example2 { + EXISTING +} diff --git a/tests/data/enumextension/stubs/enumextensions.json b/tests/data/enumextension/stubs/enumextensions.json new file mode 100644 index 0000000..5e1f2c2 --- /dev/null +++ b/tests/data/enumextension/stubs/enumextensions.json @@ -0,0 +1,31 @@ +{ + "entries": [ + { + "enum": "Example", + "name": "A", + "constructor": "(I)V", + "parameters": { + "class": "RootStub", + "field": "PROXY" + } + }, + { + "enum": "Example", + "name": "C", + "constructor": "(I)V", + "parameters": { + "class": "com/example/Stub2", + "field": "PROXY" + } + }, + { + "enum": "Example", + "name": "B", + "constructor": "(I)V", + "parameters": { + "class": "com/example/Stub1", + "field": "PROXY" + } + } + ] +} \ No newline at end of file diff --git a/tests/data/enumextension/stubs/expected/Example.java b/tests/data/enumextension/stubs/expected/Example.java new file mode 100644 index 0000000..d7c2774 --- /dev/null +++ b/tests/data/enumextension/stubs/expected/Example.java @@ -0,0 +1,10 @@ +import com.example.Stub1; +import com.example.Stub2; + +public enum Example { + EXISTING(0), + A(RootStub.PROXY.getParameter(0)), + B(Stub1.PROXY.getParameter(0)), + C(Stub2.PROXY.getParameter(0)); + Example(int parameter) {} +} diff --git a/tests/data/enumextension/stubs/expected_stub/RootStub.java b/tests/data/enumextension/stubs/expected_stub/RootStub.java new file mode 100644 index 0000000..9503085 --- /dev/null +++ b/tests/data/enumextension/stubs/expected_stub/RootStub.java @@ -0,0 +1,5 @@ +import net.neoforged.fml.common.asm.enumextension.EnumProxy; + +public class RootStub { + public static final EnumProxy PROXY = null; +} diff --git a/tests/data/enumextension/stubs/expected_stub/com/example/Stub1.java b/tests/data/enumextension/stubs/expected_stub/com/example/Stub1.java new file mode 100644 index 0000000..d405709 --- /dev/null +++ b/tests/data/enumextension/stubs/expected_stub/com/example/Stub1.java @@ -0,0 +1,7 @@ +package com.example; + +import net.neoforged.fml.common.asm.enumextension.EnumProxy; + +public class Stub1 { + public static final EnumProxy PROXY = null; +} diff --git a/tests/data/enumextension/stubs/expected_stub/com/example/Stub2.java b/tests/data/enumextension/stubs/expected_stub/com/example/Stub2.java new file mode 100644 index 0000000..5fddf75 --- /dev/null +++ b/tests/data/enumextension/stubs/expected_stub/com/example/Stub2.java @@ -0,0 +1,7 @@ +package com.example; + +import net.neoforged.fml.common.asm.enumextension.EnumProxy; + +public class Stub2 { + public static final EnumProxy PROXY = null; +} diff --git a/tests/data/enumextension/stubs/source/Example.java b/tests/data/enumextension/stubs/source/Example.java new file mode 100644 index 0000000..87f3446 --- /dev/null +++ b/tests/data/enumextension/stubs/source/Example.java @@ -0,0 +1,4 @@ +public enum Example { + EXISTING(0); + Example(int parameter) {} +} diff --git a/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java b/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java index be70f49..979cc9b 100644 --- a/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java +++ b/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java @@ -365,6 +365,44 @@ void testNestedGenericStubs() throws Exception { runInterfaceInjectionTest("nested_generic_stubs", tempDir); } } + + @Nested + class EnumExtension { + @Test + void testSimpleExtension() throws Exception { + runEnumExtensionTest("simple_extension", tempDir); + } + + @Test + void testStubs() throws Exception { + runEnumExtensionTest("stubs", tempDir); + } + + @Test + void testInnerStubs() throws Exception { + runEnumExtensionTest("inner_stubs", tempDir); + } + + @Test + void testMethodParameters() throws Exception { + runEnumExtensionTest("method_parameters", tempDir); + } + + @Test + void testConstantParameters() throws Exception { + runEnumExtensionTest("constant_parameters", tempDir); + } + + @Test + void testExtensionMarker() throws Exception { + runEnumExtensionTest("extension_marker", tempDir, "--enum-extensions-marker", "com/markers/ExtensionMarker"); + } + + @Test + void testRequiredInterface() throws Exception { + runEnumExtensionTest("required_interface", tempDir, "--enum-extensions-required-interface", "com/example/IExtensibleEnum"); + } + } @Nested class Unpick { @@ -430,6 +468,22 @@ protected final void runInterfaceInjectionTest(String testDirName, Path tempDir, } } + protected final void runEnumExtensionTest(String testDirName, Path tempDir, String... additionalArgs) throws Exception { + var stub = tempDir.resolve("jst-" + testDirName + "-stub.jar"); + testDirName = "enumextension/" + testDirName; + var testDir = testDataRoot.resolve(testDirName); + var inputPath = testDir.resolve("enumextensions.json"); + + var args = new ArrayList<>(Arrays.asList("--enable-enum-extensions", "--enum-extensions-stubs", stub.toAbsolutePath().toString(), "--enum-extensions-data", inputPath.toString())); + args.addAll(Arrays.asList(additionalArgs)); + + runTest(testDirName, UnaryOperator.identity(), args.toArray(String[]::new)); + + if (Files.exists(testDir.resolve("expected_stub"))) { + assertZipEqualsDir(stub, testDir.resolve("expected_stub")); + } + } + protected final void runUnpickTest(String testDirName, String... additionalArgs) throws Exception { testDirName = "unpick/" + testDirName; var testDir = testDataRoot.resolve(testDirName);