diff --git a/build.gradle b/build.gradle index d375aa5b..e49795dd 100644 --- a/build.gradle +++ b/build.gradle @@ -136,6 +136,7 @@ dependencies { implementation libs.fabric.mercury implementation libs.fabric.unpick + implementation libs.fabric.unpick.utils // Kotlin implementation(libs.kotlin.metadata) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 544e4485..20756f1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ fabric-lorenz-tiny = { module = "net.fabricmc:lorenz-tiny", version.ref = "loren fabric-mercury = { module = "net.fabricmc:mercury", version.ref = "mercury" } fabric-loom-nativelib = { module = "net.fabricmc:fabric-loom-native", version.ref = "loom-native" } fabric-unpick = { module = "net.fabricmc.unpick:unpick", version.ref = "unpick" } +fabric-unpick-utils = { module = "net.fabricmc.unpick:unpick-format-utils", version.ref = "unpick" } # Misc kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/src/main/java/net/fabricmc/loom/task/service/TinyRemapperService.java b/src/main/java/net/fabricmc/loom/task/service/TinyRemapperService.java index d958431b..6090ad87 100644 --- a/src/main/java/net/fabricmc/loom/task/service/TinyRemapperService.java +++ b/src/main/java/net/fabricmc/loom/task/service/TinyRemapperService.java @@ -41,6 +41,7 @@ import org.gradle.api.Project; import org.gradle.api.artifacts.ConfigurationContainer; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.FileCollection; +import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; @@ -65,7 +66,7 @@ import net.fabricmc.tinyremapper.InputTag; import net.fabricmc.tinyremapper.TinyRemapper; import net.fabricmc.tinyremapper.extension.mixin.MixinExtension; -public class TinyRemapperService extends Service implements Closeable { +public class TinyRemapperService extends Service implements TinyRemapperServiceInterface, Closeable { public static final ServiceType TYPE = new ServiceType<>(Options.class, TinyRemapperService.class); public interface Options extends Service.Options { @@ -116,6 +117,24 @@ public class TinyRemapperService extends Service im }); } + public static Provider createSimple(Project project, Provider from, Provider to) { + return TYPE.create(project, options -> { + final LoomGradleExtension extension = LoomGradleExtension.get(project); + final ConfigurationContainer configurations = project.getConfigurations(); + final FileCollection classpath = configurations.getByName(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME) + .minus(configurations.getByName(Constants.Configurations.MINECRAFT_COMPILE_LIBRARIES)) + .minus(configurations.getByName(Constants.Configurations.MINECRAFT_RUNTIME_LIBRARIES)); + + options.getFrom().set(from); + options.getTo().set(to); + options.getMappings().add(MappingsService.createOptionsWithProjectMappings(project, options.getFrom(), options.getTo())); + options.getUselegacyMixinAP().set(true); + options.getClasspath().from(classpath); + options.getKnownIndyBsms().set(extension.getKnownIndyBsms().get().stream().sorted().toList()); + options.getRemapperExtensions().set(extension.getRemapperExtensions()); + }); + } + private TinyRemapper tinyRemapper; @Nullable private KotlinRemapperClassloader kotlinRemapperClassloader; @@ -178,11 +197,13 @@ public class TinyRemapperService extends Service im return tag; } + @Override public TinyRemapper getTinyRemapperForRemapping() { isRemapping = true; return Objects.requireNonNull(tinyRemapper, "Tiny remapper has not been setup"); } + @Override public TinyRemapper getTinyRemapperForInputs() { if (isRemapping) { throw new IllegalStateException("Cannot read inputs as remapping has already started"); diff --git a/src/main/java/net/fabricmc/loom/task/service/TinyRemapperServiceInterface.java b/src/main/java/net/fabricmc/loom/task/service/TinyRemapperServiceInterface.java new file mode 100644 index 00000000..52feaedf --- /dev/null +++ b/src/main/java/net/fabricmc/loom/task/service/TinyRemapperServiceInterface.java @@ -0,0 +1,35 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.task.service; + +import org.jetbrains.annotations.VisibleForTesting; + +import net.fabricmc.tinyremapper.TinyRemapper; + +@VisibleForTesting +public interface TinyRemapperServiceInterface { + TinyRemapper getTinyRemapperForRemapping(); + TinyRemapper getTinyRemapperForInputs(); +} diff --git a/src/main/java/net/fabricmc/loom/task/service/UnpickRemapperService.java b/src/main/java/net/fabricmc/loom/task/service/UnpickRemapperService.java new file mode 100644 index 00000000..841313f1 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/task/service/UnpickRemapperService.java @@ -0,0 +1,152 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.task.service; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import daomephsta.unpick.constantmappers.datadriven.parser.v3.UnpickV3Reader; +import daomephsta.unpick.constantmappers.datadriven.parser.v3.UnpickV3Remapper; +import daomephsta.unpick.constantmappers.datadriven.parser.v3.UnpickV3Writer; +import daomephsta.unpick.constantmappers.datadriven.tree.UnpickV3Visitor; +import org.gradle.api.Project; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Nested; +import org.objectweb.asm.commons.Remapper; + +import net.fabricmc.loom.api.mappings.layered.MappingsNamespace; +import net.fabricmc.loom.configuration.providers.mappings.unpick.UnpickMetadata; +import net.fabricmc.loom.util.JarPackageIndex; +import net.fabricmc.loom.util.service.Service; +import net.fabricmc.loom.util.service.ServiceFactory; +import net.fabricmc.loom.util.service.ServiceType; +import net.fabricmc.tinyremapper.TinyRemapper; +import net.fabricmc.tinyremapper.api.TrClass; +import net.fabricmc.tinyremapper.api.TrField; + +public class UnpickRemapperService extends Service { + public static final ServiceType TYPE = new ServiceType<>(Options.class, UnpickRemapperService.class); + + public interface Options extends Service.Options { + @Nested + Property getTinyRemapper(); + } + + public static Provider createOptions(Project project, UnpickMetadata.V2 metadata) { + return TYPE.create(project, options -> { + options.getTinyRemapper().set(TinyRemapperService.createSimple(project, + project.provider(metadata::namespace), + project.provider(MappingsNamespace.NAMED::toString) + )); + }); + } + + public UnpickRemapperService(Options options, ServiceFactory serviceFactory) { + super(options, serviceFactory); + } + + /** + * Return the remapped definitions. + */ + public String remap(File input) throws IOException { + TinyRemapperServiceInterface tinyRemapperService = getServiceFactory().get(getOptions().getTinyRemapper()); + TinyRemapper tinyRemapper = tinyRemapperService.getTinyRemapperForRemapping(); + + List classpath = getOptions().getTinyRemapper().get().getClasspath().getFiles().stream().map(File::toPath).toList(); + JarPackageIndex packageIndex = JarPackageIndex.create(classpath); + + return doRemap(input, tinyRemapper, packageIndex); + } + + private String doRemap(File input, TinyRemapper remapper, JarPackageIndex packageIndex) throws IOException { + try (Reader fileReader = new BufferedReader(new FileReader(input)); + var reader = new UnpickV3Reader(fileReader)) { + var writer = new UnpickV3Writer(); + reader.accept(new UnpickRemapper(writer, remapper, packageIndex)); + return writer.getOutput().replace(System.lineSeparator(), "\n"); + } + } + + private static final class UnpickRemapper extends UnpickV3Remapper { + private final TinyRemapper tinyRemapper; + private final Remapper remapper; + private final JarPackageIndex jarPackageIndex; + + private UnpickRemapper(UnpickV3Visitor downstream, TinyRemapper tinyRemapper, JarPackageIndex jarPackageIndex) { + super(downstream); + this.tinyRemapper = tinyRemapper; + this.remapper = tinyRemapper.getEnvironment().getRemapper(); + this.jarPackageIndex = jarPackageIndex; + } + + @Override + protected String mapClassName(String className) { + return remapper.map(className); + } + + @Override + protected String mapFieldName(String className, String fieldName, String fieldDesc) { + return remapper.mapFieldName(className, fieldName, fieldDesc); + } + + @Override + protected String mapMethodName(String className, String methodName, String methodDesc) { + return remapper.mapMethodName(className, methodName, methodDesc); + } + + // Return all classes in the given package, not recursively. + @Override + protected List getClassesInPackage(String pkg) { + return jarPackageIndex.packages().getOrDefault(pkg, Collections.emptyList()) + .stream() + .map(className -> pkg + "." + className) + .toList(); + } + + @Override + protected String getFieldDesc(String className, String fieldName) { + TrClass trClass = tinyRemapper.getEnvironment().getClass(className); + + if (trClass == null) { + return null; + } + + for (TrField trField : trClass.getFields()) { + if (trField.getName().equals(fieldName)) { + return trField.getDesc(); + } + } + + return null; + } + } +} diff --git a/src/main/java/net/fabricmc/loom/task/service/UnpickService.java b/src/main/java/net/fabricmc/loom/task/service/UnpickService.java index ca944847..8168a25a 100644 --- a/src/main/java/net/fabricmc/loom/task/service/UnpickService.java +++ b/src/main/java/net/fabricmc/loom/task/service/UnpickService.java @@ -24,10 +24,12 @@ package net.fabricmc.loom.task.service; +import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -49,6 +51,8 @@ import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; import org.jetbrains.annotations.Nullable; import org.objectweb.asm.ClassReader; @@ -86,6 +90,10 @@ public class UnpickService extends Service { @InputFile RegularFileProperty getUnpickDefinitions(); + @Optional + @Nested + Property getUnpickRemapperService(); + @InputFiles ConfigurableFileCollection getUnpickConstantJar(); @@ -113,8 +121,7 @@ public class UnpickService extends Service { if (unpickMetadata instanceof UnpickMetadata.V2 v2) { if (!Objects.equals(v2.namespace(), MappingsNamespace.NAMED.toString())) { - // TODO! - throw new IllegalStateException("Unpick metadata with a namespace other than named is not yet supported"); + options.getUnpickRemapperService().set(UnpickRemapperService.createOptions(project, v2)); } } @@ -144,13 +151,11 @@ public class UnpickService extends Service { Stream.of(inputJar), Stream.ofNullable(existingClasses) ).flatMap(Function.identity()).toList(); - final Path unpickDefinitionsPath = getOptions().getUnpickDefinitions().getAsFile().get().toPath(); final Path outputJar = getOptions().getUnpickOutputJar().get().getAsFile().toPath(); - Files.deleteIfExists(outputJar); try (ZipFsClasspath zipFsClasspath = ZipFsClasspath.create(classpath); - InputStream unpickDefinitions = Files.newInputStream(unpickDefinitionsPath)) { + InputStream unpickDefinitions = getUnpickDefinitionsInputStream()) { IClassResolver classResolver = zipFsClasspath.createClassResolver().chain(ClassResolvers.classpath()); ConstantUninliner uninliner = ConstantUninliner.builder() .logger(JAVA_LOGGER) @@ -169,10 +174,31 @@ public class UnpickService extends Service { return outputJar; } + private InputStream getUnpickDefinitionsInputStream() throws IOException { + final Path unpickDefinitionsPath = getOptions().getUnpickDefinitions().getAsFile().get().toPath(); + + if (getOptions().getUnpickRemapperService().isPresent()) { + LOGGER.info("Remapping unpick definitions: {}", unpickDefinitionsPath); + + UnpickRemapperService unpickRemapperService = getServiceFactory().get(getOptions().getUnpickRemapperService()); + String remapped = unpickRemapperService.remap(unpickDefinitionsPath.toFile()); + + return new ByteArrayInputStream(remapped.getBytes(StandardCharsets.UTF_8)); + } + + LOGGER.debug("Using unpick definitions: {}", unpickDefinitionsPath); + + return Files.newInputStream(unpickDefinitionsPath); + } + public String getUnpickCacheKey() { return Checksum.of(List.of( Checksum.of(getOptions().getUnpickDefinitions().getAsFile().get()), - Checksum.of(getOptions().getUnpickConstantJar()) + Checksum.of(getOptions().getUnpickConstantJar()), + Checksum.of(getOptions().getUnpickRemapperService() + .flatMap(options -> options.getTinyRemapper() + .flatMap(TinyRemapperService.Options::getFrom)) + .getOrElse("named")) )).sha256().hex(); } diff --git a/src/main/java/net/fabricmc/loom/util/JarPackageIndex.java b/src/main/java/net/fabricmc/loom/util/JarPackageIndex.java new file mode 100644 index 00000000..cb892532 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/util/JarPackageIndex.java @@ -0,0 +1,100 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * An index of all packages and the classes directly contained within. + */ +public record JarPackageIndex(Map> packages) { + public static JarPackageIndex create(List jars) { + Map> packages = jars.stream() + .map(jar -> CompletableFuture.supplyAsync(() -> { + try { + List classes = getClasses(jar); + return groupClassesByPackage(classes); + } catch (IOException e) { + throw new RuntimeException(e); + } + }, Executors.newVirtualThreadPerTaskExecutor())) + .map(CompletableFuture::join) + .flatMap(map -> map.entrySet().stream()) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (existing, newValues) -> { + existing.addAll(newValues); + return existing; + } + )); + + return new JarPackageIndex(packages); + } + + private static List getClasses(Path jar) throws IOException { + try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(jar, false); + Stream walk = Files.walk(fs.getRoot())) { + return walk + .filter(Files::isRegularFile) + .map(Path::toString) + .filter(className -> className.endsWith(".class")) + .map(className -> className.startsWith("/") ? className.substring(1) : className) + .toList(); + } + } + + private static Map> groupClassesByPackage(List classes) { + return classes.stream() + .filter(className -> className.endsWith(".class")) // Ensure it's a class file + .collect(Collectors.groupingBy( + JarPackageIndex::extractPackageName, + Collectors.mapping( + JarPackageIndex::extractClassName, + Collectors.toList() + ) + )); + } + + // Returns the package name from a class name, e.g., "com/example/MyClass.class" -> "com.example" + private static String extractPackageName(String className) { + int lastSlashIndex = className.lastIndexOf('/'); + return lastSlashIndex == -1 ? "" : className.substring(0, lastSlashIndex).replace("/", "."); + } + + private static String extractClassName(String className) { + int lastSlashIndex = className.lastIndexOf('/'); + String simpleName = lastSlashIndex == -1 ? className : className.substring(lastSlashIndex + 1); + return simpleName.endsWith(".class") ? simpleName.substring(0, simpleName.length() - 6) : simpleName; + } +} diff --git a/src/main/java/net/fabricmc/loom/util/service/ScopedServiceFactory.java b/src/main/java/net/fabricmc/loom/util/service/ScopedServiceFactory.java index c7d8dbc2..ef4d770f 100644 --- a/src/main/java/net/fabricmc/loom/util/service/ScopedServiceFactory.java +++ b/src/main/java/net/fabricmc/loom/util/service/ScopedServiceFactory.java @@ -31,13 +31,15 @@ import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Map; +import org.jetbrains.annotations.VisibleForTesting; + import net.fabricmc.loom.util.gradle.GradleTypeAdapter; /** * An implementation of {@link ServiceFactory} that creates services scoped to the factory instance. * When the factory is closed, all services created by it are closed and discarded. */ -public final class ScopedServiceFactory implements ServiceFactory, Closeable { +public class ScopedServiceFactory implements ServiceFactory, Closeable { private final Map> servicesIdentityMap = new IdentityHashMap<>(); private final Map> servicesJsonMap = new HashMap<>(); @@ -60,7 +62,7 @@ public final class ScopedServiceFactory implements ServiceFactory, Closeable { return service; } - service = createService(options, this); + service = createService(options, getEffectiveServiceFactory()); servicesIdentityMap.put(options, service); servicesJsonMap.put(key, service); @@ -68,6 +70,11 @@ public final class ScopedServiceFactory implements ServiceFactory, Closeable { return service; } + @VisibleForTesting + protected ServiceFactory getEffectiveServiceFactory() { + return this; + } + private static > S createService(O options, ServiceFactory serviceFactory) { // We need to create the service from the provided options final Class serviceClass; diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/JarPackageIndexTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/JarPackageIndexTest.groovy new file mode 100644 index 00000000..38a2d97a --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/JarPackageIndexTest.groovy @@ -0,0 +1,101 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Specification +import spock.lang.TempDir + +import net.fabricmc.loom.util.JarPackageIndex +import net.fabricmc.loom.util.Pair +import net.fabricmc.loom.util.ZipUtils + +class JarPackageIndexTest extends Specification { + @TempDir + Path dir + + def "Create JarPackageIndex from single JAR"() { + given: + def jar = zip([ + "com/example/Foo.class", + "com/example/Bar.class", + "com/example/subpackage/Baz.class" + ]) + + when: + def index = JarPackageIndex.create([jar]) + + then: + index.packages().size() == 2 + index.packages()["com.example"] == ["Bar", "Foo"] + index.packages()["com.example.subpackage"] == ["Baz"] + } + + def "Create JarPackageIndex from multiple JARs"() { + given: + def jar1 = zip([ + "com/example/Foo.class", + "com/example/Bar.class" + ]) + def jar2 = zip([ + "com/example/subpackage/Baz.class", + "com/another/Example.class" + ]) + + when: + def index = JarPackageIndex.create([jar1, jar2]) + + then: + index.packages().size() == 3 + index.packages()["com.example"] == ["Bar", "Foo"] + index.packages()["com.example.subpackage"] == ["Baz"] + index.packages()["com.another"] == ["Example"] + } + + def "Handle empty JAR"() { + given: + def jar = zip([]) + + when: + def index = JarPackageIndex.create([jar]) + + then: + index.packages().isEmpty() + } + + private Path zip(List entries) { + def zip = Files.createTempFile(dir, "loom", ".zip") + Files.delete(zip) + + def files = entries.stream().map { + new Pair<>(it, new byte[0]) + }.toList() + + ZipUtils.add(zip, files) + return zip + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy index 9dcd5641..4d44cc3c 100644 --- a/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/ServiceTestBase.groovy @@ -24,22 +24,57 @@ package net.fabricmc.loom.test.unit.service +import groovy.transform.CompileStatic import org.gradle.api.Project +import org.mockito.Mockito import spock.lang.Specification import net.fabricmc.loom.test.util.GradleTestUtil import net.fabricmc.loom.util.service.ScopedServiceFactory +import net.fabricmc.loom.util.service.Service +import net.fabricmc.loom.util.service.ServiceFactory +import net.fabricmc.loom.util.service.ServiceType +@CompileStatic abstract class ServiceTestBase extends Specification { - ScopedServiceFactory factory + ServiceFactory factory Project project = GradleTestUtil.mockProject() + private Map mockedServices = new IdentityHashMap<>() + private ScopedServiceFactory scopedServiceFactory = new ScopedServiceFactory() + def setup() { - factory = new ScopedServiceFactory() + this.scopedServiceFactory = new ScopedServiceFactory() { + @Override + protected ServiceFactory getEffectiveServiceFactory() { + return factory + } + } + this.factory = new ServiceFactory() { + @Override + > S get(O options) { + def self = ServiceTestBase.this + return mockedServices.get(options) as S ?: scopedServiceFactory.get(options) as S + } + } + } + + Service.Options mockService(ServiceType type) { + Service.Options options = project.getObjects().newInstance(type.optionsClass()) + + for (def method : type.optionsClass().getDeclaredMethods()) { + method.invoke(options) + } + + options.serviceClass.set(type.serviceClass().name) + + Service mocked = Mockito.mock(type.serviceClass()) + mockedServices.put(options, mocked) + return options } def cleanup() { - factory.close() - factory = null + scopedServiceFactory.close() + scopedServiceFactory = null } } diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/UnpickRemapperServiceTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/UnpickRemapperServiceTest.groovy new file mode 100644 index 00000000..7c904674 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/UnpickRemapperServiceTest.groovy @@ -0,0 +1,131 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.service + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.transform.Immutable +import spock.lang.TempDir + +import net.fabricmc.loom.task.service.TinyRemapperServiceInterface +import net.fabricmc.loom.task.service.UnpickRemapperService +import net.fabricmc.loom.test.unit.service.mocks.MockTinyRemapper +import net.fabricmc.loom.test.unit.service.mocks.MockTinyRemapperService +import net.fabricmc.loom.util.Pair +import net.fabricmc.loom.util.ZipUtils + +import static org.mockito.Mockito.when + +// Based on https://github.com/Earthcomputer/unpick-v3-parser/blob/68b11c50a7c97a75218f70f5ec1291a38b178ad7/src/test/java/net/earthcomputer/unpickv3parser/remapper/TestRemapper.java +class UnpickRemapperServiceTest extends ServiceTestBase { + private static final Map> PACKAGES = [ + "unmapped.foo": [ + "unmapped.foo.A", + "unmapped.foo.B" + ], + "unmapped.bar": ["unmapped.bar.C"] + ] + + private static final Map CLASSES = [ + "unmapped.foo.A": "mapped.foo.X", + "unmapped.foo.B": "mapped.bar.Y", + "unmapped.bar.C": "mapped.bar.Z" + ] + + private static final Map FIELDS = [ + (new MemberKey("unmapped.foo.B", "baz", "I")): "quux" + ] + + private static final Map METHODS = [ + (new MemberKey("unmapped.foo.B", "foo2", "(Lunmapped/foo/A;)V")): "bar2" + ] + + @TempDir + Path tempDir + + def "remap unpick"() { + given: + def classpath = zip(PACKAGES.values().flatten()) + + def tinyRemapperOptions = mockService(MockTinyRemapperService.TYPE) + tinyRemapperOptions.classpath.from(classpath) + TinyRemapperServiceInterface tinyRemapperService = factory.get(tinyRemapperOptions) + + def mockTr = new MockTinyRemapper() + when(tinyRemapperService.tinyRemapperForRemapping).thenReturn(mockTr.tinyRemapper) + + def inputFile = tempDir.resolve("input.unpick") + inputFile.text = testFile("input") + + def options = UnpickRemapperService.TYPE.create(project) { + it.tinyRemapper.set(tinyRemapperOptions) + } + + UnpickRemapperService unpickRemapper = factory.get(options) + + CLASSES.each { unmapped, mapped -> + when(mockTr.remapper.map(unmapped)).thenReturn(mapped) + } + + FIELDS.each { key, mapped -> + when(mockTr.remapper.mapFieldName(key.owner, key.name, key.descriptor)).thenReturn(mapped) + } + + METHODS.each { key, mapped -> + when(mockTr.remapper.mapMethodName(key.owner, key.name, key.descriptor)).thenReturn(mapped) + } + + when: + def remapped = unpickRemapper.remap(inputFile.toFile()) + + then: + remapped == testFile("remapped") + } + + // Load the given name from resources + private static String testFile(String name) { + return UnpickRemapperServiceTest.class.getResource("/unpick/${name}.unpick").text + } + + private Path zip(List entries) { + def zip = Files.createTempFile(tempDir, "loom", ".zip") + Files.delete(zip) + + def files = entries.stream().map { + new Pair<>(it.replace(".", "/") + ".class", new byte[0]) + }.toList() + + ZipUtils.add(zip, files) + return zip + } + + @Immutable + static class MemberKey { + final String owner + final String name + final String descriptor + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapper.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapper.groovy new file mode 100644 index 00000000..9721c806 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapper.groovy @@ -0,0 +1,43 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.service.mocks + +import net.fabricmc.tinyremapper.TinyRemapper +import net.fabricmc.tinyremapper.api.TrEnvironment +import net.fabricmc.tinyremapper.api.TrRemapper + +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + +class MockTinyRemapper { + TinyRemapper tinyRemapper = mock(TinyRemapper.class) + TrEnvironment trEnvironment = mock(TrEnvironment.class) + TrRemapper remapper = mock(TrRemapper.class) + + MockTinyRemapper() { + when(tinyRemapper.getEnvironment()).thenReturn(trEnvironment) + when(trEnvironment.getRemapper()).thenReturn(remapper) + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapperService.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapperService.groovy new file mode 100644 index 00000000..a43006c7 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/service/mocks/MockTinyRemapperService.groovy @@ -0,0 +1,39 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2025 FabricMC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package net.fabricmc.loom.test.unit.service.mocks + +import net.fabricmc.loom.task.service.TinyRemapperService +import net.fabricmc.loom.task.service.TinyRemapperServiceInterface +import net.fabricmc.loom.util.service.Service +import net.fabricmc.loom.util.service.ServiceFactory +import net.fabricmc.loom.util.service.ServiceType + +abstract class MockTinyRemapperService extends Service implements TinyRemapperServiceInterface { + static final ServiceType TYPE = new ServiceType<>(TinyRemapperService.Options.class, MockTinyRemapperService.class) + + MockTinyRemapperService(TinyRemapperService.Options options, ServiceFactory serviceFactory) { + super(options, serviceFactory) + } +} \ No newline at end of file diff --git a/src/test/resources/unpick/input.unpick b/src/test/resources/unpick/input.unpick new file mode 100644 index 00000000..eb174bfa --- /dev/null +++ b/src/test/resources/unpick/input.unpick @@ -0,0 +1,18 @@ +unpick v3 +target_field unmapped.foo.B baz I g +target_field unmapped.bar.C foo Lunmapped/foo/A; g +target_method unmapped.foo.B foo2 (Lunmapped/foo/A;)V +group int + unmapped.foo.B.baz +group float + unmapped.foo.B.baz:int +group float + unmapped.foo.B.baz:float +group int + @scope package unmapped.foo + 0 + 1 +group int + @scope class unmapped.foo.A +group int + @scope method unmapped.foo.B foo2 (Lunmapped/foo/A;)V \ No newline at end of file diff --git a/src/test/resources/unpick/remapped.unpick b/src/test/resources/unpick/remapped.unpick new file mode 100644 index 00000000..581d4146 --- /dev/null +++ b/src/test/resources/unpick/remapped.unpick @@ -0,0 +1,28 @@ +unpick v3 + +target_field mapped.bar.Y quux I g + +target_field mapped.bar.Z null Lmapped/foo/X; g + +target_method mapped.bar.Y bar2 (Lmapped/foo/X;)V + +group int + mapped.bar.Y.* + +group float + mapped.bar.Y.quux:int + +group float + mapped.bar.Y.*:float + +group int + @scope class mapped.bar.Y + @scope class mapped.foo.X + 0 + 1 + +group int + @scope class mapped.foo.X + +group int + @scope method mapped.bar.Y bar2 (Lmapped/foo/X;)V