diff --git a/README.md b/README.md index f9bd11e..33d2149 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ Usage: jst [-hV] [--in-format=] [--libraries-list=] [--enable-parchment --parchment-mappings= [--[no-]parchment-javadoc] [--parchment-conflict-prefix=]] [--enable-accesstransformers --access-transformer= [--access-transformer=]... - [--access-transformer-validation=]] INPUT OUTPUT + [--access-transformer-validation=]] [--enable-interface-injection + [--interface-injection-stubs=] + [--interface-injection-marker=] + [--interface-injection-data=]...] INPUT OUTPUT INPUT Path to a single Java-file, a source-archive or a folder containing the source to transform. OUTPUT Path to where the resulting source should be placed. @@ -74,6 +77,16 @@ Plugin - accesstransformers The level of validation to use for ats --enable-accesstransformers Enable accesstransformers +Plugin - interface-injection + --enable-interface-injection + Enable interface-injection + --interface-injection-data= + The paths to read interface injection JSON files from + --interface-injection-marker= + The name (binary representation) of an annotation to use as a marker for + injected interfaces + --interface-injection-stubs= + The path to a zip to save interface stubs in ``` ## Licenses diff --git a/api/src/main/java/net/neoforged/jst/api/IntelliJEnvironment.java b/api/src/main/java/net/neoforged/jst/api/IntelliJEnvironment.java index 01fd4c9..e8c02a8 100644 --- a/api/src/main/java/net/neoforged/jst/api/IntelliJEnvironment.java +++ b/api/src/main/java/net/neoforged/jst/api/IntelliJEnvironment.java @@ -2,6 +2,7 @@ import com.intellij.core.CoreApplicationEnvironment; import com.intellij.core.JavaCoreProjectEnvironment; +import com.intellij.psi.JavaPsiFacade; import com.intellij.psi.PsiManager; public interface IntelliJEnvironment { @@ -10,4 +11,6 @@ public interface IntelliJEnvironment { JavaCoreProjectEnvironment getProjectEnv(); PsiManager getPsiManager(); + + JavaPsiFacade getPsiFacade(); } diff --git a/cli/build.gradle b/cli/build.gradle index cd5ab50..843b423 100644 --- a/cli/build.gradle +++ b/cli/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation "info.picocli:picocli:$picocli_version" implementation project(":parchment") implementation project(":accesstransformers") + implementation project(':interfaceinjection') implementation 'org.slf4j:slf4j-simple:2.0.13' testImplementation platform("org.junit:junit-bom:$junit_version") diff --git a/cli/src/main/java/net/neoforged/jst/cli/intellij/IntelliJEnvironmentImpl.java b/cli/src/main/java/net/neoforged/jst/cli/intellij/IntelliJEnvironmentImpl.java index ea3f479..9356f3a 100644 --- a/cli/src/main/java/net/neoforged/jst/cli/intellij/IntelliJEnvironmentImpl.java +++ b/cli/src/main/java/net/neoforged/jst/cli/intellij/IntelliJEnvironmentImpl.java @@ -19,6 +19,7 @@ import com.intellij.pom.java.InternalPersistentJavaLanguageLevelReaderService; import com.intellij.pom.java.LanguageLevel; import com.intellij.psi.JavaModuleSystem; +import com.intellij.psi.JavaPsiFacade; import com.intellij.psi.PsiElementFinder; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiFileFactory; @@ -50,6 +51,7 @@ public class IntelliJEnvironmentImpl implements IntelliJEnvironment, AutoCloseab private final MockProject project; private final JavaCoreProjectEnvironment javaEnv; private final PsiManager psiManager; + private final JavaPsiFacade psiFacade; public IntelliJEnvironmentImpl(Logger logger) throws IOException { this.logger = logger; @@ -80,6 +82,7 @@ protected VirtualFileSystem createJrtFileSystem() { LanguageLevelProjectExtension.getInstance(project).setLanguageLevel(LanguageLevel.JDK_17); psiManager = PsiManager.getInstance(project); + psiFacade = JavaPsiFacade.getInstance(project); } @Override @@ -87,6 +90,11 @@ public PsiManager getPsiManager() { return psiManager; } + @Override + public JavaPsiFacade getPsiFacade() { + return psiFacade; + } + @Override public CoreApplicationEnvironment getAppEnv() { return javaEnv.getEnvironment(); diff --git a/gradle.properties b/gradle.properties index 0cc3f45..6e777aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,4 @@ jetbrains_annotations_version=24.1.0 picocli_version=4.7.6 junit_version=5.10.3 assertj_version=3.26.0 +gson_version=2.10.1 diff --git a/interfaceinjection/build.gradle b/interfaceinjection/build.gradle new file mode 100644 index 0000000..58f5f88 --- /dev/null +++ b/interfaceinjection/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/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InjectInterfacesVisitor.java b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InjectInterfacesVisitor.java new file mode 100644 index 0000000..e24e572 --- /dev/null +++ b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InjectInterfacesVisitor.java @@ -0,0 +1,103 @@ +package net.neoforged.jst.interfaceinjection; + +import com.intellij.psi.PsiClass; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiRecursiveElementVisitor; +import com.intellij.psi.PsiWhiteSpace; +import com.intellij.psi.util.ClassUtil; +import com.intellij.util.containers.MultiMap; +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.stream.Collectors; + +class InjectInterfacesVisitor extends PsiRecursiveElementVisitor { + private final Replacements replacements; + private final MultiMap interfaces; + private final StubStore stubs; + + @Nullable + private final String marker; + + InjectInterfacesVisitor(Replacements replacements, MultiMap interfaces, StubStore stubs, @Nullable String marker) { + this.replacements = replacements; + this.interfaces = interfaces; + this.stubs = stubs; + this.marker = marker; + } + + @Override + public void visitElement(@NotNull PsiElement element) { + if (element instanceof PsiClass psiClass) { + if (psiClass.getQualifiedName() == null) { + return; + } + + String className = ClassUtil.getJVMClassName(psiClass); + inject(psiClass, interfaces.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 add implements clauses to anonymous or unnamed classes + if (targets.isEmpty() || psiClass.getImplementsList() == null) { + return; + } + + var implementsList = psiClass.isInterface() ? psiClass.getExtendsList() : psiClass.getImplementsList(); + var implementedInterfaces = Arrays.stream(implementsList.getReferencedTypes()) + .map(PsiClassType::resolve) + .filter(Objects::nonNull) + .map(PsiClass::getQualifiedName) + .collect(Collectors.toSet()); + + var interfaceImplementation = targets.stream() + .distinct() + .map(stubs::createStub) + .filter(iface -> !implementedInterfaces.contains(iface.interfaceDeclaration())) + .map(StubStore.InterfaceInformation::toString) + .map(this::decorate) + .sorted(Comparator.naturalOrder()) + .collect(Collectors.joining(", ")); + + if (implementsList.getChildren().length == 0) { + StringBuilder text = new StringBuilder(); + + // `public class Cls{}` is valid, but we cannot inject the implements exactly next to the class name, so we need + // to make sure that we have spacing + if (!(psiClass.getLBrace().getPrevSibling() instanceof PsiWhiteSpace)) { + text.append(' '); + } + text.append(psiClass.isInterface() ? "extends" : "implements").append(' '); + text.append(interfaceImplementation); + text.append(' '); + + replacements.insertBefore(psiClass.getLBrace(), text.toString()); + } else { + replacements.insertAfter(implementsList.getLastChild(), ", " + interfaceImplementation); + } + } + + private String decorate(String iface) { + if (marker == null) { + return iface; + } + return "@" + marker + " " + iface; + } +} diff --git a/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionPlugin.java b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionPlugin.java new file mode 100644 index 0000000..b5d90d4 --- /dev/null +++ b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionPlugin.java @@ -0,0 +1,22 @@ +package net.neoforged.jst.interfaceinjection; + +import net.neoforged.jst.api.SourceTransformer; +import net.neoforged.jst.api.SourceTransformerPlugin; + +/** + * Plugin that adds implements/extends clauses for interfaces to classes in a data-driven fashion, and creates stubs for these interfaces + * to be able to still compile the modified code without access to the actual interface definitions. + *

+ * Mods can use interface injection to have compile-time access to the interfaces they add to classes via Mixins. + */ +public class InterfaceInjectionPlugin implements SourceTransformerPlugin { + @Override + public String getName() { + return "interface-injection"; + } + + @Override + public SourceTransformer createTransformer() { + return new InterfaceInjectionTransformer(); + } +} diff --git a/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionTransformer.java b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionTransformer.java new file mode 100644 index 0000000..2f3fe61 --- /dev/null +++ b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/InterfaceInjectionTransformer.java @@ -0,0 +1,83 @@ +package net.neoforged.jst.interfaceinjection; + +import com.google.gson.Gson; +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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class InterfaceInjectionTransformer implements SourceTransformer { + private static final Gson GSON = new Gson(); + + @Nullable + @CommandLine.Option(names = "--interface-injection-stubs", description = "The path to a zip to save interface stubs in") + public Path stubOut; + + @Nullable + @CommandLine.Option(names = "--interface-injection-marker", description = "The name (binary representation) of an annotation to use as a marker for injected interfaces") + public String annotationMarker; + + @CommandLine.Option(names = "--interface-injection-data", description = "The paths to read interface injection JSON files from") + public List paths = new ArrayList<>(); + + private MultiMap interfaces; + private StubStore stubs; + private String marker; + + @Override + public void beforeRun(TransformContext context) { + interfaces = new MultiMap<>(); + stubs = new StubStore(context.logger(), context.environment().getPsiFacade()); + + if (annotationMarker != null) { + marker = annotationMarker.replace('/', '.').replace('$', '.'); + } + + for (Path path : paths) { + try { + var json = GSON.fromJson(Files.readString(path), JsonObject.class); + for (String clazz : json.keySet()) { + var entry = json.get(clazz); + if (entry.isJsonArray()) { + entry.getAsJsonArray().forEach(el -> interfaces.putValue(clazz, el.getAsString())); + } else { + interfaces.putValue(clazz, entry.getAsString()); + } + } + } catch (IOException exception) { + context.logger().error("Failed to read interface injection data file: %s", exception.getMessage()); + throw new UncheckedIOException(exception); + } + } + } + + @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 InjectInterfacesVisitor(replacements, interfaces, stubs, marker).visitFile(psiFile); + } +} diff --git a/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/StubStore.java b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/StubStore.java new file mode 100644 index 0000000..9f54544 --- /dev/null +++ b/interfaceinjection/src/main/java/net/neoforged/jst/interfaceinjection/StubStore.java @@ -0,0 +1,145 @@ +package net.neoforged.jst.interfaceinjection; + +import com.intellij.psi.JavaPsiFacade; +import com.intellij.psi.search.GlobalSearchScope; +import net.neoforged.jst.api.Logger; + +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.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * When injecting interfaces, we generate basic interface stubs that can be used as a separate + * artifact when recompiling the transformed code, to avoid putting the actual interfaces on the classpath + * and risking circular dependencies. + */ +class StubStore { + private final Logger logger; + private final JavaPsiFacade facade; + private final Map jvmToFqn = new HashMap<>(); + private final Map> stubs = new HashMap<>(); + + StubStore(Logger logger, JavaPsiFacade facade) { + this.logger = logger; + this.facade = facade; + } + + public InterfaceInformation createStub(String jvm) { + String generics = ""; + int typeParameterCount = 0; + + var genericsStart = jvm.indexOf('<'); + if (genericsStart != -1) { + var genericsEnd = jvm.lastIndexOf('>'); + if (genericsEnd == -1 || genericsEnd < genericsStart) { + logger.error("Interface injection %s has incomplete generics declarations", jvm); + } else { + generics = jvm.substring(genericsStart + 1, genericsEnd); + if (generics.isBlank()) { + logger.error("Interface injection %s has blank type parameters", jvm); + } else { + // Ignore any nested generics when counting the amount of parameters the interface has + typeParameterCount = generics.replaceAll("<[^>]*>", "").split(",").length; + } + } + jvm = jvm.substring(0, genericsStart); + } + + return new InterfaceInformation(createStub(jvm, typeParameterCount), generics); + } + + private String createStub(String jvm, int typeParameterCount) { + var fqn = jvmToFqn.get(jvm); + if (fqn != null) return fqn; + + var splitName = new ArrayList<>(Arrays.asList(jvm.split("/"))); + var name = splitName.remove(splitName.size() - 1); + 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; + } + + StubInterface stub = stubs.computeIfAbsent(packageName, $ -> new HashMap<>()).computeIfAbsent(byInner[0], $ -> new StubInterface(byInner[0])); + for (int i = 1; i < byInner.length; i++) { + stub = stub.getChildren(byInner[i]); + } + stub.typeParameterCount().set(typeParameterCount); + + return fqn; + } + + public 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 (StubInterface stub : stubs.values()) { + var builder = new StringBuilder(baseDeclaration); + 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 StubInterface(String name, AtomicInteger typeParameterCount, Map children) { + public StubInterface(String name) { + this(name, new AtomicInteger(), new HashMap<>()); + } + + public StubInterface getChildren(String name) { + return children.computeIfAbsent(name, StubInterface::new); + } + + public void save(Consumer consumer) { + var generics = ""; + if (typeParameterCount.get() > 0) { + generics = "<" + IntStream.range(0, typeParameterCount.get()) + .mapToObj(i -> Character.toString((char)('A' + i))) + .collect(Collectors.joining(", ")) + ">"; + } + + consumer.accept("public interface " + name + generics + " {"); + for (StubInterface child : children.values()) { + child.save(str -> consumer.accept(" " + str)); + } + consumer.accept("}"); + } + } + + record InterfaceInformation(String interfaceDeclaration, String generics) { + @Override + public String toString() { + return generics.isBlank() ? interfaceDeclaration : interfaceDeclaration + "<" + generics + ">"; + } + } +} diff --git a/interfaceinjection/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin b/interfaceinjection/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin new file mode 100644 index 0000000..03a0202 --- /dev/null +++ b/interfaceinjection/src/main/resources/META-INF/services/net.neoforged.jst.api.SourceTransformerPlugin @@ -0,0 +1 @@ +net.neoforged.jst.interfaceinjection.InterfaceInjectionPlugin diff --git a/settings.gradle b/settings.gradle index f8a0e54..7ff6427 100644 --- a/settings.gradle +++ b/settings.gradle @@ -37,3 +37,4 @@ include 'cli' include 'parchment' include 'tests' include 'accesstransformers' +include 'interfaceinjection' diff --git a/tests/data/interfaceinjection/additive_injection/expected/net/Example.java b/tests/data/interfaceinjection/additive_injection/expected/net/Example.java new file mode 100644 index 0000000..1552f09 --- /dev/null +++ b/tests/data/interfaceinjection/additive_injection/expected/net/Example.java @@ -0,0 +1,4 @@ +package net; + +public class Example implements Runnable, com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/additive_injection/expected/net/Example2.java b/tests/data/interfaceinjection/additive_injection/expected/net/Example2.java new file mode 100644 index 0000000..c1a7710 --- /dev/null +++ b/tests/data/interfaceinjection/additive_injection/expected/net/Example2.java @@ -0,0 +1,6 @@ +package net; + +import java.util.*; + +public class Example2 implements Runnable, Consumer, com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/additive_injection/injectedinterfaces.json b/tests/data/interfaceinjection/additive_injection/injectedinterfaces.json new file mode 100644 index 0000000..01252c5 --- /dev/null +++ b/tests/data/interfaceinjection/additive_injection/injectedinterfaces.json @@ -0,0 +1,4 @@ +{ + "net/Example": "com/example/InjectedInterface", + "net/Example2": "com/example/InjectedInterface" +} diff --git a/tests/data/interfaceinjection/additive_injection/source/net/Example.java b/tests/data/interfaceinjection/additive_injection/source/net/Example.java new file mode 100644 index 0000000..81ac612 --- /dev/null +++ b/tests/data/interfaceinjection/additive_injection/source/net/Example.java @@ -0,0 +1,4 @@ +package net; + +public class Example implements Runnable { +} diff --git a/tests/data/interfaceinjection/additive_injection/source/net/Example2.java b/tests/data/interfaceinjection/additive_injection/source/net/Example2.java new file mode 100644 index 0000000..283d3d0 --- /dev/null +++ b/tests/data/interfaceinjection/additive_injection/source/net/Example2.java @@ -0,0 +1,6 @@ +package net; + +import java.util.*; + +public class Example2 implements Runnable, Consumer { +} diff --git a/tests/data/interfaceinjection/generics/expected/com/MyTarget.java b/tests/data/interfaceinjection/generics/expected/com/MyTarget.java new file mode 100644 index 0000000..220e439 --- /dev/null +++ b/tests/data/interfaceinjection/generics/expected/com/MyTarget.java @@ -0,0 +1,4 @@ +package com; + +public class MyTarget implements com.InjectedGeneric> { +} diff --git a/tests/data/interfaceinjection/generics/expected_stub/com/InjectedGeneric.java b/tests/data/interfaceinjection/generics/expected_stub/com/InjectedGeneric.java new file mode 100644 index 0000000..a294d74 --- /dev/null +++ b/tests/data/interfaceinjection/generics/expected_stub/com/InjectedGeneric.java @@ -0,0 +1,4 @@ +package com; + +public interface InjectedGeneric { +} diff --git a/tests/data/interfaceinjection/generics/injectedinterfaces.json b/tests/data/interfaceinjection/generics/injectedinterfaces.json new file mode 100644 index 0000000..b6c391e --- /dev/null +++ b/tests/data/interfaceinjection/generics/injectedinterfaces.json @@ -0,0 +1,3 @@ +{ + "com/MyTarget": "com/InjectedGeneric>" +} diff --git a/tests/data/interfaceinjection/generics/source/com/MyTarget.java b/tests/data/interfaceinjection/generics/source/com/MyTarget.java new file mode 100644 index 0000000..05b1f47 --- /dev/null +++ b/tests/data/interfaceinjection/generics/source/com/MyTarget.java @@ -0,0 +1,4 @@ +package com; + +public class MyTarget { +} diff --git a/tests/data/interfaceinjection/injected_marker/expected/SomeClass.java b/tests/data/interfaceinjection/injected_marker/expected/SomeClass.java new file mode 100644 index 0000000..76b9e8b --- /dev/null +++ b/tests/data/interfaceinjection/injected_marker/expected/SomeClass.java @@ -0,0 +1,2 @@ +public class SomeClass implements @com.markers.InjectedMarker com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/injected_marker/injectedinterfaces.json b/tests/data/interfaceinjection/injected_marker/injectedinterfaces.json new file mode 100644 index 0000000..88ee31e --- /dev/null +++ b/tests/data/interfaceinjection/injected_marker/injectedinterfaces.json @@ -0,0 +1,3 @@ +{ + "SomeClass": "com/example/InjectedInterface" +} diff --git a/tests/data/interfaceinjection/injected_marker/source/SomeClass.java b/tests/data/interfaceinjection/injected_marker/source/SomeClass.java new file mode 100644 index 0000000..b248f9b --- /dev/null +++ b/tests/data/interfaceinjection/injected_marker/source/SomeClass.java @@ -0,0 +1,2 @@ +public class SomeClass { +} diff --git a/tests/data/interfaceinjection/inner_stubs/expected/ExampleClass.java b/tests/data/interfaceinjection/inner_stubs/expected/ExampleClass.java new file mode 100644 index 0000000..b6c1385 --- /dev/null +++ b/tests/data/interfaceinjection/inner_stubs/expected/ExampleClass.java @@ -0,0 +1,2 @@ +public class ExampleClass implements com.example.InjectedInterface.Inner, com.example.InjectedInterface.Inner.SubInner { +} diff --git a/tests/data/interfaceinjection/inner_stubs/expected_stub/com/example/InjectedInterface.java b/tests/data/interfaceinjection/inner_stubs/expected_stub/com/example/InjectedInterface.java new file mode 100644 index 0000000..e769375 --- /dev/null +++ b/tests/data/interfaceinjection/inner_stubs/expected_stub/com/example/InjectedInterface.java @@ -0,0 +1,8 @@ +package com.example; + +public interface InjectedInterface { + public interface Inner { + public interface SubInner { + } + } +} diff --git a/tests/data/interfaceinjection/inner_stubs/injectedinterfaces.json b/tests/data/interfaceinjection/inner_stubs/injectedinterfaces.json new file mode 100644 index 0000000..53cbb85 --- /dev/null +++ b/tests/data/interfaceinjection/inner_stubs/injectedinterfaces.json @@ -0,0 +1,3 @@ +{ + "ExampleClass": ["com/example/InjectedInterface$Inner", "com/example/InjectedInterface$Inner$SubInner"] +} diff --git a/tests/data/interfaceinjection/inner_stubs/source/ExampleClass.java b/tests/data/interfaceinjection/inner_stubs/source/ExampleClass.java new file mode 100644 index 0000000..942c154 --- /dev/null +++ b/tests/data/interfaceinjection/inner_stubs/source/ExampleClass.java @@ -0,0 +1,2 @@ +public class ExampleClass { +} diff --git a/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterface.java b/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterface.java new file mode 100644 index 0000000..2f6baad --- /dev/null +++ b/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterface.java @@ -0,0 +1,4 @@ +package com.example; + +public interface ExampleInterface extends com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterfaceAdditive.java b/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterfaceAdditive.java new file mode 100644 index 0000000..c62ac5e --- /dev/null +++ b/tests/data/interfaceinjection/interface_target/expected/com/example/ExampleInterfaceAdditive.java @@ -0,0 +1,4 @@ +package com.example; + +public interface ExampleInterfaceAdditive extends Runnable, com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/interface_target/injectedinterfaces.json b/tests/data/interfaceinjection/interface_target/injectedinterfaces.json new file mode 100644 index 0000000..34cb959 --- /dev/null +++ b/tests/data/interfaceinjection/interface_target/injectedinterfaces.json @@ -0,0 +1,4 @@ +{ + "com/example/ExampleInterface": "com/example/InjectedInterface", + "com/example/ExampleInterfaceAdditive": "com/example/InjectedInterface" +} diff --git a/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterface.java b/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterface.java new file mode 100644 index 0000000..4af674f --- /dev/null +++ b/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterface.java @@ -0,0 +1,4 @@ +package com.example; + +public interface ExampleInterface { +} diff --git a/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterfaceAdditive.java b/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterfaceAdditive.java new file mode 100644 index 0000000..2a1ee38 --- /dev/null +++ b/tests/data/interfaceinjection/interface_target/source/com/example/ExampleInterfaceAdditive.java @@ -0,0 +1,4 @@ +package com.example; + +public interface ExampleInterfaceAdditive extends Runnable { +} diff --git a/tests/data/interfaceinjection/multiple_interfaces/expected/MyTarget.java b/tests/data/interfaceinjection/multiple_interfaces/expected/MyTarget.java new file mode 100644 index 0000000..a502b5e --- /dev/null +++ b/tests/data/interfaceinjection/multiple_interfaces/expected/MyTarget.java @@ -0,0 +1,2 @@ +public class MyTarget implements com.example.I1, com.example.I2 { +} diff --git a/tests/data/interfaceinjection/multiple_interfaces/injectedinterfaces.json b/tests/data/interfaceinjection/multiple_interfaces/injectedinterfaces.json new file mode 100644 index 0000000..c5b1c49 --- /dev/null +++ b/tests/data/interfaceinjection/multiple_interfaces/injectedinterfaces.json @@ -0,0 +1,3 @@ +{ + "MyTarget": ["com/example/I1", "com/example/I2"] +} diff --git a/tests/data/interfaceinjection/multiple_interfaces/source/MyTarget.java b/tests/data/interfaceinjection/multiple_interfaces/source/MyTarget.java new file mode 100644 index 0000000..7a786b3 --- /dev/null +++ b/tests/data/interfaceinjection/multiple_interfaces/source/MyTarget.java @@ -0,0 +1,2 @@ +public class MyTarget { +} diff --git a/tests/data/interfaceinjection/simple_injection/expected/net/me/Example.java b/tests/data/interfaceinjection/simple_injection/expected/net/me/Example.java new file mode 100644 index 0000000..4ef26d8 --- /dev/null +++ b/tests/data/interfaceinjection/simple_injection/expected/net/me/Example.java @@ -0,0 +1,4 @@ +package net.me; + +public class Example implements com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/simple_injection/expected/net/me/Example2.java b/tests/data/interfaceinjection/simple_injection/expected/net/me/Example2.java new file mode 100644 index 0000000..ab2a748 --- /dev/null +++ b/tests/data/interfaceinjection/simple_injection/expected/net/me/Example2.java @@ -0,0 +1,4 @@ +package net.me; + +public class Example2 extends Object implements com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/simple_injection/injectedinterfaces.json b/tests/data/interfaceinjection/simple_injection/injectedinterfaces.json new file mode 100644 index 0000000..8734e42 --- /dev/null +++ b/tests/data/interfaceinjection/simple_injection/injectedinterfaces.json @@ -0,0 +1,4 @@ +{ + "net/me/Example": "com/example/InjectedInterface", + "net/me/Example2": "com/example/InjectedInterface" +} diff --git a/tests/data/interfaceinjection/simple_injection/source/net/me/Example.java b/tests/data/interfaceinjection/simple_injection/source/net/me/Example.java new file mode 100644 index 0000000..8fedec2 --- /dev/null +++ b/tests/data/interfaceinjection/simple_injection/source/net/me/Example.java @@ -0,0 +1,4 @@ +package net.me; + +public class Example { +} diff --git a/tests/data/interfaceinjection/simple_injection/source/net/me/Example2.java b/tests/data/interfaceinjection/simple_injection/source/net/me/Example2.java new file mode 100644 index 0000000..7ef0582 --- /dev/null +++ b/tests/data/interfaceinjection/simple_injection/source/net/me/Example2.java @@ -0,0 +1,4 @@ +package net.me; + +public class Example2 extends Object{ +} diff --git a/tests/data/interfaceinjection/stubs/expected/ExampleClass.java b/tests/data/interfaceinjection/stubs/expected/ExampleClass.java new file mode 100644 index 0000000..9cb6328 --- /dev/null +++ b/tests/data/interfaceinjection/stubs/expected/ExampleClass.java @@ -0,0 +1,2 @@ +public class ExampleClass implements InjectedRootInterface, com.example.II2, com.example.InjectedInterface { +} diff --git a/tests/data/interfaceinjection/stubs/expected_stub/InjectedRootInterface.java b/tests/data/interfaceinjection/stubs/expected_stub/InjectedRootInterface.java new file mode 100644 index 0000000..54fc472 --- /dev/null +++ b/tests/data/interfaceinjection/stubs/expected_stub/InjectedRootInterface.java @@ -0,0 +1,2 @@ +public interface InjectedRootInterface { +} diff --git a/tests/data/interfaceinjection/stubs/expected_stub/com/example/II2.java b/tests/data/interfaceinjection/stubs/expected_stub/com/example/II2.java new file mode 100644 index 0000000..450585f --- /dev/null +++ b/tests/data/interfaceinjection/stubs/expected_stub/com/example/II2.java @@ -0,0 +1,4 @@ +package com.example; + +public interface II2 { +} diff --git a/tests/data/interfaceinjection/stubs/expected_stub/com/example/InjectedInterface.java b/tests/data/interfaceinjection/stubs/expected_stub/com/example/InjectedInterface.java new file mode 100644 index 0000000..6cc96a2 --- /dev/null +++ b/tests/data/interfaceinjection/stubs/expected_stub/com/example/InjectedInterface.java @@ -0,0 +1,4 @@ +package com.example; + +public interface InjectedInterface { +} diff --git a/tests/data/interfaceinjection/stubs/injectedinterfaces.json b/tests/data/interfaceinjection/stubs/injectedinterfaces.json new file mode 100644 index 0000000..a80c2ef --- /dev/null +++ b/tests/data/interfaceinjection/stubs/injectedinterfaces.json @@ -0,0 +1,3 @@ +{ + "ExampleClass": ["com/example/InjectedInterface", "com/example/II2", "InjectedRootInterface"] +} diff --git a/tests/data/interfaceinjection/stubs/source/ExampleClass.java b/tests/data/interfaceinjection/stubs/source/ExampleClass.java new file mode 100644 index 0000000..942c154 --- /dev/null +++ b/tests/data/interfaceinjection/stubs/source/ExampleClass.java @@ -0,0 +1,2 @@ +public class ExampleClass { +} 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 f0696ad..3a7b350 100644 --- a/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java +++ b/tests/src/test/java/net/neoforged/jst/tests/EmbeddedTest.java @@ -283,6 +283,68 @@ void testIllegal() throws Exception { } } + @Nested + class InterfaceInjection { + @TempDir + Path tempDir; + + @Test + void testSimpleInjection() throws Exception { + runInterfaceInjectionTest("simple_injection", tempDir); + } + + @Test + void testAdditiveInjection() throws Exception { + runInterfaceInjectionTest("additive_injection", tempDir); + } + + @Test + void testInterfaceTarget() throws Exception { + runInterfaceInjectionTest("interface_target", tempDir); + } + + @Test + void testStubs() throws Exception { + runInterfaceInjectionTest("stubs", tempDir); + } + + @Test + void testInnerStubs() throws Exception { + runInterfaceInjectionTest("inner_stubs", tempDir); + } + + @Test + void testMultipleInterfaces() throws Exception { + runInterfaceInjectionTest("multiple_interfaces", tempDir); + } + + @Test + void testInjectedMarker() throws Exception { + runInterfaceInjectionTest("injected_marker", tempDir, "--interface-injection-marker", "com/markers/InjectedMarker"); + } + + @Test + void testGenerics() throws Exception { + runInterfaceInjectionTest("generics", tempDir); + } + } + + protected final void runInterfaceInjectionTest(String testDirName, Path tempDir, String... additionalArgs) throws Exception { + var stub = tempDir.resolve("jst-" + testDirName + "-stub.jar"); + testDirName = "interfaceinjection/" + testDirName; + var testDir = testDataRoot.resolve(testDirName); + var inputPath = testDir.resolve("injectedinterfaces.json"); + + var args = new ArrayList<>(Arrays.asList("--enable-interface-injection", "--interface-injection-stubs", stub.toAbsolutePath().toString(), "--interface-injection-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 runATTest(String testDirName) throws Exception { testDirName = "accesstransformer/" + testDirName; var atPath = testDataRoot.resolve(testDirName).resolve("accesstransformer.cfg"); @@ -319,7 +381,16 @@ protected final void runTest(String testDirName, UnaryOperator consoleMa arguments.add(outputFile.toString()); var consoleOut = consoleMapper.apply(runTool(arguments.toArray(String[]::new))); - try (var zipFile = new ZipFile(outputFile.toFile())) { + assertZipEqualsDir(outputFile, expectedDir); + + var expectedLog = testDir.resolve("expected.log"); + if (Files.exists(expectedLog)) { + assertThat(expectedLog).content().isEqualToNormalizingNewlines(consoleOut); + } + } + + protected final void assertZipEqualsDir(Path zip, Path expectedDir) throws IOException { + try (var zipFile = new ZipFile(zip.toFile())) { var it = zipFile.entries().asIterator(); while (it.hasNext()) { var entry = it.next(); @@ -328,14 +399,17 @@ protected final void runTest(String testDirName, UnaryOperator consoleMa } var actualFile = normalizeLines(new String(zipFile.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8)); - var expectedFile = normalizeLines(Files.readString(expectedDir.resolve(entry.getName()), StandardCharsets.UTF_8)); - assertEquals(expectedFile, actualFile); - } - } - var expectedLog = testDir.resolve("expected.log"); - if (Files.exists(expectedLog)) { - assertThat(expectedLog).content().isEqualToNormalizingNewlines(consoleOut); + var path = expectedDir.resolve(entry.getName()); + if (Files.exists(path)) { + var expectedFile = normalizeLines(Files.readString(path, StandardCharsets.UTF_8)); + assertEquals(expectedFile, actualFile); + } else { + assertThat("") + .describedAs("Expected content at " + path + " but file wasn't found") + .isEqualTo(actualFile); + } + } } }