├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ └── java │ │ └── io │ │ └── codechicken │ │ └── diffpatch │ │ ├── package-info.java │ │ ├── cli │ │ ├── package-info.java │ │ ├── PatchModeValueConverter.java │ │ ├── ArchiveFormatValueConverter.java │ │ ├── CliOperation.java │ │ ├── BakePatchesOperation.java │ │ ├── DiffPatchCli.java │ │ └── DiffOperation.java │ │ ├── diff │ │ ├── package-info.java │ │ ├── PatienceDiffer.java │ │ ├── LineMatchedDiffer.java │ │ └── Differ.java │ │ ├── util │ │ ├── package-info.java │ │ ├── archiver │ │ │ ├── package-info.java │ │ │ ├── ArchiveWriter.java │ │ │ ├── ArchiveReader.java │ │ │ ├── AbstractArchiveOutputStreamWriter.java │ │ │ ├── ZipArchiveOutputStreamWriter.java │ │ │ ├── TarArchiveOutputStreamWriter.java │ │ │ ├── ArchiveInputStreamReader.java │ │ │ └── ArchiveFormat.java │ │ ├── PatchMode.java │ │ ├── IOValidationException.java │ │ ├── Operation.java │ │ ├── Diff.java │ │ ├── LogLevel.java │ │ ├── Utils.java │ │ ├── ConsumingOutputStream.java │ │ ├── ArchiveBuilder.java │ │ ├── FileCollector.java │ │ ├── CharRepresenter.java │ │ ├── LineRange.java │ │ ├── PatchFile.java │ │ ├── Patch.java │ │ ├── Output.java │ │ └── Input.java │ │ ├── match │ │ ├── package-info.java │ │ ├── LineMatching.java │ │ ├── PatienceMatch.java │ │ └── FuzzyLineMatcher.java │ │ └── patch │ │ └── package-info.java └── test │ ├── resources │ ├── patches │ │ ├── AToANoNewline.txt.patch │ │ ├── CreateA.txt.patch │ │ ├── DeleteA.txt.patch │ │ ├── DeleteB.txt.patch │ │ ├── ModifiedA.txt.patch │ │ ├── ModifiedB.txt.patch │ │ ├── ModifiedAAutoHeader.txt.patch │ │ └── ModifiedBAutoHeader.txt.patch │ ├── files │ │ ├── ANoNewline.txt │ │ ├── A.txt │ │ └── B.txt │ └── rejects │ │ └── ModifiedA.txt.patch.rej │ └── java │ └── io │ └── codechicken │ └── diffpatch │ ├── test │ └── TestBase.java │ ├── cli │ ├── BakePatchesTest.java │ ├── DiffOperationTests.java │ └── PatchOperationTests.java │ └── util │ ├── OutputTests.java │ ├── archiver │ └── ArchiveFormatTests.java │ └── InputTests.java ├── README.md ├── settings.gradle ├── .gitignore ├── LICENSE.txt ├── gradlew.bat └── gradlew /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheCBProject/DiffPatch/master/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.cli; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/diff/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.diff; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.util; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/match/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.match; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/patch/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.patch; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiffPatch 2 | Fuzzy Diff and Patch implementation. 3 | Java port of: https://github.com/Chicken-Bones/DiffPatch 4 | With a better CLI interface. 5 | 6 | Builds can be found [here](https://maven.covers1624.net/codechicken/DiffPatch) 7 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by covers1624 on 30/5/24. 3 | */ 4 | @NonNullApi 5 | package io.codechicken.diffpatch.util.archiver; 6 | 7 | import net.covers1624.quack.annotation.NonNullApi; 8 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/PatchMode.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | /** 4 | * Created by covers1624 on 11/8/20. 5 | */ 6 | public enum PatchMode { 7 | EXACT, 8 | ACCESS, 9 | OFFSET, 10 | FUZZY; 11 | } 12 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenLocal() 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | plugins { 9 | id 'org.gradle.toolchains.foojay-resolver-convention' version '0.5.0' 10 | } 11 | 12 | rootProject.name = 'DiffPatch' 13 | 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # exclude all 2 | /* 3 | 4 | # Include Important Folders 5 | !src/ 6 | 7 | # Other Files. 8 | !LICENSE.txt 9 | !README.md 10 | 11 | # Gradle stuff 12 | !gradle/ 13 | !gradlew 14 | !gradlew.bat 15 | !build.gradle 16 | !settings.gradle 17 | 18 | # Include git important files 19 | !.gitmodules 20 | !.gitignore 21 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/IOValidationException.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | /** 4 | * Created by covers1624 on 30/5/24. 5 | */ 6 | public class IOValidationException extends Exception { 7 | 8 | public IOValidationException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/ArchiveWriter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import java.io.Closeable; 4 | import java.io.IOException; 5 | 6 | /** 7 | * Created by covers1624 on 19/7/20. 8 | */ 9 | public interface ArchiveWriter extends Closeable { 10 | 11 | void writeEntry(String name, byte[] bytes) throws IOException; 12 | } 13 | -------------------------------------------------------------------------------- /src/test/resources/patches/AToANoNewline.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ b/A.txt 3 | @@ -6,4 +6,4 @@ 4 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 5 | Quisque quis molestie dui. 6 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 7 | -Ut molestie ex dolor. 8 | +Ut molestie ex dolor. 9 | \ No newline at end of file 10 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/PatchModeValueConverter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.util.PatchMode; 4 | import joptsimple.util.EnumConverter; 5 | 6 | /** 7 | * Created by covers1624 on 11/8/20. 8 | */ 9 | public class PatchModeValueConverter extends EnumConverter { 10 | 11 | public PatchModeValueConverter() { 12 | super(PatchMode.class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Operation.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | /** 4 | * A patch operation. 5 | */ 6 | public enum Operation { 7 | DELETE("-"), 8 | INSERT("+"), 9 | EQUAL(" "); 10 | 11 | private final String prefix; 12 | 13 | Operation(String prefix) { 14 | this.prefix = prefix; 15 | } 16 | 17 | public String getPrefix() { 18 | return prefix; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/ArchiveFormatValueConverter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 4 | import joptsimple.util.EnumConverter; 5 | 6 | /** 7 | * Created by covers1624 on 19/7/20. 8 | */ 9 | public class ArchiveFormatValueConverter extends EnumConverter { 10 | 11 | public ArchiveFormatValueConverter() { 12 | super(ArchiveFormat.class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/files/ANoNewline.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 3 | Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 4 | Sed viverra ligula in lacus vulputate iaculis. 5 | Pellentesque tincidunt mauris sit amet auctor interdum. 6 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 7 | Quisque quis molestie dui. 8 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 9 | Ut molestie ex dolor. -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Diff.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | /** 4 | * Represents a single line Difference. 5 | */ 6 | public class Diff { 7 | 8 | public final Operation op; 9 | public String text; 10 | 11 | public Diff(Operation op, String text) { 12 | this.op = op; 13 | this.text = text; 14 | } 15 | 16 | public Diff(Diff other) { 17 | this(other.op, other.text); 18 | } 19 | 20 | @Override 21 | public String toString() { 22 | return op.getPrefix() + text; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/resources/files/A.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 3 | Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 4 | Sed viverra ligula in lacus vulputate iaculis. 5 | Pellentesque tincidunt mauris sit amet auctor interdum. 6 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 7 | Quisque quis molestie dui. 8 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 9 | Ut molestie ex dolor. 10 | -------------------------------------------------------------------------------- /src/test/resources/files/B.txt: -------------------------------------------------------------------------------- 1 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 2 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 3 | Sed viverra ligula in lacus vulputate iaculis. 4 | Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 5 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 6 | Pellentesque tincidunt mauris sit amet auctor interdum. 7 | Quisque quis molestie dui. 8 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 9 | Ut molestie ex dolor. 10 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/ArchiveReader.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import net.covers1624.quack.io.IOUtils; 4 | 5 | import java.io.Closeable; 6 | import java.io.IOException; 7 | import java.util.List; 8 | import java.util.Set; 9 | 10 | /** 11 | * Created by covers1624 on 19/7/20. 12 | */ 13 | public interface ArchiveReader extends Closeable { 14 | 15 | Set getEntries(); 16 | 17 | byte[] getBytes(String entry); 18 | 19 | default List readLines(String entry) throws IOException { 20 | return IOUtils.readAll(getBytes(entry)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/resources/patches/CreateA.txt.patch: -------------------------------------------------------------------------------- 1 | --- /dev/null 2 | +++ b/A.txt 3 | @@ -1,0 +1,9 @@ 4 | +Lorem ipsum dolor sit amet, consectetur adipiscing elit. 5 | +Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 6 | +Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 7 | +Sed viverra ligula in lacus vulputate iaculis. 8 | +Pellentesque tincidunt mauris sit amet auctor interdum. 9 | +Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 10 | +Quisque quis molestie dui. 11 | +Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 12 | +Ut molestie ex dolor. 13 | -------------------------------------------------------------------------------- /src/test/resources/patches/DeleteA.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ /dev/null 3 | @@ -1,9 +1,0 @@ 4 | -Lorem ipsum dolor sit amet, consectetur adipiscing elit. 5 | -Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 6 | -Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 7 | -Sed viverra ligula in lacus vulputate iaculis. 8 | -Pellentesque tincidunt mauris sit amet auctor interdum. 9 | -Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 10 | -Quisque quis molestie dui. 11 | -Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 12 | -Ut molestie ex dolor. 13 | -------------------------------------------------------------------------------- /src/test/resources/patches/DeleteB.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ /dev/null 3 | @@ -1,9 +1,0 @@ 4 | -Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 5 | -Lorem ipsum dolor sit amet, consectetur adipiscing elit. 6 | -Sed viverra ligula in lacus vulputate iaculis. 7 | -Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 8 | -Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 9 | -Pellentesque tincidunt mauris sit amet auctor interdum. 10 | -Quisque quis molestie dui. 11 | -Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 12 | -Ut molestie ex dolor. 13 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/AbstractArchiveOutputStreamWriter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import org.apache.commons.compress.archivers.ArchiveOutputStream; 4 | 5 | import java.io.IOException; 6 | 7 | /** 8 | * Created by covers1624 on 19/7/20. 9 | */ 10 | public abstract class AbstractArchiveOutputStreamWriter> implements ArchiveWriter { 11 | 12 | protected final T os; 13 | 14 | public AbstractArchiveOutputStreamWriter(T os) { 15 | this.os = os; 16 | } 17 | 18 | @Override 19 | public void close() throws IOException { 20 | os.close(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/LogLevel.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | /** 4 | * Created by covers1624 on 17/7/23. 5 | */ 6 | public enum LogLevel { 7 | OFF(true, false), 8 | ERROR(true, false), 9 | WARN(true, false), 10 | INFO(false, false), 11 | DEBUG(true, true), 12 | ALL(true, true); 13 | 14 | public final boolean printLevelName; 15 | public final boolean printAllLevelNames; 16 | 17 | LogLevel(boolean printLevelName, boolean printAllLevelNames) { 18 | this.printLevelName = printLevelName; 19 | this.printAllLevelNames = printAllLevelNames; 20 | } 21 | 22 | public boolean shouldLog(LogLevel required) { 23 | return ordinal() >= required.ordinal(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/diff/PatienceDiffer.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.diff; 2 | 3 | import io.codechicken.diffpatch.match.PatienceMatch; 4 | import io.codechicken.diffpatch.util.CharRepresenter; 5 | 6 | import java.util.List; 7 | 8 | public class PatienceDiffer extends Differ { 9 | 10 | public PatienceDiffer() { 11 | this(null); 12 | } 13 | 14 | public PatienceDiffer(CharRepresenter charRep) { 15 | super(charRep); 16 | } 17 | 18 | @Override 19 | public int[] match(List lines1, List lines2) { 20 | String lineModeString1 = charRep.linesToChars(lines1); 21 | String lineModeString2 = charRep.linesToChars(lines2); 22 | return new PatienceMatch().match(lineModeString1, lineModeString2, charRep.getMaxLineChar()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/resources/patches/ModifiedA.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ b/A.txt 3 | @@ -1,9 +1,9 @@ 4 | -Lorem ipsum dolor sit amet, consectetur adipiscing elit. 5 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 6 | -Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 7 | +Lorem ipsum dolor sit amet, consectetur adipiscing elit. 8 | Sed viverra ligula in lacus vulputate iaculis. 9 | -Pellentesque tincidunt mauris sit amet auctor interdum. 10 | +Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 11 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 12 | +Pellentesque tincidunt mauris sit amet auctor interdum. 13 | Quisque quis molestie dui. 14 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 15 | Ut molestie ex dolor. 16 | -------------------------------------------------------------------------------- /src/test/resources/patches/ModifiedB.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ b/A.txt 3 | @@ -1,9 +1,9 @@ 4 | -Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 5 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 6 | -Sed viverra ligula in lacus vulputate iaculis. 7 | +Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 8 | Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 9 | -Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 10 | +Sed viverra ligula in lacus vulputate iaculis. 11 | Pellentesque tincidunt mauris sit amet auctor interdum. 12 | +Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 13 | Quisque quis molestie dui. 14 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 15 | Ut molestie ex dolor. 16 | -------------------------------------------------------------------------------- /src/test/resources/patches/ModifiedAAutoHeader.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ b/A.txt 3 | @@ -1,9 +_,9 @@ 4 | -Lorem ipsum dolor sit amet, consectetur adipiscing elit. 5 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 6 | -Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 7 | +Lorem ipsum dolor sit amet, consectetur adipiscing elit. 8 | Sed viverra ligula in lacus vulputate iaculis. 9 | -Pellentesque tincidunt mauris sit amet auctor interdum. 10 | +Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 11 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 12 | +Pellentesque tincidunt mauris sit amet auctor interdum. 13 | Quisque quis molestie dui. 14 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 15 | Ut molestie ex dolor. 16 | -------------------------------------------------------------------------------- /src/test/resources/patches/ModifiedBAutoHeader.txt.patch: -------------------------------------------------------------------------------- 1 | --- a/A.txt 2 | +++ b/A.txt 3 | @@ -1,9 +_,9 @@ 4 | -Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 5 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 6 | -Sed viverra ligula in lacus vulputate iaculis. 7 | +Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 8 | Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 9 | -Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 10 | +Sed viverra ligula in lacus vulputate iaculis. 11 | Pellentesque tincidunt mauris sit amet auctor interdum. 12 | +Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 13 | Quisque quis molestie dui. 14 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 15 | Ut molestie ex dolor. 16 | -------------------------------------------------------------------------------- /src/test/resources/rejects/ModifiedA.txt.patch.rej: -------------------------------------------------------------------------------- 1 | ++++ REJECTED HUNK: 1 2 | @@ -1,9 +1,9 @@ 3 | -Lorem ipsum dolor sit amet, consectetur adipiscing elit. 4 | Aenean nec sapien nisi. Nulla ut sem ut ligula sagittis hendrerit. 5 | -Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 6 | +Lorem ipsum dolor sit amet, consectetur adipiscing elit. 7 | Sed viverra ligula in lacus vulputate iaculis. 8 | -Pellentesque tincidunt mauris sit amet auctor interdum. 9 | +Sed congue in felis at pharetra. Aenean nec nibh ornare nisl laoreet aliquam. 10 | Donec eu faucibus velit. Curabitur interdum diam vel ipsum tincidunt sagittis. 11 | +Pellentesque tincidunt mauris sit amet auctor interdum. 12 | Quisque quis molestie dui. 13 | Morbi ac venenatis ex, a pretium ligula. Quisque dapibus risus nec urna vehicula fermentum. 14 | Ut molestie ex dolor. 15 | ++++ END HUNK 16 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/ZipArchiveOutputStreamWriter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; 4 | import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; 5 | 6 | import java.io.IOException; 7 | 8 | /** 9 | * Created by covers1624 on 19/7/20. 10 | */ 11 | public class ZipArchiveOutputStreamWriter extends AbstractArchiveOutputStreamWriter { 12 | 13 | public ZipArchiveOutputStreamWriter(ZipArchiveOutputStream os) { 14 | super(os); 15 | } 16 | 17 | @Override 18 | public void writeEntry(String name, byte[] bytes) throws IOException { 19 | ZipArchiveEntry entry = new ZipArchiveEntry(name); 20 | entry.setSize(bytes.length); 21 | os.putArchiveEntry(entry); 22 | os.write(bytes); 23 | os.closeArchiveEntry(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/TarArchiveOutputStreamWriter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import org.apache.commons.compress.archivers.tar.TarArchiveEntry; 4 | import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; 5 | 6 | import java.io.IOException; 7 | 8 | /** 9 | * Created by covers1624 on 19/7/20. 10 | */ 11 | public class TarArchiveOutputStreamWriter extends AbstractArchiveOutputStreamWriter { 12 | 13 | public TarArchiveOutputStreamWriter(TarArchiveOutputStream os) { 14 | super(os); 15 | os.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); 16 | } 17 | 18 | @Override 19 | public void writeEntry(String name, byte[] bytes) throws IOException { 20 | TarArchiveEntry entry = new TarArchiveEntry(name); 21 | entry.setSize(bytes.length); 22 | os.putArchiveEntry(entry); 23 | os.write(bytes); 24 | os.closeArchiveEntry(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Utils.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import net.covers1624.quack.collection.FastStream; 4 | 5 | import java.util.Set; 6 | 7 | /** 8 | * Created by covers1624 on 19/7/20. 9 | */ 10 | public class Utils { 11 | 12 | public static String stripStart(char start, String str) { 13 | if (!str.isEmpty() && str.charAt(0) == start) { 14 | return str.substring(1); 15 | } 16 | return str; 17 | } 18 | 19 | public static Set filterPrefixed(Set toFilter, String[] filters) { 20 | if (filters.length == 0) return toFilter; 21 | 22 | return FastStream.of(toFilter) 23 | .filterNot(e -> { 24 | for (String s : filters) { 25 | if (e.startsWith(s)) { 26 | return true; 27 | } 28 | } 29 | return false; 30 | }) 31 | .toSet(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 covers1624, Chicken-Bones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/ConsumingOutputStream.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import java.io.OutputStream; 4 | import java.util.function.Consumer; 5 | 6 | /** 7 | * Created by covers1624 on 20/12/18. 8 | */ 9 | public class ConsumingOutputStream extends OutputStream { 10 | 11 | private static final char CR = '\r'; 12 | private static final char LF = '\n'; 13 | 14 | private final Consumer consumer; 15 | private final StringBuilder buffer = new StringBuilder(); 16 | 17 | public ConsumingOutputStream(Consumer consumer) { 18 | this.consumer = consumer; 19 | } 20 | 21 | @Override 22 | public void write(int b) { 23 | char ch = (char) (b & 0xFF); 24 | if (ch == CR) { 25 | return; 26 | } 27 | buffer.append(ch); 28 | if (ch == LF) { 29 | flush(); 30 | } 31 | } 32 | 33 | @Override 34 | public void flush() { 35 | String str = buffer.toString(); 36 | if (str.endsWith("\n")) { 37 | str = str.replaceAll("\n", ""); 38 | consumer.accept(str); 39 | buffer.setLength(0); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/ArchiveBuilder.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 4 | import io.codechicken.diffpatch.util.archiver.ArchiveWriter; 5 | 6 | import javax.annotation.WillClose; 7 | import java.io.ByteArrayOutputStream; 8 | import java.io.IOException; 9 | import java.io.OutputStream; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | import java.util.Set; 13 | 14 | /** 15 | * Created by covers1624 on 22/6/24. 16 | */ 17 | public class ArchiveBuilder { 18 | 19 | private final Map entries = new LinkedHashMap<>(); 20 | 21 | public Set getEntries() { 22 | return entries.keySet(); 23 | } 24 | 25 | public byte[] getBytes(String entry) { 26 | return entries.get(entry); 27 | } 28 | 29 | public ArchiveBuilder put(String name, byte[] data) { 30 | entries.put(name, data); 31 | return this; 32 | } 33 | 34 | public byte[] toBytes(ArchiveFormat format) throws IOException { 35 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 36 | write(format, bos); 37 | return bos.toByteArray(); 38 | } 39 | 40 | public void write(ArchiveFormat format, @WillClose OutputStream os) throws IOException { 41 | try (ArchiveWriter writer = format.createWriter(os)) { 42 | for (Map.Entry entry : entries.entrySet()) { 43 | writer.writeEntry(entry.getKey(), entry.getValue()); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/CliOperation.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.util.LogLevel; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.io.IOException; 7 | import java.io.PrintStream; 8 | import java.util.function.Consumer; 9 | 10 | /** 11 | * Created by covers1624 on 11/8/20. 12 | */ 13 | public abstract class CliOperation { 14 | 15 | final PrintStream logger; 16 | final LogLevel level; 17 | 18 | private final Consumer helpCallback; 19 | 20 | protected CliOperation(PrintStream logger, LogLevel level, Consumer helpCallback) { 21 | this.logger = logger; 22 | this.level = level; 23 | this.helpCallback = helpCallback; 24 | } 25 | 26 | public abstract Result operate() throws IOException; 27 | 28 | public final void printHelp() throws IOException { 29 | helpCallback.accept(logger); 30 | } 31 | 32 | public final void log(LogLevel level, String str, Object... args) { 33 | if (this.level.shouldLog(level)) { 34 | if (this.level.printAllLevelNames || level.printLevelName) { 35 | logger.print("[" + level + "] "); 36 | } 37 | logger.println(String.format(str, args)); 38 | } 39 | } 40 | 41 | public static class Result { 42 | 43 | public final int exit; 44 | public final @Nullable T summary; 45 | 46 | public Result(int exit) { 47 | this(exit, null); 48 | } 49 | 50 | public Result(int exit, @Nullable T summary) { 51 | this.exit = exit; 52 | this.summary = summary; 53 | } 54 | 55 | public void throwOnError() { 56 | if (exit != 0) { 57 | throw new RuntimeException("Operation has non zero exit code: " + exit); 58 | } 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/ArchiveInputStreamReader.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import io.codechicken.diffpatch.util.Utils; 4 | import net.covers1624.quack.io.IOUtils; 5 | import org.apache.commons.compress.archivers.ArchiveEntry; 6 | import org.apache.commons.compress.archivers.ArchiveInputStream; 7 | 8 | import java.io.IOException; 9 | import java.util.Collections; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | import java.util.Set; 13 | 14 | /** 15 | * A wrapper for an {@link ArchiveInputStream} that indexes and stores 16 | * each entries content. 17 | *

