diff --git a/src/main/java/net/fabricmc/loom/configuration/decompile/SingleJarDecompileConfiguration.java b/src/main/java/net/fabricmc/loom/configuration/decompile/SingleJarDecompileConfiguration.java index 6965a0bf..e7ffd658 100644 --- a/src/main/java/net/fabricmc/loom/configuration/decompile/SingleJarDecompileConfiguration.java +++ b/src/main/java/net/fabricmc/loom/configuration/decompile/SingleJarDecompileConfiguration.java @@ -58,7 +58,7 @@ public class SingleJarDecompileConfiguration extends DecompileConfiguration { task.getInputJarName().set(minecraftJar.getName()); - task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", minecraftJar.getPath())); + task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", minecraftJar.getPath())); task.dependsOn(project.getTasks().named("validateAccessWidener")); task.setDescription("Decompile minecraft using %s.".formatted(decompilerName)); diff --git a/src/main/java/net/fabricmc/loom/configuration/decompile/SplitDecompileConfiguration.java b/src/main/java/net/fabricmc/loom/configuration/decompile/SplitDecompileConfiguration.java index 0c8df985..99c67973 100644 --- a/src/main/java/net/fabricmc/loom/configuration/decompile/SplitDecompileConfiguration.java +++ b/src/main/java/net/fabricmc/loom/configuration/decompile/SplitDecompileConfiguration.java @@ -55,7 +55,7 @@ public final class SplitDecompileConfiguration extends DecompileConfiguration commonDecompileTask = createDecompileTasks("Common", task -> { task.getInputJarName().set(commonJar.getName()); - task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", commonJar.getPath())); + task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", commonJar.getPath())); if (mappingConfiguration.hasUnpickDefinitions()) { File unpickJar = new File(extension.getMappingConfiguration().mappingsWorkingDir().toFile(), "minecraft-common-unpicked.jar"); @@ -65,7 +65,7 @@ public final class SplitDecompileConfiguration extends DecompileConfiguration clientOnlyDecompileTask = createDecompileTasks("ClientOnly", task -> { task.getInputJarName().set(clientOnlyJar.getName()); - task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", clientOnlyJar.getPath())); + task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", clientOnlyJar.getPath())); if (mappingConfiguration.hasUnpickDefinitions()) { File unpickJar = new File(extension.getMappingConfiguration().mappingsWorkingDir().toFile(), "minecraft-clientonly-unpicked.jar"); diff --git a/src/main/java/net/fabricmc/loom/decompilers/ClassLineNumbers.java b/src/main/java/net/fabricmc/loom/decompilers/ClassLineNumbers.java new file mode 100644 index 00000000..2c872406 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/ClassLineNumbers.java @@ -0,0 +1,158 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2019-2021 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.decompilers; + +import static java.text.MessageFormat.format; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.jetbrains.annotations.Nullable; + +public record ClassLineNumbers(Map lineMap) { + public ClassLineNumbers { + Objects.requireNonNull(lineMap, "lineMap"); + + if (lineMap.isEmpty()) { + throw new IllegalArgumentException("lineMap is empty"); + } + } + + public static ClassLineNumbers readMappings(Path lineMappingsPath) { + try (BufferedReader reader = Files.newBufferedReader(lineMappingsPath)) { + return readMappings(reader); + } catch (IOException e) { + throw new UncheckedIOException("Exception reading LineMappings file.", e); + } + } + + public static ClassLineNumbers readMappings(BufferedReader reader) { + var lineMap = new HashMap(); + + String line = null; + int lineNumber = 0; + + record CurrentClass(String className, int maxLine, int maxLineDest) { + void putEntry(Map entries, Map mappings) { + var entry = new ClassLineNumbers.Entry(className(), maxLine(), maxLineDest(), Collections.unmodifiableMap(mappings)); + + final ClassLineNumbers.Entry previous = entries.put(className(), entry); + + if (previous != null) { + throw new IllegalStateException("Duplicate class line mappings for " + className()); + } + } + } + + CurrentClass currentClass = null; + Map currentMappings = new HashMap<>(); + + try { + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + continue; + } + + final String[] segments = line.trim().split("\t"); + + if (line.charAt(0) != '\t') { + if (currentClass != null) { + currentClass.putEntry(lineMap, currentMappings); + currentMappings = new HashMap<>(); + } + + currentClass = new CurrentClass(segments[0], Integer.parseInt(segments[1]), Integer.parseInt(segments[2])); + } else { + Objects.requireNonNull(currentClass, "No class line mappings found for line " + lineNumber); + currentMappings.put(Integer.parseInt(segments[0]), Integer.parseInt(segments[1])); + } + + lineNumber++; + } + } catch (Exception e) { + throw new RuntimeException(format("Exception reading mapping line @{0}: {1}", lineNumber, line), e); + } + + assert currentClass != null; + currentClass.putEntry(lineMap, currentMappings); + + return new ClassLineNumbers(Collections.unmodifiableMap(lineMap)); + } + + public void write(Writer writer) throws IOException { + for (Map.Entry entry : lineMap.entrySet()) { + entry.getValue().write(writer); + } + } + + /** + * Merge two ClassLineNumbers together, throwing an exception if there are any duplicate class line mappings. + */ + @Nullable + public static ClassLineNumbers merge(@Nullable ClassLineNumbers a, @Nullable ClassLineNumbers b) { + if (a == null) { + return b; + } else if (b == null) { + return a; + } + + var lineMap = new HashMap<>(a.lineMap()); + + for (Map.Entry entry : b.lineMap().entrySet()) { + lineMap.merge(entry.getKey(), entry.getValue(), (v1, v2) -> { + throw new IllegalStateException("Duplicate class line mappings for " + entry.getKey()); + }); + } + + return new ClassLineNumbers(Collections.unmodifiableMap(lineMap)); + } + + public record Entry(String className, int maxLine, int maxLineDest, Map lineMap) { + public void write(Writer writer) throws IOException { + writer.write(className); + writer.write('\t'); + writer.write(Integer.toString(maxLine)); + writer.write('\t'); + writer.write(Integer.toString(maxLineDest)); + writer.write('\n'); + + for (Map.Entry lineEntry : lineMap.entrySet()) { + writer.write('\t'); + writer.write(Integer.toString(lineEntry.getKey())); + writer.write('\t'); + writer.write(Integer.toString(lineEntry.getValue())); + writer.write('\n'); + } + } + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/LineNumberRemapper.java b/src/main/java/net/fabricmc/loom/decompilers/LineNumberRemapper.java index 307a340e..86720e05 100644 --- a/src/main/java/net/fabricmc/loom/decompilers/LineNumberRemapper.java +++ b/src/main/java/net/fabricmc/loom/decompilers/LineNumberRemapper.java @@ -24,11 +24,6 @@ package net.fabricmc.loom.decompilers; -import static java.text.MessageFormat.format; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.nio.file.FileVisitResult; @@ -37,57 +32,21 @@ import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; -import java.util.HashMap; -import java.util.Map; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import net.fabricmc.loom.util.Constants; -import net.fabricmc.loom.util.IOStringConsumer; -/** - * Created by covers1624 on 18/02/19. - */ -public class LineNumberRemapper { - private final Map lineMap = new HashMap<>(); +public record LineNumberRemapper(ClassLineNumbers lineNumbers) { + private static final Logger LOGGER = LoggerFactory.getLogger(LineNumberRemapper.class); - public void readMappings(File lineMappings) { - try (BufferedReader reader = new BufferedReader(new FileReader(lineMappings))) { - RClass clazz = null; - String line = null; - int i = 0; - - try { - while ((line = reader.readLine()) != null) { - if (line.isEmpty()) { - continue; - } - - String[] segs = line.trim().split("\t"); - - if (line.charAt(0) != '\t') { - clazz = lineMap.computeIfAbsent(segs[0], RClass::new); - clazz.maxLine = Integer.parseInt(segs[1]); - clazz.maxLineDest = Integer.parseInt(segs[2]); - } else { - clazz.lineMap.put(Integer.parseInt(segs[0]), Integer.parseInt(segs[1])); - } - - i++; - } - } catch (Exception e) { - throw new RuntimeException(format("Exception reading mapping line @{0}: {1}", i, line), e); - } - } catch (IOException e) { - throw new RuntimeException("Exception reading LineMappings file.", e); - } - } - - public void process(IOStringConsumer logger, Path input, Path output) throws IOException { + public void process(Path input, Path output) throws IOException { Files.walkFileTree(input, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { @@ -108,9 +67,7 @@ public class LineNumberRemapper { String idx = rel.substring(0, rel.length() - 6); - if (logger != null) { - logger.accept("Remapping " + idx); - } + LOGGER.debug("Remapping line numbers for class: " + idx); int dollarPos = idx.indexOf('$'); //This makes the assumption that only Java classes are to be remapped. @@ -118,12 +75,12 @@ public class LineNumberRemapper { idx = idx.substring(0, dollarPos); } - if (lineMap.containsKey(idx)) { + if (lineNumbers.lineMap().containsKey(idx)) { try (InputStream is = Files.newInputStream(file)) { ClassReader reader = new ClassReader(is); ClassWriter writer = new ClassWriter(0); - reader.accept(new LineNumberVisitor(Constants.ASM_VERSION, writer, lineMap.get(idx)), 0); + reader.accept(new LineNumberVisitor(Constants.ASM_VERSION, writer, lineNumbers.lineMap().get(idx)), 0); Files.write(dst, writer.toByteArray()); return FileVisitResult.CONTINUE; } @@ -137,11 +94,11 @@ public class LineNumberRemapper { } private static class LineNumberVisitor extends ClassVisitor { - private final RClass rClass; + private final ClassLineNumbers.Entry lineNumbers; - LineNumberVisitor(int api, ClassVisitor classVisitor, RClass rClass) { + LineNumberVisitor(int api, ClassVisitor classVisitor, ClassLineNumbers.Entry lineNumbers) { super(api, classVisitor); - this.rClass = rClass; + this.lineNumbers = lineNumbers; } @Override @@ -153,30 +110,19 @@ public class LineNumberRemapper { if (tLine <= 0) { super.visitLineNumber(line, start); - } else if (tLine >= rClass.maxLine) { - super.visitLineNumber(rClass.maxLineDest, start); + } else if (tLine >= lineNumbers.maxLine()) { + super.visitLineNumber(lineNumbers.maxLineDest(), start); } else { Integer matchedLine = null; - while (tLine <= rClass.maxLine && ((matchedLine = rClass.lineMap.get(tLine)) == null)) { + while (tLine <= lineNumbers.maxLine() && ((matchedLine = lineNumbers.lineMap().get(tLine)) == null)) { tLine++; } - super.visitLineNumber(matchedLine != null ? matchedLine : rClass.maxLineDest, start); + super.visitLineNumber(matchedLine != null ? matchedLine : lineNumbers.maxLineDest(), start); } } }; } } - - private static class RClass { - private final String name; - private int maxLine; - private int maxLineDest; - private final Map lineMap = new HashMap<>(); - - private RClass(String name) { - this.name = name; - } - } } diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/CachedData.java b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedData.java new file mode 100644 index 00000000..3674fe78 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedData.java @@ -0,0 +1,209 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.decompilers.ClassLineNumbers; + +// Serialised data for a class entry in the cache +// Uses the RIFF format, allows for appending the line numbers to the end of the file +// Stores the source code and line numbers for the class +public record CachedData(String className, String sources, @Nullable ClassLineNumbers.Entry lineNumbers) { + public static final CachedFileStore.EntrySerializer SERIALIZER = new EntrySerializer(); + + private static final String HEADER_ID = "LOOM"; + private static final String NAME_ID = "NAME"; + private static final String SOURCES_ID = "SRC "; + private static final String LINE_NUMBERS_ID = "LNUM"; + + private static final Logger LOGGER = LoggerFactory.getLogger(CachedData.class); + + public CachedData { + Objects.requireNonNull(className, "className"); + Objects.requireNonNull(sources, "sources"); + + if (lineNumbers != null) { + if (!className.equals(lineNumbers.className())) { + throw new IllegalArgumentException("Class name does not match line numbers class name"); + } + } + } + + public void write(FileChannel fileChannel) { + try (var c = new RiffChunk(HEADER_ID, fileChannel)) { + writeClassname(fileChannel); + writeSource(fileChannel); + + if (lineNumbers != null) { + writeLineNumbers(fileChannel); + } + } catch (IOException e) { + throw new RuntimeException("Failed to write cached data", e); + } + } + + private void writeClassname(FileChannel fileChannel) throws IOException { + try (var c = new RiffChunk(NAME_ID, fileChannel)) { + fileChannel.write(ByteBuffer.wrap(className.getBytes(StandardCharsets.UTF_8))); + } + } + + private void writeSource(FileChannel fileChannel) throws IOException { + try (var c = new RiffChunk(SOURCES_ID, fileChannel)) { + fileChannel.write(ByteBuffer.wrap(sources.getBytes(StandardCharsets.UTF_8))); + } + } + + private void writeLineNumbers(FileChannel fileChannel) throws IOException { + Objects.requireNonNull(lineNumbers); + + try (var c = new RiffChunk(LINE_NUMBERS_ID, fileChannel); + StringWriter stringWriter = new StringWriter()) { + lineNumbers.write(stringWriter); + fileChannel.write(ByteBuffer.wrap(stringWriter.toString().getBytes(StandardCharsets.UTF_8))); + } + } + + public static CachedData read(InputStream inputStream) throws IOException { + // Read and validate the RIFF header + final String header = readHeader(inputStream); + + if (!header.equals(HEADER_ID)) { + throw new IOException("Invalid RIFF header: " + header + ", expected " + HEADER_ID); + } + + // Read the data length + int length = readInt(inputStream); + + String className = null; + String sources = null; + ClassLineNumbers.Entry lineNumbers = null; + + while (inputStream.available() > 0) { + String chunkHeader = readHeader(inputStream); + int chunkLength = readInt(inputStream); + byte[] chunkData = readBytes(inputStream, chunkLength); + + switch (chunkHeader) { + case NAME_ID -> { + if (className != null) { + throw new IOException("Duplicate name chunk"); + } + + className = new String(chunkData, StandardCharsets.UTF_8); + } + case SOURCES_ID -> { + if (sources != null) { + throw new IOException("Duplicate sources chunk"); + } + + sources = new String(chunkData, StandardCharsets.UTF_8); + } + case LINE_NUMBERS_ID -> { + if (lineNumbers != null) { + throw new IOException("Duplicate line numbers chunk"); + } + + try (var br = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(chunkData), StandardCharsets.UTF_8))) { + ClassLineNumbers classLineNumbers = ClassLineNumbers.readMappings(br); + + if (classLineNumbers.lineMap().size() != 1) { + throw new IOException("Expected exactly one class line numbers entry got " + classLineNumbers.lineMap().size() + " entries"); + } + + lineNumbers = classLineNumbers.lineMap().values().iterator().next(); + } + } + default -> { + // Skip unknown chunk + LOGGER.warn("Skipping unknown chunk: {} of size {}", chunkHeader, chunkLength); + inputStream.skip(chunkLength); + } + } + } + + if (sources == null) { + throw new IOException("Missing sources"); + } + + return new CachedData(className, sources, lineNumbers); + } + + private static String readHeader(InputStream inputStream) throws IOException { + byte[] header = readBytes(inputStream, 4); + return new String(header, StandardCharsets.US_ASCII); + } + + private static int readInt(InputStream inputStream) throws IOException { + byte[] bytes = readBytes(inputStream, 4); + return ByteBuffer.wrap(bytes).getInt(); + } + + private static byte[] readBytes(InputStream inputStream, int length) throws IOException { + byte[] bytes = new byte[length]; + + int read = inputStream.read(bytes); + + if (read != length) { + throw new IOException("Failed to read bytes expected " + length + " bytes but got " + read + " bytes"); + } + + return bytes; + } + + static class EntrySerializer implements CachedFileStore.EntrySerializer { + @Override + public CachedData read(Path path) throws IOException { + try (var inputStream = new BufferedInputStream(Files.newInputStream(path))) { + return CachedData.read(inputStream); + } + } + + @Override + public void write(CachedData entry, Path path) throws IOException { + try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + entry.write(fileChannel); + } + } + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStore.java b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStore.java new file mode 100644 index 00000000..ad73ec4c --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStore.java @@ -0,0 +1,42 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.file.Path; + +import org.jetbrains.annotations.Nullable; + +public interface CachedFileStore { + @Nullable T getEntry(String key) throws IOException; + + void putEntry(String key, T entry) throws IOException; + + interface EntrySerializer { + T read(Path path) throws IOException; + + void write(T entry, Path path) throws IOException; + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStoreImpl.java b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStoreImpl.java new file mode 100644 index 00000000..545c6b7e --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedFileStoreImpl.java @@ -0,0 +1,141 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import org.jetbrains.annotations.Nullable; + +public record CachedFileStoreImpl(Path root, EntrySerializer entrySerializer, CacheRules cacheRules) implements CachedFileStore { + public CachedFileStoreImpl { + Objects.requireNonNull(root, "root"); + } + + @Override + public @Nullable T getEntry(String key) throws IOException { + Path path = resolve(key); + + if (Files.notExists(path)) { + return null; + } + + // Update last modified, so recently used files stay in the cache + Files.setLastModifiedTime(path, FileTime.from(Instant.now())); + return entrySerializer.read(path); + } + + @Override + public void putEntry(String key, T data) throws IOException { + Path path = resolve(key); + Files.createDirectories(path.getParent()); + entrySerializer.write(data, path); + } + + private Path resolve(String key) { + return root.resolve(key); + } + + public void prune() throws IOException { + // Sorted oldest -> newest + List entries = new ArrayList<>(); + + // Iterate over all the files in the cache, and store them into the sorted list. + try (Stream walk = Files.walk(root)) { + Iterator iterator = walk.iterator(); + + while (iterator.hasNext()) { + final Path entry = iterator.next(); + + if (!Files.isRegularFile(entry)) { + continue; + } + + insertSorted(entries, new PathEntry(entry)); + } + } + + // Delete the oldest files to get under the max file limit + if (entries.size() > cacheRules.maxFiles) { + for (int i = 0; i < cacheRules.maxFiles; i++) { + PathEntry toRemove = entries.remove(0); + Files.delete(toRemove.path); + } + } + + final Instant maxAge = Instant.now().minus(cacheRules().maxAge()); + Iterator iterator = entries.iterator(); + + while (iterator.hasNext()) { + final PathEntry entry = iterator.next(); + + if (entry.lastModified().toInstant().isAfter(maxAge)) { + // File is not longer than the max age + // As this is a sorted list we don't need to keep checking + break; + } + + // Remove all files over the max age + iterator.remove(); + Files.delete(entry.path); + } + } + + private void insertSorted(List list, PathEntry entry) { + int index = Collections.binarySearch(list, entry, Comparator.comparing(PathEntry::lastModified)); + + if (index < 0) { + index = -index - 1; + } + + list.add(index, entry); + } + + /** + * The rules for the cache. + * + * @param maxFiles The maximum number of files in the cache + * @param maxAge The maximum age of a file in the cache + */ + public record CacheRules(long maxFiles, Duration maxAge) { + } + + record PathEntry(Path path, FileTime lastModified) { + PathEntry(Path path) throws IOException { + this(path, Files.getLastModifiedTime(path)); + } + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/CachedJarProcessor.java b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedJarProcessor.java new file mode 100644 index 00000000..82952210 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/CachedJarProcessor.java @@ -0,0 +1,268 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.decompilers.ClassLineNumbers; +import net.fabricmc.loom.util.FileSystemUtil; + +public record CachedJarProcessor(CachedFileStore fileStore, String baseHash) { + private static final Logger LOGGER = LoggerFactory.getLogger(CachedJarProcessor.class); + + public WorkRequest prepareJob(Path inputJar) throws IOException { + boolean isIncomplete = false; + boolean hasSomeExisting = false; + + Path incompleteJar = Files.createTempFile("loom-cache-incomplete", ".jar"); + Path existingJar = Files.createTempFile("loom-cache-existing", ".jar"); + + // We must delete the empty files, so they can be created as a zip + Files.delete(incompleteJar); + Files.delete(existingJar); + + // Sources name -> hash + Map outputNameMap = new HashMap<>(); + Map lineNumbersMap = new HashMap<>(); + + int hits = 0; + int misses = 0; + + try (FileSystemUtil.Delegate inputFs = FileSystemUtil.getJarFileSystem(inputJar, false); + FileSystemUtil.Delegate incompleteFs = FileSystemUtil.getJarFileSystem(incompleteJar, true); + FileSystemUtil.Delegate existingFs = FileSystemUtil.getJarFileSystem(existingJar, true)) { + final List inputClasses = JarWalker.findClasses(inputFs); + + for (ClassEntry entry : inputClasses) { + String outputFileName = entry.sourcesFileName(); + String fullHash = baseHash + "/" + entry.hash(inputFs.getRoot()); + + final CachedData entryData = fileStore.getEntry(fullHash); + + if (entryData == null) { + // Cached entry was not found, so copy the input to the incomplete jar to be processed + entry.copyTo(inputFs.getRoot(), incompleteFs.getRoot()); + isIncomplete = true; + outputNameMap.put(outputFileName, fullHash); + + LOGGER.debug("Cached entry ({}) not found, going to process {}", fullHash, outputFileName); + misses++; + } else { + final Path outputPath = existingFs.getPath(outputFileName); + Files.createDirectories(outputPath.getParent()); + Files.writeString(outputPath, entryData.sources()); + lineNumbersMap.put(entryData.className(), entryData.lineNumbers()); + hasSomeExisting = true; + + LOGGER.debug("Cached entry ({}) found: {}", fullHash, outputFileName); + hits++; + } + } + } + + // A jar file that will be created by the work action, containing the newly processed items. + Path outputJar = Files.createTempFile("loom-cache-output", ".jar"); + Files.delete(outputJar); + + final ClassLineNumbers lineNumbers = lineNumbersMap.isEmpty() ? null : new ClassLineNumbers(Collections.unmodifiableMap(lineNumbersMap)); + final var stats = new CacheStats(hits, misses); + + if (isIncomplete && !hasSomeExisting) { + // The cache contained nothing of use, fully process the input jar + Files.delete(incompleteJar); + Files.delete(existingJar); + + LOGGER.info("No cached entries found, going to process the whole jar"); + return new FullWorkJob(inputJar, outputJar, outputNameMap) + .asRequest(stats, lineNumbers); + } else if (isIncomplete) { + // The cache did not contain everything so we have some work to do + LOGGER.info("Some cached entries found, using partial work job"); + return new PartialWorkJob(incompleteJar, existingJar, outputJar, outputNameMap) + .asRequest(stats, lineNumbers); + } else { + // The cached contained everything we need, so the existing jar is the output + LOGGER.info("All cached entries found, using completed work job"); + Files.delete(incompleteJar); + return new CompletedWorkJob(existingJar) + .asRequest(stats, lineNumbers); + } + } + + public void completeJob(Path output, WorkJob workJob, ClassLineNumbers lineNumbers) throws IOException { + if (workJob instanceof CompletedWorkJob completedWorkJob) { + // Fully complete, nothing new to cache + Files.move(completedWorkJob.completed(), output); + return; + } + + // Work has been done, we need to cache the newly processed items + if (workJob instanceof WorkToDoJob workToDoJob) { + // Sources name -> hash + Map outputNameMap = workToDoJob.outputNameMap(); + + try (FileSystemUtil.Delegate outputFs = FileSystemUtil.getJarFileSystem(workToDoJob.output(), false); + Stream walk = Files.walk(outputFs.getRoot())) { + Iterator iterator = walk.iterator(); + + while (iterator.hasNext()) { + final Path fsPath = iterator.next(); + + if (fsPath.startsWith("/META-INF/")) { + continue; + } + + if (!Files.isRegularFile(fsPath)) { + continue; + } + + final String hash = outputNameMap.get(fsPath.toString().substring(outputFs.getRoot().toString().length())); + + if (hash == null) { + throw new IllegalStateException("Unexpected output: " + fsPath); + } + + // Trim the leading / and the .java extension + final String className = fsPath.toString().substring(1, fsPath.toString().length() - ".java".length()); + final String sources = Files.readString(fsPath); + + ClassLineNumbers.Entry lineMapEntry = null; + + if (lineNumbers != null) { + lineMapEntry = lineNumbers.lineMap().get(className); + } + + final var cachedData = new CachedData(className, sources, lineMapEntry); + fileStore.putEntry(hash, cachedData); + + LOGGER.debug("Saving processed entry ({}) to cache: {}", hash, fsPath); + } + } + } else { + throw new IllegalStateException(); + } + + if (workJob instanceof PartialWorkJob partialWorkJob) { + // Copy all the existing items to the output jar + try (FileSystemUtil.Delegate outputFs = FileSystemUtil.getJarFileSystem(partialWorkJob.output(), false); + FileSystemUtil.Delegate existingFs = FileSystemUtil.getJarFileSystem(partialWorkJob.existing(), false); + Stream walk = Files.walk(existingFs.getRoot())) { + Iterator iterator = walk.iterator(); + + while (iterator.hasNext()) { + Path existingPath = iterator.next(); + + if (!Files.isRegularFile(existingPath)) { + continue; + } + + final Path outputPath = outputFs.getRoot().resolve(existingPath.toString()); + + LOGGER.debug("Copying existing entry to output: {}", existingPath); + Files.createDirectories(outputPath.getParent()); + Files.copy(existingPath, outputPath); + } + } + + Files.delete(partialWorkJob.existing()); + Files.move(partialWorkJob.output(), output); + } else if (workJob instanceof FullWorkJob fullWorkJob) { + // Nothing to merge, just use the output jar + Files.move(fullWorkJob.output, output); + } else { + throw new IllegalStateException(); + } + } + + public record WorkRequest(WorkJob job, CacheStats stats, @Nullable ClassLineNumbers lineNumbers) { + } + + public record CacheStats(int hits, int misses) { + } + + public sealed interface WorkJob permits CompletedWorkJob, WorkToDoJob { + default WorkRequest asRequest(CacheStats stats, @Nullable ClassLineNumbers lineNumbers) { + return new WorkRequest(this, stats, lineNumbers); + } + } + + public sealed interface WorkToDoJob extends WorkJob permits PartialWorkJob, FullWorkJob { + /** + * A path to jar file containing all the classes to be processed. + */ + Path incomplete(); + + /** + * @return A jar file to be written to during processing + */ + Path output(); + + /** + * @return A map of sources name to hash + */ + Map outputNameMap(); + } + + /** + * No work to be done, all restored from cache. + * + * @param completed + */ + public record CompletedWorkJob(Path completed) implements WorkJob { + } + + /** + * Some work needs to be done. + * + * @param incomplete A path to jar file containing all the classes to be processed + * @param existing A path pointing to a jar containing existing classes that have previously been processed + * @param output A path to a temporary jar where work output should be written to + * @param outputNameMap A map of sources name to hash + */ + public record PartialWorkJob(Path incomplete, Path existing, Path output, Map outputNameMap) implements WorkToDoJob { + } + + /** + * The full jar must be processed. + * + * @param incomplete A path to jar file containing all the classes to be processed + * @param output A path to a temporary jar where work output should be written to + * @param outputNameMap A map of sources name to hash + */ + public record FullWorkJob(Path incomplete, Path output, Map outputNameMap) implements WorkToDoJob { + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/ClassEntry.java b/src/main/java/net/fabricmc/loom/decompilers/cache/ClassEntry.java new file mode 100644 index 00000000..f01db22f --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/ClassEntry.java @@ -0,0 +1,75 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.StringJoiner; + +import net.fabricmc.loom.util.Checksum; + +public record ClassEntry(String parentClass, List innerClasses) { + /** + * Copy the class and its inner classes to the target root. + * @param sourceRoot The root of the source jar + * @param targetRoot The root of the target jar + * + * @throws IOException If an error occurs while copying the files + */ + public void copyTo(Path sourceRoot, Path targetRoot) throws IOException { + Path targetPath = targetRoot.resolve(parentClass); + Files.createDirectories(targetPath.getParent()); + Files.copy(sourceRoot.resolve(parentClass), targetPath); + + for (String innerClass : innerClasses) { + Files.copy(sourceRoot.resolve(innerClass), targetRoot.resolve(innerClass)); + } + } + + /** + * Hash the class and its inner classes using sha256. + * @param root The root of the jar + * @return The hash of the class and its inner classes + * + * @throws IOException If an error occurs while hashing the files + */ + public String hash(Path root) throws IOException { + StringJoiner joiner = new StringJoiner(","); + + joiner.add(Checksum.sha256Hex(Files.readAllBytes(root.resolve(parentClass)))); + + for (String innerClass : innerClasses) { + joiner.add(Checksum.sha256Hex(Files.readAllBytes(root.resolve(innerClass)))); + } + + return Checksum.sha256Hex(joiner.toString().getBytes()); + } + + public String sourcesFileName() { + return parentClass.replace(".class", ".java"); + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/JarWalker.java b/src/main/java/net/fabricmc/loom/decompilers/cache/JarWalker.java new file mode 100644 index 00000000..ab2f9924 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/JarWalker.java @@ -0,0 +1,108 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.fabricmc.loom.util.FileSystemUtil; + +public final class JarWalker { + private static final Logger LOGGER = LoggerFactory.getLogger(JarWalker.class); + + private JarWalker() { + } + + public static List findClasses(Path jar) throws IOException { + try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(jar)) { + return findClasses(fs); + } + } + + public static List findClasses(FileSystemUtil.Delegate fs) throws IOException { + List outerClasses = new ArrayList<>(); + Map> innerClasses = new HashMap<>(); + + // Iterate over all the classes in the jar, and store them into the sorted list. + try (Stream walk = Files.walk(fs.getRoot())) { + Iterator iterator = walk.iterator(); + + while (iterator.hasNext()) { + final Path entry = iterator.next(); + + if (!Files.isRegularFile(entry)) { + continue; + } + + final String fileName = entry.toString().substring(fs.getRoot().toString().length()); + + if (!fileName.endsWith(".class")) { + continue; + } + + boolean isInnerClass = fileName.contains("$"); + + if (isInnerClass) { + String outerClassName = fileName.substring(0, fileName.indexOf('$')) + ".class"; + innerClasses.computeIfAbsent(outerClassName, k -> new ArrayList<>()).add(fileName); + } else { + outerClasses.add(fileName); + } + } + } + + LOGGER.info("Found {} outer classes and {} inner classes", outerClasses.size(), innerClasses.size()); + + Collections.sort(outerClasses); + + List classEntries = new ArrayList<>(); + + for (String outerClass : outerClasses) { + List innerClasList = innerClasses.get(outerClass); + + if (innerClasList == null) { + innerClasList = Collections.emptyList(); + } else { + Collections.sort(innerClasList); + } + + ClassEntry classEntry = new ClassEntry(outerClass, Collections.unmodifiableList(innerClasList)); + classEntries.add(classEntry); + } + + return Collections.unmodifiableList(classEntries); + } +} diff --git a/src/main/java/net/fabricmc/loom/decompilers/cache/RiffChunk.java b/src/main/java/net/fabricmc/loom/decompilers/cache/RiffChunk.java new file mode 100644 index 00000000..73c8afae --- /dev/null +++ b/src/main/java/net/fabricmc/loom/decompilers/cache/RiffChunk.java @@ -0,0 +1,69 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.decompilers.cache; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; + +/** + * Write a RIFF chunk to a file channel + * + *

Works by writing the chunk header and then reserving space for the chunk size. + * The chunk size is then written after the chunk data has been written. + */ +public class RiffChunk implements AutoCloseable { + private final long position; + private final FileChannel fileChannel; + + public RiffChunk(String id, FileChannel fileChannel) throws IOException { + if (id.length() != 4) { + throw new IllegalArgumentException("ID must be 4 characters long"); + } + + // Write the chunk header and reserve space for the chunk size + fileChannel.write(ByteBuffer.wrap(id.getBytes(StandardCharsets.US_ASCII))); + this.position = fileChannel.position(); + fileChannel.write(ByteBuffer.allocate(4)); + + // Store the position and file channel for later use + this.fileChannel = fileChannel; + } + + @Override + public void close() throws IOException { + long endPosition = fileChannel.position(); + long chunkSize = endPosition - position - 4; + + if (chunkSize > Integer.MAX_VALUE) { + throw new IOException("Chunk size is too large"); + } + + fileChannel.position(position); + fileChannel.write(ByteBuffer.allocate(Integer.BYTES).putInt((int) (chunkSize)).flip()); + fileChannel.position(endPosition); + } +} diff --git a/src/main/java/net/fabricmc/loom/extension/LoomFiles.java b/src/main/java/net/fabricmc/loom/extension/LoomFiles.java index 9d115ab0..f0d1a8b9 100644 --- a/src/main/java/net/fabricmc/loom/extension/LoomFiles.java +++ b/src/main/java/net/fabricmc/loom/extension/LoomFiles.java @@ -50,4 +50,5 @@ public interface LoomFiles { File getRemapClasspathFile(); File getGlobalMinecraftRepo(); File getLocalMinecraftRepo(); + File getDecompileCache(String version); } diff --git a/src/main/java/net/fabricmc/loom/extension/LoomFilesBaseImpl.java b/src/main/java/net/fabricmc/loom/extension/LoomFilesBaseImpl.java index d7f0c469..8b23e4f9 100644 --- a/src/main/java/net/fabricmc/loom/extension/LoomFilesBaseImpl.java +++ b/src/main/java/net/fabricmc/loom/extension/LoomFilesBaseImpl.java @@ -107,4 +107,9 @@ public abstract class LoomFilesBaseImpl implements LoomFiles { public File getLocalMinecraftRepo() { return new File(getRootProjectPersistentCache(), "minecraftMaven"); } + + @Override + public File getDecompileCache(String version) { + return new File(getUserCache(), "decompile/" + version + ".zip"); + } } diff --git a/src/main/java/net/fabricmc/loom/task/GenerateSourcesTask.java b/src/main/java/net/fabricmc/loom/task/GenerateSourcesTask.java index 0e235b0b..74769dbc 100644 --- a/src/main/java/net/fabricmc/loom/task/GenerateSourcesTask.java +++ b/src/main/java/net/fabricmc/loom/task/GenerateSourcesTask.java @@ -24,6 +24,7 @@ package net.fabricmc.loom.task; +import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -36,26 +37,33 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.StringJoiner; import java.util.UUID; import java.util.stream.Collectors; import javax.inject.Inject; import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; import org.gradle.api.services.ServiceReference; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.options.Option; import org.gradle.process.ExecOperations; import org.gradle.process.ExecResult; import org.gradle.work.DisableCachingByDefault; @@ -64,8 +72,12 @@ import org.gradle.workers.WorkParameters; import org.gradle.workers.WorkQueue; import org.gradle.workers.WorkerExecutor; import org.gradle.workers.internal.WorkerDaemonClientsManager; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import net.fabricmc.loom.LoomGradleExtension; import net.fabricmc.loom.api.decompilers.DecompilationMetadata; import net.fabricmc.loom.api.decompilers.DecompilerOptions; import net.fabricmc.loom.api.decompilers.LoomDecompiler; @@ -75,7 +87,12 @@ import net.fabricmc.loom.configuration.processors.MappingProcessorContextImpl; import net.fabricmc.loom.configuration.processors.MinecraftJarProcessorManager; import net.fabricmc.loom.configuration.providers.minecraft.MinecraftJar; import net.fabricmc.loom.configuration.providers.minecraft.mapped.AbstractMappedMinecraftProvider; +import net.fabricmc.loom.decompilers.ClassLineNumbers; import net.fabricmc.loom.decompilers.LineNumberRemapper; +import net.fabricmc.loom.decompilers.cache.CachedData; +import net.fabricmc.loom.decompilers.cache.CachedFileStoreImpl; +import net.fabricmc.loom.decompilers.cache.CachedJarProcessor; +import net.fabricmc.loom.util.Checksum; import net.fabricmc.loom.util.Constants; import net.fabricmc.loom.util.ExceptionUtil; import net.fabricmc.loom.util.FileSystemUtil; @@ -95,6 +112,8 @@ import net.fabricmc.mappingio.tree.MemoryMappingTree; @DisableCachingByDefault public abstract class GenerateSourcesTask extends AbstractLoomTask { + private static final Logger LOGGER = LoggerFactory.getLogger(GenerateSourcesTask.class); + private static final String CACHE_VERSION = "v1"; private final DecompilerOptions decompilerOptions; /** @@ -122,10 +141,25 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { @Optional public abstract ConfigurableFileCollection getUnpickClasspath(); + @InputFiles + @Optional + @ApiStatus.Internal + public abstract ConfigurableFileCollection getUnpickRuntimeClasspath(); + @OutputFile @Optional public abstract RegularFileProperty getUnpickOutputJar(); + @Input + @Option(option = "use-cache", description = "Use the decompile cache") + @ApiStatus.Experimental + public abstract Property getUseCache(); + + // Internal outputs + @ApiStatus.Internal + @Internal + protected abstract RegularFileProperty getDecompileCacheFile(); + // Injects @Inject public abstract WorkerExecutor getWorkerExecutor(); @@ -147,6 +181,12 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { getOutputs().upToDateWhen((o) -> false); getClasspath().from(decompilerOptions.getClasspath()).finalizeValueOnRead(); dependsOn(decompilerOptions.getClasspath().getBuiltBy()); + + LoomGradleExtension extension = LoomGradleExtension.get(getProject()); + getDecompileCacheFile().set(extension.getFiles().getDecompileCache(CACHE_VERSION)); + getUnpickRuntimeClasspath().from(getProject().getConfigurations().getByName(Constants.Configurations.UNPICK_CLASSPATH)); + + getUseCache().convention(false); } @TaskAction @@ -157,21 +197,195 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { throw new UnsupportedOperationException("GenSources task requires a 64bit JVM to run due to the memory requirements."); } + if (!getUseCache().get()) { + try (var timer = new Timer("Decompiled sources")) { + runWithoutCache(); + } + + return; + } + + LOGGER.warn("Using decompile cache is experimental and may not work as expected."); + + try (var timer = new Timer("Decompiled sources with cache")) { + final Path cacheFile = getDecompileCacheFile().getAsFile().get().toPath(); + + // TODO ensure we have a lock on this file to prevent multiple tasks from running at the same time + // TODO handle being unable to read the cache file + Files.createDirectories(cacheFile.getParent()); + + try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(cacheFile, true)) { + runWithCache(fs.getRoot()); + } + } + } + + private void runWithCache(Path cacheRoot) throws IOException { final MinecraftJar minecraftJar = rebuildInputJar(); - // Input jar is the jar to decompile, this may be unpicked. + final var cacheRules = new CachedFileStoreImpl.CacheRules(50_000, Duration.ofDays(90)); + final var decompileCache = new CachedFileStoreImpl<>(cacheRoot, CachedData.SERIALIZER, cacheRules); + final String cacheKey = getCacheKey(); + final CachedJarProcessor cachedJarProcessor = new CachedJarProcessor(decompileCache, cacheKey); + final CachedJarProcessor.WorkRequest workRequest; + + LOGGER.info("Decompile cache key: {}", cacheKey); + + try (var timer = new Timer("Prepare job")) { + workRequest = cachedJarProcessor.prepareJob(minecraftJar.getPath()); + } + + final CachedJarProcessor.WorkJob job = workRequest.job(); + final CachedJarProcessor.CacheStats cacheStats = workRequest.stats(); + + getProject().getLogger().lifecycle("Decompiling: Cache stats: {} hits, {} misses", cacheStats.hits(), cacheStats.misses()); + + ClassLineNumbers outputLineNumbers = null; + + if (job instanceof CachedJarProcessor.WorkToDoJob workToDoJob) { + Path inputJar = workToDoJob.incomplete(); + @Nullable Path existing = (job instanceof CachedJarProcessor.PartialWorkJob partialWorkJob) ? partialWorkJob.existing() : null; + + if (getUnpickDefinitions().isPresent()) { + try (var timer = new Timer("Unpick")) { + inputJar = unpickJar(inputJar, existing); + } + } + + try (var timer = new Timer("Decompile")) { + outputLineNumbers = runDecompileJob(inputJar, workToDoJob.output(), existing); + } + + if (Files.notExists(workToDoJob.output())) { + throw new RuntimeException("Failed to decompile sources"); + } + } else if (job instanceof CachedJarProcessor.CompletedWorkJob completedWorkJob) { + // Nothing to do :) + } + + // The final output sources jar + final Path sourcesJar = getOutputJar().get().getAsFile().toPath(); + Files.deleteIfExists(sourcesJar); + + try (var timer = new Timer("Complete job")) { + cachedJarProcessor.completeJob(sourcesJar, job, outputLineNumbers); + } + + // This is the minecraft jar used at runtime. + final Path classesJar = minecraftJar.getPath(); + + // Remap the line numbers with the new and existing numbers + final ClassLineNumbers existingLinenumbers = workRequest.lineNumbers(); + final ClassLineNumbers lineNumbers = ClassLineNumbers.merge(existingLinenumbers, outputLineNumbers); + + if (lineNumbers == null) { + LOGGER.info("No line numbers to remap, skipping remapping"); + return; + } + + Path tempJar = Files.createTempFile("loom", "linenumber-remap.jar"); + Files.delete(tempJar); + + try (var timer = new Timer("Remap line numbers")) { + remapLineNumbers(lineNumbers, classesJar, tempJar); + } + + Files.move(tempJar, classesJar, StandardCopyOption.REPLACE_EXISTING); + + try (var timer = new Timer("Prune cache")) { + decompileCache.prune(); + } + } + + private void runWithoutCache() throws IOException { + final MinecraftJar minecraftJar = rebuildInputJar(); + Path inputJar = minecraftJar.getPath(); - // Runtime jar is the jar used to run the game - final Path runtimeJar = inputJar; + // The final output sources jar + final Path sourcesJar = getOutputJar().get().getAsFile().toPath(); if (getUnpickDefinitions().isPresent()) { - inputJar = unpickJar(inputJar); + try (var timer = new Timer("Unpick")) { + inputJar = unpickJar(inputJar, null); + } } + ClassLineNumbers lineNumbers; + + try (var timer = new Timer("Decompile")) { + lineNumbers = runDecompileJob(inputJar, sourcesJar, null); + } + + if (Files.notExists(sourcesJar)) { + throw new RuntimeException("Failed to decompile sources"); + } + + if (lineNumbers == null) { + LOGGER.info("No line numbers to remap, skipping remapping"); + return; + } + + // This is the minecraft jar used at runtime. + final Path classesJar = minecraftJar.getPath(); + final Path tempJar = Files.createTempFile("loom", "linenumber-remap.jar"); + Files.delete(tempJar); + + try (var timer = new Timer("Remap line numbers")) { + remapLineNumbers(lineNumbers, classesJar, tempJar); + } + + Files.move(tempJar, classesJar, StandardCopyOption.REPLACE_EXISTING); + } + + private String getCacheKey() { + var sj = new StringJoiner(","); + sj.add(getDecompilerCheckKey()); + sj.add(getUnpickCacheKey()); + + LOGGER.info("Decompile cache data: {}", sj); + + try { + return Checksum.sha256Hex(sj.toString().getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private String getDecompilerCheckKey() { + var sj = new StringJoiner(","); + sj.add(decompilerOptions.getDecompilerClassName().get()); + sj.add(fileCollectionHash(decompilerOptions.getClasspath())); + + for (Map.Entry entry : decompilerOptions.getOptions().get().entrySet()) { + sj.add(entry.getKey() + "=" + entry.getValue()); + } + + return sj.toString(); + } + + private String getUnpickCacheKey() { + if (!getUnpickDefinitions().isPresent()) { + return ""; + } + + var sj = new StringJoiner(","); + sj.add(fileHash(getUnpickDefinitions().getAsFile().get())); + sj.add(fileCollectionHash(getUnpickConstantJar())); + sj.add(fileCollectionHash(getUnpickRuntimeClasspath())); + + return sj.toString(); + } + + @Nullable + private ClassLineNumbers runDecompileJob(Path inputJar, Path outputJar, @Nullable Path existingJar) throws IOException { + final Platform platform = Platform.CURRENT; + final Path lineMapFile = File.createTempFile("loom", "linemap").toPath(); + Files.delete(lineMapFile); + if (!platform.supportsUnixDomainSockets()) { getProject().getLogger().warn("Decompile worker logging disabled as Unix Domain Sockets is not supported on your operating system."); - doWork(null, inputJar, runtimeJar); - return; + doWork(null, inputJar, outputJar, lineMapFile, existingJar); + return readLineNumbers(lineMapFile); } // Set up the IPC path to get the log output back from the forked JVM @@ -180,12 +394,14 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { try (ThreadedProgressLoggerConsumer loggerConsumer = new ThreadedProgressLoggerConsumer(getProject(), decompilerOptions.getName(), "Decompiling minecraft sources"); IPCServer logReceiver = new IPCServer(ipcPath, loggerConsumer)) { - doWork(logReceiver, inputJar, runtimeJar); + doWork(logReceiver, inputJar, outputJar, lineMapFile, existingJar); } catch (InterruptedException e) { throw new RuntimeException("Failed to shutdown log receiver", e); } finally { Files.deleteIfExists(ipcPath); } + + return readLineNumbers(lineMapFile); } // Re-run the named minecraft provider to give us a fresh jar to decompile. @@ -214,13 +430,13 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { ); } - private Path unpickJar(Path inputJar) { + private Path unpickJar(Path inputJar, @Nullable Path existingJar) { final Path outputJar = getUnpickOutputJar().get().getAsFile().toPath(); - final List args = getUnpickArgs(inputJar, outputJar); + final List args = getUnpickArgs(inputJar, outputJar, existingJar); ExecResult result = getExecOperations().javaexec(spec -> { spec.getMainClass().set("daomephsta.unpick.cli.Main"); - spec.classpath(getProject().getConfigurations().getByName(Constants.Configurations.UNPICK_CLASSPATH)); + spec.classpath(getUnpickRuntimeClasspath()); spec.args(args); spec.systemProperty("java.util.logging.config.file", writeUnpickLogConfig().getAbsolutePath()); }); @@ -230,7 +446,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { return outputJar; } - private List getUnpickArgs(Path inputJar, Path outputJar) { + private List getUnpickArgs(Path inputJar, Path outputJar, @Nullable Path existingJar) { var fileArgs = new ArrayList(); fileArgs.add(inputJar.toFile()); @@ -247,6 +463,10 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { fileArgs.add(file); } + if (existingJar != null) { + fileArgs.add(existingJar.toFile()); + } + return fileArgs.stream() .map(File::getAbsolutePath) .toList(); @@ -265,25 +485,40 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { return unpickLoggingConfigFile; } - private void doWork(@Nullable IPCServer ipcServer, Path inputJar, Path runtimeJar) { + private void remapLineNumbers(ClassLineNumbers lineNumbers, Path inputJar, Path outputJar) throws IOException { + Objects.requireNonNull(lineNumbers, "lineNumbers"); + final var remapper = new LineNumberRemapper(lineNumbers); + + try (FileSystemUtil.Delegate inFs = FileSystemUtil.getJarFileSystem(inputJar, false); + FileSystemUtil.Delegate outFs = FileSystemUtil.getJarFileSystem(outputJar, true)) { + remapper.process(inFs.get().getPath("/"), outFs.get().getPath("/")); + } + } + + private void doWork(@Nullable IPCServer ipcServer, Path inputJar, Path outputJar, Path linemapFile, @Nullable Path existingJar) { final String jvmMarkerValue = UUID.randomUUID().toString(); final WorkQueue workQueue = createWorkQueue(jvmMarkerValue); + ConfigurableFileCollection classpath = getProject().files(); + classpath.from(getProject().getConfigurations().getByName(Constants.Configurations.MINECRAFT_COMPILE_LIBRARIES)); + + if (existingJar != null) { + classpath.from(existingJar); + } + workQueue.submit(DecompileAction.class, params -> { params.getDecompilerOptions().set(decompilerOptions.toDto()); params.getInputJar().set(inputJar.toFile()); - params.getRuntimeJar().set(runtimeJar.toFile()); - params.getSourcesDestinationJar().set(getOutputJar()); - params.getLinemap().set(getMappedJarFileWithSuffix("-sources.lmap", runtimeJar)); - params.getLinemapJar().set(getMappedJarFileWithSuffix("-linemapped.jar", runtimeJar)); + params.getOutputJar().set(outputJar.toFile()); + params.getLinemapFile().set(linemapFile.toFile()); params.getMappings().set(getMappings().toFile()); if (ipcServer != null) { params.getIPCPath().set(ipcServer.getPath().toFile()); } - params.getClassPath().setFrom(getProject().getConfigurations().getByName(Constants.Configurations.MINECRAFT_COMPILE_LIBRARIES)); + params.getClassPath().setFrom(classpath); }); try { @@ -325,10 +560,8 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { Property getDecompilerOptions(); RegularFileProperty getInputJar(); - RegularFileProperty getRuntimeJar(); - RegularFileProperty getSourcesDestinationJar(); - RegularFileProperty getLinemap(); - RegularFileProperty getLinemapJar(); + RegularFileProperty getOutputJar(); + RegularFileProperty getLinemapFile(); RegularFileProperty getMappings(); RegularFileProperty getIPCPath(); @@ -356,10 +589,8 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { private void doDecompile(IOStringConsumer logger) { final Path inputJar = getParameters().getInputJar().get().getAsFile().toPath(); - final Path sourcesDestinationJar = getParameters().getSourcesDestinationJar().get().getAsFile().toPath(); - final Path linemap = getParameters().getLinemap().get().getAsFile().toPath(); - final Path linemapJar = getParameters().getLinemapJar().get().getAsFile().toPath(); - final Path runtimeJar = getParameters().getRuntimeJar().get().getAsFile().toPath(); + final Path linemap = getParameters().getLinemapFile().get().getAsFile().toPath(); + final Path outputJar = getParameters().getOutputJar().get().getAsFile().toPath(); final DecompilerOptions.Dto decompilerOptions = getParameters().getDecompilerOptions().get(); @@ -375,7 +606,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { throw new RuntimeException("Failed to create decompiler", e); } - DecompilationMetadata metadata = new DecompilationMetadata( + final var metadata = new DecompilationMetadata( decompilerOptions.maxThreads(), getParameters().getMappings().get().getAsFile().toPath(), getLibraries(), @@ -385,7 +616,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { decompiler.decompile( inputJar, - sourcesDestinationJar, + outputJar, linemap, metadata ); @@ -396,28 +627,6 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { } catch (IOException e) { throw new UncheckedIOException("Failed to close loggers", e); } - - if (Files.exists(linemap)) { - try { - // Line map the actually jar used to run the game, not the one used to decompile - remapLineNumbers(metadata.logger(), runtimeJar, linemap, linemapJar); - - Files.copy(linemapJar, runtimeJar, StandardCopyOption.REPLACE_EXISTING); - Files.delete(linemapJar); - } catch (IOException e) { - throw new UncheckedIOException("Failed to remap line numbers", e); - } - } - } - - private void remapLineNumbers(IOStringConsumer logger, Path oldCompiledJar, Path linemap, Path linemappedJarDestination) throws IOException { - LineNumberRemapper remapper = new LineNumberRemapper(); - remapper.readMappings(linemap.toFile()); - - try (FileSystemUtil.Delegate inFs = FileSystemUtil.getJarFileSystem(oldCompiledJar.toFile(), true); - FileSystemUtil.Delegate outFs = FileSystemUtil.getJarFileSystem(linemappedJarDestination.toFile(), true)) { - remapper.process(logger, inFs.get().getPath("/"), outFs.get().getPath("/")); - } } private Collection getLibraries() { @@ -425,16 +634,6 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { } } - public static File getMappedJarFileWithSuffix(String suffix, Path runtimeJar) { - final String path = runtimeJar.toFile().getAbsolutePath(); - - if (!path.toLowerCase(Locale.ROOT).endsWith(".jar")) { - throw new RuntimeException("Invalid mapped JAR path: " + path); - } - - return new File(path.substring(0, path.length() - 4) + suffix); - } - private Path getMappings() { Path inputMappings = getExtension().getMappingConfiguration().tinyMappings; @@ -493,8 +692,25 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { return outputMappings; } - public interface MappingsProcessor { - boolean transform(MemoryMappingTree mappings); + public static File getJarFileWithSuffix(String suffix, Path runtimeJar) { + final String path = runtimeJar.toFile().getAbsolutePath(); + + if (!path.toLowerCase(Locale.ROOT).endsWith(".jar")) { + throw new RuntimeException("Invalid mapped JAR path: " + path); + } + + return new File(path.substring(0, path.length() - 4) + suffix); + } + + @Nullable + private static ClassLineNumbers readLineNumbers(Path linemapFile) throws IOException { + if (Files.notExists(linemapFile)) { + return null; + } + + try (BufferedReader reader = Files.newBufferedReader(linemapFile, StandardCharsets.UTF_8)) { + return ClassLineNumbers.readMappings(reader); + } } private static Constructor getDecompilerConstructor(String clazz) { @@ -507,4 +723,43 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask { throw new RuntimeException(e); } } + + private static String fileHash(File file) { + try { + return Checksum.sha256Hex(Files.readAllBytes(file.toPath())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String fileCollectionHash(FileCollection files) { + var sj = new StringJoiner(","); + + files.getFiles() + .stream() + .sorted(Comparator.comparing(File::getAbsolutePath)) + .map(GenerateSourcesTask::fileHash) + .forEach(sj::add); + + return sj.toString(); + } + + public interface MappingsProcessor { + boolean transform(MemoryMappingTree mappings); + } + + private final class Timer implements AutoCloseable { + private final String name; + private final long start; + + Timer(String name) { + this.name = name; + this.start = System.currentTimeMillis(); + } + + @Override + public void close() { + getProject().getLogger().info("{} took {}ms", name, System.currentTimeMillis() - start); + } + } } diff --git a/src/main/java/net/fabricmc/loom/util/Checksum.java b/src/main/java/net/fabricmc/loom/util/Checksum.java index 50fcbec0..0b13b980 100644 --- a/src/main/java/net/fabricmc/loom/util/Checksum.java +++ b/src/main/java/net/fabricmc/loom/util/Checksum.java @@ -67,6 +67,11 @@ public class Checksum { } } + public static String sha256Hex(byte[] input) throws IOException { + HashCode hash = ByteSource.wrap(input).hash(Hashing.sha256()); + return Checksum.toHex(hash.asBytes()); + } + public static String sha1Hex(Path path) throws IOException { HashCode hash = Files.asByteSource(path.toFile()).hash(Hashing.sha1()); return toHex(hash.asBytes()); diff --git a/src/main/java/net/fabricmc/loom/util/FileSystemUtil.java b/src/main/java/net/fabricmc/loom/util/FileSystemUtil.java index 0a097c83..07fe08cf 100644 --- a/src/main/java/net/fabricmc/loom/util/FileSystemUtil.java +++ b/src/main/java/net/fabricmc/loom/util/FileSystemUtil.java @@ -43,6 +43,10 @@ public final class FileSystemUtil { return get().getPath(path, more); } + public Path getRoot() { + return get().getPath("/"); + } + public byte[] readAllBytes(String path) throws IOException { Path fsPath = getPath(path); diff --git a/src/main/java/net/fabricmc/loom/util/ZipUtils.java b/src/main/java/net/fabricmc/loom/util/ZipUtils.java index 8fdf1fd6..714dce83 100644 --- a/src/main/java/net/fabricmc/loom/util/ZipUtils.java +++ b/src/main/java/net/fabricmc/loom/util/ZipUtils.java @@ -79,13 +79,13 @@ public class ZipUtils { public static void unpackAll(Path zip, Path output) throws IOException { try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(zip, false); - Stream walk = Files.walk(fs.get().getPath("/"))) { + Stream walk = Files.walk(fs.getRoot())) { Iterator iterator = walk.iterator(); while (iterator.hasNext()) { Path fsPath = iterator.next(); if (!Files.isRegularFile(fsPath)) continue; - Path dstPath = output.resolve(fs.get().getPath("/").relativize(fsPath).toString()); + Path dstPath = output.resolve(fs.getRoot().relativize(fsPath).toString()); Path dstPathParent = dstPath.getParent(); if (dstPathParent != null) Files.createDirectories(dstPathParent); Files.copy(fsPath, dstPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/DebugLineNumbersTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/DebugLineNumbersTest.groovy index dd220320..e7eda02b 100644 --- a/src/test/groovy/net/fabricmc/loom/test/integration/DebugLineNumbersTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/integration/DebugLineNumbersTest.groovy @@ -84,7 +84,7 @@ class DebugLineNumbersTest extends Specification implements GradleProjectTestTra ''' when: // First generate sources - def genSources = gradle.run(task: "genSources") + def genSources = gradle.run(task: "genSources", args: ["--info"]) genSources.task(":genSources").outcome == SUCCESS // Print out the source of the file diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/DecompileTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/DecompileTest.groovy index 7431aae0..dd8dca6d 100644 --- a/src/test/groovy/net/fabricmc/loom/test/integration/DecompileTest.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/integration/DecompileTest.groovy @@ -74,4 +74,36 @@ class DecompileTest extends Specification implements GradleProjectTestTrait { where: version << STANDARD_TEST_VERSIONS } + + def "decompile cache"() { + setup: + def gradle = gradleProject(project: "minimalBase", version: PRE_RELEASE_GRADLE) + gradle.buildSrc("decompile") + gradle.buildGradle << ''' + dependencies { + minecraft "com.mojang:minecraft:1.20.4" + mappings "net.fabricmc:yarn:1.20.4+build.3:v2" + } + ''' + + when: + def result = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"]) + + // Add fabric API to the project, this introduces some transitive access wideners + gradle.buildGradle << ''' + dependencies { + modImplementation "net.fabricmc.fabric-api:fabric-api:0.96.4+1.20.4" + } + ''' + + def result2 = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"]) + + // And run again, with no changes + def result3 = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"]) + + then: + result.task(":genSourcesWithVineflower").outcome == SUCCESS + result2.task(":genSourcesWithVineflower").outcome == SUCCESS + result3.task(":genSourcesWithVineflower").outcome == SUCCESS + } } diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/buildSrc/decompile/CustomDecompiler.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/buildSrc/decompile/CustomDecompiler.groovy index 90829f74..31609a91 100644 --- a/src/test/groovy/net/fabricmc/loom/test/integration/buildSrc/decompile/CustomDecompiler.groovy +++ b/src/test/groovy/net/fabricmc/loom/test/integration/buildSrc/decompile/CustomDecompiler.groovy @@ -26,6 +26,8 @@ package net.fabricmc.loom.test.integration.buildSrc.decompile import java.nio.file.Path +import com.google.common.io.Files + import net.fabricmc.loom.api.decompilers.DecompilationMetadata import net.fabricmc.loom.api.decompilers.LoomDecompiler @@ -33,5 +35,6 @@ class CustomDecompiler implements LoomDecompiler { @Override void decompile(Path compiledJar, Path sourcesDestination, Path linemapDestination, DecompilationMetadata metaData) { println("Running custom decompiler") + Files.touch(sourcesDestination.toFile()) } } diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/ClassLineNumbersTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/ClassLineNumbersTest.groovy new file mode 100644 index 00000000..37fd125b --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/ClassLineNumbersTest.groovy @@ -0,0 +1,99 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2023 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 spock.lang.Specification + +import net.fabricmc.loom.decompilers.ClassLineNumbers + +class ClassLineNumbersTest extends Specification { + def "read linemap"() { + when: + def reader = new BufferedReader(new StringReader(LINE_MAP)) + def lineNumbers = ClassLineNumbers.readMappings(reader) + def lineMap = lineNumbers.lineMap() + + then: + lineMap.size() == 2 + lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].lineMap().size() == 39 + lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].maxLine() == 203 + lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].maxLineDest() == 187 + + lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].lineMap().size() == 6 + lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].maxLine() == 25 + lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].maxLineDest() == 30 + } + + private static final String LINE_MAP = """ +net/minecraft/server/dedicated/ServerPropertiesHandler\t203\t187 +\t48\t187 +\t91\t92 +\t96\t97 +\t110\t108 +\t112\t109 +\t113\t110 +\t115\t111 +\t116\t112 +\t118\t113 +\t119\t113 +\t120\t113 +\t122\t114 +\t130\t115 +\t147\t129 +\t149\t131 +\t151\t133 +\t154\t136 +\t158\t141 +\t159\t142 +\t163\t144 +\t164\t145 +\t165\t146 +\t166\t147 +\t168\t149 +\t169\t150 +\t170\t151 +\t172\t153 +\t175\t155 +\t176\t156 +\t177\t157 +\t178\t158 +\t181\t160 +\t186\t165 +\t187\t166 +\t192\t171 +\t194\t173 +\t195\t174 +\t197\t176 +\t203\t182 + +net/minecraft/server/dedicated/ServerPropertiesLoader\t25\t30 +\t11\t15 +\t12\t16 +\t16\t20 +\t20\t24 +\t24\t28 +\t25\t30 +""" +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/JarWalkerTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/JarWalkerTest.groovy new file mode 100644 index 00000000..7f0d0c57 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/JarWalkerTest.groovy @@ -0,0 +1,87 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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 spock.lang.Specification + +import net.fabricmc.loom.decompilers.cache.JarWalker +import net.fabricmc.loom.test.util.ZipTestUtils +import net.fabricmc.loom.util.FileSystemUtil + +class JarWalkerTest extends Specification { + def "find classes in jar"() { + given: + def jar = ZipTestUtils.createZip([ + "net/fabricmc/Test.class": "", + "net/fabricmc/other/Test.class": "", + "net/fabricmc/other/Test\$Inner.class": "", + "net/fabricmc/other/Test\$1.class": "", + ]) + when: + def entries = JarWalker.findClasses(jar) + then: + entries.size() == 2 + + entries[0].parentClass() == "net/fabricmc/Test.class" + entries[0].sourcesFileName() == "net/fabricmc/Test.java" + entries[0].innerClasses().size() == 0 + + entries[1].parentClass() == "net/fabricmc/other/Test.class" + entries[1].sourcesFileName() == "net/fabricmc/other/Test.java" + entries[1].innerClasses().size() == 2 + entries[1].innerClasses()[0] == "net/fabricmc/other/Test\$1.class" + entries[1].innerClasses()[1] == "net/fabricmc/other/Test\$Inner.class" + } + + def "Hash Classes"() { + given: + def jar = ZipTestUtils.createZip(zipEntries) + when: + def entries = JarWalker.findClasses(jar) + def hash = FileSystemUtil.getJarFileSystem(jar).withCloseable { fs -> + return entries[0].hash(fs.root) + } + then: + entries.size() == 1 + hash == expectedHash + where: + expectedHash | zipEntries + "2339de144d8a4a1198adf8142b6d3421ec0baacea13c9ade42a93071b6d62e43" | [ + "net/fabricmc/Test.class": "abc123", + ] + "1053cfadf4e371ec89ff5b58d9b3bdb80373f3179e804b2e241171223709f4d1" | [ + "net/fabricmc/other/Test.class": "Hello", + "net/fabricmc/other/Test\$Inner.class": "World", + "net/fabricmc/other/Test\$Inner\$2.class": "123", + "net/fabricmc/other/Test\$1.class": "test", + ] + "f30b705f3a921b60103a4ee9951aff59b6db87cc289ba24563743d753acff433" | [ + "net/fabricmc/other/Test.class": "Hello", + "net/fabricmc/other/Test\$Inner.class": "World", + "net/fabricmc/other/Test\$Inner\$2.class": "abc123", + "net/fabricmc/other/Test\$1.class": "test", + ] + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedDataTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedDataTest.groovy new file mode 100644 index 00000000..d43417ea --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedDataTest.groovy @@ -0,0 +1,62 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.cache + +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +import spock.lang.Specification +import spock.lang.TempDir + +import net.fabricmc.loom.decompilers.ClassLineNumbers +import net.fabricmc.loom.decompilers.cache.CachedData + +class CachedDataTest extends Specification { + @TempDir + Path testPath + + // Simple test to check if the CachedData class can be written and read from a file + def "Read + Write CachedData"() { + given: + def lineNumberEntry = new ClassLineNumbers.Entry("net/test/TestClass", 1, 2, [1: 2, 4: 7]) + def cachedData = new CachedData("net/test/TestClass", "Example sources", lineNumberEntry) + def path = testPath.resolve("cachedData.bin") + when: + // Write the cachedData to a file + FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE).withCloseable { + cachedData.write(it) + } + + // And read it back + def readCachedData = Files.newInputStream(path).withCloseable { + return CachedData.read(it) + } + + then: + cachedData == readCachedData + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedFileStoreTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedFileStoreTest.groovy new file mode 100644 index 00000000..04292ab4 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedFileStoreTest.groovy @@ -0,0 +1,132 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.cache + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime +import java.time.Duration +import java.time.Instant + +import spock.lang.Specification +import spock.lang.TempDir + +import net.fabricmc.loom.decompilers.cache.CachedFileStore +import net.fabricmc.loom.decompilers.cache.CachedFileStoreImpl +import net.fabricmc.loom.util.FileSystemUtil + +class CachedFileStoreTest extends Specification { + @TempDir + Path testPath + + FileSystemUtil.Delegate zipDelegate + Path root + + void setup() { + zipDelegate = FileSystemUtil.getJarFileSystem(testPath.resolve("cache.zip"), true) + root = zipDelegate.get().getPath("/") + } + + void cleanup() { + zipDelegate.close() + } + + def "putEntry"() { + given: + def cacheRules = new CachedFileStoreImpl.CacheRules(100, Duration.ofDays(7)) + def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules) + when: + store.putEntry("abc", "Hello world".bytes) + then: + Files.exists(root.resolve("abc")) + } + + def "getEntry"() { + given: + def cacheRules = new CachedFileStoreImpl.CacheRules(100, Duration.ofDays(7)) + def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules) + when: + store.putEntry("abc", "Hello world".bytes) + def entry = store.getEntry("abc") + def unknownEntry = store.getEntry("123") + then: + entry == "Hello world".bytes + unknownEntry == null + } + + def "pruneManyFiles"() { + given: + def cacheRules = new CachedFileStoreImpl.CacheRules(250, Duration.ofDays(7)) + def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules) + when: + + for (i in 0..<500) { + def key = "test_" + i + store.putEntry(key, "Hello world".bytes) + // Higher files are older and should be removed. + Files.setLastModifiedTime(root.resolve(key), FileTime.from(Instant.now().minusSeconds(i))) + } + + store.prune() + + then: + Files.exists(root.resolve("test_0")) + Files.exists(root.resolve("test_100")) + Files.notExists(root.resolve("test_300")) + } + + def "pruneOldFiles"() { + given: + def cacheRules = new CachedFileStoreImpl.CacheRules(1000, Duration.ofSeconds(250)) + def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules) + when: + + for (i in 0..<500) { + def key = "test_" + i + store.putEntry(key, "Hello world".bytes) + // Higher files are older and should be removed. + Files.setLastModifiedTime(root.resolve(key), FileTime.from(Instant.now().minusSeconds(i))) + } + + store.prune() + + then: + Files.exists(root.resolve("test_0")) + Files.exists(root.resolve("test_100")) + Files.notExists(root.resolve("test_300")) + } + + private static CachedFileStore.EntrySerializer BYTE_ARRAY_SERIALIZER = new CachedFileStore.EntrySerializer() { + @Override + byte[] read(Path path) throws IOException { + return Files.readAllBytes(path) + } + + @Override + void write(byte[] entry, Path path) throws IOException { + Files.write(path, entry) + } + } +} diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedJarProcessorTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedJarProcessorTest.groovy new file mode 100644 index 00000000..6b157603 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/unit/cache/CachedJarProcessorTest.groovy @@ -0,0 +1,241 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2024 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.cache + +import java.nio.file.Files + +import spock.lang.Specification + +import net.fabricmc.loom.decompilers.ClassLineNumbers +import net.fabricmc.loom.decompilers.cache.CachedData +import net.fabricmc.loom.decompilers.cache.CachedFileStore +import net.fabricmc.loom.decompilers.cache.CachedJarProcessor +import net.fabricmc.loom.test.util.ZipTestUtils +import net.fabricmc.loom.util.ZipUtils + +class CachedJarProcessorTest extends Specification { + static Map jarEntries = [ + "net/fabricmc/Example.class": "", + "net/fabricmc/other/Test.class": "", + "net/fabricmc/other/Test\$Inner.class": "", + "net/fabricmc/other/Test\$1.class": "", + ] + + static String ExampleHash = "abc123/cd372fb85148700fa88095e3492d3f9f5beb43e555e5ff26d95f5a6adc36f8e6" + static String TestHash = "abc123/ecd40b16ec50b636a390cb8da716a22606965f14e526e3051144dd567f336bc5" + + static CachedData ExampleCachedData = new CachedData("net/fabricmc/Example", "Example sources", lineNumber("net/fabricmc/Example")) + static CachedData TestCachedData = new CachedData("net/fabricmc/other/Test", "Test sources", lineNumber("net/fabricmc/other/Test")) + + def "prepare full work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.FullWorkJob + + then: + workRequest.lineNumbers() == null + workJob.outputNameMap().size() == 2 + + // Expect two calls looking for the existing entry in the cache + 2 * cache.getEntry(_) >> null + + 0 * _ // Strict mock + } + + def "prepare partial work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.PartialWorkJob + def lineMap = workRequest.lineNumbers().lineMap() + + then: + lineMap.size() == 1 + lineMap.get("net/fabricmc/Example") == ExampleCachedData.lineNumbers() + + workJob.outputNameMap().size() == 1 + ZipUtils.unpackNullable(workJob.existing(), "net/fabricmc/Example.java") == "Example sources".bytes + + // Provide one cached entry + // And then one call not finding the entry in the cache + 1 * cache.getEntry(ExampleHash) >> ExampleCachedData + 1 * cache.getEntry(_) >> null + + 0 * _ // Strict mock + } + + def "prepare completed work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.CompletedWorkJob + def lineMap = workRequest.lineNumbers().lineMap() + + then: + lineMap.size() == 2 + lineMap.get("net/fabricmc/Example") == ExampleCachedData.lineNumbers() + lineMap.get("net/fabricmc/other/Test") == TestCachedData.lineNumbers() + + workJob.completed() != null + ZipUtils.unpackNullable(workJob.completed(), "net/fabricmc/Example.java") == "Example sources".bytes + ZipUtils.unpackNullable(workJob.completed(), "net/fabricmc/other/Test.java") == "Test sources".bytes + + // Provide one cached entry + // And then two calls not finding the entry in the cache + 1 * cache.getEntry(ExampleHash) >> ExampleCachedData + 1 * cache.getEntry(TestHash) >> TestCachedData + + 0 * _ // Strict mock + } + + def "complete full work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.FullWorkJob + + // Do the work, such as decompiling. + ZipUtils.add(workJob.output(), "net/fabricmc/Example.java", "Example sources") + ZipUtils.add(workJob.output(), "net/fabricmc/other/Test.java", "Test sources") + + def outputJar = Files.createTempFile("loom-test-output", ".jar") + Files.delete(outputJar) + + ClassLineNumbers lineNumbers = lineNumbers([ + "net/fabricmc/Example", + "net/fabricmc/other/Test" + ]) + processor.completeJob(outputJar, workJob, lineNumbers) + + then: + workJob.outputNameMap().size() == 2 + + ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes + ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes + + // Expect two calls looking for the existing entry in the cache + 1 * cache.getEntry(ExampleHash) >> null + 1 * cache.getEntry(TestHash) >> null + + // Expect the new work to be put into the cache + 1 * cache.putEntry(ExampleHash, ExampleCachedData) + 1 * cache.putEntry(TestHash, TestCachedData) + + 0 * _ // Strict mock + } + + def "complete partial work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.PartialWorkJob + + // Do the work + ZipUtils.add(workJob.output(), "net/fabricmc/other/Test.java", "Test sources") + + def outputJar = Files.createTempFile("loom-test-output", ".jar") + Files.delete(outputJar) + + ClassLineNumbers lineNumbers = lineNumbers([ + "net/fabricmc/Example", + "net/fabricmc/other/Test" + ]) + processor.completeJob(outputJar, workJob, lineNumbers) + + then: + workJob.outputNameMap().size() == 1 + + ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes + ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes + + // The cache already contains sources for example, but not for test + 1 * cache.getEntry(ExampleHash) >> ExampleCachedData + 1 * cache.getEntry(TestHash) >> null + + // Expect the new work to be put into the cache + 1 * cache.putEntry(TestHash, TestCachedData) + + 0 * _ // Strict mock + } + + def "complete completed work job"() { + given: + def jar = ZipTestUtils.createZip(jarEntries) + def cache = Mock(CachedFileStore) + def processor = new CachedJarProcessor(cache, "abc123") + + when: + def workRequest = processor.prepareJob(jar) + def workJob = workRequest.job() as CachedJarProcessor.CompletedWorkJob + + def outputJar = Files.createTempFile("loom-test-output", ".jar") + Files.delete(outputJar) + + ClassLineNumbers lineNumbers = lineNumbers([ + "net/fabricmc/Example", + "net/fabricmc/other/Test" + ]) + processor.completeJob(outputJar, workJob, lineNumbers) + + then: + ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes + ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes + + // The cache already contains sources for example, but not for test + 1 * cache.getEntry(ExampleHash) >> ExampleCachedData + 1 * cache.getEntry(TestHash) >> TestCachedData + + 0 * _ // Strict mock + } + + private static ClassLineNumbers lineNumbers(List names) { + return new ClassLineNumbers(names.collectEntries { [it, lineNumber(it)] }) + } + + private static ClassLineNumbers.Entry lineNumber(String name) { + return new ClassLineNumbers.Entry(name, 0, 0, [:]) + } +}