18 | * Created by covers1624 on 19/7/20. 19 | */ 20 | public class ArchiveInputStreamReader implements ArchiveReader { 21 | 22 | private final Map archiveIndex = new LinkedHashMap<>(); 23 | private final ArchiveInputStream is; 24 | 25 | public ArchiveInputStreamReader(ArchiveInputStream is, String prefix) { 26 | this.is = is; 27 | try { 28 | ArchiveEntry entry; 29 | while ((entry = is.getNextEntry()) != null) { 30 | if (entry.isDirectory()) continue; 31 | 32 | String name = Utils.stripStart('/', entry.getName()); 33 | if (!prefix.isEmpty() && !entry.getName().startsWith(prefix)) continue; 34 | 35 | archiveIndex.put(Utils.stripStart('/', name.substring(prefix.length())), IOUtils.toBytes(is)); 36 | } 37 | } catch (IOException e) { 38 | throw new RuntimeException("Failed to index archive", e); 39 | } 40 | } 41 | 42 | @Override 43 | public Set getEntries() { 44 | return Collections.unmodifiableSet(archiveIndex.keySet()); 45 | } 46 | 47 | @Override 48 | public byte[] getBytes(String entry) { 49 | return archiveIndex.get(entry); 50 | } 51 | 52 | @Override 53 | public void close() throws IOException { 54 | is.close(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/diff/LineMatchedDiffer.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.diff; 2 | 3 | import io.codechicken.diffpatch.match.FuzzyLineMatcher; 4 | import io.codechicken.diffpatch.util.CharRepresenter; 5 | import net.covers1624.quack.collection.FastStream; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | /** 11 | * Created by covers1624 on 15/5/21. 12 | */ 13 | public class LineMatchedDiffer extends PatienceDiffer { 14 | 15 | private List wordModeLines1 = new ArrayList<>(); 16 | private List wordModeLines2 = new ArrayList<>(); 17 | 18 | private int maxMatchOffset = FuzzyLineMatcher.MatchMatrix.DEFAULT_MAX_OFFSET; 19 | private float minMatchScore = FuzzyLineMatcher.DEFAULT_MIN_MATCH_SCORE; 20 | 21 | public LineMatchedDiffer() { 22 | super(); 23 | } 24 | 25 | public LineMatchedDiffer(CharRepresenter charRep) { 26 | super(charRep); 27 | } 28 | 29 | @Override 30 | public int[] match(List lines1, List lines2) { 31 | int[] matches = super.match(lines1, lines2); 32 | wordModeLines1 = FastStream.of(lines1).map(charRep::wordsToChars).toList(); 33 | wordModeLines2 = FastStream.of(lines2).map(charRep::wordsToChars).toList(); 34 | FuzzyLineMatcher matcher = new FuzzyLineMatcher(); 35 | matcher.maxMatchOffset = maxMatchOffset; 36 | matcher.minMatchScore = minMatchScore; 37 | matcher.matchLinesByWords(matches, wordModeLines1, wordModeLines2); 38 | return matches; 39 | } 40 | 41 | //@formatter:off 42 | public List getWordModeLines1() { return wordModeLines1; } 43 | public List getWordModeLines2() { return wordModeLines2; } 44 | public int getMaxMatchOffset() { return maxMatchOffset; } 45 | public void setMaxMatchOffset(int maxMatchOffset) { this.maxMatchOffset = maxMatchOffset; } 46 | public float getMinMatchScore() { return minMatchScore; } 47 | public void setMinMatchScore(float minMatchScore) { this.minMatchScore = minMatchScore; } 48 | //@formatter:on 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/diff/Differ.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.diff; 2 | 3 | import io.codechicken.diffpatch.match.LineMatching; 4 | import io.codechicken.diffpatch.util.CharRepresenter; 5 | import io.codechicken.diffpatch.util.Diff; 6 | import io.codechicken.diffpatch.util.Operation; 7 | import io.codechicken.diffpatch.util.Patch; 8 | import net.covers1624.quack.collection.FastStream; 9 | import org.jetbrains.annotations.Nullable; 10 | 11 | import java.util.Collections; 12 | import java.util.List; 13 | 14 | public abstract class Differ { 15 | 16 | public static final int DEFAULT_CONTEXT = 3; 17 | 18 | protected final CharRepresenter charRep; 19 | 20 | public Differ() { 21 | this(null); 22 | } 23 | 24 | public Differ(@Nullable CharRepresenter charRep) { 25 | if (charRep == null) { 26 | charRep = new CharRepresenter(); 27 | } 28 | this.charRep = charRep; 29 | } 30 | 31 | public abstract int[] match(List lines1, List lines2); 32 | 33 | public List diff(List lines1, List lines2) { 34 | return LineMatching.makeDiffList(match(lines1, lines2), lines1, lines2); 35 | } 36 | 37 | public List makePatches(List lines1, List lines2) { 38 | return makePatches(lines1, lines2, DEFAULT_CONTEXT, true); 39 | } 40 | 41 | public List makePatches(List lines1, List lines2, int numContextLines, boolean collate) { 42 | return makePatches(diff(lines1, lines2), numContextLines, collate); 43 | } 44 | 45 | public static List makeFileAdded(List lines) { 46 | Patch patch = make(lines, Operation.INSERT); 47 | return patch.length2 == 0 ? Collections.emptyList() : Collections.singletonList(patch); 48 | } 49 | 50 | public static List makeFileRemoved(List lines) { 51 | Patch patch = make(lines, Operation.DELETE); 52 | return patch.length1 == 0 ? Collections.emptyList() : Collections.singletonList(patch); 53 | } 54 | 55 | public static List makePatches(List diffs, int numContextLines, boolean collate) { 56 | Patch p = new Patch(); 57 | p.diffs = diffs; 58 | p.recalculateLength(); 59 | p.trim(numContextLines); 60 | if (p.length1 == 0) { 61 | return Collections.emptyList(); 62 | } 63 | if (!collate) { 64 | p.uncollate(); 65 | } 66 | return p.split(numContextLines); 67 | } 68 | 69 | private static Patch make(List lines, Operation op) { 70 | Patch patch = new Patch(); 71 | patch.diffs = FastStream.of(lines).map(l -> new Diff(op, l)).toList(); 72 | patch.recalculateLength(); 73 | return patch; 74 | } 75 | 76 | public CharRepresenter getCharRep() { 77 | return charRep; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/test/TestBase.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.test; 2 | 3 | import io.codechicken.diffpatch.util.ConsumingOutputStream; 4 | import net.covers1624.quack.io.IOUtils; 5 | 6 | import java.io.*; 7 | import java.nio.charset.StandardCharsets; 8 | import java.util.*; 9 | 10 | /** 11 | * Created by covers1624 on 30/5/24. 12 | */ 13 | public abstract class TestBase { 14 | 15 | private static final char[] HEX = "0123456789ABCDEF".toCharArray(); 16 | 17 | public static Map> generateRandomFiles(Random randy) { 18 | Map> files = new LinkedHashMap<>(); 19 | 20 | int numFiles = 10 + randy.nextInt(100); 21 | for (int i = 0; i < numFiles; i++) { 22 | String fileName = randomFileNameAndPath(randy, 4) + ".txt"; 23 | int numLines = 10 + randy.nextInt(100); 24 | List lines = new ArrayList<>(numLines); 25 | for (int j = 0; j < numLines; j++) { 26 | lines.add(generateRandomHex(randy, 5 + randy.nextInt(100))); 27 | } 28 | files.put(fileName, lines); 29 | } 30 | 31 | return files; 32 | } 33 | 34 | private static String randomFileNameAndPath(Random randy, int segments) { 35 | StringBuilder ret = new StringBuilder(); 36 | for (int i = 0; i < segments; i++) { 37 | if (i != 0) { 38 | ret.append("/"); 39 | } 40 | ret.append(generateRandomHex(randy, 1 + randy.nextInt(5))); 41 | } 42 | return ret.toString(); 43 | } 44 | 45 | public static String generateRandomHex(Random randy, int len) { 46 | StringBuilder builder = new StringBuilder(); 47 | for (int i = 0; i < len; i++) { 48 | builder.append(HEX[randy.nextInt(HEX.length)]); 49 | } 50 | return builder.toString(); 51 | } 52 | 53 | public static PrintStream collectLines(List lines) { 54 | return new PrintStream(new ConsumingOutputStream(lines::add), true); 55 | } 56 | 57 | public static String testResourceString(String resource) throws IOException { 58 | try (InputStream is = TestBase.class.getResourceAsStream(resource)) { 59 | if (is == null) throw new FileNotFoundException("Did not find test resource: " + resource); 60 | 61 | return new String(IOUtils.toBytes(is), StandardCharsets.UTF_8); 62 | } 63 | } 64 | 65 | public static InputStream testResourceStream(String resource) throws IOException { 66 | return new ByteArrayInputStream(testResource(resource)); 67 | } 68 | 69 | public static byte[] testResource(String resource) throws IOException { 70 | // We read this to bytes and return a ByteArrayInputStream, so we never need to close this resource. 71 | // It makes tests more concise, whilst still being resource safe. 72 | try (InputStream is = TestBase.class.getResourceAsStream(resource)) { 73 | if (is == null) throw new FileNotFoundException("Did not find test resource: " + resource); 74 | 75 | return IOUtils.toBytes(is); 76 | } 77 | } 78 | 79 | public static InputStream stream(byte[] bytes) { 80 | return new ByteArrayInputStream(bytes); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/FileCollector.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import net.covers1624.quack.collection.ColUtils; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.io.IOException; 7 | import java.nio.charset.StandardCharsets; 8 | import java.util.*; 9 | 10 | /** 11 | * Created by covers1624 on 11/8/20. 12 | */ 13 | public class FileCollector { 14 | 15 | private final Map files = new LinkedHashMap<>(); 16 | 17 | /** 18 | * Adds a List of lines to the collector. 19 | * 20 | * @param name The file name. 21 | * @param lines The lines in the file. 22 | * @return Returns true if lines were added. 23 | */ 24 | public boolean consume(String name, List lines) { 25 | if (files.containsKey(name)) return false; 26 | 27 | files.put(name, new LinesCollectedEntry(lines)); 28 | return true; 29 | } 30 | 31 | /** 32 | * Add a binary file to the collector. 33 | * 34 | * @param name The file name. 35 | * @param bytes The bytes to add. 36 | * @return Returns true if the file was added. 37 | */ 38 | public boolean consume(String name, byte[] bytes) { 39 | if (files.containsKey(name)) return false; 40 | 41 | files.put(name, new BinaryCollectedEntry(bytes)); 42 | return true; 43 | } 44 | 45 | public Map get() { 46 | return Collections.unmodifiableMap(files); 47 | } 48 | 49 | public Set keySet() { 50 | return get().keySet(); 51 | } 52 | 53 | public Collection values() { 54 | return get().values(); 55 | } 56 | 57 | public boolean isEmpty() { 58 | return files.isEmpty(); 59 | } 60 | 61 | public @Nullable CollectedEntry getSingleFile() { 62 | if (files.isEmpty()) return null; 63 | 64 | if (files.size() != 1) { 65 | throw new IllegalStateException("Expected 1 file in FileCollector, got: " + files.size()); 66 | } 67 | 68 | return ColUtils.only(files.values()); 69 | } 70 | 71 | public abstract static class CollectedEntry { 72 | 73 | public abstract byte[] toBytes(String lineEnding, boolean emptyNewline) throws IOException; 74 | } 75 | 76 | public static class LinesCollectedEntry extends CollectedEntry { 77 | 78 | public final List lines; 79 | 80 | public LinesCollectedEntry(List lines) { 81 | this.lines = Collections.unmodifiableList(new ArrayList<>(lines)); 82 | } 83 | 84 | @Override 85 | public byte[] toBytes(String lineEnding, boolean emptyNewline) throws IOException { 86 | String file = String.join(lineEnding, lines); 87 | if (emptyNewline) { 88 | file += lineEnding; 89 | } 90 | return file.getBytes(StandardCharsets.UTF_8); 91 | } 92 | } 93 | 94 | public static class BinaryCollectedEntry extends CollectedEntry { 95 | 96 | public final byte[] bytes; 97 | 98 | public BinaryCollectedEntry(byte[] bytes) { 99 | this.bytes = bytes; 100 | } 101 | 102 | @Override 103 | public byte[] toBytes(String lineEnding, boolean emptyNewline) throws IOException { 104 | return bytes; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/CharRepresenter.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import it.unimi.dsi.fastutil.objects.Object2CharMap; 4 | import it.unimi.dsi.fastutil.objects.Object2CharOpenHashMap; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Arrays; 8 | import java.util.List; 9 | 10 | /** 11 | * Converts Equal lines into equal single characters 12 | * and Equal single words into equal single characters. 13 | */ 14 | public class CharRepresenter { 15 | 16 | private final List charToLine = new ArrayList<>(); 17 | private final Object2CharMap lineToChar = new Object2CharOpenHashMap<>(); 18 | 19 | private final List charToWord = new ArrayList<>(); 20 | private final Object2CharMap wordToChar = new Object2CharOpenHashMap<>(); 21 | 22 | public CharRepresenter() { 23 | charToLine.add("\0");//lets avoid the 0 char 24 | 25 | //keep ascii chars as their own values 26 | for (char i = 0; i < 0x80; i++) { 27 | charToWord.add(Character.valueOf(i).toString()); 28 | } 29 | } 30 | 31 | public String getWordForChar(char ch) { 32 | return charToWord.get(ch); 33 | } 34 | 35 | public char addLine(String line) { 36 | return lineToChar.computeCharIfAbsent(line, e -> { 37 | charToLine.add(line); 38 | return (char) (charToLine.size() - 1); 39 | }); 40 | } 41 | 42 | public char addWord(String word) { 43 | if (word.length() == 1 && word.charAt(0) <= 0x80) { 44 | return word.charAt(0); 45 | } 46 | 47 | return wordToChar.computeCharIfAbsent(word, e -> { 48 | charToWord.add(word); 49 | return (char) (charToWord.size() - 1); 50 | }); 51 | } 52 | 53 | private char[] buf = new char[4096]; 54 | 55 | public String wordsToChars(String line) { 56 | int b = 0; 57 | 58 | for (int i = 0, len; i < line.length(); i += len) { 59 | char c = line.charAt(i); 60 | //identify word 61 | len = 1; 62 | if (Character.isLetter(c)) { 63 | while (i + len < line.length() && Character.isLetterOrDigit(line.charAt(i + len))) { 64 | len++; 65 | } 66 | } else if (Character.isDigit(c)) { 67 | while (i + len < line.length() && Character.isDigit(line.charAt(i + len))) { 68 | len++; 69 | } 70 | } else if (c == ' ' || c == '\t') { 71 | while (i + len < line.length() && line.charAt(i + len) == c) { 72 | len++; 73 | } 74 | } 75 | String word = line.substring(i, i + len); 76 | if (b >= buf.length) { 77 | buf = Arrays.copyOf(buf, buf.length * 2); 78 | } 79 | buf[b++] = addWord(word); 80 | } 81 | return new String(buf, 0, b); 82 | } 83 | 84 | public String linesToChars(List lines) { 85 | char[] buf = new char[lines.size()]; 86 | for (int i = 0; i < lines.size(); i++) { 87 | buf[i] = addLine(lines.get(i)); 88 | } 89 | return new String(buf); 90 | } 91 | 92 | public int getMaxLineChar() { 93 | return charToLine.size(); 94 | } 95 | 96 | public int getMaxWordChar() { 97 | return charToWord.size(); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/LineRange.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Comparator; 5 | import java.util.List; 6 | 7 | public class LineRange { 8 | 9 | private int start; 10 | private int end; 11 | 12 | public LineRange() { 13 | } 14 | 15 | public LineRange(int start, int end) { 16 | this.start = start; 17 | this.end = end; 18 | } 19 | 20 | public boolean contains(int i) { 21 | return start <= i && i < end; 22 | } 23 | 24 | public boolean contains(LineRange r) { 25 | return r.start >= start && r.end <= end; 26 | } 27 | 28 | public boolean intersects(LineRange r) { 29 | return r.start < end || r.end > start; 30 | } 31 | 32 | public LineRange add(int i) { 33 | return new LineRange(start + i, end + i); 34 | } 35 | 36 | public LineRange sub(int i) { 37 | return new LineRange(start - i, end - i); 38 | } 39 | 40 | public List except(List except) { 41 | return except(except, false); 42 | } 43 | 44 | public List except(List except, boolean presorted) { 45 | if (!presorted) { 46 | except = new ArrayList<>(except); 47 | except.sort(Comparator.comparingInt(e -> e.start)); 48 | } 49 | List ret = new ArrayList<>(); 50 | int start = this.start; 51 | for (LineRange r : except) { 52 | if (r.start - start > 0) { 53 | ret.add(new LineRange(start, r.start)); 54 | } 55 | start = r.end; 56 | } 57 | if (this.end - start > 0) { 58 | ret.add(new LineRange(start, end)); 59 | } 60 | return ret; 61 | } 62 | 63 | //@formatter:off 64 | public int getStart() { return start; } 65 | public void setStart(int start) { this.start = start; } 66 | public int getEnd() { return end; } 67 | public void setEnd(int end) { this.end = end; } 68 | public int getLength() { return end - start; } 69 | public void setLength(int len) { this.end = start + len; } 70 | public int getLast() { return end - 1; } 71 | public void setLast(int last) { this.end = last + 1; } 72 | public int getFirst() { return getStart(); } 73 | public void setFirst(int first) { setStart(first); } 74 | //@formatter:on 75 | 76 | public static LineRange fromFirstLast(int first, int last) { 77 | LineRange range = new LineRange(); 78 | range.setFirst(first); 79 | range.setLast(last); 80 | return range; 81 | } 82 | 83 | public static LineRange fromStartLen(int start, int len) { 84 | LineRange range = new LineRange(); 85 | range.setStart(start); 86 | range.setLength(len); 87 | return range; 88 | } 89 | 90 | public static LineRange union(LineRange r1, LineRange r2) { 91 | return new LineRange(Math.min(r1.start, r2.start), Math.max(r1.end, r2.end)); 92 | } 93 | 94 | public static LineRange intersection(LineRange r1, LineRange r2) { 95 | return new LineRange(Math.max(r1.start, r2.start), Math.min(r1.end, r2.end)); 96 | } 97 | 98 | @Override 99 | public boolean equals(Object o) { 100 | if (this == o) { 101 | return true; 102 | } 103 | if (o == null || getClass() != o.getClass()) { 104 | return false; 105 | } 106 | 107 | LineRange lineRange = (LineRange) o; 108 | 109 | if (getStart() != lineRange.getStart()) { 110 | return false; 111 | } 112 | return getEnd() == lineRange.getEnd(); 113 | } 114 | 115 | @Override 116 | public int hashCode() { 117 | int result = getStart(); 118 | result = 31 * result + getEnd(); 119 | return result; 120 | } 121 | 122 | @Override 123 | public String toString() { 124 | return "[" + start + "," + end + ")"; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/cli/BakePatchesTest.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.test.TestBase; 4 | import io.codechicken.diffpatch.util.ArchiveBuilder; 5 | import io.codechicken.diffpatch.util.Input; 6 | import io.codechicken.diffpatch.util.LogLevel; 7 | import io.codechicken.diffpatch.util.Output; 8 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 9 | import io.codechicken.diffpatch.util.archiver.ArchiveReader; 10 | import org.junit.jupiter.api.Test; 11 | 12 | import java.io.ByteArrayInputStream; 13 | import java.io.ByteArrayOutputStream; 14 | import java.io.IOException; 15 | import java.nio.charset.StandardCharsets; 16 | 17 | import static io.codechicken.diffpatch.cli.BakePatchesOperation.*; 18 | import static io.codechicken.diffpatch.util.archiver.ArchiveFormat.ZIP; 19 | import static org.junit.jupiter.api.Assertions.assertEquals; 20 | import static org.junit.jupiter.api.Assertions.assertTrue; 21 | 22 | /** 23 | * Created by covers1624 on 25/6/24. 24 | */ 25 | public class BakePatchesTest extends TestBase { 26 | 27 | @Test 28 | public void testBakeSingle() throws IOException { 29 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 30 | CliOperation.Result result = BakePatchesOperation.builder() 31 | .logTo(System.out) 32 | .level(LogLevel.ALL) 33 | .patchesInput(Input.SingleInput.pipe(testResourceStream("/patches/ModifiedAAutoHeader.txt.patch"), "A.txt.patch")) 34 | .bakedOutput(Output.SingleOutput.pipe(output)) 35 | .build() 36 | .operate(); 37 | assertEquals(0, result.exit); 38 | assertEquals(testResourceString("/patches/ModifiedA.txt.patch"), output.toString("UTF-8")); 39 | } 40 | 41 | @Test 42 | public void testBakeMulti() throws IOException { 43 | byte[] patches = new ArchiveBuilder() 44 | .put("A.txt.patch", testResource("/patches/ModifiedAAutoHeader.txt.patch")) 45 | .put("B.txt.patch", testResource("/patches/ModifiedBAutoHeader.txt.patch")) 46 | .toBytes(ZIP); 47 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 48 | CliOperation.Result result = BakePatchesOperation.builder() 49 | .logTo(System.out) 50 | .level(LogLevel.ALL) 51 | .patchesInput(Input.MultiInput.archive(ZIP, patches)) 52 | .bakedOutput(Output.MultiOutput.archive(ZIP, output)) 53 | .build() 54 | .operate(); 55 | assertEquals(0, result.exit); 56 | 57 | try (ArchiveReader input = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 58 | assertEquals(testResourceString("/patches/ModifiedA.txt.patch"), new String(input.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 59 | assertEquals(testResourceString("/patches/ModifiedB.txt.patch"), new String(input.getBytes("B.txt.patch"), StandardCharsets.UTF_8)); 60 | } 61 | } 62 | 63 | @Test 64 | @Deprecated 65 | public void testBakeLegacy() throws IOException { 66 | byte[] patches = new ArchiveBuilder() 67 | .put("A.txt.patch", testResource("/patches/ModifiedAAutoHeader.txt.patch")) 68 | .put("B.txt.patch", testResource("/patches/ModifiedBAutoHeader.txt.patch")) 69 | .toBytes(ZIP); 70 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 71 | PatchOperation.bakePatches( 72 | Input.MultiInput.archive(ZIP, patches), 73 | Output.MultiOutput.archive(ZIP, bos), 74 | System.lineSeparator() 75 | ); 76 | 77 | try (ArchiveReader input = ZIP.createReader(new ByteArrayInputStream(bos.toByteArray()))) { 78 | assertEquals(testResourceString("/patches/ModifiedA.txt.patch"), new String(input.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 79 | assertEquals(testResourceString("/patches/ModifiedB.txt.patch"), new String(input.getBytes("B.txt.patch"), StandardCharsets.UTF_8)); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/PatchFile.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import org.jetbrains.annotations.Nullable; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.regex.Matcher; 8 | import java.util.regex.Pattern; 9 | 10 | /** 11 | * Represents a singular Patch file. 12 | */ 13 | public class PatchFile { 14 | 15 | private static final Pattern HUNK_OFFSET = Pattern.compile("@@ -(\\d+),(\\d+) \\+([_\\d]+),(\\d+) @@"); 16 | private static final String NO_NEW_LINE = "\\ No newline at end of file"; 17 | 18 | public @Nullable String name; 19 | public @Nullable String basePath; 20 | public @Nullable String patchedPath; 21 | public boolean noNewLine; 22 | 23 | public List patches = new ArrayList<>(); 24 | 25 | public static PatchFile fromLines(String name, List lines, boolean verifyHeaders) { 26 | PatchFile patchFile = new PatchFile(); 27 | patchFile.name = name; 28 | Patch patch = null; 29 | int delta = 0; 30 | int i = 0; 31 | for (String line : lines) { 32 | i++; 33 | 34 | //ignore blank lines 35 | if (line.isEmpty()) { 36 | continue; 37 | } 38 | 39 | //context 40 | if (patch == null && line.charAt(0) != '@') { 41 | if (i == 1 && line.startsWith("--- ")) { 42 | patchFile.basePath = line.substring(4); 43 | } else if (i == 2) { 44 | patchFile.patchedPath = line.substring(4); 45 | } else { 46 | throw new IllegalArgumentException(String.format("Invalid context line in '%s' at %s:'%s'", name, i, line)); 47 | } 48 | continue; 49 | } 50 | 51 | switch (line.charAt(0)) { 52 | case '@': { 53 | Matcher matcher = HUNK_OFFSET.matcher(line); 54 | if (!matcher.find()) { 55 | throw new IllegalArgumentException(String.format("Invalid patch line in '%s' at %s:'%s'", name, i, line)); 56 | } 57 | patch = new Patch(); 58 | patch.start1 = Integer.parseInt(matcher.group(1)) - 1; 59 | patch.length1 = Integer.parseInt(matcher.group(2)); 60 | patch.length2 = Integer.parseInt(matcher.group(4)); 61 | 62 | String start2Str = matcher.group(3); 63 | if (start2Str.equals("_")) { 64 | patch.start2 = patch.start1 + delta; 65 | } else { 66 | patch.start2 = Integer.parseInt(start2Str) - 1; 67 | if (verifyHeaders && patch.start2 != patch.start1 + delta) { 68 | throw new IllegalArgumentException(String.format("Applied Offset Mismatch in '%s' at %s. Expected: %d, Actual: %d", name, i, patch.start1 + delta + 1, patch.start2 + 1)); 69 | } 70 | } 71 | delta += patch.length2 - patch.length1; 72 | patchFile.patches.add(patch); 73 | break; 74 | } 75 | case ' ': 76 | patch.diffs.add(new Diff(Operation.EQUAL, line.substring(1))); 77 | break; 78 | case '+': 79 | patch.diffs.add(new Diff(Operation.INSERT, line.substring(1))); 80 | break; 81 | case '-': 82 | patch.diffs.add(new Diff(Operation.DELETE, line.substring(1))); 83 | break; 84 | case '\\': 85 | if (line.equals(NO_NEW_LINE)) { 86 | patchFile.noNewLine = true; 87 | break; 88 | } 89 | default: 90 | throw new IllegalArgumentException(String.format("Invalid patch line in '%s' at %s:'%s'", line, i, line)); 91 | } 92 | } 93 | return patchFile; 94 | } 95 | 96 | @Override 97 | public String toString() { 98 | return String.join("\n", toLines(false)); 99 | } 100 | 101 | public List toLines(boolean autoHeader) { 102 | List lines = new ArrayList<>(); 103 | if (basePath != null && patchedPath != null) { 104 | lines.add("--- " + basePath); 105 | lines.add("+++ " + patchedPath); 106 | } 107 | 108 | for (Patch p : patches) { 109 | lines.add(autoHeader ? p.getAutoHeader() : p.getHeader()); 110 | for (Diff diff : p.diffs) { 111 | lines.add(diff.toString()); 112 | } 113 | } 114 | return lines; 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/match/LineMatching.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.match; 2 | 3 | import io.codechicken.diffpatch.util.Diff; 4 | import io.codechicken.diffpatch.util.LineRange; 5 | import io.codechicken.diffpatch.util.Operation; 6 | import io.codechicken.diffpatch.util.Patch; 7 | import org.apache.commons.lang3.tuple.Pair; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | public class LineMatching { 13 | 14 | public static List> unmatchedRanges(int[] matches, int len2) { 15 | List> ret = new ArrayList<>(); 16 | int len1 = matches.length; 17 | int start1 = 0, start2 = 0; 18 | do { 19 | // search for a matchpoint 20 | int end1 = start1; 21 | while (end1 < len1 && matches[end1] < 0) { 22 | end1++; 23 | } 24 | 25 | int end2 = end1 == len1 ? len2 : matches[end1]; 26 | if (end1 != start1 || end2 != start2) { 27 | ret.add(Pair.of(new LineRange(start1, end1), new LineRange(start2, end2))); 28 | start1 = end1; 29 | start2 = end2; 30 | } else { // matchpoint follows on from start, no unmatched lines 31 | start1++; 32 | start2++; 33 | } 34 | } 35 | while (start1 < len1 || start2 < len2); 36 | return ret; 37 | } 38 | 39 | public static int[] fromUnmatchedRanges(List> unmatchedRanges, int len1) { 40 | int[] matches = new int[len1]; 41 | int start1 = 0, start2 = 0; 42 | for (Pair entry : unmatchedRanges) { 43 | LineRange range1 = entry.getLeft(); 44 | LineRange range2 = entry.getRight(); 45 | while (start1 < range1.getStart()) { 46 | matches[start1++] = start2++; 47 | } 48 | 49 | if (start2 != range2.getStart()) { 50 | throw new IllegalArgumentException("Unequal number of lines between umatched ranges on each side"); 51 | } 52 | 53 | while (start1 < range1.getEnd()) { 54 | matches[start1++] = -1; 55 | } 56 | 57 | start2 = range2.getEnd(); 58 | } 59 | 60 | while (start1 < len1) { 61 | matches[start1++] = start2++; 62 | } 63 | 64 | return matches; 65 | } 66 | 67 | public static List> unmatchedRanges(List patches) { 68 | List> ret = new ArrayList<>(); 69 | for (Patch patch : patches) { 70 | List diffs = patch.diffs; 71 | int start1 = patch.start1, start2 = patch.start2; 72 | for (int i = 0; i < diffs.size(); ) { 73 | // skip matched 74 | while (i < diffs.size() && diffs.get(i).op == Operation.EQUAL) { 75 | start1++; 76 | start2++; 77 | i++; 78 | } 79 | 80 | int end1 = start1, end2 = start2; 81 | while (i < diffs.size() && diffs.get(i).op != Operation.EQUAL) { 82 | if (diffs.get(i++).op == Operation.DELETE) { 83 | end1++; 84 | } else { 85 | end2++; 86 | } 87 | } 88 | 89 | if (end1 != start1 || end2 != start2) { 90 | ret.add(Pair.of(new LineRange(start1, end1), new LineRange(start2, end2))); 91 | } 92 | 93 | start1 = end1; 94 | start2 = end2; 95 | } 96 | } 97 | return ret; 98 | } 99 | 100 | public static int[] fromPatches(List patches, int len1) { 101 | return fromUnmatchedRanges(unmatchedRanges(patches), len1); 102 | } 103 | 104 | public static List makeDiffList(int[] matches, List lines1, List lines2) { 105 | List list = new ArrayList<>(); 106 | int l = 0; 107 | int r = 0; 108 | for (int i = 0; i < matches.length; i++) { 109 | if (matches[i] < 0) { 110 | continue; 111 | } 112 | 113 | while (l < i) { 114 | list.add(new Diff(Operation.DELETE, lines1.get(l++))); 115 | } 116 | while (r < matches[i]) { 117 | list.add(new Diff(Operation.INSERT, lines2.get(r++))); 118 | } 119 | if (!lines1.get(l).equals(lines2.get(r))) { 120 | list.add(new Diff(Operation.DELETE, lines1.get(l))); 121 | list.add(new Diff(Operation.INSERT, lines2.get(r))); 122 | } else { 123 | list.add(new Diff(Operation.EQUAL, lines1.get(l))); 124 | } 125 | l++; 126 | r++; 127 | } 128 | while (l < lines1.size()) { 129 | list.add(new Diff(Operation.DELETE, lines1.get(l++))); 130 | } 131 | while (r < lines2.size()) { 132 | list.add(new Diff(Operation.INSERT, lines2.get(r++))); 133 | } 134 | return list; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/archiver/ArchiveFormat.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; 4 | import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; 5 | import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; 6 | import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; 7 | import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream; 8 | import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; 9 | import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; 10 | import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; 11 | import org.apache.commons.compress.compressors.xz.XZCompressorInputStream; 12 | import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream; 13 | import org.jetbrains.annotations.Nullable; 14 | 15 | import java.io.IOException; 16 | import java.io.InputStream; 17 | import java.io.OutputStream; 18 | import java.nio.file.Path; 19 | import java.util.Arrays; 20 | import java.util.Collections; 21 | import java.util.HashSet; 22 | import java.util.Set; 23 | 24 | /** 25 | * Created by covers1624 on 19/7/20. 26 | */ 27 | public enum ArchiveFormat { 28 | //@formatter:off 29 | ZIP("ZIP", ".zip", ".jar") { 30 | @Override public ArchiveReader createReader(InputStream is, String prefix) { return new ArchiveInputStreamReader(new ZipArchiveInputStream(is), prefix); } 31 | @Override public ArchiveWriter createWriter(OutputStream os) { return new ZipArchiveOutputStreamWriter(new ZipArchiveOutputStream(os)); } 32 | }, 33 | TAR("TAR", ".tar") { 34 | @Override public ArchiveReader createReader(InputStream is, String prefix) { return ArchiveFormat.makeTarReader(is, prefix); } 35 | @Override public ArchiveWriter createWriter(OutputStream os) { return ArchiveFormat.makeTarWriter(os); } 36 | }, 37 | TAR_XZ("TAR_XZ", ".tar.xz", ".txz") { 38 | @Override public ArchiveReader createReader(InputStream is, String prefix) throws IOException { return ArchiveFormat.makeTarReader(new XZCompressorInputStream(is), prefix); } 39 | @Override public ArchiveWriter createWriter(OutputStream os) throws IOException { return ArchiveFormat.makeTarWriter(new XZCompressorOutputStream(os)); } 40 | }, 41 | TAR_GZIP("TAR_GZIP", ".tar.gz", ".taz", ".tgz") { 42 | @Override public ArchiveReader createReader(InputStream is, String prefix) throws IOException { return ArchiveFormat.makeTarReader(new GzipCompressorInputStream(is), prefix); } 43 | @Override public ArchiveWriter createWriter(OutputStream os) throws IOException { return ArchiveFormat.makeTarWriter(new GzipCompressorOutputStream(os)); } 44 | }, 45 | TAR_BZIP2("TAR_BZIP2", ".tar.bz2", ".tb2", ".tbz", ".tbz2", ".tz2") { 46 | @Override public ArchiveReader createReader(InputStream is, String prefix) throws IOException { return ArchiveFormat.makeTarReader(new BZip2CompressorInputStream(is), prefix); } 47 | @Override public ArchiveWriter createWriter(OutputStream os) throws IOException { return ArchiveFormat.makeTarWriter(new BZip2CompressorOutputStream(os)); } 48 | }; 49 | //@formatter:on 50 | 51 | private final String name; 52 | private final Set fileExtensions; 53 | 54 | ArchiveFormat(String name, String... extensions) { 55 | this.name = name; 56 | this.fileExtensions = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(extensions))); 57 | } 58 | 59 | public String getName() { 60 | return name; 61 | } 62 | 63 | public Set getFileExtensions() { 64 | return fileExtensions; 65 | } 66 | 67 | /** 68 | * Tries to find a supported {@link ArchiveFormat} for a given file name. 69 | * This makes assumptions based on the Extension of the file. 70 | * 71 | * @param fName The File name. 72 | * @return The assumed {@link ArchiveFormat} or null if one cannot be found. 73 | */ 74 | public static @Nullable ArchiveFormat findFormat(String fName) { 75 | for (ArchiveFormat format : values()) { 76 | for (String ext : format.fileExtensions) { 77 | if (fName.endsWith(ext)) { 78 | return format; 79 | } 80 | } 81 | } 82 | return null; 83 | } 84 | 85 | public static @Nullable ArchiveFormat findFormat(Path fName) { 86 | return findFormat(fName.toString()); 87 | } 88 | 89 | private static ArchiveReader makeTarReader(InputStream is, String prefix) { 90 | return new ArchiveInputStreamReader(new TarArchiveInputStream(is), prefix); 91 | } 92 | 93 | private static ArchiveWriter makeTarWriter(OutputStream os) { 94 | return new TarArchiveOutputStreamWriter(new TarArchiveOutputStream(os)); 95 | } 96 | 97 | public ArchiveReader createReader(InputStream is) throws IOException { 98 | return createReader(is, ""); 99 | } 100 | 101 | public abstract ArchiveReader createReader(InputStream is, String prefix) throws IOException; 102 | 103 | public abstract ArchiveWriter createWriter(OutputStream os) throws IOException; 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/util/OutputTests.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import io.codechicken.diffpatch.test.TestBase; 4 | import io.codechicken.diffpatch.util.Output.MultiOutput; 5 | import io.codechicken.diffpatch.util.Output.SingleOutput; 6 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 7 | import io.codechicken.diffpatch.util.archiver.ArchiveReader; 8 | import net.covers1624.quack.collection.FastStream; 9 | import net.covers1624.quack.io.IOUtils; 10 | import net.covers1624.quack.io.NullOutputStream; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.io.TempDir; 13 | 14 | import java.io.FilterOutputStream; 15 | import java.io.IOException; 16 | import java.nio.charset.StandardCharsets; 17 | import java.nio.file.Files; 18 | import java.nio.file.Path; 19 | import java.util.HashMap; 20 | import java.util.List; 21 | import java.util.Map; 22 | import java.util.Random; 23 | import java.util.stream.Stream; 24 | 25 | import static org.junit.jupiter.api.Assertions.*; 26 | 27 | /** 28 | * Created by covers1624 on 30/5/24. 29 | */ 30 | public class OutputTests extends TestBase { 31 | 32 | @Test 33 | public void testSingleOutputPipe() { 34 | SingleOutput output = SingleOutput.pipe(new FilterOutputStream(NullOutputStream.INSTANCE) { 35 | @Override 36 | public void close() { 37 | fail("Should never be called."); 38 | } 39 | }); 40 | assertDoesNotThrow(() -> output.validate("asdf")); 41 | assertDoesNotThrow(() -> output.open().close()); 42 | } 43 | 44 | @Test 45 | public void testSingleOutputFilePreconditions(@TempDir Path tempDir) throws IOException { 46 | assertThrows(IOValidationException.class, () -> SingleOutput.path(tempDir).validate("asdf")); 47 | assertDoesNotThrow(() -> SingleOutput.path(tempDir.resolve("test.txt")).validate("asdf")); 48 | Files.createFile(tempDir.resolve("test.txt")); 49 | assertDoesNotThrow(() -> SingleOutput.path(tempDir.resolve("test.txt")).validate("asdf")); 50 | } 51 | 52 | @Test 53 | public void testMultiOutputFolderPreconditions(@TempDir Path tempDir) throws IOException { 54 | assertDoesNotThrow(() -> MultiOutput.folder(tempDir).validate("asdf")); 55 | assertDoesNotThrow(() -> MultiOutput.folder(tempDir.resolve("test.txt")).validate("asdf")); 56 | Files.createFile(tempDir.resolve("test.txt")); 57 | assertThrows(IOValidationException.class, () -> MultiOutput.folder(tempDir.resolve("test.txt")).validate("asdf")); 58 | } 59 | 60 | @Test 61 | public void testMultiOutputFolderOutputCleared(@TempDir Path tempDir) throws IOException { 62 | Map> randomFiles = generateRandomFiles(new Random()); 63 | writeFiles(MultiOutput.folder(tempDir), true, randomFiles); 64 | assertEquals(randomFiles, readFiles(tempDir)); 65 | } 66 | 67 | @Test 68 | public void testMultiOutputFolderOutputNotCleared(@TempDir Path tempDir) throws IOException { 69 | Map> randomFiles = generateRandomFiles(new Random()); 70 | writeFiles(tempDir, randomFiles); 71 | 72 | Map> randomFiles2 = generateRandomFiles(new Random()); 73 | writeFiles(MultiOutput.folder(tempDir), false, randomFiles2); 74 | 75 | Map> merged = new HashMap<>(randomFiles); 76 | merged.putAll(randomFiles2); 77 | assertEquals(merged, readFiles(tempDir)); 78 | } 79 | 80 | @Test 81 | public void testMultiOutputArchivePath(@TempDir Path tempDir) throws IOException { 82 | assertThrows(IOValidationException.class, () -> SingleOutput.path(tempDir).validate("asdf")); 83 | assertDoesNotThrow(() -> MultiOutput.archive(ArchiveFormat.ZIP, tempDir.resolve("test.zip")).validate("asdf")); 84 | Files.createFile(tempDir.resolve("test.zip")); 85 | assertDoesNotThrow(() -> MultiOutput.archive(ArchiveFormat.ZIP, tempDir.resolve("test.zip")).validate("asdf")); 86 | 87 | Map> randomFiles = generateRandomFiles(new Random()); 88 | writeFiles(MultiOutput.archive(ArchiveFormat.ZIP, tempDir.resolve("test.zip")), true, randomFiles); 89 | 90 | Map> writtenFiles = new HashMap<>(); 91 | try (ArchiveReader reader = ArchiveFormat.ZIP.createReader(Files.newInputStream(tempDir.resolve("test.zip")))) { 92 | for (String entry : reader.getEntries()) { 93 | writtenFiles.put(entry, reader.readLines(entry)); 94 | } 95 | } 96 | assertEquals(randomFiles, writtenFiles); 97 | } 98 | 99 | private static void writeFiles(MultiOutput output, boolean clearOutput, Map> randomFiles2) throws IOException { 100 | try (MultiOutput out = output) { 101 | out.open(clearOutput); 102 | for (Map.Entry> entry : randomFiles2.entrySet()) { 103 | out.write(entry.getKey(), String.join("\n", entry.getValue()).getBytes(StandardCharsets.UTF_8)); 104 | } 105 | } 106 | } 107 | 108 | private static Map> readFiles(Path tempDir) throws IOException { 109 | Map> writtenFiles = new HashMap<>(); 110 | try (Stream files = Files.walk(tempDir)) { 111 | for (Path file : FastStream.of(files)) { 112 | if (Files.isDirectory(file)) continue; 113 | writtenFiles.put(tempDir.relativize(file).toString(), Files.readAllLines(file)); 114 | } 115 | } 116 | return writtenFiles; 117 | } 118 | 119 | private static void writeFiles(Path tempDir, Map> randomFiles) throws IOException { 120 | for (Map.Entry> entry : randomFiles.entrySet()) { 121 | Files.write(IOUtils.makeParents(tempDir.resolve(entry.getKey())), String.join("\n", entry.getValue()).getBytes(StandardCharsets.UTF_8)); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/util/archiver/ArchiveFormatTests.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util.archiver; 2 | 3 | import io.codechicken.diffpatch.test.TestBase; 4 | import joptsimple.internal.Strings; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.io.ByteArrayInputStream; 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.IOException; 10 | import java.nio.charset.StandardCharsets; 11 | import java.util.*; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | import static org.junit.jupiter.api.Assertions.assertNull; 15 | 16 | /** 17 | * Created by covers1624 on 11/2/21. 18 | */ 19 | public class ArchiveFormatTests extends TestBase { 20 | 21 | @Test 22 | public void testDetection() { 23 | //Zip 24 | assertEquals(ArchiveFormat.ZIP, ArchiveFormat.findFormat("some/path/abcd.zip")); 25 | assertEquals(ArchiveFormat.ZIP, ArchiveFormat.findFormat("some\\path\\abcd.zip")); 26 | assertNull(ArchiveFormat.findFormat("some/path/abcd.nozip")); 27 | assertEquals(ArchiveFormat.ZIP, ArchiveFormat.findFormat("some/path/abcd.jar")); 28 | assertEquals(ArchiveFormat.ZIP, ArchiveFormat.findFormat("some\\path\\abcd.jar")); 29 | assertNull(ArchiveFormat.findFormat("some/path/abcd.nojar")); 30 | 31 | //Tar 32 | assertEquals(ArchiveFormat.TAR, ArchiveFormat.findFormat("some/path/abcd.tar")); 33 | assertEquals(ArchiveFormat.TAR, ArchiveFormat.findFormat("some\\path\\abcd.tar")); 34 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notar")); 35 | 36 | //Tar XZ 37 | assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormat.findFormat("some/path/abcd.tar.xz")); 38 | assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormat.findFormat("some\\path\\abcd.tar.xz")); 39 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notar.xz")); 40 | 41 | assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormat.findFormat("some/path/abcd.txz")); 42 | assertEquals(ArchiveFormat.TAR_XZ, ArchiveFormat.findFormat("some\\path\\abcd.txz")); 43 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notxz")); 44 | 45 | //Tar GZIP 46 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some/path/abcd.tar.gz")); 47 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some\\path\\abcd.tar.gz")); 48 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notar.gz")); 49 | 50 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some/path/abcd.taz")); 51 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some\\path\\abcd.taz")); 52 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notaz")); 53 | 54 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some/path/abcd.tgz")); 55 | assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormat.findFormat("some\\path\\abcd.tgz")); 56 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notgz")); 57 | 58 | //Tar BZIP2 59 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some/path/abcd.tar.bz2")); 60 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some\\path\\abcd.tar.bz2")); 61 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notar.bz2")); 62 | 63 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some/path/abcd.tb2")); 64 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some\\path\\abcd.tb2")); 65 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notb2")); 66 | 67 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some/path/abcd.tbz")); 68 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some\\path\\abcd.tbz")); 69 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notbz")); 70 | 71 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some/path/abcd.tbz2")); 72 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some\\path\\abcd.tbz2")); 73 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notbz2")); 74 | 75 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some/path/abcd.tz2")); 76 | assertEquals(ArchiveFormat.TAR_BZIP2, ArchiveFormat.findFormat("some\\path\\abcd.tz2")); 77 | assertNull(ArchiveFormat.findFormat("some/path/abcd.notz2")); 78 | } 79 | 80 | @Test 81 | public void testZipReadWrite() throws Throwable { 82 | doReadWrite(ArchiveFormat.ZIP); 83 | doReadWrite(ArchiveFormat.TAR); 84 | doReadWrite(ArchiveFormat.TAR_XZ); 85 | doReadWrite(ArchiveFormat.TAR_GZIP); 86 | doReadWrite(ArchiveFormat.TAR_BZIP2); 87 | } 88 | 89 | public static void doReadWrite(ArchiveFormat format) throws IOException { 90 | Random randy = new Random(); 91 | Map> origFiles = generateRandomFiles(randy); 92 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 93 | try (ArchiveWriter writer = format.createWriter(bos)) { 94 | for (Map.Entry> entry : origFiles.entrySet()) { 95 | String str = Strings.join(entry.getValue(), "\n"); 96 | writer.writeEntry(entry.getKey(), str.getBytes(StandardCharsets.UTF_8)); 97 | } 98 | } 99 | try (ArchiveReader reader = format.createReader(new ByteArrayInputStream(bos.toByteArray()))) { 100 | Set archiveKeys = reader.getEntries(); 101 | assertEquals(origFiles.size(), archiveKeys.size()); 102 | assertEquals(origFiles.keySet(), archiveKeys); 103 | // Assert order is identical. 104 | assertEquals(new ArrayList<>(origFiles.keySet()), new ArrayList<>(reader.getEntries())); 105 | 106 | for (Map.Entry> entry : origFiles.entrySet()) { 107 | List expected = entry.getValue(); 108 | List archive = reader.readLines(entry.getKey()); 109 | assertEquals(expected.size(), archive.size()); 110 | assertEquals(expected, archive); 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/match/PatienceMatch.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.match; 2 | 3 | import it.unimi.dsi.fastutil.ints.AbstractInt2IntMap; 4 | import it.unimi.dsi.fastutil.ints.Int2IntMap; 5 | import it.unimi.dsi.fastutil.ints.IntArrayList; 6 | import it.unimi.dsi.fastutil.ints.IntList; 7 | 8 | import java.util.ArrayList; 9 | import java.util.Collections; 10 | import java.util.List; 11 | 12 | public class PatienceMatch { 13 | 14 | //working fields for matching 15 | private String chars1; 16 | private String chars2; 17 | private int[] unique1; 18 | private int[] unique2; 19 | private int[] matches; 20 | 21 | private void match(int start1, int end1, int start2, int end2) { 22 | // step 1: match up identical starting lines 23 | while (start1 < end1 && start2 < end2 && chars1.charAt(start1) == chars2.charAt(start2)) { 24 | matches[start1++] = start2++; 25 | } 26 | 27 | // step 2: match up identical ending lines 28 | while (start1 < end1 && start2 < end2 && chars1.charAt(end1 - 1) == chars2.charAt(end2 - 1)) { 29 | matches[--end1] = --end2; 30 | } 31 | 32 | if (start1 == end1 || start2 == end2 // no lines on a side 33 | || end1 - start1 + end2 - start2 <= 3) { // either a 1-2 or 2-1 which would've been matched by steps 1 and 2 34 | return; 35 | } 36 | 37 | // step 3: match up common unique lines 38 | boolean any = false; 39 | for (Int2IntMap.Entry entry : lcsUnique(start1, end1, start2, end2)) { 40 | int m1 = entry.getIntKey(); 41 | int m2 = entry.getIntValue(); 42 | matches[m1] = m2; 43 | any = true; 44 | 45 | //step 4: recurse 46 | match(start1, m1, start2, m2); 47 | 48 | start1 = m1 + 1; 49 | start2 = m2 + 1; 50 | } 51 | 52 | if (any) { 53 | match(start1, end1, start2, end2); 54 | } 55 | } 56 | 57 | private int[] match() { 58 | matches = new int[chars1.length()]; 59 | for (int i = 0; i < chars1.length(); i++) { 60 | matches[i] = -1; 61 | } 62 | 63 | match(0, chars1.length(), 0, chars2.length()); 64 | return matches; 65 | } 66 | 67 | public int[] match(String chars1, String chars2, int maxChar) { 68 | if (unique1 == null || unique1.length < maxChar) { 69 | unique1 = new int[maxChar]; 70 | unique2 = new int[maxChar]; 71 | for (int i = 0; i < maxChar; i++) { 72 | unique1[i] = unique2[i] = -1; 73 | } 74 | } 75 | 76 | this.chars1 = chars1; 77 | this.chars2 = chars2; 78 | 79 | return match(); 80 | } 81 | 82 | private final IntList subChars = new IntArrayList(); 83 | 84 | private List lcsUnique(int start1, int end1, int start2, int end2) { 85 | //identify all the unique chars in chars1 86 | for (int i = start1; i < end1; i++) { 87 | int c = chars1.charAt(i); 88 | 89 | if (unique1[c] == -1) {//no lines 90 | unique1[c] = i; 91 | subChars.add(c); 92 | } else { 93 | unique1[c] = -2;//not unique 94 | } 95 | } 96 | 97 | //identify all the unique chars in chars2, provided they were unique in chars1 98 | for (int i = start2; i < end2; i++) { 99 | int c = chars2.charAt(i); 100 | if (unique1[c] < 0) { 101 | continue; 102 | } 103 | 104 | unique2[c] = unique2[c] == -1 ? i : -2; 105 | } 106 | 107 | //extract common unique subsequences 108 | IntList common1 = new IntArrayList(); 109 | IntList common2 = new IntArrayList(); 110 | for (int i : subChars) { 111 | if (unique1[i] >= 0 && unique2[i] >= 0) { 112 | common1.add(unique1[i]); 113 | common2.add(unique2[i]); 114 | } 115 | unique1[i] = unique2[i] = -1; //reset for next use 116 | } 117 | subChars.clear(); 118 | 119 | if (common2.isEmpty()) { 120 | return Collections.emptyList(); 121 | } 122 | List ret = new ArrayList<>(); 123 | 124 | // repose the longest common subsequence as longest ascending subsequence 125 | // note that common2 is already sorted by order of appearance in file1 by of char allocation 126 | for (int i : lasIndices(common2)) { 127 | ret.add(new AbstractInt2IntMap.BasicEntry(common1.getInt(i), common2.getInt(i))); 128 | } 129 | return ret; 130 | } 131 | 132 | //https://en.wikipedia.org/wiki/Patience_sorting 133 | public static int[] lasIndices(IntList sequence) { 134 | if (sequence.isEmpty()) { 135 | return new int[0]; 136 | } 137 | 138 | List pileTops = new ArrayList<>(); 139 | pileTops.add(new LCANode(0, null)); 140 | for (int i = 1; i < sequence.size(); i++) { 141 | int v = sequence.getInt(i); 142 | 143 | //binary search for the first pileTop > v 144 | int a = 0; 145 | int b = pileTops.size(); 146 | while (a != b) { 147 | int c = (a + b) / 2; 148 | if (sequence.getInt(pileTops.get(c).value) > v) { 149 | b = c; 150 | } else { 151 | a = c + 1; 152 | } 153 | } 154 | 155 | if (a < pileTops.size()) { 156 | pileTops.set(a, new LCANode(i, a > 0 ? pileTops.get(a - 1) : null)); 157 | } else { 158 | pileTops.add(new LCANode(i, pileTops.get(a - 1))); 159 | } 160 | } 161 | 162 | //follow pointers back through path 163 | int[] las = new int[pileTops.size()]; 164 | int j = pileTops.size() - 1; 165 | for (LCANode node = pileTops.get(j); node != null; node = node.prev) { 166 | las[j--] = node.value; 167 | } 168 | 169 | return las; 170 | } 171 | 172 | private static class LCANode { 173 | 174 | public final int value; 175 | public final LCANode prev; 176 | 177 | public LCANode(int value, LCANode prev) { 178 | this.value = value; 179 | this.prev = prev; 180 | } 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/BakePatchesOperation.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.util.*; 4 | import io.codechicken.diffpatch.util.Input.MultiInput; 5 | import io.codechicken.diffpatch.util.Input.SingleInput; 6 | import io.codechicken.diffpatch.util.Output.MultiOutput; 7 | import io.codechicken.diffpatch.util.Output.SingleOutput; 8 | import net.covers1624.quack.io.NullOutputStream; 9 | import net.covers1624.quack.util.SneakyUtils; 10 | import org.jetbrains.annotations.Nullable; 11 | 12 | import java.io.IOException; 13 | import java.io.OutputStream; 14 | import java.io.PrintStream; 15 | import java.nio.charset.StandardCharsets; 16 | import java.util.List; 17 | import java.util.Objects; 18 | import java.util.function.Consumer; 19 | 20 | import static io.codechicken.diffpatch.util.LogLevel.ERROR; 21 | 22 | /** 23 | * Created by covers1624 on 6/16/25. 24 | */ 25 | public class BakePatchesOperation extends CliOperation { 26 | 27 | final boolean summary; 28 | final Input patchesInput; 29 | final Output bakedOutput; 30 | final String patchesPrefix; 31 | final String lineEnding; 32 | 33 | private BakePatchesOperation(PrintStream logger, LogLevel level, Consumer helpCallback, boolean summary, Input patchesInput, Output bakedOutput, String patchesPrefix, String lineEnding) { 34 | super(logger, level, helpCallback); 35 | this.summary = summary; 36 | this.patchesInput = patchesInput; 37 | this.bakedOutput = bakedOutput; 38 | this.patchesPrefix = patchesPrefix; 39 | this.lineEnding = lineEnding; 40 | } 41 | 42 | public static Builder builder() { 43 | return new Builder(); 44 | } 45 | 46 | @Override 47 | public Result operate() throws IOException { 48 | try { 49 | patchesInput.validate("bake input"); 50 | } catch (IOValidationException ex) { 51 | throw new IllegalArgumentException(ex.getMessage()); 52 | } 53 | if (patchesInput instanceof SingleInput) { 54 | SingleInput input = (SingleInput) patchesInput; 55 | if (!(bakedOutput instanceof SingleOutput)) { 56 | log(ERROR, "Can't specify baked output directory or archive when baking a single file."); 57 | printHelp(); 58 | return new Result<>(-1); 59 | } 60 | SingleOutput output = (SingleOutput) bakedOutput; 61 | try (OutputStream os = output.open()) { 62 | os.write(bakePatch(input.name(), input.readLines(), lineEnding).getBytes(StandardCharsets.UTF_8)); 63 | os.flush(); 64 | } 65 | return new Result<>(0, new BakeSummary()); 66 | } 67 | 68 | if (!(patchesInput instanceof MultiInput)) { 69 | log(ERROR, "Can't patch between single files and folders/archives."); 70 | printHelp(); 71 | return new Result<>(-1); 72 | } 73 | 74 | try (MultiInput in = (MultiInput) patchesInput; 75 | MultiOutput out = (MultiOutput) bakedOutput) { 76 | in.open(patchesPrefix); 77 | out.open(true); 78 | for (String file : in.index()) { 79 | out.write(file, bakePatch(file, in.readLines(file), lineEnding).getBytes(StandardCharsets.UTF_8)); 80 | } 81 | } 82 | return new Result<>(0, new BakeSummary()); 83 | } 84 | 85 | private static String bakePatch(String fileName, List lines, String lineEnding) { 86 | PatchFile patch = PatchFile.fromLines(fileName, lines, true); 87 | String baked = String.join(lineEnding, patch.toLines(false)); 88 | return baked + lineEnding; 89 | } 90 | 91 | public static class BakeSummary { 92 | } 93 | 94 | public static class Builder { 95 | 96 | private static final PrintStream NULL_STREAM = new PrintStream(NullOutputStream.INSTANCE); 97 | 98 | private PrintStream logger = NULL_STREAM; 99 | private Consumer helpCallback = SneakyUtils.nullCons(); 100 | private LogLevel level = LogLevel.WARN; 101 | private boolean summary; 102 | private @Nullable Input patchesInput; 103 | private @Nullable Output bakedOutput; 104 | private String patchesPrefix = ""; 105 | private String lineEnding = System.lineSeparator(); 106 | 107 | private Builder() { 108 | } 109 | 110 | public Builder logTo(Consumer func) { 111 | return logTo(new ConsumingOutputStream(func)); 112 | } 113 | 114 | public Builder logTo(PrintStream logger) { 115 | this.logger = Objects.requireNonNull(logger); 116 | return this; 117 | } 118 | 119 | public Builder logTo(OutputStream logger) { 120 | return logTo(new PrintStream(logger)); 121 | } 122 | 123 | public Builder helpCallback(Consumer helpCallback) { 124 | this.helpCallback = Objects.requireNonNull(helpCallback); 125 | return this; 126 | } 127 | 128 | public Builder level(LogLevel level) { 129 | this.level = level; 130 | return this; 131 | } 132 | 133 | public Builder summary(boolean summary) { 134 | this.summary = summary; 135 | return this; 136 | } 137 | 138 | public Builder patchesInput(Input patchesInput) { 139 | this.patchesInput = Objects.requireNonNull(patchesInput); 140 | return this; 141 | } 142 | 143 | public Builder bakedOutput(Output bakedOutput) { 144 | this.bakedOutput = Objects.requireNonNull(bakedOutput); 145 | return this; 146 | } 147 | 148 | public Builder patchesPrefix(String patchesPrefix) { 149 | this.patchesPrefix = Objects.requireNonNull(patchesPrefix); 150 | return this; 151 | } 152 | 153 | public Builder lineEnding(String lineEnding) { 154 | this.lineEnding = lineEnding; 155 | return this; 156 | } 157 | 158 | public BakePatchesOperation build() { 159 | if (patchesInput == null) throw new IllegalStateException("patchesInput is required."); 160 | if (bakedOutput == null) throw new IllegalStateException("bakedOutput is required."); 161 | 162 | return new BakePatchesOperation(logger, level, helpCallback, summary, patchesInput, bakedOutput, patchesPrefix, lineEnding); 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/util/InputTests.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import io.codechicken.diffpatch.util.Input.MultiInput; 4 | import io.codechicken.diffpatch.util.Input.SingleInput; 5 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 6 | import io.codechicken.diffpatch.util.archiver.ArchiveWriter; 7 | import net.covers1624.quack.io.IOUtils; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.io.TempDir; 10 | 11 | import java.io.FilterInputStream; 12 | import java.io.IOException; 13 | import java.nio.charset.StandardCharsets; 14 | import java.nio.file.Files; 15 | import java.nio.file.Path; 16 | import java.util.*; 17 | 18 | import static io.codechicken.diffpatch.test.TestBase.generateRandomFiles; 19 | import static org.junit.jupiter.api.Assertions.*; 20 | 21 | /** 22 | * Created by covers1624 on 31/5/24. 23 | */ 24 | public class InputTests { 25 | 26 | @Test 27 | public void testSingleInputString() throws IOException { 28 | SingleInput input = SingleInput.string("asdf\n1234\nfdsa"); 29 | assertDoesNotThrow(() -> input.validate("asdf")); 30 | assertDoesNotThrow(() -> input.open().close()); 31 | assertEquals(input.readLines(), Arrays.asList("asdf", "1234", "fdsa")); 32 | } 33 | 34 | @Test 35 | public void testSingleInputPipe() { 36 | SingleInput input = SingleInput.pipe(new FilterInputStream(null) { 37 | @Override 38 | public void close() { 39 | fail("Should never be called."); 40 | } 41 | }); 42 | 43 | assertDoesNotThrow(() -> input.validate("asdf")); 44 | assertDoesNotThrow(() -> input.open().close()); 45 | } 46 | 47 | @Test 48 | public void testSingleInputFilePreconditions(@TempDir Path tempDir) throws IOException { 49 | assertThrows(IOValidationException.class, () -> SingleInput.path(tempDir).validate("asdf")); 50 | assertThrows(IOValidationException.class, () -> SingleInput.path(tempDir.resolve("test.txt")).validate("asdf")); 51 | Files.createFile(tempDir.resolve("test.txt")); 52 | assertDoesNotThrow(() -> SingleInput.path(tempDir.resolve("test.txt")).validate("asdf")); 53 | } 54 | 55 | @Test 56 | public void testMultiInputFolderPreconditions(@TempDir Path tempDir) throws IOException { 57 | assertDoesNotThrow(() -> MultiInput.folder(tempDir).validate("asdf")); 58 | assertThrows(IOValidationException.class, () -> MultiInput.folder(tempDir.resolve("test/")).validate("asdf")); 59 | assertThrows(IOValidationException.class, () -> MultiInput.folder(tempDir.resolve("test.txt")).validate("asdf")); 60 | Files.createFile(tempDir.resolve("test.txt")); 61 | assertThrows(IOValidationException.class, () -> MultiInput.folder(tempDir.resolve("test.txt")).validate("asdf")); 62 | } 63 | 64 | @Test 65 | public void testMultiInputFolder(@TempDir Path tempDir) throws IOException { 66 | Map> randomFiles = generateRandomFiles(new Random()); 67 | writeFiles(tempDir, randomFiles); 68 | try (MultiInput input = MultiInput.folder(tempDir)) { 69 | input.open(""); 70 | assertEquals(randomFiles.keySet(), input.index()); 71 | assertEquals(randomFiles, readFiles(input)); 72 | } 73 | } 74 | 75 | @Test 76 | public void testMultiInputFolderWithPrefix(@TempDir Path tempDir) throws IOException { 77 | Map> randomFiles = generateRandomFiles(new Random()); 78 | 79 | writeFiles(tempDir.resolve("nested/"), randomFiles); 80 | try (MultiInput input = MultiInput.folder(tempDir)) { 81 | input.open("nested/"); 82 | assertEquals(randomFiles.keySet(), input.index()); 83 | assertEquals(randomFiles, readFiles(input)); 84 | } 85 | } 86 | 87 | @Test 88 | public void testMultiInputArchive(@TempDir Path tempDir) throws IOException { 89 | Map> randomFiles = generateRandomFiles(new Random()); 90 | 91 | writeZip(tempDir.resolve("test.zip"), randomFiles); 92 | Map> readFiles = new HashMap<>(); 93 | try (MultiInput input = MultiInput.archive(ArchiveFormat.ZIP, tempDir.resolve("test.zip"))) { 94 | input.open(""); 95 | for (String index : input.index()) { 96 | readFiles.put(index, input.readLines(index)); 97 | } 98 | } 99 | assertEquals(randomFiles, readFiles); 100 | } 101 | 102 | @Test 103 | public void testMultiInputArchivePrefix(@TempDir Path tempDir) throws IOException { 104 | Map> randomFiles = generateRandomFiles(new Random()); 105 | Map> randomFiles2 = generateRandomFiles(new Random()); 106 | 107 | Map> written = new HashMap<>(randomFiles); 108 | randomFiles2.forEach((k, v) -> written.put("nested/" + k, v)); 109 | writeZip(tempDir.resolve("test.zip"), written); 110 | Map> readFiles = new HashMap<>(); 111 | try (MultiInput input = MultiInput.archive(ArchiveFormat.ZIP, tempDir.resolve("test.zip"))) { 112 | input.open("nested/"); 113 | for (String index : input.index()) { 114 | readFiles.put(index, input.readLines(index)); 115 | } 116 | } 117 | assertEquals(randomFiles2, readFiles); 118 | } 119 | 120 | private static void writeZip(Path zip, Map> files) throws IOException { 121 | try (ArchiveWriter aw = ArchiveFormat.ZIP.createWriter(Files.newOutputStream(IOUtils.makeParents(zip)))) { 122 | for (Map.Entry> entry : files.entrySet()) { 123 | aw.writeEntry(entry.getKey(), String.join("\n", entry.getValue()).getBytes(StandardCharsets.UTF_8)); 124 | } 125 | } 126 | } 127 | 128 | private static void writeFiles(Path tempDir, Map> randomFiles) throws IOException { 129 | for (Map.Entry> entry : randomFiles.entrySet()) { 130 | Files.write(IOUtils.makeParents(tempDir.resolve(entry.getKey())), String.join("\n", entry.getValue()).getBytes(StandardCharsets.UTF_8)); 131 | } 132 | } 133 | 134 | private static Map> readFiles(MultiInput input) throws IOException { 135 | Map> files = new HashMap<>(); 136 | for (String index : input.index()) { 137 | files.put(index, input.readLines(index)); 138 | } 139 | return files; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Patch.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import net.covers1624.quack.collection.FastStream; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.function.Function; 8 | 9 | /** 10 | * Also known as a Hunk 11 | * Represents a sequence of Diffs. 12 | */ 13 | public class Patch { 14 | 15 | public List diffs; 16 | public int start1; 17 | public int start2; 18 | public int length1; 19 | public int length2; 20 | 21 | public Patch() { 22 | diffs = new ArrayList<>(); 23 | } 24 | 25 | public Patch(Patch other) { 26 | this.diffs = FastStream.of(other.diffs).map(Diff::new).toList(); 27 | this.start1 = other.start1; 28 | this.start2 = other.start2; 29 | this.length1 = other.length1; 30 | this.length2 = other.length2; 31 | } 32 | 33 | private LineRange trimRange(LineRange range) { 34 | int start = 0; 35 | while (start < diffs.size() && diffs.get(start).op == Operation.EQUAL) { 36 | start++; 37 | } 38 | if (start == diffs.size()) { 39 | return LineRange.fromStartLen(range.getStart(), 0); 40 | } 41 | 42 | int end = diffs.size(); 43 | while (end > start && diffs.get(end - 1).op == Operation.EQUAL) { 44 | end--; 45 | } 46 | return new LineRange(range.getStart() + start, range.getEnd() - (diffs.size() - end)); 47 | } 48 | 49 | public void recalculateLength() { 50 | length1 = diffs.size(); 51 | length2 = diffs.size(); 52 | for (Diff diff : diffs) { 53 | if (diff.op == Operation.DELETE) { 54 | length2--; 55 | } else if (diff.op == Operation.INSERT) { 56 | length1--; 57 | } 58 | } 59 | } 60 | 61 | public void trim(int numContextLines) { 62 | LineRange r = trimRange(LineRange.fromStartLen(0, diffs.size())); 63 | 64 | if (r.getLength() == 0) { 65 | length1 = length2 = 0; 66 | diffs.clear(); 67 | return; 68 | } 69 | 70 | int trimStart = r.getStart() - numContextLines; 71 | int trimEnd = diffs.size() - r.getEnd() - numContextLines; 72 | if (trimStart > 0) { 73 | diffs.subList(0, trimStart).clear(); 74 | start1 += trimStart; 75 | start2 += trimStart; 76 | length1 -= trimStart; 77 | length2 -= trimStart; 78 | } 79 | 80 | if (trimEnd > 0) { 81 | diffs.subList(diffs.size() - trimEnd, diffs.size()).clear(); 82 | length1 -= trimEnd; 83 | length2 -= trimEnd; 84 | } 85 | } 86 | 87 | public void uncollate() { 88 | List unCollatedDiffs = new ArrayList<>(diffs.size()); 89 | List addDiffs = new ArrayList<>(); 90 | for (Diff d : diffs) { 91 | if (d.op == Operation.DELETE) { 92 | unCollatedDiffs.add(d); 93 | } else if (d.op == Operation.INSERT) { 94 | addDiffs.add(d); 95 | } else { 96 | unCollatedDiffs.addAll(addDiffs); 97 | addDiffs.clear(); 98 | unCollatedDiffs.add(d); 99 | } 100 | } 101 | unCollatedDiffs.addAll(addDiffs); //patches may not end with context diffs 102 | diffs = unCollatedDiffs; 103 | } 104 | 105 | public List split(int numContextLines) { 106 | if (diffs.isEmpty()) { 107 | return new ArrayList<>(); 108 | } 109 | List ranges = new ArrayList<>(); 110 | int start = 0; 111 | int n = 0; 112 | for (int i = 0; i < diffs.size(); i++) { 113 | if (diffs.get(i).op == Operation.EQUAL) { 114 | n++; 115 | continue; 116 | } 117 | 118 | if (n > numContextLines * 2) { 119 | ranges.add(new LineRange(start, i - n + numContextLines)); 120 | start = i - numContextLines; 121 | } 122 | 123 | n = 0; 124 | } 125 | 126 | ranges.add(new LineRange(start, diffs.size())); 127 | 128 | List patches = new ArrayList<>(diffs.size()); 129 | int end1 = start1; 130 | int end2 = start2; 131 | int endDiffIndex = 0; 132 | for (LineRange r : ranges) { 133 | int skip = r.getStart() - endDiffIndex; 134 | Patch patch = new Patch(); 135 | patch.start1 = end1 + skip; 136 | patch.start2 = end2 + skip; 137 | patch.diffs = new ArrayList<>(diffs.subList(r.getStart(), r.getEnd())); 138 | patch.recalculateLength(); 139 | patches.add(patch); 140 | end1 = patch.start1 + patch.length1; 141 | end2 = patch.start2 + patch.length2; 142 | endDiffIndex = r.getEnd(); 143 | } 144 | return patches; 145 | } 146 | 147 | public void combine(Patch patch2, List lines1) { 148 | if (getRange1().intersects(patch2.getRange1()) || getRange2().intersects(patch2.getRange2())) { 149 | throw new IllegalArgumentException("Patches overlap"); 150 | } 151 | 152 | while (start1 + length1 < patch2.start1) { 153 | diffs.add(new Diff(Operation.EQUAL, lines1.get(start1 + length1))); 154 | length1++; 155 | length2++; 156 | } 157 | 158 | if (start2 + length2 != patch2.start2) { 159 | throw new IllegalArgumentException("Unequal distance between end of patch1 and start of patch2 in context and patched"); 160 | } 161 | diffs.addAll(patch2.diffs); 162 | length1 += patch2.length1; 163 | length2 += patch2.length2; 164 | } 165 | 166 | //@formatter:off 167 | public String getHeader() { return String.format("@@ -%d,%d +%d,%d @@", start1 + 1, length1, start2 + 1, length2); } 168 | public String getAutoHeader() { return String.format("@@ -%d,%d +_,%d @@", start1 + 1, length1, length2); } 169 | public List getContextLines() { return getContextLines(Function.identity()); } 170 | public List getContextLines(Function f) { return FastStream.of(diffs).filter(e -> e.op != Operation.INSERT).map(e -> f.apply(e.text)).toList(); } 171 | public List getPatchedLines() { return getPatchedLines(Function.identity()); } 172 | public List getPatchedLines(Function f) { return FastStream.of(diffs).filter(e -> e.op != Operation.DELETE).map(e -> f.apply(e.text)).toList(); } 173 | public LineRange getRange1() { return LineRange.fromStartLen(start1, length1); } 174 | public LineRange getRange2() { return LineRange.fromStartLen(start2, length2); } 175 | public LineRange getTrimmedRange1() { return trimRange(getRange1()); } 176 | public LineRange getTrimmedRange2() { return trimRange(getRange2()); } 177 | //@formatter:on 178 | 179 | @Override 180 | public String toString() { 181 | return getHeader() + "\n" + FastStream.of(diffs).map(Diff::toString).join("\n"); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/cli/DiffOperationTests.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.test.TestBase; 4 | import io.codechicken.diffpatch.util.ArchiveBuilder; 5 | import io.codechicken.diffpatch.util.Input; 6 | import io.codechicken.diffpatch.util.LogLevel; 7 | import io.codechicken.diffpatch.util.Output; 8 | import io.codechicken.diffpatch.util.archiver.ArchiveReader; 9 | import net.covers1624.quack.io.NullOutputStream; 10 | import org.junit.jupiter.api.Disabled; 11 | import org.junit.jupiter.api.Test; 12 | 13 | import java.io.ByteArrayInputStream; 14 | import java.io.ByteArrayOutputStream; 15 | import java.io.IOException; 16 | import java.nio.charset.StandardCharsets; 17 | 18 | import static io.codechicken.diffpatch.util.archiver.ArchiveFormat.ZIP; 19 | import static org.junit.jupiter.api.Assertions.assertEquals; 20 | import static org.junit.jupiter.api.Assertions.assertThrows; 21 | 22 | /** 23 | * Created by covers1624 on 22/6/24. 24 | */ 25 | public class DiffOperationTests extends TestBase { 26 | 27 | @Test 28 | public void testDiffSingle() throws IOException { 29 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 30 | CliOperation.Result result = DiffOperation.builder() 31 | .logTo(System.out) 32 | .level(LogLevel.ALL) 33 | .baseInput(Input.SingleInput.pipe(testResourceStream("/files/A.txt"), "a/A.txt")) 34 | .changedInput(Input.SingleInput.pipe(testResourceStream("/files/B.txt"), "b/A.txt")) 35 | .patchesOutput(Output.SingleOutput.pipe(output)) 36 | .build() 37 | .operate(); 38 | assertEquals(1, result.exit); 39 | assertEquals(testResourceString("/patches/ModifiedA.txt.patch"), output.toString("UTF-8")); 40 | } 41 | 42 | @Test 43 | public void testDiffSingleReverse() throws IOException { 44 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 45 | CliOperation.Result result = DiffOperation.builder() 46 | .logTo(System.out) 47 | .level(LogLevel.ALL) 48 | .baseInput(Input.SingleInput.pipe(testResourceStream("/files/B.txt"), "a/A.txt")) 49 | .changedInput(Input.SingleInput.pipe(testResourceStream("/files/A.txt"), "b/A.txt")) 50 | .patchesOutput(Output.SingleOutput.pipe(output)) 51 | .build() 52 | .operate(); 53 | assertEquals(1, result.exit); 54 | assertEquals(testResourceString("/patches/ModifiedB.txt.patch"), output.toString("UTF-8")); 55 | } 56 | 57 | @Test 58 | public void testDiffArchive() throws IOException { 59 | byte[] a = new ArchiveBuilder() 60 | .put("A.txt", testResource("/files/A.txt")) 61 | .toBytes(ZIP); 62 | byte[] b = new ArchiveBuilder() 63 | .put("A.txt", testResource("/files/B.txt")) 64 | .toBytes(ZIP); 65 | 66 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 67 | CliOperation.Result result = DiffOperation.builder() 68 | .logTo(System.out) 69 | .level(LogLevel.ALL) 70 | .baseInput(Input.MultiInput.archive(ZIP, a)) 71 | .changedInput(Input.MultiInput.archive(ZIP, b)) 72 | .patchesOutput(Output.MultiOutput.archive(ZIP, output)) 73 | .build() 74 | .operate(); 75 | assertEquals(1, result.exit); 76 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 77 | assertEquals(testResourceString("/patches/ModifiedA.txt.patch"), new String(ar.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 78 | } 79 | } 80 | 81 | @Test 82 | public void testCreatePatch() throws IOException { 83 | byte[] a = new ArchiveBuilder() 84 | .toBytes(ZIP); 85 | byte[] b = new ArchiveBuilder() 86 | .put("A.txt", testResource("/files/A.txt")) 87 | .toBytes(ZIP); 88 | 89 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 90 | CliOperation.Result result = DiffOperation.builder() 91 | .logTo(System.out) 92 | .level(LogLevel.ALL) 93 | .baseInput(Input.MultiInput.archive(ZIP, a)) 94 | .changedInput(Input.MultiInput.archive(ZIP, b)) 95 | .patchesOutput(Output.MultiOutput.archive(ZIP, output)) 96 | .build() 97 | .operate(); 98 | assertEquals(1, result.exit); 99 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 100 | assertEquals(testResourceString("/patches/CreateA.txt.patch"), new String(ar.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 101 | } 102 | } 103 | 104 | @Test 105 | public void testDeletePatch() throws IOException { 106 | byte[] a = new ArchiveBuilder() 107 | .put("A.txt", testResource("/files/A.txt")) 108 | .toBytes(ZIP); 109 | byte[] b = new ArchiveBuilder() 110 | .toBytes(ZIP); 111 | 112 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 113 | CliOperation.Result result = DiffOperation.builder() 114 | .logTo(System.out) 115 | .level(LogLevel.ALL) 116 | .baseInput(Input.MultiInput.archive(ZIP, a)) 117 | .changedInput(Input.MultiInput.archive(ZIP, b)) 118 | .patchesOutput(Output.MultiOutput.archive(ZIP, output)) 119 | .build() 120 | .operate(); 121 | assertEquals(1, result.exit); 122 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 123 | assertEquals(testResourceString("/patches/DeleteA.txt.patch"), new String(ar.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 124 | } 125 | } 126 | 127 | @Test 128 | public void testRemoveTrailingNewlineBroken() { 129 | assertThrows(AssertionError.class, this::testRemoveTrailingNewline); 130 | } 131 | 132 | @Test 133 | @Disabled ("Currently we are unable to detect these and emit these patches.") 134 | public void testRemoveTrailingNewline() throws IOException { 135 | byte[] a = new ArchiveBuilder() 136 | .put("A.txt", testResource("/files/A.txt")) 137 | .toBytes(ZIP); 138 | byte[] b = new ArchiveBuilder() 139 | .put("A.txt", testResource("/files/ANoNewline.txt")) 140 | .toBytes(ZIP); 141 | 142 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 143 | CliOperation.Result result = DiffOperation.builder() 144 | .logTo(System.out) 145 | .level(LogLevel.ALL) 146 | .baseInput(Input.MultiInput.archive(ZIP, a)) 147 | .changedInput(Input.MultiInput.archive(ZIP, b)) 148 | .patchesOutput(Output.MultiOutput.archive(ZIP, output)) 149 | .build() 150 | .operate(); 151 | assertEquals(1, result.exit); 152 | 153 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 154 | assertEquals(testResourceString("/patches/AToANoNewline.txt.patch"), new String(ar.getBytes("A.txt.patch"), StandardCharsets.UTF_8)); 155 | } 156 | } 157 | 158 | @Test 159 | public void testDiffEmpty() throws IOException { 160 | CliOperation.Result result = DiffOperation.builder() 161 | .logTo(System.out) 162 | .level(LogLevel.ALL) 163 | .baseInput(Input.SingleInput.string("")) 164 | .changedInput(Input.SingleInput.string("")) 165 | .patchesOutput(Output.SingleOutput.pipe(NullOutputStream.INSTANCE)) 166 | .build() 167 | .operate(); 168 | assertEquals(0, result.exit); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Output.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 4 | import io.codechicken.diffpatch.util.archiver.ArchiveWriter; 5 | import net.covers1624.quack.io.IOUtils; 6 | import net.covers1624.quack.util.SneakyUtils; 7 | import org.jetbrains.annotations.Nullable; 8 | 9 | import java.io.IOException; 10 | import java.io.OutputStream; 11 | import java.nio.file.Files; 12 | import java.nio.file.OpenOption; 13 | import java.nio.file.Path; 14 | import java.util.Comparator; 15 | import java.util.stream.Stream; 16 | 17 | /** 18 | * Represents either a {@link SingleOutput} or {@link MultiOutput} 19 | *

20 | * Created by covers1624 on 30/5/24. 21 | * 22 | * @see SingleOutput 23 | * @see MultiOutput 24 | */ 25 | public abstract class Output { 26 | 27 | /** 28 | * Check any preconditions about the output prior to any work occurring. 29 | *

30 | * Used to check if folder outputs are actually folders, or archives are actually zips, etc. 31 | * 32 | * @param kind Descriptive name of the output. I.E patches, outputs, rejects, etc. 33 | */ 34 | public abstract void validate(String kind) throws IOValidationException; 35 | 36 | /** 37 | * Used to compare if an input and an output point to the same underlying file. 38 | * 39 | * @param input The input. 40 | * @return If the input points to the same file as the output. 41 | */ 42 | public boolean isSamePath(Input input) { 43 | return false; 44 | } 45 | 46 | /** 47 | * Represents an output with a single file destination. 48 | */ 49 | public static abstract class SingleOutput extends Output { 50 | 51 | /** 52 | * Single file output, for an existing {@link OutputStream} such as stdout. 53 | * 54 | * @param out The stream. 55 | * @return The output. 56 | */ 57 | public static SingleOutput pipe(OutputStream out) { 58 | return new ToStream(out); 59 | } 60 | 61 | /** 62 | * Single file output, for a {@link Path}. 63 | * 64 | * @param path The path. 65 | * @param opts Any open options for the path. 66 | * @return The output. 67 | */ 68 | public static SingleOutput path(Path path, OpenOption... opts) { 69 | return new ToPath(path, opts); 70 | } 71 | 72 | /** 73 | * Open the output for writing. 74 | * 75 | * @return The stream. 76 | */ 77 | public abstract OutputStream open() throws IOException; 78 | 79 | public static class ToStream extends SingleOutput { 80 | 81 | private final OutputStream out; 82 | 83 | public ToStream(OutputStream out) { this.out = out; } 84 | 85 | @Override 86 | public void validate(String kind) { 87 | // Always valid. 88 | } 89 | 90 | @Override 91 | public OutputStream open() { 92 | return IOUtils.protectClose(out); 93 | } 94 | } 95 | 96 | public static class ToPath extends SingleOutput { 97 | 98 | private final Path path; 99 | private final OpenOption[] opts; 100 | 101 | public ToPath(Path path, OpenOption... opts) { 102 | this.path = path; 103 | this.opts = opts; 104 | } 105 | 106 | @Override 107 | public void validate(String kind) throws IOValidationException { 108 | if (Files.exists(path) && !Files.isRegularFile(path)) { 109 | throw new IOValidationException("Output '" + kind + "' already exists and is not a file."); 110 | } 111 | } 112 | 113 | @Override 114 | public OutputStream open() throws IOException { 115 | return Files.newOutputStream(IOUtils.makeParents(path), opts); 116 | } 117 | } 118 | } 119 | 120 | /** 121 | * Represents an output capable of receiving multiple files, 122 | * such as an Archive or a Folder. 123 | */ 124 | public static abstract class MultiOutput extends Output implements AutoCloseable { 125 | 126 | /** 127 | * Create a {@link MultiOutput} which writes an archive to a file. 128 | * 129 | * @param format The format of the archive. 130 | * @param path The destination. 131 | * @return The output. 132 | */ 133 | public static MultiOutput archive(ArchiveFormat format, Path path) { 134 | return new PathArchiveMultiOutput(format, path); 135 | } 136 | 137 | /** 138 | * Create a {@link MultiOutput} which writes an archive to a file. 139 | *

140 | * Will attempt to automatically detect the archive format based on file name. 141 | *

142 | * If the format can not be detected it will throw a {@link IllegalArgumentException} 143 | * 144 | * @param path The path. 145 | * @return The output. 146 | * @throws IllegalArgumentException If the format cannot be detected. 147 | */ 148 | public static MultiOutput detectedArchive(Path path) throws IllegalArgumentException { 149 | ArchiveFormat format = ArchiveFormat.findFormat(path); 150 | if (format == null) throw new IllegalArgumentException("Unable to detect archive format for " + path.getFileName()); 151 | 152 | return archive(format, path); 153 | } 154 | 155 | /** 156 | * Create a {@link MultiOutput} which writes an archive to the given stream. 157 | * 158 | * @param format The format of the archive. 159 | * @param stream The destination. 160 | * @return The output. 161 | */ 162 | public static MultiOutput archive(ArchiveFormat format, OutputStream stream) { 163 | return new PipeArchiveMultiOutput(format, stream); 164 | } 165 | 166 | /** 167 | * Create a {@link MultiOutput} which writes an archive to a folder. 168 | * 169 | * @param output The destination folder. 170 | * @return The output. 171 | */ 172 | public static MultiOutput folder(Path output) { 173 | return new FolderMultiOutput(output); 174 | } 175 | 176 | /** 177 | * Called to open any internal resources and set up the output for writing. 178 | * 179 | * @param clearOutput If the output should be wiped, or written over top of. 180 | * This only effects {@link FolderMultiOutput}. Archives 181 | * are always rewritten. 182 | */ 183 | public abstract void open(boolean clearOutput) throws IOException; 184 | 185 | /** 186 | * Called to write a file to the output. 187 | * 188 | * @param path The relative path of the output. Will not contain a starting slash. 189 | * @param data The data. 190 | */ 191 | public abstract void write(String path, byte[] data) throws IOException; 192 | 193 | @Override 194 | public abstract void close() throws IOException; 195 | } 196 | 197 | public static abstract class ArchiveMultiOutput extends MultiOutput { 198 | 199 | private final ArchiveFormat format; 200 | private @Nullable ArchiveWriter aw; 201 | 202 | public ArchiveMultiOutput(ArchiveFormat format) { 203 | this.format = format; 204 | } 205 | 206 | protected abstract OutputStream openStream() throws IOException; 207 | 208 | @Override 209 | public void open(boolean clearOutput) throws IOException { 210 | if (aw != null) throw new IllegalStateException("Already opened."); 211 | 212 | aw = format.createWriter(openStream()); 213 | } 214 | 215 | @Override 216 | public void write(String path, byte[] data) throws IOException { 217 | if (aw == null) throw new IllegalStateException("Not opened."); 218 | 219 | aw.writeEntry(path, data); 220 | } 221 | 222 | @Override 223 | public void close() throws IOException { 224 | if (aw == null) throw new IllegalStateException("Not opened."); 225 | 226 | aw.close(); 227 | } 228 | } 229 | 230 | public static class PathArchiveMultiOutput extends ArchiveMultiOutput { 231 | 232 | private final Path path; 233 | 234 | public PathArchiveMultiOutput(ArchiveFormat format, Path path) { 235 | super(format); 236 | this.path = path; 237 | } 238 | 239 | @Override 240 | public void validate(String kind) throws IOValidationException { 241 | if (Files.exists(path) && !Files.isRegularFile(path)) { 242 | throw new IOValidationException("Output '" + kind + "' already exists and is not a file."); 243 | } 244 | } 245 | 246 | @Override 247 | protected OutputStream openStream() throws IOException { 248 | return Files.newOutputStream(path); 249 | } 250 | } 251 | 252 | public static class PipeArchiveMultiOutput extends ArchiveMultiOutput { 253 | 254 | private final OutputStream os; 255 | 256 | public PipeArchiveMultiOutput(ArchiveFormat format, OutputStream os) { 257 | super(format); 258 | this.os = os; 259 | } 260 | 261 | @Override 262 | public void validate(String kind) { 263 | // Always valid. 264 | } 265 | 266 | @Override 267 | protected OutputStream openStream() { 268 | return IOUtils.protectClose(os); 269 | } 270 | } 271 | 272 | public static class FolderMultiOutput extends MultiOutput { 273 | 274 | public final Path folder; 275 | 276 | public FolderMultiOutput(Path folder) { 277 | this.folder = folder; 278 | } 279 | 280 | @Override 281 | public void validate(String kind) throws IOValidationException { 282 | if (Files.exists(folder) && !Files.isDirectory(folder)) { 283 | throw new IOValidationException("Output '" + kind + "' already exists and is not a file."); 284 | } 285 | } 286 | 287 | @Override 288 | public void open(boolean clearOutput) throws IOException { 289 | if (clearOutput && Files.exists(folder)) { 290 | try (Stream stream = Files.walk(folder)) { 291 | stream.sorted(Comparator.reverseOrder()).forEach(SneakyUtils.sneak(Files::delete)); 292 | } 293 | } 294 | } 295 | 296 | @Override 297 | public void write(String path, byte[] data) throws IOException { 298 | Files.write(IOUtils.makeParents(folder.resolve(path)), data); 299 | } 300 | 301 | @Override 302 | public void close() { } 303 | 304 | @Override 305 | public boolean isSamePath(Input input) { 306 | if (!(input instanceof Input.FolderMultiInput)) return false; 307 | 308 | return folder.equals(((Input.FolderMultiInput) input).folder); 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/test/java/io/codechicken/diffpatch/cli/PatchOperationTests.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.test.TestBase; 4 | import io.codechicken.diffpatch.util.ArchiveBuilder; 5 | import io.codechicken.diffpatch.util.Input.MultiInput; 6 | import io.codechicken.diffpatch.util.Input.SingleInput; 7 | import io.codechicken.diffpatch.util.LogLevel; 8 | import io.codechicken.diffpatch.util.Output.MultiOutput; 9 | import io.codechicken.diffpatch.util.Output.SingleOutput; 10 | import io.codechicken.diffpatch.util.archiver.ArchiveReader; 11 | import org.junit.jupiter.api.Disabled; 12 | import org.junit.jupiter.api.Test; 13 | 14 | import java.io.ByteArrayInputStream; 15 | import java.io.ByteArrayOutputStream; 16 | import java.io.IOException; 17 | import java.nio.charset.StandardCharsets; 18 | 19 | import static io.codechicken.diffpatch.util.archiver.ArchiveFormat.ZIP; 20 | import static org.junit.jupiter.api.Assertions.assertEquals; 21 | import static org.junit.jupiter.api.Assertions.assertTrue; 22 | 23 | /** 24 | * Created by covers1624 on 22/6/24. 25 | */ 26 | public class PatchOperationTests extends TestBase { 27 | 28 | @Test 29 | public void testPatchSingle() throws IOException { 30 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 31 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 32 | CliOperation.Result result = PatchOperation.builder() 33 | .logTo(System.out) 34 | .level(LogLevel.ALL) 35 | .baseInput(SingleInput.pipe(testResourceStream("/files/A.txt"))) 36 | .patchesInput(SingleInput.pipe(testResourceStream("/patches/ModifiedA.txt.patch"))) 37 | .rejectsOutput(SingleOutput.pipe(rejects)) 38 | .patchedOutput(SingleOutput.pipe(output)) 39 | .build() 40 | .operate(); 41 | 42 | assertEquals(0, result.exit); 43 | assertEquals(0, rejects.size()); 44 | assertEquals(testResourceString("/files/B.txt"), output.toString("UTF-8")); 45 | } 46 | 47 | @Test 48 | public void testPatchSingleReverse() throws IOException { 49 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 50 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 51 | CliOperation.Result result = PatchOperation.builder() 52 | .logTo(System.out) 53 | .level(LogLevel.ALL) 54 | .baseInput(SingleInput.pipe(testResourceStream("/files/B.txt"))) 55 | .patchesInput(SingleInput.pipe(testResourceStream("/patches/ModifiedB.txt.patch"))) 56 | .rejectsOutput(SingleOutput.pipe(rejects)) 57 | .patchedOutput(SingleOutput.pipe(output)) 58 | .build() 59 | .operate(); 60 | 61 | assertEquals(0, result.exit); 62 | assertEquals(0, rejects.size()); 63 | assertEquals(testResourceString("/files/A.txt"), output.toString("UTF-8")); 64 | } 65 | 66 | @Test 67 | public void testPatchArchive() throws IOException { 68 | byte[] base = new ArchiveBuilder() 69 | .put("A.txt", testResource("/files/A.txt")) 70 | .toBytes(ZIP); 71 | byte[] patches = new ArchiveBuilder() 72 | .put("A.txt.patch", testResource("/patches/ModifiedA.txt.patch")) 73 | .toBytes(ZIP); 74 | 75 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 76 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 77 | CliOperation.Result result = PatchOperation.builder() 78 | .logTo(System.out) 79 | .level(LogLevel.ALL) 80 | .baseInput(MultiInput.archive(ZIP, base)) 81 | .patchesInput(MultiInput.archive(ZIP, patches)) 82 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 83 | .patchedOutput(MultiOutput.archive(ZIP, output)) 84 | .build() 85 | .operate(); 86 | 87 | assertEquals(0, result.exit); 88 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 89 | assertEquals(testResourceString("/files/B.txt"), new String(ar.getBytes("A.txt"), StandardCharsets.UTF_8)); 90 | } 91 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 92 | assertTrue(ar.getEntries().isEmpty()); 93 | } 94 | } 95 | 96 | @Test 97 | public void testPatchReject() throws IOException { 98 | // We try and patch B with the A -> B patch. 99 | byte[] base = new ArchiveBuilder() 100 | .put("A.txt", testResource("/files/B.txt")) 101 | .toBytes(ZIP); 102 | byte[] patches = new ArchiveBuilder() 103 | .put("A.txt.patch", testResource("/patches/ModifiedA.txt.patch")) 104 | .toBytes(ZIP); 105 | 106 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 107 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 108 | CliOperation.Result result = PatchOperation.builder() 109 | .logTo(System.out) 110 | .level(LogLevel.ALL) 111 | .baseInput(MultiInput.archive(ZIP, base)) 112 | .patchesInput(MultiInput.archive(ZIP, patches)) 113 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 114 | .patchedOutput(MultiOutput.archive(ZIP, output)) 115 | .build() 116 | .operate(); 117 | 118 | assertEquals(1, result.exit); 119 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 120 | // The 'A' file (which was B as input), should just be 'B' as we rejected the hunk. 121 | assertEquals(testResourceString("/files/B.txt"), new String(ar.getBytes("A.txt"), StandardCharsets.UTF_8)); 122 | } 123 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 124 | assertEquals(testResourceString("/rejects/ModifiedA.txt.patch.rej"), new String(ar.getBytes("A.txt.patch.rej"), StandardCharsets.UTF_8)); 125 | } 126 | } 127 | 128 | @Test 129 | @Disabled ("Creating diffs and patching new files currently does not respect trailing newlines.") 130 | public void testCreatePatch() throws IOException { 131 | byte[] base = new ArchiveBuilder() 132 | .toBytes(ZIP); 133 | byte[] patches = new ArchiveBuilder() 134 | .put("A.txt.patch", testResource("/patches/CreateA.txt.patch")) 135 | .toBytes(ZIP); 136 | 137 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 138 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 139 | CliOperation.Result result = PatchOperation.builder() 140 | .logTo(System.out) 141 | .level(LogLevel.ALL) 142 | .baseInput(MultiInput.archive(ZIP, base)) 143 | .patchesInput(MultiInput.archive(ZIP, patches)) 144 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 145 | .patchedOutput(MultiOutput.archive(ZIP, output)) 146 | .build() 147 | .operate(); 148 | 149 | assertEquals(0, result.exit); 150 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 151 | assertEquals(testResourceString("/files/A.txt"), new String(ar.getBytes("A.txt"), StandardCharsets.UTF_8)); 152 | } 153 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 154 | assertTrue(ar.getEntries().isEmpty()); 155 | } 156 | } 157 | 158 | @Test 159 | public void testDeletePatch() throws IOException { 160 | byte[] base = new ArchiveBuilder() 161 | .put("A.txt", testResource("/files/A.txt")) 162 | .toBytes(ZIP); 163 | byte[] patches = new ArchiveBuilder() 164 | .put("A.txt.patch", testResource("/patches/DeleteA.txt.patch")) 165 | .toBytes(ZIP); 166 | 167 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 168 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 169 | CliOperation.Result result = PatchOperation.builder() 170 | .logTo(System.out) 171 | .level(LogLevel.ALL) 172 | .baseInput(MultiInput.archive(ZIP, base)) 173 | .patchesInput(MultiInput.archive(ZIP, patches)) 174 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 175 | .patchedOutput(MultiOutput.archive(ZIP, output)) 176 | .build() 177 | .operate(); 178 | 179 | assertEquals(0, result.exit); 180 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 181 | assertTrue(ar.getEntries().isEmpty()); 182 | } 183 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 184 | assertTrue(ar.getEntries().isEmpty()); 185 | } 186 | } 187 | 188 | @Test 189 | public void testDeleteMultiplePatch() throws IOException { 190 | byte[] base = new ArchiveBuilder() 191 | .put("A.txt", testResource("/files/A.txt")) 192 | .put("B.txt", testResource("/files/B.txt")) 193 | .toBytes(ZIP); 194 | byte[] patches = new ArchiveBuilder() 195 | .put("A.txt.patch", testResource("/patches/DeleteA.txt.patch")) 196 | .put("B.txt.patch", testResource("/patches/DeleteB.txt.patch")) 197 | .toBytes(ZIP); 198 | 199 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 200 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 201 | CliOperation.Result result = PatchOperation.builder() 202 | .logTo(System.out) 203 | .level(LogLevel.ALL) 204 | .baseInput(MultiInput.archive(ZIP, base)) 205 | .patchesInput(MultiInput.archive(ZIP, patches)) 206 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 207 | .patchedOutput(MultiOutput.archive(ZIP, output)) 208 | .build() 209 | .operate(); 210 | 211 | assertEquals(0, result.exit); 212 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 213 | assertTrue(ar.getEntries().isEmpty()); 214 | } 215 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 216 | assertTrue(ar.getEntries().isEmpty()); 217 | } 218 | } 219 | 220 | @Test 221 | public void patchNoFiles() throws IOException { 222 | byte[] base = new ArchiveBuilder() 223 | .put("A.txt", testResource("/files/A.txt")) 224 | .put("B.txt", testResource("/files/B.txt")) 225 | .put("ANoNewline.txt", testResource("/files/ANoNewline.txt")) 226 | .toBytes(ZIP); 227 | 228 | byte[] patches = new ArchiveBuilder() 229 | .toBytes(ZIP); 230 | 231 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 232 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 233 | CliOperation.Result result = PatchOperation.builder() 234 | .logTo(System.out) 235 | .level(LogLevel.ALL) 236 | .baseInput(MultiInput.archive(ZIP, base)) 237 | .patchesInput(MultiInput.archive(ZIP, patches)) 238 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 239 | .patchedOutput(MultiOutput.archive(ZIP, output)) 240 | .build() 241 | .operate(); 242 | 243 | assertEquals(0, result.exit); 244 | 245 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 246 | assertEquals(testResourceString("/files/A.txt"), new String(ar.getBytes("A.txt"), StandardCharsets.UTF_8)); 247 | assertEquals(testResourceString("/files/B.txt"), new String(ar.getBytes("B.txt"), StandardCharsets.UTF_8)); 248 | // File without trailing newline should not be modified. 249 | assertEquals(testResourceString("/files/ANoNewline.txt"), new String(ar.getBytes("ANoNewline.txt"), StandardCharsets.UTF_8)); 250 | } 251 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 252 | assertTrue(ar.getEntries().isEmpty()); 253 | } 254 | } 255 | 256 | @Test 257 | public void patchRemoveTrailingNewline() throws IOException { 258 | byte[] base = new ArchiveBuilder() 259 | .put("A.txt", testResource("/files/A.txt")) 260 | .toBytes(ZIP); 261 | 262 | byte[] patches = new ArchiveBuilder() 263 | .put("A.txt.patch", testResource("/patches/AToANoNewline.txt.patch")) 264 | .toBytes(ZIP); 265 | 266 | ByteArrayOutputStream output = new ByteArrayOutputStream(); 267 | ByteArrayOutputStream rejects = new ByteArrayOutputStream(); 268 | CliOperation.Result result = PatchOperation.builder() 269 | .logTo(System.out) 270 | .level(LogLevel.ALL) 271 | .baseInput(MultiInput.archive(ZIP, base)) 272 | .patchesInput(MultiInput.archive(ZIP, patches)) 273 | .rejectsOutput(MultiOutput.archive(ZIP, rejects)) 274 | .patchedOutput(MultiOutput.archive(ZIP, output)) 275 | .build() 276 | .operate(); 277 | 278 | assertEquals(0, result.exit); 279 | 280 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(output.toByteArray()))) { 281 | assertEquals(testResourceString("/files/ANoNewline.txt"), new String(ar.getBytes("A.txt"), StandardCharsets.UTF_8)); 282 | } 283 | try (ArchiveReader ar = ZIP.createReader(new ByteArrayInputStream(rejects.toByteArray()))) { 284 | assertTrue(ar.getEntries().isEmpty()); 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/match/FuzzyLineMatcher.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.match; 2 | 3 | import io.codechicken.diffpatch.util.LineRange; 4 | import org.apache.commons.lang3.tuple.MutablePair; 5 | import org.apache.commons.lang3.tuple.Pair; 6 | import org.jetbrains.annotations.Nullable; 7 | 8 | import java.util.Arrays; 9 | import java.util.List; 10 | 11 | public class FuzzyLineMatcher { 12 | 13 | public static final float DEFAULT_MIN_MATCH_SCORE = 0.5f; 14 | 15 | public int maxMatchOffset = MatchMatrix.DEFAULT_MAX_OFFSET; 16 | public float minMatchScore = DEFAULT_MIN_MATCH_SCORE; 17 | 18 | public void matchLinesByWords(int[] matches, List wmLines1, List wmLines2) { 19 | for (Pair entry : LineMatching.unmatchedRanges(matches, wmLines2.size())) { 20 | LineRange range1 = entry.getLeft(); 21 | LineRange range2 = entry.getRight(); 22 | if (range1.getLength() == 0 || range2.getLength() == 0) { 23 | continue; 24 | } 25 | 26 | int[] match = match(wmLines1.subList(range1.getStart(), range1.getEnd()), wmLines2.subList(range2.getStart(), range2.getEnd())); 27 | for (int i = 0; i < match.length; i++) { 28 | if (match[i] >= 0) { 29 | matches[range1.getStart() + i] = range2.getStart() + match[i]; 30 | } 31 | } 32 | } 33 | } 34 | 35 | public int[] match(List pattern, List search) { 36 | if (search.size() < pattern.size()) { 37 | int[] rMatch = match(search, pattern); 38 | int[] nMatch = new int[pattern.size()]; 39 | Arrays.fill(nMatch, -1); 40 | 41 | for (int i = 0; i < rMatch.length; i++) { 42 | if (rMatch[i] >= 0) { 43 | nMatch[rMatch[i]] = i; 44 | } 45 | } 46 | 47 | return nMatch; 48 | } 49 | 50 | if (pattern.isEmpty()) { 51 | return new int[0]; 52 | } 53 | 54 | float bestScore = minMatchScore; 55 | int[] bestMatch = null; 56 | 57 | MatchMatrix mm = new MatchMatrix(pattern, search, maxMatchOffset, null); 58 | for (int i = mm.workingRange.getFirst(); ; i++) { 59 | Pair pair = mm.match(i); 60 | if (!pair.getLeft()) { 61 | break; 62 | } 63 | float score = pair.getRight(); 64 | if (score > bestScore) { 65 | bestScore = score; 66 | bestMatch = mm.path(); 67 | } 68 | } 69 | 70 | if (bestMatch == null) { 71 | int[] ret = new int[pattern.size()]; 72 | Arrays.fill(ret, -1); 73 | return ret; 74 | } 75 | 76 | return bestMatch; 77 | } 78 | 79 | // assumes the lines are in word to char mode 80 | // return 0.0 poor match to 1.0 perfect match 81 | // uses LevenshtienDistance. A distance with half the maximum number of errors is considered a 0.0 scored match 82 | public static float matchLines(String s, String t) { 83 | int d = levenshteinDistance(s, t); 84 | if (d == 0) { 85 | return 1f;//perfect match 86 | } 87 | 88 | float max = Math.max(s.length(), t.length()) / 2f; 89 | return Math.max(0f, 1f - d / max); 90 | } 91 | 92 | // https://en.wikipedia.org/wiki/Levenshtein_distance 93 | public static int levenshteinDistance(String s, String t) { 94 | // degenerate cases 95 | if (s.equals(t)) { 96 | return 0; 97 | } 98 | if (s.isEmpty()) { 99 | return t.length(); 100 | } 101 | if (t.isEmpty()) { 102 | return s.length(); 103 | } 104 | 105 | // create two work vectors of integer distances 106 | // previous 107 | int[] v0 = new int[t.length() + 1]; 108 | // current 109 | int[] v1 = new int[t.length() + 1]; 110 | 111 | // initialize v1 (the current row of distances) 112 | // this row is A[0][i]: edit distance for an empty s 113 | // the distance is just the number of characters to delete from t 114 | for (int i = 0; i < v1.length; i++) { 115 | v1[i] = i; 116 | } 117 | 118 | for (int i = 0; i < s.length(); i++) { 119 | // swap v1 to v0, reuse old v0 as new v1 120 | int[] tmp = v0; 121 | v0 = v1; 122 | v1 = tmp; 123 | 124 | // calculate v1 (current row distances) from the previous row v0 125 | 126 | // first element of v1 is A[i+1][0] 127 | // edit distance is delete (i+1) chars from s to match empty t 128 | v1[0] = i + 1; 129 | 130 | // use formula to fill in the rest of the row 131 | for (int j = 0; j < t.length(); j++) { 132 | int del = v0[j + 1] + 1; 133 | int ins = v1[j] + 1; 134 | int subs = v0[j] + (s.charAt(i) == t.charAt(j) ? 0 : 1); 135 | v1[j + 1] = Math.min(del, Math.min(ins, subs)); 136 | } 137 | } 138 | 139 | return v1[t.length()]; 140 | } 141 | 142 | public static class MatchMatrix { 143 | 144 | public static final int DEFAULT_MAX_OFFSET = 5; 145 | 146 | private final int patternLength; 147 | private final LineRange range; 148 | // maximum offset between line matches in a run 149 | private final int maxOffset; 150 | 151 | public LineRange workingRange; 152 | 153 | // location of first pattern line in search lines. Starting offset for a match 154 | private int pos = Integer.MIN_VALUE; 155 | // consecutive matches for pattern offset from loc by up to maxOffset 156 | // first entry is for pattern starting at loc in text, last entry is offset +maxOffset 157 | private final StraightMatch[] matches; 158 | // offset index of first node in best path 159 | private int firstNode; 160 | 161 | public MatchMatrix(List pattern, List search) { 162 | this(pattern, search, DEFAULT_MAX_OFFSET, null); 163 | } 164 | 165 | public MatchMatrix(List pattern, List search, int maxOffset, @Nullable LineRange range) { 166 | if (range == null) { 167 | range = LineRange.fromStartLen(0, search.size()); 168 | } 169 | patternLength = pattern.size(); 170 | this.range = range; 171 | this.maxOffset = maxOffset; 172 | workingRange = LineRange.fromFirstLast(range.getStart() - maxOffset, range.getEnd() - patternLength); 173 | 174 | matches = new StraightMatch[maxOffset + 1]; 175 | for (int i = 0; i <= maxOffset; i++) { 176 | matches[i] = new StraightMatch(pattern, search, range); 177 | } 178 | } 179 | 180 | public Pair match(int loc) { 181 | MutablePair ret = new MutablePair<>(); 182 | ret.setRight(0f); 183 | if (!workingRange.contains(loc)) { 184 | ret.setLeft(false); 185 | return ret; 186 | } 187 | 188 | if (loc == pos + 1) { 189 | stepForward(); 190 | } else if (loc == pos - 1) { 191 | stepBackward(); 192 | } else { 193 | init(loc); 194 | } 195 | 196 | ret.setRight(recalculate()); 197 | ret.setLeft(true); 198 | return ret; 199 | } 200 | 201 | private void init(int loc) { 202 | pos = loc; 203 | 204 | for (int i = 0; i <= maxOffset; i++) { 205 | matches[i].update(loc + i); 206 | } 207 | } 208 | 209 | private void stepForward() { 210 | pos++; 211 | 212 | StraightMatch reuse = matches[0]; 213 | for (int i = 1; i <= maxOffset; i++) { 214 | matches[i - 1] = matches[i]; 215 | } 216 | 217 | matches[maxOffset] = reuse; 218 | reuse.update(pos + maxOffset); 219 | } 220 | 221 | private void stepBackward() { 222 | pos--; 223 | 224 | StraightMatch reuse = matches[maxOffset]; 225 | for (int i = maxOffset; i > 0; i--) { 226 | matches[i] = matches[i - 1]; 227 | } 228 | 229 | matches[0] = reuse; 230 | reuse.update(pos); 231 | } 232 | 233 | // calculates the best path through the match matrix 234 | // all paths must start with the first line of pattern matched to the line at loc (0 offset) 235 | private float recalculate() { 236 | // tail nodes have sum = score 237 | for (int j = 0; j <= maxOffset; j++) { 238 | MatchNode node = matches[j].nodes[patternLength - 1]; 239 | node.sum = node.score; 240 | node.next = -1;//no next 241 | } 242 | 243 | // calculate best paths for all nodes excluding head 244 | for (int i = patternLength - 2; i >= 0; i--) { 245 | for (int j = 0; j <= maxOffset; j++) { 246 | // for each node 247 | MatchNode node = matches[j].nodes[i]; 248 | int maxk = -1; 249 | float maxsum = 0; 250 | for (int k = 0; k <= maxOffset; k++) { 251 | int l = i + offsetsToPatternDistance(j, k); 252 | if (l >= patternLength) { 253 | continue; 254 | } 255 | 256 | float sum = matches[k].nodes[l].sum; 257 | if (k > j) { 258 | sum -= 0.5f * (k - j); // penalty for skipping lines in search text 259 | } 260 | 261 | if (sum > maxsum) { 262 | maxk = k; 263 | maxsum = sum; 264 | } 265 | } 266 | 267 | node.sum = maxsum + node.score; 268 | node.next = maxk; 269 | } 270 | } 271 | 272 | // find starting node 273 | { 274 | firstNode = 0; 275 | float maxsum = matches[0].nodes[0].sum; 276 | for (int k = 1; k <= maxOffset; k++) { 277 | float sum = matches[k].nodes[0].sum; 278 | if (sum > maxsum) { 279 | firstNode = k; 280 | maxsum = sum; 281 | } 282 | } 283 | } 284 | 285 | // return best path value 286 | return matches[firstNode].nodes[0].sum / patternLength; 287 | } 288 | 289 | private int locInRange(int loc) { 290 | return range.contains(loc) ? loc : -1; 291 | } 292 | 293 | public int[] path() { 294 | int[] path = new int[patternLength]; 295 | 296 | int offset = firstNode; // offset of current node 297 | MatchNode node = matches[firstNode].nodes[0]; 298 | path[0] = locInRange(pos + offset); 299 | 300 | int i = 0; // index in pattern of current node 301 | while (node.next >= 0) { 302 | int delta = offsetsToPatternDistance(offset, node.next); 303 | while (delta-- > 1) { // skipped pattern lines 304 | path[++i] = -1; 305 | } 306 | 307 | offset = node.next; 308 | node = matches[offset].nodes[++i]; 309 | path[i] = locInRange(pos + i + offset); 310 | } 311 | 312 | while (++i < path.length) { // trailing lines with no match 313 | path[i] = -1; 314 | } 315 | 316 | return path; 317 | } 318 | 319 | public String visualise() { 320 | int[] path = path(); 321 | StringBuilder sb = new StringBuilder(); 322 | for (int j = 0; j <= maxOffset; j++) { 323 | sb.append(j).append(':'); 324 | StraightMatch line = matches[j]; 325 | for (int i = 0; i < patternLength; i++) { 326 | boolean inPath = path[i] > 0 && path[i] == pos + i + j; 327 | sb.append(inPath ? '[' : ' '); 328 | int score = Math.round(line.nodes[i].score * 100); 329 | sb.append(score == 100 ? "%%" : score); 330 | sb.append(inPath ? ']' : ' '); 331 | } 332 | sb.append("\n"); 333 | } 334 | return sb.toString(); 335 | } 336 | 337 | private static int offsetsToPatternDistance(int i, int j) { 338 | return j >= i ? 1 : 1 + i - j; 339 | } 340 | 341 | // contains match entries for consecutive characters of a pattern and the search text starting at line offset loc 342 | private static class StraightMatch { 343 | 344 | private final int patternLength; 345 | private final List pattern; 346 | private final List search; 347 | private final LineRange range; 348 | 349 | public final MatchNode[] nodes; 350 | 351 | public StraightMatch(List pattern, List search, LineRange range) { 352 | patternLength = pattern.size(); 353 | this.pattern = pattern; 354 | this.search = search; 355 | this.range = range; 356 | 357 | nodes = new MatchNode[patternLength]; 358 | for (int i = 0; i < patternLength; i++) { 359 | nodes[i] = new MatchNode(); 360 | } 361 | } 362 | 363 | public void update(int loc) { 364 | for (int i = 0; i < patternLength; i++) { 365 | int l = i + loc; 366 | if (l < range.getStart() || l >= range.getEnd()) { 367 | nodes[i].score = 0; 368 | } else { 369 | nodes[i].score = matchLines(pattern.get(i), search.get(l)); 370 | } 371 | } 372 | } 373 | } 374 | 375 | private static class MatchNode { 376 | 377 | // score of this match (1.0 = perfect, 0.0 = no match) 378 | public float score; 379 | // sum of the match scores in the best path up to this node 380 | public float sum; 381 | // offset index of the next node in the path 382 | public int next; 383 | } 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/DiffPatchCli.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.diff.Differ; 4 | import io.codechicken.diffpatch.match.FuzzyLineMatcher; 5 | import io.codechicken.diffpatch.util.Input; 6 | import io.codechicken.diffpatch.util.Input.MultiInput; 7 | import io.codechicken.diffpatch.util.LogLevel; 8 | import io.codechicken.diffpatch.util.Output; 9 | import io.codechicken.diffpatch.util.Output.MultiOutput; 10 | import io.codechicken.diffpatch.util.Output.SingleOutput; 11 | import io.codechicken.diffpatch.util.PatchMode; 12 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 13 | import joptsimple.OptionParser; 14 | import joptsimple.OptionSet; 15 | import joptsimple.OptionSpec; 16 | import joptsimple.util.EnumConverter; 17 | import joptsimple.util.PathConverter; 18 | import net.covers1624.quack.util.SneakyUtils; 19 | import org.jetbrains.annotations.Contract; 20 | import org.jetbrains.annotations.Nullable; 21 | import org.jetbrains.annotations.VisibleForTesting; 22 | 23 | import java.io.IOException; 24 | import java.io.PrintStream; 25 | import java.nio.file.Files; 26 | import java.nio.file.Path; 27 | import java.nio.file.Paths; 28 | import java.util.List; 29 | 30 | import static java.util.Arrays.asList; 31 | 32 | /** 33 | * Created by covers1624 on 16/7/20. 34 | */ 35 | public class DiffPatchCli { 36 | 37 | public static void main(String[] args) throws IOException { 38 | System.exit(mainI(args, System.err, System.out)); 39 | } 40 | 41 | public static int mainI(String[] args, PrintStream logger, PrintStream pipe) throws IOException { 42 | CliOperation operation = parseOperation(logger, pipe, args); 43 | if (operation == null) return -1; 44 | 45 | return operation.operate().exit; 46 | } 47 | 48 | @VisibleForTesting 49 | static @Nullable CliOperation parseOperation(PrintStream logger, PrintStream pipe, String... args) throws IOException { 50 | OptionParser parser = new OptionParser(); 51 | OptionSpec nonOptions = parser.nonOptions(); 52 | 53 | //Utility 54 | OptionSpec helpOpt = parser.acceptsAll(asList("h", "help"), "Prints this help.").forHelp(); 55 | OptionSpec verboseOpt = parser.acceptsAll(asList("v", "verbose"), "Prints more stuff. Alias for --log-level ALL"); 56 | OptionSpec logLevelOpt = parser.acceptsAll(asList("l", "log-level"), "Set the Logging level.") 57 | .availableUnless(verboseOpt) 58 | .withRequiredArg() 59 | .withValuesConvertedBy(new EnumConverter(LogLevel.class) { }) 60 | .defaultsTo(LogLevel.INFO); 61 | OptionSpec summaryOpt = parser.acceptsAll(asList("s", "summary"), "Prints a changes summary at the end."); 62 | 63 | //Shared 64 | OptionSpec outputOpt = parser.acceptsAll(asList("o", "output"), "Sets the output path.") 65 | .withRequiredArg() 66 | .withValuesConvertedBy(new PathConverter()); 67 | OptionSpec outputArchiveOpt = parser.acceptsAll(asList("A", "archive"), "Treat output as an archive. Allows printing multi-output to STDOUT.") 68 | .withRequiredArg() 69 | .withValuesConvertedBy(new ArchiveFormatValueConverter()); 70 | OptionSpec baseArchiveOpt = parser.acceptsAll(asList("B", "archive-base"), "Treat the base path as an archive.") 71 | .withRequiredArg() 72 | .withValuesConvertedBy(new ArchiveFormatValueConverter()); 73 | OptionSpec basePathPrefixOpt = parser.acceptsAll(asList("base-path-prefix"), "The prefix to assume for paths of base files.") 74 | .withRequiredArg() 75 | .ofType(String.class) 76 | .defaultsTo("a/"); 77 | OptionSpec modifiedPathPrefixOpt = parser.acceptsAll(asList("modified-path-prefix"), "The prefix to assume for paths of modified files.") 78 | .withRequiredArg() 79 | .ofType(String.class) 80 | .defaultsTo("b/"); 81 | OptionSpec lineEndingOpt = parser.acceptsAll(asList("line-endings"), "Set the Line Endings to use. Defaults to system line endings.") 82 | .withRequiredArg() 83 | .withValuesConvertedBy(new EnumConverter(LineEnding.class) { }) 84 | .defaultsTo(LineEnding.system()); 85 | 86 | //Diff specific 87 | OptionSpec doDiffOpt = parser.acceptsAll(asList("d", "diff"), "Does a Diff operation."); 88 | OptionSpec autoHeaderOpt = parser.acceptsAll(asList("h", "auto-header"), "Enables the generation of auto-headers. Using _ as the start2 index.") 89 | .availableIf(doDiffOpt); 90 | OptionSpec contextOpt = parser.acceptsAll(asList("c", "context"), "Number of context lines to generate in diffs.") 91 | .availableIf(doDiffOpt) 92 | .withRequiredArg() 93 | .ofType(Integer.class) 94 | .defaultsTo(Differ.DEFAULT_CONTEXT); 95 | OptionSpec modifiedArchiveOpt = parser.acceptsAll(asList("M", "archive-modified"), "Treat the modified path as an archive.") 96 | .availableIf(doDiffOpt) 97 | .withRequiredArg() 98 | .withValuesConvertedBy(new ArchiveFormatValueConverter()); 99 | 100 | //Patch specific 101 | OptionSpec doPatchOpt = parser.acceptsAll(asList("p", "patch"), "Does a Patch operation."); 102 | OptionSpec rejectOpt = parser.acceptsAll(asList("r", "reject"), "Saves patch rejects to the specified path / archive") 103 | .availableIf(doPatchOpt) 104 | .withRequiredArg() 105 | .withValuesConvertedBy(new PathConverter()); 106 | OptionSpec rejectArchiveOpt = parser.acceptsAll(asList("H", "archive-rejects"), "Treat reject output as an archive.") 107 | .availableIf(doPatchOpt) 108 | .withRequiredArg() 109 | .withValuesConvertedBy(new ArchiveFormatValueConverter()); 110 | OptionSpec fuzzOpt = parser.acceptsAll(asList("f", "fuzz"), "The minimum fuzz match quality, anything lower will be treated as a failure.") 111 | .availableIf(doPatchOpt) 112 | .withRequiredArg() 113 | .ofType(Float.class) 114 | .defaultsTo(FuzzyLineMatcher.DEFAULT_MIN_MATCH_SCORE); 115 | OptionSpec offsetOpt = parser.acceptsAll(asList("O", "offset"), "The max line offset allowed for fuzzy matching, larger than this will be treated as a failure.") 116 | .availableIf(doPatchOpt) 117 | .withRequiredArg() 118 | .ofType(Integer.class) 119 | .defaultsTo(FuzzyLineMatcher.MatchMatrix.DEFAULT_MAX_OFFSET); 120 | OptionSpec modeOpt = parser.acceptsAll(asList("m", "mode"), "The desired patching mode.") 121 | .availableIf(doPatchOpt) 122 | .withRequiredArg() 123 | .withValuesConvertedBy(new PatchModeValueConverter()) 124 | .defaultsTo(PatchMode.EXACT); 125 | OptionSpec patchesArchiveOpt = parser.acceptsAll(asList("N", "archive-patches"), "Treat the patches path as an archive.") 126 | .availableIf(doPatchOpt) 127 | .withRequiredArg() 128 | .withValuesConvertedBy(new ArchiveFormatValueConverter()); 129 | 130 | //Patch Baking specific 131 | OptionSpec doBakeOpt = parser.acceptsAll(asList("b", "bake"), "Bake the patches, removing auto-header and DiffPatch specific formats."); 132 | 133 | // Patch shared 134 | OptionSpec patchPrefix = parser.acceptsAll(asList("P", "prefix"), "Prefix path for reading patches from patches input.") 135 | .availableIf(doPatchOpt, doBakeOpt) 136 | .withRequiredArg() 137 | .ofType(String.class) 138 | .defaultsTo(""); 139 | 140 | OptionSet optSet = parser.parse(args); 141 | if (optSet.has(helpOpt)) { 142 | parser.printHelpOn(logger); 143 | return null; 144 | } 145 | 146 | LogLevel level = optSet.valueOf(logLevelOpt); 147 | if (level == null) { 148 | level = optSet.has(summaryOpt) ? LogLevel.DEBUG : LogLevel.INFO; 149 | } 150 | 151 | boolean summary = optSet.has(summaryOpt); 152 | LineEnding lineEnding = optSet.valueOf(lineEndingOpt); 153 | List arguments = optSet.valuesOf(nonOptions); 154 | 155 | if (optSet.has(doDiffOpt)) { 156 | if (arguments.size() != 2) { 157 | logger.println("Expected 2 arguments, got: " + arguments.size()); 158 | parser.printHelpOn(logger); 159 | return null; 160 | } 161 | 162 | Path aPath = Paths.get(arguments.get(0)); 163 | Path bPath = Paths.get(arguments.get(1)); 164 | Path outputPath = optSet.valueOf(outputOpt); 165 | 166 | return DiffOperation.builder() 167 | .logTo(logger) 168 | .helpCallback(SneakyUtils.sneak(parser::printHelpOn)) 169 | .baseInput(getInput( 170 | detectFormat(optSet.valueOf(baseArchiveOpt), aPath), 171 | aPath 172 | )) 173 | .changedInput(getInput( 174 | detectFormat(optSet.valueOf(modifiedArchiveOpt), bPath), 175 | bPath 176 | )) 177 | .patchesOutput(getOutput( 178 | detectFormat(optSet.valueOf(outputArchiveOpt), outputPath), 179 | outputPath, 180 | pipe 181 | )) 182 | .level(level) 183 | .summary(summary) 184 | .autoHeader(optSet.has(autoHeaderOpt)) 185 | .context(optSet.valueOf(contextOpt)) 186 | .aPrefix(optSet.valueOf(basePathPrefixOpt)) 187 | .bPrefix(optSet.valueOf(modifiedPathPrefixOpt)) 188 | .lineEnding(lineEnding.chars) 189 | .build(); 190 | } 191 | if (optSet.has(doPatchOpt)) { 192 | if (arguments.size() != 2) { 193 | logger.println("Expected 2 arguments, got: " + arguments.size()); 194 | parser.printHelpOn(logger); 195 | return null; 196 | } 197 | Path base = Paths.get(arguments.get(0)); 198 | Path patches = Paths.get(arguments.get(1)); 199 | Path outputPath = optSet.valueOf(outputOpt); 200 | Path rejectsPath = optSet.valueOf(rejectOpt); 201 | 202 | return PatchOperation.builder() 203 | .logTo(logger) 204 | .helpCallback(SneakyUtils.sneak(parser::printHelpOn)) 205 | .level(level) 206 | .summary(summary) 207 | .baseInput(getInput( 208 | detectFormat(optSet.valueOf(baseArchiveOpt), base), 209 | base 210 | )) 211 | .patchesInput(getInput( 212 | detectFormat(optSet.valueOf(patchesArchiveOpt), patches), 213 | patches 214 | )) 215 | .patchedOutput(getOutput( 216 | detectFormat(optSet.valueOf(outputArchiveOpt), outputPath), 217 | outputPath, 218 | pipe 219 | )) 220 | .rejectsOutput(getOutput( 221 | detectFormat(optSet.valueOf(rejectArchiveOpt), rejectsPath), 222 | rejectsPath, 223 | null 224 | )) 225 | .minFuzz(optSet.valueOf(fuzzOpt)) 226 | .maxOffset(optSet.valueOf(offsetOpt)) 227 | .mode(optSet.valueOf(modeOpt)) 228 | .patchesPrefix(optSet.valueOf(patchPrefix)) 229 | .aPrefix(optSet.valueOf(basePathPrefixOpt)) 230 | .bPrefix(optSet.valueOf(modifiedPathPrefixOpt)) 231 | .lineEnding(lineEnding.chars) 232 | .build(); 233 | } 234 | 235 | if (optSet.has(doBakeOpt)) { 236 | if (arguments.size() != 1) { 237 | logger.println("Expected one argument, got: " + arguments.size()); 238 | parser.printHelpOn(logger); 239 | return null; 240 | } 241 | Path patchesPath = Paths.get(arguments.get(0)); 242 | Path outputPath = optSet.valueOf(outputOpt); 243 | 244 | return BakePatchesOperation.builder() 245 | .logTo(logger) 246 | .helpCallback(SneakyUtils.sneak(parser::printHelpOn)) 247 | .level(level) 248 | .summary(summary) 249 | .patchesInput(getInput( 250 | detectFormat(optSet.valueOf(baseArchiveOpt), patchesPath), 251 | patchesPath 252 | )) 253 | .bakedOutput(getOutput( 254 | detectFormat(optSet.valueOf(outputArchiveOpt), outputPath), 255 | outputPath, 256 | pipe 257 | )) 258 | .patchesPrefix(optSet.valueOf(patchPrefix)) 259 | .lineEnding(lineEnding.chars) 260 | .build(); 261 | } 262 | 263 | logger.println("Expected --diff, --patch, or --bake."); 264 | parser.printHelpOn(logger); 265 | return null; 266 | } 267 | 268 | private static Input getInput(@Nullable ArchiveFormat format, Path path) { 269 | if (format != null) { 270 | return MultiInput.archive(format, path); 271 | } 272 | return Files.isDirectory(path) ? MultiInput.folder(path) : Input.SingleInput.path(path); 273 | } 274 | 275 | @Contract ("!null,!null,_->!null;!null,_,!null->!null") 276 | private static @Nullable Output getOutput(@Nullable ArchiveFormat outputFormat, @Nullable Path outputPath, @Nullable PrintStream pipe) { 277 | if (outputFormat != null) { 278 | if (outputPath != null) { 279 | return MultiOutput.archive(outputFormat, outputPath); 280 | } 281 | if (pipe != null) { 282 | return MultiOutput.archive(outputFormat, pipe); 283 | } 284 | return null; 285 | } 286 | if (outputPath != null) { 287 | return Files.isDirectory(outputPath) ? MultiOutput.folder(outputPath) : SingleOutput.path(outputPath); 288 | } 289 | if (pipe != null) { 290 | return SingleOutput.pipe(pipe); 291 | } 292 | return null; 293 | } 294 | 295 | private static @Nullable ArchiveFormat detectFormat(@Nullable ArchiveFormat existing, @Nullable Path detectFrom) { 296 | if (existing != null) return existing; 297 | if (detectFrom != null) return ArchiveFormat.findFormat(detectFrom.getFileName()); 298 | 299 | return null; 300 | } 301 | 302 | public enum LineEnding { 303 | CR("\r"), 304 | LF("\n"), 305 | CRLF("\r\n"); 306 | public final String chars; 307 | 308 | LineEnding(String chars) { 309 | this.chars = chars; 310 | } 311 | 312 | public static LineEnding system() { 313 | switch (System.lineSeparator()) { 314 | case "\r": 315 | return CR; 316 | case "\r\n": 317 | return CRLF; 318 | case "\n": 319 | default: // No idea, just return LF 320 | return LF; 321 | } 322 | } 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/util/Input.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.util; 2 | 3 | import io.codechicken.diffpatch.util.archiver.ArchiveFormat; 4 | import io.codechicken.diffpatch.util.archiver.ArchiveReader; 5 | import net.covers1624.quack.collection.FastStream; 6 | import net.covers1624.quack.io.IOUtils; 7 | import org.jetbrains.annotations.Nullable; 8 | 9 | import java.io.*; 10 | import java.nio.charset.StandardCharsets; 11 | import java.nio.file.Files; 12 | import java.nio.file.OpenOption; 13 | import java.nio.file.Path; 14 | import java.util.Collections; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Set; 18 | import java.util.function.Function; 19 | import java.util.stream.Collectors; 20 | import java.util.stream.Stream; 21 | 22 | /** 23 | * Represents either a {@link SingleInput} or {@link MultiInput}. 24 | *

25 | * Created by covers1624 on 30/5/24. 26 | * 27 | * @see SingleInput 28 | * @see MultiInput 29 | */ 30 | public abstract class Input { 31 | 32 | /** 33 | * Check any preconditions about the input prior to any work occurring. 34 | *

35 | * Used to check if inputs exist, folders are actually folders, etc. 36 | * 37 | * @param kind Descriptive name of the input. I.E patches, original, changed, etc. 38 | */ 39 | public abstract void validate(String kind) throws IOValidationException; 40 | 41 | /** 42 | * Represents an input with a single file source. 43 | */ 44 | public static abstract class SingleInput extends Input { 45 | 46 | /** 47 | * Single file input for an existing {@link String}. 48 | *

49 | * This stream will have the name of 'pipe', diffing against this 50 | * will produce this name in the +/- header lines. 51 | * 52 | * @param str The string. 53 | * @return The input. 54 | */ 55 | public static SingleInput string(String str) { 56 | return new FromString(str, "pipe"); 57 | } 58 | 59 | /** 60 | * Single file input for an existing {@link InputStream}, such as stdin. 61 | * 62 | * @param str The string. 63 | * @param name The name of the stream. Used in +/- header lines of diffs. 64 | * @return The input. 65 | */ 66 | public static SingleInput string(String str, String name) { 67 | return new FromString(str, name); 68 | } 69 | 70 | /** 71 | * Single file input for an existing {@link InputStream}, such as stdin. 72 | *

73 | * This stream will have the name of 'pipe', diffing against this 74 | * will produce this name in the +/- header lines. 75 | * 76 | * @param is The stream. 77 | * @return The input. 78 | */ 79 | public static SingleInput pipe(InputStream is) { 80 | return pipe(is, "pipe"); 81 | } 82 | 83 | /** 84 | * Single file input for an existing {@link InputStream}, such as stdin. 85 | * 86 | * @param is The stream. 87 | * @param name The name of the stream. Used in +/- header lines of diffs. 88 | * @return The input. 89 | */ 90 | public static SingleInput pipe(InputStream is, String name) { 91 | return new FromStream(is, name); 92 | } 93 | 94 | /** 95 | * A single file input. for a {@link Path}. 96 | * 97 | * @param path The path. 98 | * @param opts Any open options for the path. 99 | * @return The input. 100 | */ 101 | public static SingleInput path(Path path, OpenOption... opts) { 102 | return new FromPath(path, opts); 103 | } 104 | 105 | public abstract InputStream open() throws IOException; 106 | 107 | public List readLines() throws IOException { 108 | try (BufferedReader reader = new BufferedReader(new InputStreamReader(open(), StandardCharsets.UTF_8))) { 109 | return FastStream.of(reader.lines()).toList(); 110 | } 111 | } 112 | 113 | public abstract String name(); 114 | 115 | public static final class FromString extends SingleInput { 116 | 117 | private final String str; 118 | private final String name; 119 | 120 | public FromString(String str, String name) { 121 | this.str = str; 122 | this.name = name; 123 | } 124 | 125 | @Override 126 | public void validate(String kind) { 127 | // Always valid. 128 | } 129 | 130 | @Override 131 | public InputStream open() throws IOException { 132 | return new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8)); 133 | } 134 | 135 | @Override 136 | public List readLines() throws IOException { 137 | try (BufferedReader reader = new BufferedReader(new StringReader(str))) { 138 | return FastStream.of(reader.lines()).toList(); 139 | } 140 | } 141 | 142 | @Override 143 | public String name() { 144 | return name; 145 | } 146 | } 147 | 148 | public static final class FromStream extends SingleInput { 149 | 150 | private final InputStream is; 151 | private final String name; 152 | 153 | public FromStream(InputStream is, String name) { 154 | this.is = is; 155 | this.name = name; 156 | } 157 | 158 | @Override 159 | public void validate(String kind) { 160 | // Always valid. 161 | } 162 | 163 | @Override 164 | public InputStream open() throws IOException { 165 | return IOUtils.protectClose(is); 166 | } 167 | 168 | @Override 169 | public String name() { 170 | return name; 171 | } 172 | } 173 | 174 | public static class FromPath extends SingleInput { 175 | 176 | private final Path path; 177 | private final OpenOption[] opts; 178 | 179 | public FromPath(Path path, OpenOption... opts) { 180 | this.path = path; 181 | this.opts = opts; 182 | } 183 | 184 | @Override 185 | public void validate(String kind) throws IOValidationException { 186 | if (Files.notExists(path)) throw new IOValidationException("Input '" + kind + "' does not exist."); 187 | if (!Files.isRegularFile(path)) throw new IOValidationException("Input '" + kind + "' is not a file."); 188 | } 189 | 190 | @Override 191 | public InputStream open() throws IOException { 192 | return Files.newInputStream(path, opts); 193 | } 194 | 195 | @Override 196 | public String name() { 197 | return path.toString(); 198 | } 199 | } 200 | } 201 | 202 | /** 203 | * Represents an input capable or providing multiple files, 204 | * such as an Archive or a Folder. 205 | */ 206 | public static abstract class MultiInput extends Input implements AutoCloseable { 207 | 208 | /** 209 | * Create a {@link MultiInput} which reads from an archive file. 210 | * 211 | * @param format The format of the archive. 212 | * @param path The path. 213 | * @return The input. 214 | */ 215 | public static MultiInput archive(ArchiveFormat format, Path path) { 216 | return new PathArchiveMultiInput(format, path); 217 | } 218 | 219 | /** 220 | * Create a {@link MultiInput} which reads from an archive file. 221 | *

222 | * Will attempt to automatically detect the archive format based on file name. 223 | *

224 | * If the format can not be detected it will throw a {@link IllegalArgumentException} 225 | * 226 | * @param path The path. 227 | * @return The input. 228 | * @throws IllegalArgumentException If the format cannot be detected. 229 | */ 230 | public static MultiInput detectedArchive(Path path) throws IllegalArgumentException { 231 | ArchiveFormat format = ArchiveFormat.findFormat(path); 232 | if (format == null) throw new IllegalArgumentException("Unable to detect archive format for " + path.getFileName()); 233 | 234 | return archive(format, path); 235 | } 236 | 237 | /** 238 | * Create a {@link MultiInput} which reads from an array of bytes. 239 | * 240 | * @param format The format of the archive. 241 | * @param bytes The bytes. 242 | * @return The input. 243 | */ 244 | public static MultiInput archive(ArchiveFormat format, byte[] bytes) { 245 | return archive(format, new ByteArrayInputStream(bytes)); 246 | } 247 | 248 | /** 249 | * Create a {@link MultiInput} which reads from an existing stream. 250 | * 251 | * @param format The format of the archive. 252 | * @param stream The path. 253 | * @return The input. 254 | */ 255 | public static MultiInput archive(ArchiveFormat format, InputStream stream) { 256 | return new PipeArchiveMultiInput(format, stream); 257 | } 258 | 259 | /** 260 | * Create a {@link MultiInput} which reads from a folder. 261 | * 262 | * @param folder The folder. 263 | * @return The input. 264 | */ 265 | public static MultiInput folder(Path folder) { 266 | return new FolderMultiInput(folder); 267 | } 268 | 269 | /** 270 | * Called to open any internal resources and set up the input for reading. 271 | * 272 | * @param prefix A prefix directory to read from. 273 | */ 274 | public abstract void open(String prefix) throws IOException; 275 | 276 | /** 277 | * Get the index for this input. 278 | * 279 | * @return The index of this input. 280 | */ 281 | public abstract Set index() throws IOException; 282 | 283 | /** 284 | * Read the given entry as a List of Strings. 285 | * 286 | * @param key The entry to read. 287 | * @return The List of Strings for this entry. 288 | */ 289 | public List readLines(String key) throws IOException { 290 | try (BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(read(key)), StandardCharsets.UTF_8))) { 291 | return FastStream.of(reader.lines()).toList(); 292 | } 293 | } 294 | 295 | /** 296 | * Read the given entry as an array of bytes. 297 | * 298 | * @param key The entry to read. 299 | * @return The bytes of the entry. 300 | */ 301 | public byte[] read(String key) throws IOException { 302 | byte[] data = tryRead(key); 303 | if (data != null) return data; 304 | 305 | throw new FileNotFoundException("Failed to find " + key + " in MultiInput."); 306 | } 307 | 308 | /** 309 | * Try and read the given entry as an array of bytes. 310 | * 311 | * @param key The entry to read. 312 | * @return The bytes of the entry, or {@code null}. 313 | */ 314 | public abstract byte @Nullable [] tryRead(String key) throws IOException; 315 | 316 | @Override 317 | public abstract void close() throws IOException; 318 | } 319 | 320 | public static abstract class ArchiveMultiInput extends MultiInput { 321 | 322 | private final ArchiveFormat format; 323 | private @Nullable ArchiveReader ar; 324 | 325 | protected ArchiveMultiInput(ArchiveFormat format) { 326 | this.format = format; 327 | } 328 | 329 | protected abstract InputStream openStream() throws IOException; 330 | 331 | @Override 332 | public void open(String prefix) throws IOException { 333 | if (ar != null) throw new IllegalStateException("Already opened."); 334 | 335 | ar = format.createReader(openStream(), prefix); 336 | } 337 | 338 | @Override 339 | public Set index() { 340 | if (ar == null) throw new IllegalStateException("Not opened."); 341 | 342 | return ar.getEntries(); 343 | } 344 | 345 | @Override 346 | public byte @Nullable [] tryRead(String key) { 347 | if (ar == null) throw new IllegalStateException("Not opened."); 348 | 349 | return ar.getBytes(key); 350 | } 351 | 352 | @Override 353 | public void close() throws IOException { 354 | if (ar == null) throw new IllegalStateException("Not opened."); 355 | 356 | ar.close(); 357 | } 358 | } 359 | 360 | public static class PathArchiveMultiInput extends ArchiveMultiInput { 361 | 362 | private final Path path; 363 | 364 | protected PathArchiveMultiInput(ArchiveFormat format, Path path) { 365 | super(format); 366 | this.path = path; 367 | } 368 | 369 | @Override 370 | public void validate(String kind) throws IOValidationException { 371 | if (Files.notExists(path)) { 372 | throw new IOValidationException("Input '" + kind + "' does not exist."); 373 | } 374 | } 375 | 376 | @Override 377 | protected InputStream openStream() throws IOException { 378 | return Files.newInputStream(path); 379 | } 380 | } 381 | 382 | public static class PipeArchiveMultiInput extends ArchiveMultiInput { 383 | 384 | private final InputStream is; 385 | 386 | protected PipeArchiveMultiInput(ArchiveFormat format, InputStream is) { 387 | super(format); 388 | this.is = is; 389 | } 390 | 391 | @Override 392 | public void validate(String kind) { 393 | // Always valid. 394 | } 395 | 396 | @Override 397 | protected InputStream openStream() { 398 | return IOUtils.protectClose(is); 399 | } 400 | } 401 | 402 | public static class FolderMultiInput extends MultiInput { 403 | 404 | public final Path folder; 405 | private @Nullable Map index; 406 | 407 | public FolderMultiInput(Path folder) { 408 | this.folder = folder; 409 | } 410 | 411 | @Override 412 | public void validate(String kind) throws IOValidationException { 413 | if (Files.notExists(folder)) throw new IOValidationException("Input '" + kind + "' does not exist."); 414 | if (!Files.isDirectory(folder)) throw new IOValidationException("Expected input '" + kind + "' to be a directory."); 415 | } 416 | 417 | @Override 418 | public void open(String prefix) throws IOException { 419 | if (index != null) throw new IllegalStateException("Already opened."); 420 | 421 | Path toIndex; 422 | if (!prefix.isEmpty()) { 423 | toIndex = folder.resolve(prefix); 424 | } else { 425 | toIndex = folder; 426 | } 427 | try (Stream stream = Files.walk(toIndex)) { 428 | index = stream.filter(Files::isRegularFile) 429 | .collect(Collectors.toMap(e -> Utils.stripStart('/', toIndex.relativize(e).toString().replace("\\", "/")), Function.identity())); 430 | } 431 | } 432 | 433 | @Override 434 | public Set index() throws IOException { 435 | if (index == null) throw new IllegalStateException("Not opened."); 436 | 437 | return Collections.unmodifiableSet(index.keySet()); 438 | } 439 | 440 | @Override 441 | public byte @Nullable [] tryRead(String key) throws IOException { 442 | if (index == null) throw new IllegalStateException("Not opened."); 443 | 444 | Path path = index.get(key); 445 | if (path == null) return null; 446 | 447 | return Files.readAllBytes(path); 448 | } 449 | 450 | @Override 451 | public void close() { 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /src/main/java/io/codechicken/diffpatch/cli/DiffOperation.java: -------------------------------------------------------------------------------- 1 | package io.codechicken.diffpatch.cli; 2 | 3 | import io.codechicken.diffpatch.diff.Differ; 4 | import io.codechicken.diffpatch.diff.PatienceDiffer; 5 | import io.codechicken.diffpatch.util.*; 6 | import io.codechicken.diffpatch.util.FileCollector.CollectedEntry; 7 | import io.codechicken.diffpatch.util.Input.MultiInput; 8 | import io.codechicken.diffpatch.util.Input.SingleInput; 9 | import io.codechicken.diffpatch.util.Output.MultiOutput; 10 | import io.codechicken.diffpatch.util.Output.SingleOutput; 11 | import net.covers1624.quack.collection.FastStream; 12 | import net.covers1624.quack.io.NullOutputStream; 13 | import net.covers1624.quack.util.SneakyUtils; 14 | import org.apache.commons.lang3.StringUtils; 15 | import org.jetbrains.annotations.Nullable; 16 | 17 | import java.io.IOException; 18 | import java.io.OutputStream; 19 | import java.io.PrintStream; 20 | import java.io.PrintWriter; 21 | import java.util.*; 22 | import java.util.function.Consumer; 23 | import java.util.function.Supplier; 24 | 25 | import static io.codechicken.diffpatch.util.LogLevel.*; 26 | import static io.codechicken.diffpatch.util.Utils.filterPrefixed; 27 | 28 | /** 29 | * Handles doing a Diff operation from the CLI. 30 | *

31 | * Created by covers1624 on 11/8/20. 32 | */ 33 | public class DiffOperation extends CliOperation { 34 | 35 | final boolean summary; 36 | final Input baseInput; 37 | final Input changedInput; 38 | final String aPrefix; 39 | final String bPrefix; 40 | final boolean autoHeader; 41 | final int context; 42 | final Output patchOutput; 43 | final String lineEnding; 44 | final String[] ignorePrefixes; 45 | private final Supplier differFactory; 46 | 47 | private DiffOperation( 48 | PrintStream logger, 49 | LogLevel level, 50 | Consumer helpCallback, 51 | boolean summary, 52 | Input baseInput, 53 | Input changedInput, 54 | String aPrefix, 55 | String bPrefix, 56 | boolean autoHeader, 57 | int context, 58 | Output patchOutput, 59 | String lineEnding, 60 | String[] ignorePrefixes, 61 | Supplier differFactory 62 | ) { 63 | super(logger, level, helpCallback); 64 | this.summary = summary; 65 | this.baseInput = baseInput; 66 | this.changedInput = changedInput; 67 | this.aPrefix = aPrefix; 68 | this.bPrefix = bPrefix; 69 | this.autoHeader = autoHeader; 70 | this.context = context; 71 | this.patchOutput = patchOutput; 72 | this.lineEnding = lineEnding; 73 | this.ignorePrefixes = ignorePrefixes; 74 | this.differFactory = differFactory; 75 | } 76 | 77 | public static Builder builder() { 78 | return new Builder(); 79 | } 80 | 81 | @Override 82 | public Result operate() throws IOException { 83 | try { 84 | baseInput.validate("base input"); 85 | changedInput.validate("changed input"); 86 | patchOutput.validate("patch output"); 87 | } catch (IOValidationException ex) { 88 | log(ERROR, ex.getMessage()); 89 | printHelp(); 90 | return new Result<>(-1); 91 | } 92 | 93 | FileCollector patches = new FileCollector(); 94 | DiffSummary summary = new DiffSummary(); 95 | // If inputs are both files, and no format is set, we are diffing singular files. 96 | if (baseInput instanceof SingleInput && changedInput instanceof SingleInput) { 97 | SingleInput base = (SingleInput) baseInput; 98 | SingleInput changed = (SingleInput) changedInput; 99 | if (!(patchOutput instanceof SingleOutput)) { 100 | log(ERROR, "Can't specify output directory or archive when diffing single files."); 101 | printHelp(); 102 | return new Result<>(-1); 103 | } 104 | SingleOutput output = (SingleOutput) patchOutput; 105 | 106 | List lines = doDiff(summary, base.name(), changed.name(), base.readLines(), changed.readLines(), context, autoHeader); 107 | boolean changes = false; 108 | if (!lines.isEmpty()) { 109 | changes = true; 110 | try (PrintWriter out = new PrintWriter(output.open())) { 111 | out.print(String.join(lineEnding, lines)); 112 | out.print(lineEnding); 113 | } 114 | } 115 | if (this.summary) { 116 | summary.print(logger, true); 117 | } 118 | return new Result<>(changes ? 1 : 0, summary); 119 | } 120 | 121 | if (!(baseInput instanceof MultiInput)) { 122 | log(ERROR, "Can't diff between single files and folders/archives."); 123 | printHelp(); 124 | return new Result<>(-1); 125 | } 126 | 127 | if (!(changedInput instanceof MultiInput)) { 128 | log(ERROR, "Can't diff between folders/archives and single files."); 129 | printHelp(); 130 | return new Result<>(-1); 131 | } 132 | 133 | try (MultiInput base = (MultiInput) baseInput; 134 | MultiInput changed = (MultiInput) changedInput) { 135 | base.open(""); 136 | changed.open(""); 137 | Set aIndex = filterPrefixed(base.index(), ignorePrefixes); 138 | Set bIndex = filterPrefixed(changed.index(), ignorePrefixes); 139 | doDiff(patches, summary, aIndex, bIndex, base, changed, context, autoHeader); 140 | } 141 | 142 | boolean changes = false; 143 | if (!patches.isEmpty()) { 144 | changes = true; 145 | if (patchOutput instanceof SingleOutput) { 146 | SingleOutput singleOut = (SingleOutput) patchOutput; 147 | try (PrintWriter out = new PrintWriter(singleOut.open())) { 148 | for (CollectedEntry entry : patches.values()) { 149 | // Safe, we only add generated lines to this collector. 150 | List lines = ((FileCollector.LinesCollectedEntry) entry).lines; 151 | lines.forEach(line -> { 152 | out.print(line); 153 | out.print(lineEnding); 154 | }); 155 | } 156 | } 157 | } else { 158 | try (MultiOutput output = (MultiOutput) patchOutput) { 159 | output.open(true); 160 | for (Map.Entry entry : patches.get().entrySet()) { 161 | output.write(entry.getKey(), entry.getValue().toBytes(lineEnding, true)); 162 | } 163 | } 164 | } 165 | } 166 | if (this.summary) { 167 | summary.print(logger, false); 168 | } 169 | return new Result<>(changes ? 1 : 0, summary); 170 | } 171 | 172 | private void doDiff(FileCollector patches, DiffSummary summary, Set aEntries, Set bEntries, MultiInput aInput, MultiInput bInput, int context, boolean autoHeader) { 173 | List added = FastStream.of(bEntries).filter(e -> !aEntries.contains(e)).sorted().toList(); 174 | List common = FastStream.of(aEntries).filter(bEntries::contains).sorted().toList(); 175 | List removed = FastStream.of(aEntries).filter(e -> !bEntries.contains(e)).sorted().toList(); 176 | String aPrefix = StringUtils.appendIfMissing(StringUtils.isEmpty(this.aPrefix) ? "a" : this.aPrefix, "/"); 177 | String bPrefix = StringUtils.appendIfMissing(StringUtils.isEmpty(this.bPrefix) ? "b" : this.bPrefix, "/"); 178 | for (String file : added) { 179 | try { 180 | String bName = bPrefix + StringUtils.removeStart(file, "/"); 181 | List aLines = Collections.emptyList(); 182 | List bLines = bInput.readLines(file); 183 | List patchLines = doDiff(summary, null, bName, aLines, bLines, context, autoHeader); 184 | if (!patchLines.isEmpty()) { 185 | summary.addedFiles++; 186 | patches.consume(file + ".patch", patchLines); 187 | } else { 188 | summary.unchangedFiles++; 189 | } 190 | } catch (IOException e) { 191 | log(ERROR, "Failed to read file: %s", file); 192 | } 193 | } 194 | for (String file : common) { 195 | try { 196 | String aName = aPrefix + StringUtils.removeStart(file, "/"); 197 | String bName = bPrefix + StringUtils.removeStart(file, "/"); 198 | List aLines = aInput.readLines(file); 199 | List bLines = bInput.readLines(file); 200 | List patchLines = doDiff(summary, aName, bName, aLines, bLines, context, autoHeader); 201 | if (!patchLines.isEmpty()) { 202 | summary.changedFiles++; 203 | patches.consume(file + ".patch", patchLines); 204 | } else { 205 | summary.unchangedFiles++; 206 | } 207 | } catch (IOException e) { 208 | log(ERROR, "Failed to read file: %s", file); 209 | } 210 | } 211 | for (String file : removed) { 212 | try { 213 | String aName = aPrefix + StringUtils.removeStart(file, "/"); 214 | List aLines = aInput.readLines(file); 215 | List bLines = Collections.emptyList(); 216 | List patchLines = doDiff(summary, aName, null, aLines, bLines, context, autoHeader); 217 | if (!patchLines.isEmpty()) { 218 | summary.removedFiles++; 219 | patches.consume(file + ".patch", patchLines); 220 | } else { 221 | summary.unchangedFiles++; 222 | } 223 | } catch (IOException e) { 224 | log(ERROR, "Failed to read file: %s", file); 225 | } 226 | } 227 | } 228 | 229 | private List doDiff(DiffSummary summary, @Nullable String aName, @Nullable String bName, List aLines, List bLines, int context, boolean autoHeader) { 230 | PatchFile patchFile = new PatchFile(); 231 | patchFile.basePath = aName != null ? aName : "/dev/null"; 232 | patchFile.patchedPath = bName != null ? bName : "/dev/null"; 233 | if (aLines.isEmpty()) { 234 | patchFile.patches = Differ.makeFileAdded(bLines); 235 | } else if (bLines.isEmpty()) { 236 | patchFile.patches = Differ.makeFileRemoved(aLines); 237 | } else { 238 | patchFile.patches = differFactory.get().makePatches(aLines, bLines, context, true); 239 | } 240 | if (patchFile.patches.isEmpty()) { 241 | log(DEBUG, "%s -> %s\n No changes.", aName, bName); 242 | return Collections.emptyList(); 243 | } 244 | long added = FastStream.of(patchFile.patches) 245 | .flatMap(e -> e.diffs) 246 | .filter(e -> e.op == Operation.INSERT) 247 | .count(); 248 | long removed = FastStream.of(patchFile.patches) 249 | .flatMap(e -> e.diffs) 250 | .filter(e -> e.op == Operation.DELETE) 251 | .count(); 252 | if (this.summary) { 253 | summary.addedLines += added; 254 | summary.removedLines += removed; 255 | } 256 | log(this.summary ? INFO : DEBUG, "%s -> %s\n %d Added.\n %d Removed.", aName, bName, added, removed); 257 | 258 | return patchFile.toLines(autoHeader); 259 | } 260 | 261 | public static class DiffSummary { 262 | 263 | public int unchangedFiles; 264 | public int addedFiles; 265 | public int changedFiles; 266 | public int removedFiles; 267 | 268 | public long addedLines; 269 | public long removedLines; 270 | 271 | public void print(PrintStream logger, boolean slim) { 272 | logger.println("Diff Summary:"); 273 | if (!slim) { 274 | logger.println(" UnChanged files: " + unchangedFiles); 275 | logger.println(" Added files: " + addedFiles); 276 | logger.println(" Changed files: " + changedFiles); 277 | logger.println(" Removed files: " + removedFiles); 278 | } 279 | 280 | logger.println(" Added lines: " + addedLines); 281 | logger.println(" Removed lines: " + removedLines); 282 | } 283 | } 284 | 285 | public static class Builder { 286 | 287 | private static final PrintStream NULL_STREAM = new PrintStream(NullOutputStream.INSTANCE); 288 | 289 | private PrintStream logger = NULL_STREAM; 290 | private Consumer helpCallback = SneakyUtils.nullCons(); 291 | private LogLevel level = LogLevel.WARN; 292 | private boolean summary; 293 | private @Nullable Input baseInput; 294 | private @Nullable Input changedInput; 295 | private boolean autoHeader; 296 | private int context = Differ.DEFAULT_CONTEXT; 297 | private @Nullable Output patchesOutput; 298 | private String aPrefix = "a/"; 299 | private String bPrefix = "b/"; 300 | private String lineEnding = System.lineSeparator(); 301 | private Supplier differFactory = PatienceDiffer::new; 302 | 303 | private final List ignorePrefixes = new LinkedList<>(); 304 | 305 | private Builder() { 306 | } 307 | 308 | public Builder logTo(Consumer func) { 309 | return logTo(new ConsumingOutputStream(func)); 310 | } 311 | 312 | public Builder logTo(PrintStream logger) { 313 | this.logger = Objects.requireNonNull(logger); 314 | return this; 315 | } 316 | 317 | public Builder logTo(OutputStream logger) { 318 | return logTo(new PrintStream(logger)); 319 | } 320 | 321 | public Builder helpCallback(Consumer helpCallback) { 322 | this.helpCallback = Objects.requireNonNull(helpCallback); 323 | return this; 324 | } 325 | 326 | public Builder level(LogLevel level) { 327 | this.level = level; 328 | return this; 329 | } 330 | 331 | public Builder summary(boolean summary) { 332 | this.summary = summary; 333 | return this; 334 | } 335 | 336 | public Builder baseInput(Input baseInput) { 337 | this.baseInput = Objects.requireNonNull(baseInput); 338 | return this; 339 | } 340 | 341 | public Builder changedInput(Input changedInput) { 342 | this.changedInput = Objects.requireNonNull(changedInput); 343 | return this; 344 | } 345 | 346 | public Builder aPrefix(String aPrefix) { 347 | this.aPrefix = aPrefix; 348 | return this; 349 | } 350 | 351 | public Builder bPrefix(String bPrefix) { 352 | this.bPrefix = bPrefix; 353 | return this; 354 | } 355 | 356 | public Builder autoHeader(boolean autoHeader) { 357 | this.autoHeader = autoHeader; 358 | return this; 359 | } 360 | 361 | public Builder context(int context) { 362 | this.context = context; 363 | return this; 364 | } 365 | 366 | public Builder patchesOutput(Output patchesOutput) { 367 | this.patchesOutput = Objects.requireNonNull(patchesOutput); 368 | return this; 369 | } 370 | 371 | public Builder lineEnding(String lineEnding) { 372 | this.lineEnding = lineEnding; 373 | return this; 374 | } 375 | 376 | public Builder ignorePrefix(String prefix) { 377 | ignorePrefixes.add(prefix); 378 | return this; 379 | } 380 | 381 | public Builder differFactory(Supplier factory) { 382 | differFactory = factory; 383 | return this; 384 | } 385 | 386 | public DiffOperation build() { 387 | if (baseInput == null) throw new IllegalStateException("baseInput is required."); 388 | if (changedInput == null) throw new IllegalStateException("changedInput is required."); 389 | if (patchesOutput == null) throw new IllegalStateException("patchesOutput is required."); 390 | 391 | return new DiffOperation( 392 | logger, 393 | level, 394 | helpCallback, 395 | summary, 396 | baseInput, 397 | changedInput, 398 | aPrefix, 399 | bPrefix, 400 | autoHeader, 401 | context, 402 | patchesOutput, 403 | lineEnding, 404 | ignorePrefixes.toArray(new String[0]), 405 | differFactory 406 | ); 407 | } 408 | } 409 | } 410 | --------------------------------------------------------------------------------