From 3f6a16224e3866662b94b087142a9fcbef023e2e Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 21 Mar 2023 03:34:46 +0100 Subject: [PATCH] initial commit: some diff algorithm --- .gitignore | 3 + LICENSE.md | 7 + README.md | 9 + build.gradle.kts | 60 ++ gradle.properties | 2 + gradle/wrapper/gradle-wrapper.properties | 5 + settings.gradle.kts | 3 + .../kotlin/dev/gitlive/difflib/DiffUtils.kt | 218 ++++++ .../dev/gitlive/difflib/UnifiedDiffUtils.kt | 311 ++++++++ .../dev/gitlive/difflib/algorithm/Change.kt | 38 + .../difflib/algorithm/DiffAlgorithmFactory.kt | 28 + .../difflib/algorithm/DiffAlgorithmI.kt | 47 ++ .../algorithm/DiffAlgorithmListener.kt | 34 + .../difflib/algorithm/myers/MeyersDiff.kt | 163 +++++ .../myers/MeyersDiffWithLinearSpace.kt | 202 +++++ .../difflib/algorithm/myers/PathNode.kt | 96 +++ .../gitlive/difflib/patch/AbstractDelta.kt | 73 ++ .../dev/gitlive/difflib/patch/ChangeDelta.kt | 61 ++ .../kotlin/dev/gitlive/difflib/patch/Chunk.kt | 143 ++++ .../gitlive/difflib/patch/ConflictOutput.kt | 29 + .../dev/gitlive/difflib/patch/DeleteDelta.kt | 57 ++ .../dev/gitlive/difflib/patch/DeltaType.kt | 53 ++ .../gitlive/difflib/patch/DiffException.kt | 30 + .../dev/gitlive/difflib/patch/EqualDelta.kt | 36 + .../dev/gitlive/difflib/patch/InsertDelta.kt | 57 ++ .../kotlin/dev/gitlive/difflib/patch/Patch.kt | 198 +++++ .../difflib/patch/PatchFailedException.kt | 30 + .../dev/gitlive/difflib/patch/VerifyChunk.kt | 24 + .../dev/gitlive/difflib/text/DiffRow.kt | 75 ++ .../gitlive/difflib/text/DiffRowGenerator.kt | 690 ++++++++++++++++++ .../dev/gitlive/difflib/text/StringUtils.kt | 77 ++ .../difflib/unifieddiff/UnifiedDiff.kt | 61 ++ .../difflib/unifieddiff/UnifiedDiffFile.kt | 53 ++ .../unifieddiff/UnifiedDiffParserException.kt | 27 + .../difflib/unifieddiff/UnifiedDiffReader.kt | 448 ++++++++++++ .../difflib/unifieddiff/package-info.kt | 29 + .../kotlin/net.sergeych.merge3/merge3.kt | 209 ++++++ src/jvmTest/kotlin/testMerge.kt | 49 ++ 38 files changed, 3735 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/DiffUtils.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/UnifiedDiffUtils.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/Change.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmFactory.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmI.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmListener.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiff.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiffWithLinearSpace.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/PathNode.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/AbstractDelta.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/ChangeDelta.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/Chunk.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/ConflictOutput.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/DeleteDelta.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/DeltaType.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/DiffException.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/EqualDelta.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/InsertDelta.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/Patch.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/PatchFailedException.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/patch/VerifyChunk.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRow.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRowGenerator.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/text/StringUtils.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiff.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffFile.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffParserException.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffReader.kt create mode 100644 src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/package-info.kt create mode 100644 src/commonMain/kotlin/net.sergeych.merge3/merge3.kt create mode 100644 src/jvmTest/kotlin/testMerge.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ea68f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.gradle/ +/.idea/ +/build/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dd1aa9c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2023 Sergey S. Chernov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a96b32e --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# 3-way merge + +Tho tools to make smart merge of two versions of changed original data. See [merge3] function online docs. + + +## Acknowledgments + +This work is based on the original kotlin diff port https://github.com/GitLiveApp/kotlin-diff-utils work, unfortunatley its packaging is not compatible with current kotlin MP formats so I can't just use it as the dependencies. The wholde `dev.gitlive.difflib` is taken from the link above, where it was published under the [Apache 2.0 license at the moment of borrowing this code](https://github.com/GitLiveApp/kotlin-diff-utils/blob/master/LICENSE). + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5ac3bf9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + kotlin("multiplatform") version "1.8.0" +} + +group = "net.sergeych" +version = "0.0.1-SNAPSHOT" + +repositories { + mavenCentral() + maven("https://maven.universablockchain.com") +} + +kotlin { + jvm { + jvmToolchain(8) + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + js(IR) { + browser { + commonWebpackConfig { +// cssSupport { +// enabled.set(true) +// } + } + } + } + val hostOs = System.getProperty("os.name") + val isMingwX64 = hostOs.startsWith("Windows") + val nativeTarget = when { + hostOs == "Mac OS X" -> macosX64("native") + hostOs == "Linux" -> linuxX64("native") + isMingwX64 -> mingwX64("native") + else -> throw GradleException("Host OS is not supported in Kotlin/Native.") + } + + + sourceSets { + val commonMain by getting { + dependencies { + implementation("dev.gitlive:kotlin-diff-utils:4.1.4") + implementation("net.sergeych:mp_stools:[1.3.3,)") + + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + val jvmMain by getting + val jvmTest by getting + val jsMain by getting + val jsTest by getting + val nativeMain by getting + val nativeTest by getting + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9dcc3a7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +kotlin.js.compiler=ir diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..60c76b3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6374abb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "mp_diff" + diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/DiffUtils.kt b/src/commonMain/kotlin/dev/gitlive/difflib/DiffUtils.kt new file mode 100644 index 0000000..41feb84 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/DiffUtils.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib + +import dev.gitlive.difflib.algorithm.DiffAlgorithmFactory +import dev.gitlive.difflib.algorithm.DiffAlgorithmI +import dev.gitlive.difflib.algorithm.DiffAlgorithmListener +import dev.gitlive.difflib.algorithm.myers.MeyersDiff +import dev.gitlive.difflib.patch.Patch +import dev.gitlive.difflib.patch.PatchFailedException + +internal typealias Predicate = (T) -> Boolean +internal typealias BiPredicate = (T, R) -> Boolean +internal typealias Consumer = (T) -> Unit +internal typealias Function = (T) -> R +internal typealias BiFunction = (T, T2) -> R +internal typealias BiConsumer = (T, U) -> Unit +internal typealias LineReader = suspend () -> String? + +/** + * Implements the difference and patching engine + */ +object DiffUtils { + /** + * This factory generates the DEFAULT_DIFF algorithm for all these routines. + */ + var DEFAULT_DIFF: DiffAlgorithmFactory = MeyersDiff.Companion.factory() + @kotlin.jvm.JvmStatic + fun withDefaultDiffAlgorithmFactory(factory: DiffAlgorithmFactory) { + DEFAULT_DIFF = factory + } + + /** + * Computes the difference between the original and revised list of elements + * with default diff algorithm + * + * @param types to be diffed + * @param original The original text. Must not be `null`. + * @param revised The revised text. Must not be `null`. + * @param progress progress listener + * @return The patch describing the difference between the original and + * revised sequences. Never `null`. + */ + fun diff(original: List, revised: List, progress: DiffAlgorithmListener?): Patch { + return diff(original, revised, DEFAULT_DIFF.create(), progress) + } + + fun diff(original: List, revised: List): Patch { + return diff(original, revised, DEFAULT_DIFF.create(), null) + } + + fun diff(original: List, revised: List, includeEqualParts: Boolean): Patch { + return diff(original, revised, DEFAULT_DIFF.create(), null, includeEqualParts) + } + + /** + * Computes the difference between the original and revised text. + */ + fun diff( + sourceText: String, targetText: String, + progress: DiffAlgorithmListener? + ): Patch { + return diff(sourceText.trimEnd('\n').lines(), targetText.trimEnd('\n').lines(), progress) + } + + /** + * Computes the difference between the original and revised list of elements + * with default diff algorithm + * + * @param source The original text. Must not be `null`. + * @param target The revised text. Must not be `null`. + * + * @param equalizer the equalizer object to replace the default compare + * algorithm (Object.equals). If `null` the default equalizer of the + * default algorithm is used.. + * @return The patch describing the difference between the original and + * revised sequences. Never `null`. + */ + fun diff( + source: List, target: List, + equalizer: BiPredicate? + ): Patch { + return if (equalizer != null) { + diff( + source, target, + DEFAULT_DIFF.create(equalizer) + ) + } else diff(source, target, MeyersDiff()) + } + + fun diff( + source: List, target: List, + includeEqualParts: Boolean, + equalizer: BiPredicate? + ): Patch { + return if (equalizer != null) { + diff(source, target, DEFAULT_DIFF.create(equalizer), null, includeEqualParts) + } else diff(source, target, MeyersDiff()) + } + + fun diff( + original: List, revised: List, + algorithm: DiffAlgorithmI, progress: DiffAlgorithmListener? + ): Patch { + return diff(original, revised, algorithm, progress, false) + } + + /** + * Computes the difference between the original and revised list of elements + * with default diff algorithm + * + * @param original The original text. Must not be `null`. + * @param revised The revised text. Must not be `null`. + * @param algorithm The diff algorithm. Must not be `null`. + * @param progress The diff algorithm listener. + * @param includeEqualParts Include equal data parts into the patch. + * @return The patch describing the difference between the original and + * revised sequences. Never `null`. + */ + fun diff( + original: List, revised: List, + algorithm: DiffAlgorithmI, progress: DiffAlgorithmListener?, + includeEqualParts: Boolean + ): Patch { + return Patch.Companion.generate( + original, + revised, + algorithm.computeDiff(original, revised, progress), + includeEqualParts + ) + } + + /** + * Computes the difference between the original and revised list of elements + * with default diff algorithm + * + * @param original The original text. Must not be `null`. + * @param revised The revised text. Must not be `null`. + * @param algorithm The diff algorithm. Must not be `null`. + * @return The patch describing the difference between the original and + * revised sequences. Never `null`. + */ + @kotlin.jvm.JvmStatic + fun diff(original: List, revised: List, algorithm: DiffAlgorithmI): Patch { + return diff(original, revised, algorithm, null) + } + + /** + * Computes the difference between the given texts inline. This one uses the + * "trick" to make out of texts lists of characters, like DiffRowGenerator + * does and merges those changes at the end together again. + * + * @param original + * @param revised + * @return + */ + @kotlin.jvm.JvmStatic + fun diffInline(original: String, revised: String): Patch { + val origList: MutableList = ArrayList() + val revList: MutableList = ArrayList() + for (character in original) { + origList.add(character.toString()) + } + for (character in revised) { + revList.add(character.toString()) + } + val patch: Patch = diff(origList, revList) + for (delta in patch.getDeltas()) { + delta.source.lines = compressLines(delta.source.lines, "") + delta.target.lines = compressLines(delta.target.lines, "") + } + return patch + } + + private fun compressLines(lines: List, delimiter: String): List { + return if (lines.isEmpty()) { + emptyList() + } else listOf(lines.joinToString(delimiter)) + } + + /** + * Patch the original text with given patch + * + * @param original the original text + * @param patch the given patch + * @return the revised text + * @throws PatchFailedException if can't apply patch + */ + @kotlin.jvm.JvmStatic +// @Throws(PatchFailedException::class) + fun patch(original: List, patch: Patch): List { + return patch.applyTo(original) + } + + /** + * Unpatch the revised text for a given patch + * + * @param revised the revised text + * @param patch the given patch + * @return the original text + */ + fun unpatch(revised: List, patch: Patch): List { + return patch.restore(revised) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/UnifiedDiffUtils.kt b/src/commonMain/kotlin/dev/gitlive/difflib/UnifiedDiffUtils.kt new file mode 100644 index 0000000..057ef6d --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/UnifiedDiffUtils.kt @@ -0,0 +1,311 @@ +/* + * Copyright 2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib + +import dev.gitlive.difflib.patch.AbstractDelta +import dev.gitlive.difflib.patch.ChangeDelta +import dev.gitlive.difflib.patch.Chunk +import dev.gitlive.difflib.patch.Patch + +/** + * + * @author toben + */ +object UnifiedDiffUtils { + private val UNIFIED_DIFF_CHUNK_REGEXP = + Regex("^@@\\s+-(?:(\\d+)(?:,(\\d+))?)\\s+\\+(?:(\\d+)(?:,(\\d+))?)\\s+@@$") + private const val NULL_FILE_INDICATOR = "/dev/null" + + /** + * Parse the given text in unified format and creates the list of deltas for it. + * + * @param diff the text in unified format + * @return the patch with deltas. + */ + @kotlin.jvm.JvmStatic + fun parseUnifiedDiff(diff: List): Patch { + var inPrelude = true + val rawChunk: MutableList> = ArrayList() + val patch = Patch() + var old_ln = 0 + var new_ln = 0 + var tag: String + var rest: String + for (line in diff) { + // Skip leading lines until after we've seen one starting with '+++' + if (inPrelude) { + if (line.startsWith("+++")) { + inPrelude = false + } + continue + } + val m = UNIFIED_DIFF_CHUNK_REGEXP.find(line) + if (m != null) { + // Process the lines in the previous chunk + processLinesInPrevChunk(rawChunk, patch, old_ln, new_ln) + // Parse the @@ header + old_ln = if (m.groups[1] == null) 1 else m.groupValues[1].toInt() + new_ln = if (m.groups[3] == null) 1 else m.groupValues[3].toInt() + if (old_ln == 0) { + old_ln = 1 + } + if (new_ln == 0) { + new_ln = 1 + } + } else { + if (line.length > 0) { + tag = line.substring(0, 1) + rest = line.substring(1) + if (" " == tag || "+" == tag || "-" == tag) { + rawChunk.add(arrayOf(tag, rest)) + } + } else { + rawChunk.add(arrayOf(" ", "")) + } + } + } + + // Process the lines in the last chunk + processLinesInPrevChunk(rawChunk, patch, old_ln, new_ln) + return patch + } + + private fun processLinesInPrevChunk( + rawChunk: MutableList>, + patch: Patch, + old_ln: Int, + new_ln: Int + ) { + var tag: String + var rest: String + if (!rawChunk.isEmpty()) { + val oldChunkLines: MutableList = ArrayList() + val newChunkLines: MutableList = ArrayList() + val removePosition: MutableList = ArrayList() + val addPosition: MutableList = ArrayList() + var removeNum = 0 + var addNum = 0 + for (raw_line in rawChunk) { + tag = raw_line[0] + rest = raw_line[1] + if (" " == tag || "-" == tag) { + removeNum++ + oldChunkLines.add(rest) + if ("-" == tag) { + removePosition.add(old_ln - 1 + removeNum) + } + } + if (" " == tag || "+" == tag) { + addNum++ + newChunkLines.add(rest) + if ("+" == tag) { + addPosition.add(new_ln - 1 + addNum) + } + } + } + patch.addDelta( + ChangeDelta( + Chunk( + old_ln - 1, oldChunkLines, removePosition + ), Chunk( + new_ln - 1, newChunkLines, addPosition + ) + ) + ) + rawChunk.clear() + } + } + + /** + * generateUnifiedDiff takes a Patch and some other arguments, returning the Unified Diff format + * text representing the Patch. Author: Bill James (tankerbay@gmail.com). + * + * @param originalFileName - Filename of the original (unrevised file) + * @param revisedFileName - Filename of the revised file + * @param originalLines - Lines of the original file + * @param patch - Patch created by the diff() function + * @param contextSize - number of lines of context output around each difference in the file. + * @return List of strings representing the Unified Diff representation of the Patch argument. + */ + @kotlin.jvm.JvmStatic + fun generateUnifiedDiff( + originalFileName: String?, + revisedFileName: String?, originalLines: List, patch: Patch, + contextSize: Int + ): List { + if (!patch.getDeltas().isEmpty()) { + val ret: MutableList = ArrayList() + ret.add("--- ${originalFileName ?: NULL_FILE_INDICATOR}") + ret.add("+++ ${revisedFileName ?: NULL_FILE_INDICATOR}") + val patchDeltas: List> = ArrayList>( + patch.getDeltas() + ) + + // code outside the if block also works for single-delta issues. + val deltas: MutableList> = ArrayList() // current + // list + // of + // Delta's to + // process + var delta = patchDeltas[0] + deltas.add(delta) // add the first Delta to the current set + // if there's more than 1 Delta, we may need to output them together + if (patchDeltas.size > 1) { + for (i in 1 until patchDeltas.size) { + val position = delta.source.position // store + // the + // current + // position + // of + // the first Delta + + // Check if the next Delta is too close to the current + // position. + // And if it is, add it to the current set + val nextDelta = patchDeltas[i] + if (position + delta.source.size() + contextSize >= nextDelta.source.position - contextSize) { + deltas.add(nextDelta) + } else { + // if it isn't, output the current set, + // then create a new set and add the current Delta to + // it. + val curBlock = processDeltas( + originalLines, + deltas, contextSize, false + ) + ret.addAll(curBlock) + deltas.clear() + deltas.add(nextDelta) + } + delta = nextDelta + } + } + // don't forget to process the last set of Deltas + val curBlock = processDeltas( + originalLines, deltas, + contextSize, patchDeltas.size == 1 && originalFileName == null + ) + ret.addAll(curBlock) + return ret + } + return ArrayList() + } + + /** + * processDeltas takes a list of Deltas and outputs them together in a single block of + * Unified-Diff-format text. Author: Bill James (tankerbay@gmail.com). + * + * @param origLines - the lines of the original file + * @param deltas - the Deltas to be output as a single block + * @param contextSize - the number of lines of context to place around block + * @return + */ + private fun processDeltas( + origLines: List, + deltas: List>, contextSize: Int, newFile: Boolean + ): List { + val buffer: MutableList = ArrayList() + var origTotal = 0 // counter for total lines output from Original + var revTotal = 0 // counter for total lines output from Original + var line: Int + var curDelta = deltas[0] + var origStart: Int + if (newFile) { + origStart = 0 + } else { + // NOTE: +1 to overcome the 0-offset Position + origStart = curDelta.source.position + 1 - contextSize + if (origStart < 1) { + origStart = 1 + } + } + var revStart = curDelta.target.position + 1 - contextSize + if (revStart < 1) { + revStart = 1 + } + + // find the start of the wrapper context code + var contextStart = curDelta.source.position - contextSize + if (contextStart < 0) { + contextStart = 0 // clamp to the start of the file + } + + // output the context before the first Delta + line = contextStart + while (line < curDelta.source.position) { + buffer.add(" " + origLines[line]) + origTotal++ + revTotal++ + line++ + } + + // output the first Delta + buffer.addAll(getDeltaText(curDelta)) + origTotal += curDelta.source.lines.size + revTotal += curDelta.target.lines.size + var deltaIndex = 1 + while (deltaIndex < deltas.size) { // for each of the other Deltas + val nextDelta = deltas[deltaIndex] + val intermediateStart = (curDelta.source.position + curDelta.source.lines.size) + line = intermediateStart + while (line < nextDelta.source.position) { + // output the code between the last Delta and this one + buffer.add(" " + origLines[line]) + origTotal++ + revTotal++ + line++ + } + buffer.addAll(getDeltaText(nextDelta)) // output the Delta + origTotal += nextDelta.source.lines.size + revTotal += nextDelta.target.lines.size + curDelta = nextDelta + deltaIndex++ + } + + // Now output the post-Delta context code, clamping the end of the file + contextStart = (curDelta.source.position + curDelta.source.lines.size) + line = contextStart + while (line < contextStart + contextSize + && line < origLines.size + ) { + buffer.add(" " + origLines[line]) + origTotal++ + revTotal++ + line++ + } + + // Create and insert the block header, conforming to the Unified Diff standard + buffer.add(0, "@@ -$origStart,$origTotal +$revStart,$revTotal @@") + return buffer + } + + /** + * getDeltaText returns the lines to be added to the Unified Diff text from the Delta parameter. Author: Bill James (tankerbay@gmail.com). + * + * @param delta - the Delta to output + * @return list of String lines of code. + */ + private fun getDeltaText(delta: AbstractDelta): List { + val buffer: MutableList = ArrayList() + for (line in delta.source.lines) { + buffer.add("-$line") + } + for (line in delta.target.lines) { + buffer.add("+$line") + } + return buffer + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/Change.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/Change.kt new file mode 100644 index 0000000..31ab65b --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/Change.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm + +import dev.gitlive.difflib.patch.DeltaType + +/** + * + * @author [Tobias Warneke](t.warneke@gmx.net) + */ +class Change( + val deltaType: DeltaType, + val startOriginal: Int, + val endOriginal: Int, + val startRevised: Int, + val endRevised: Int +) { + fun withEndOriginal(endOriginal: Int): Change { + return Change(deltaType, startOriginal, endOriginal, startRevised, endRevised) + } + + fun withEndRevised(endRevised: Int): Change { + return Change(deltaType, startOriginal, endOriginal, startRevised, endRevised) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmFactory.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmFactory.kt new file mode 100644 index 0000000..a7a5433 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2021 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm + +import dev.gitlive.difflib.BiPredicate + +/** + * Tool to create new instances of a diff algorithm. This one is only needed at the moment to + * set DiffUtils default diff algorithm. + * @author tw + */ +interface DiffAlgorithmFactory { + fun create(): DiffAlgorithmI + fun create(equalizer: BiPredicate): DiffAlgorithmI +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmI.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmI.kt new file mode 100644 index 0000000..e2f30c2 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmI.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2018 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm + +/** + * Interface of a diff algorithm. + * + * @author Tobias Warneke (t.warneke@gmx.net) + * @param type of data that is diffed. + */ +interface DiffAlgorithmI { + /** + * Computes the changeset to patch the source list to the target list. + * + * @param source source data + * @param target target data + * @param progress progress listener + * @return + */ + fun computeDiff(source: List, target: List, progress: DiffAlgorithmListener?): List + + /** + * Simple extension to compute a changeset using arrays. + * + * @param source + * @param target + * @param progress + * @return + */ +// @Throws(DiffException::class) + fun computeDiff(source: Array, target: Array, progress: DiffAlgorithmListener?): List { + return computeDiff(source.toList(), target.toList(), progress) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmListener.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmListener.kt new file mode 100644 index 0000000..f56e4e3 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/DiffAlgorithmListener.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2018 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm + +/** + * + * @author Tobias Warneke (t.warneke@gmx.net) + */ +interface DiffAlgorithmListener { + fun diffStart() + + /** + * This is a step within the diff algorithm. Due to different implementations the value + * is not strict incrementing to the max and is not garantee to reach the max. It could + * stop before. + * @param value + * @param max + */ + fun diffStep(value: Int, max: Int) + fun diffEnd() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiff.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiff.kt new file mode 100644 index 0000000..e6bc759 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiff.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm.myers + +import dev.gitlive.difflib.BiPredicate +import dev.gitlive.difflib.algorithm.Change +import dev.gitlive.difflib.algorithm.DiffAlgorithmFactory +import dev.gitlive.difflib.algorithm.DiffAlgorithmI +import dev.gitlive.difflib.algorithm.DiffAlgorithmListener +import dev.gitlive.difflib.patch.DeltaType + +/** + * A clean-room implementation of Eugene Meyers greedy differencing algorithm. + */ +class MeyersDiff : DiffAlgorithmI { + private val equalizer: BiPredicate + + constructor() { + equalizer = { a: T, b: T -> a!! == b } + } + + constructor(equalizer: BiPredicate) { + this.equalizer = equalizer + } + + /** + * {@inheritDoc} + * + * Return empty diff if get the error while procession the difference. + */ + override fun computeDiff(source: List, target: List, progress: DiffAlgorithmListener?): List { + progress?.diffStart() + val path = buildPath(source, target, progress) + val result = buildRevision(path, source, target) + progress?.diffEnd() + return result + } + + /** + * Computes the minimum diffpath that expresses de differences between the + * original and revised sequences, according to Gene Myers differencing + * algorithm. + * + * @param orig The original sequence. + * @param rev The revised sequence. + * @return A minimum [Path][PathNode] accross the differences graph. + * @throws DifferentiationFailedException if a diff path could not be found. + */ + private fun buildPath(orig: List, rev: List, progress: DiffAlgorithmListener?): PathNode { + + // these are local constants + val N = orig.size + val M = rev.size + val MAX = N + M + 1 + val size = 1 + 2 * MAX + val middle = size / 2 + val diagonal = arrayOfNulls(size) + diagonal[middle + 1] = PathNode(0, -1, true, true, null) + for (d in 0 until MAX) { + progress?.diffStep(d, MAX) + var k = -d + while (k <= d) { + val kmiddle = middle + k + val kplus = kmiddle + 1 + val kminus = kmiddle - 1 + var prev: PathNode? + var i: Int + if (k == -d || k != d && diagonal[kminus]!!.i < diagonal[kplus]!!.i) { + i = diagonal[kplus]!!.i + prev = diagonal[kplus] + } else { + i = diagonal[kminus]!!.i + 1 + prev = diagonal[kminus] + } + diagonal[kminus] = null // no longer used + var j = i - k + var node = PathNode(i, j, false, false, prev) + while (i < N && j < M && equalizer(orig[i], rev[j])) { + i++ + j++ + } + if (i != node.i) { + node = PathNode(i, j, true, false, node) + } + diagonal[kmiddle] = node + if (i >= N && j >= M) { + return diagonal[kmiddle]!! + } + k += 2 + } + diagonal[middle + d - 1] = null + } + throw IllegalStateException("could not find a diff path") + } + + /** + * Constructs a [Patch] from a difference path. + * + * @param actualPath The path. + * @param orig The original sequence. + * @param rev The revised sequence. + * @return A [Patch] script corresponding to the path. + * @throws DifferentiationFailedException if a [Patch] could not be + * built from the given path. + */ + private fun buildRevision(actualPath: PathNode, orig: List, rev: List): List { + var path: PathNode? = actualPath + val changes: MutableList = ArrayList() + if (path!!.isSnake) { + path = path.prev + } + while (path?.prev != null && path.prev!!.j >= 0) { + check(!path.isSnake) { "bad diffpath: found snake when looking for diff" } + val i = path.i + val j = path.j + path = path.prev + val ianchor = path!!.i + val janchor = path.j + if (ianchor == i && janchor != j) { + changes.add(Change(DeltaType.INSERT, ianchor, i, janchor, j)) + } else if (ianchor != i && janchor == j) { + changes.add(Change(DeltaType.DELETE, ianchor, i, janchor, j)) + } else { + changes.add(Change(DeltaType.CHANGE, ianchor, i, janchor, j)) + } + if (path.isSnake) { + path = path.prev + } + } + return changes + } + + companion object { + /** + * Factory to create instances of this specific diff algorithm. + */ + @kotlin.jvm.JvmStatic + fun factory(): DiffAlgorithmFactory { + return object : DiffAlgorithmFactory { + override fun create(): DiffAlgorithmI { + return MeyersDiff() + } + + override fun create(equalizer: BiPredicate): DiffAlgorithmI { + return MeyersDiff(equalizer) + } + } + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiffWithLinearSpace.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiffWithLinearSpace.kt new file mode 100644 index 0000000..5666dec --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/MeyersDiffWithLinearSpace.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2021 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm.myers + +import dev.gitlive.difflib.BiPredicate +import dev.gitlive.difflib.Consumer +import dev.gitlive.difflib.algorithm.Change +import dev.gitlive.difflib.algorithm.DiffAlgorithmFactory +import dev.gitlive.difflib.algorithm.DiffAlgorithmI +import dev.gitlive.difflib.algorithm.DiffAlgorithmListener +import dev.gitlive.difflib.patch.DeltaType + +/** + * + * @author tw + */ +class MeyersDiffWithLinearSpace : DiffAlgorithmI { + private val equalizer: BiPredicate + + constructor() { + equalizer = { a: T, b: T -> a!! == b } + } + + constructor(equalizer: BiPredicate) { + this.equalizer = equalizer + } + + override fun computeDiff(source: List, target: List, progress: DiffAlgorithmListener?): List { + progress?.diffStart() + val data = DiffData(source, target) + val maxIdx = source.size + target.size + buildScript(data, 0, source.size, 0, target.size) { idx: Int -> progress?.diffStep(idx, maxIdx) } + progress?.diffEnd() + return data.script + } + + private fun buildScript(data: DiffData, start1: Int, end1: Int, start2: Int, end2: Int, progress: Consumer?) { + progress?.invoke((end1 - start1) / 2 + (end2 - start2) / 2) + val middle: Snake? = getMiddleSnake(data, start1, end1, start2, end2) + if (middle == null || middle.start == end1 && middle.diag == end1 - end2 || middle.end == start1 && middle.diag == start1 - start2) { + var i = start1 + var j = start2 + while (i < end1 || j < end2) { + if (i < end1 && j < end2 && equalizer(data.source[i], data.target[j])) { + //script.append(new KeepCommand<>(left.charAt(i))); + ++i + ++j + } else { + //TODO: compress these commands. + if (end1 - start1 > end2 - start2) { + //script.append(new DeleteCommand<>(left.charAt(i))); + if (data.script.isEmpty() + || data.script[data.script.size - 1].endOriginal != i || data.script[data.script.size - 1].deltaType != DeltaType.DELETE + ) { + data.script.add(Change(DeltaType.DELETE, i, i + 1, j, j)) + } else { + data.script[data.script.size - 1] = data.script[data.script.size - 1].withEndOriginal(i + 1) + } + ++i + } else { + if (data.script.isEmpty() + || data.script[data.script.size - 1].endRevised != j || data.script[data.script.size - 1].deltaType != DeltaType.INSERT + ) { + data.script.add(Change(DeltaType.INSERT, i, i, j, j + 1)) + } else { + data.script[data.script.size - 1] = data.script[data.script.size - 1].withEndRevised(j + 1) + } + ++j + } + } + } + } else { + buildScript(data, start1, middle.start, start2, middle.start - middle.diag, progress) + buildScript(data, middle.end, end1, middle.end - middle.diag, end2, progress) + } + } + + private fun getMiddleSnake(data: DiffData, start1: Int, end1: Int, start2: Int, end2: Int): Snake? { + val m = end1 - start1 + val n = end2 - start2 + if (m == 0 || n == 0) { + return null + } + val delta = m - n + val sum = n + m + val offset = (if (sum % 2 == 0) sum else sum + 1) / 2 + data.vDown[1 + offset] = start1 + data.vUp[1 + offset] = end1 + 1 + for (d in 0..offset) { + // Down + run { + var k = -d + while (k <= d) { + + // First step + val i = k + offset + if (k == -d || k != d && data.vDown[i - 1] < data.vDown[i + 1]) { + data.vDown[i] = data.vDown[i + 1] + } else { + data.vDown[i] = data.vDown[i - 1] + 1 + } + var x = data.vDown[i] + var y = x - start1 + start2 - k + while (x < end1 && y < end2 && equalizer(data.source[x], data.target[y])) { + data.vDown[i] = ++x + ++y + } + // Second step + if (delta % 2 != 0 && delta - d <= k && k <= delta + d) { + if (data.vUp[i - delta] <= data.vDown[i]) { + return buildSnake(data, data.vUp[i - delta], k + start1 - start2, end1, end2) + } + } + k += 2 + } + } + + // Up + var k = delta - d + while (k <= delta + d) { + + // First step + val i = k + offset - delta + if (k == delta - d + || k != delta + d && data.vUp[i + 1] <= data.vUp[i - 1] + ) { + data.vUp[i] = data.vUp[i + 1] - 1 + } else { + data.vUp[i] = data.vUp[i - 1] + } + var x = data.vUp[i] - 1 + var y = x - start1 + start2 - k + while (x >= start1 && y >= start2 && equalizer(data.source[x], data.target[y])) { + data.vUp[i] = x-- + y-- + } + // Second step + if (delta % 2 == 0 && -d <= k && k <= d) { + if (data.vUp[i] <= data.vDown[i + delta]) { + return buildSnake(data, data.vUp[i], k + start1 - start2, end1, end2) + } + } + k += 2 + } + } + throw IllegalStateException("could not find a diff path") + } + + private fun buildSnake(data: DiffData, start: Int, diag: Int, end1: Int, end2: Int): Snake { + var end = start + while (end - diag < end2 && end < end1 && equalizer(data.source[end], data.target[end - diag])) { + ++end + } + return Snake(start, end, diag) + } + + private inner class DiffData(val source: List, val target: List) { + val size: Int + val vDown: IntArray + val vUp: IntArray + val script: MutableList + + init { + size = source.size + target.size + 2 + vDown = IntArray(size) + vUp = IntArray(size) + script = ArrayList() + } + } + + private inner class Snake(val start: Int, val end: Int, val diag: Int) + companion object { + /** + * Factory to create instances of this specific diff algorithm. + */ + @kotlin.jvm.JvmStatic + fun factory(): DiffAlgorithmFactory { + return object : DiffAlgorithmFactory { + override fun create(): DiffAlgorithmI { + return MeyersDiffWithLinearSpace() + } + + override fun create(equalizer: BiPredicate): DiffAlgorithmI { + return MeyersDiffWithLinearSpace(equalizer) + } + } + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/PathNode.kt b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/PathNode.kt new file mode 100644 index 0000000..f28dae3 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/algorithm/myers/PathNode.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.algorithm.myers + +/** + * A node in a diffpath. + * + * @author [Juanco Anez](mailto:juanco@suigeneris.org) + */ +class PathNode( + /** + * Position in the original sequence. + */ + val i: Int, + /** + * Position in the revised sequence. + */ + val j: Int, snake: Boolean, + /** + * Is this a bootstrap node? + * + * + * In bottstrap nodes one of the two corrdinates is less than zero. + * + * @return tru if this is a bootstrap node. + */ + val isBootstrap: Boolean, prev: PathNode? +) { + /** + * The previous node in the path. + */ + var prev: PathNode? = null + val isSnake: Boolean + + /** + * Skips sequences of [PathNodes][PathNode] until a snake or bootstrap node is found, or the end of the + * path is reached. + * + * @return The next first [PathNode] or bootstrap node in the path, or `null` if none found. + */ + fun previousSnake(): PathNode? { + if (isBootstrap) { + return null + } + return if (!isSnake && prev != null) { + prev!!.previousSnake() + } else this + } + + /** + * {@inheritDoc} + */ + override fun toString(): String { + val buf = StringBuilder("[") + var node: PathNode? = this + while (node != null) { + buf.append("(") + buf.append(node.i) + buf.append(",") + buf.append(node.j) + buf.append(")") + node = node.prev + } + buf.append("]") + return buf.toString() + } + + /** + * Concatenates a new path node with an existing diffpath. + * + * @param i The position in the original sequence for the new node. + * @param j The position in the revised sequence for the new node. + * @param prev The previous node in the path. + */ + init { + if (snake) { + this.prev = prev + } else { + this.prev = prev?.previousSnake() + } + isSnake = snake + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/AbstractDelta.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/AbstractDelta.kt new file mode 100644 index 0000000..c1d92db --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/AbstractDelta.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2018 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Abstract delta between a source and a target. + * @author Tobias Warneke (t.warneke@gmx.net) + */ +abstract class AbstractDelta(val type: DeltaType, val source: Chunk, val target: Chunk) { + + /** + * Verify the chunk of this delta, to fit the target. + * @param target + * @throws PatchFailedException + */ +// @Throws(PatchFailedException::class) + protected fun verifyChunkToFitTarget(target: List): VerifyChunk { + return source.verifyChunk(target) + } + +// @Throws(PatchFailedException::class) + fun verifyAntApplyTo(target: MutableList): VerifyChunk { + val verify = verifyChunkToFitTarget(target) + if (verify == VerifyChunk.OK) { + applyTo(target) + } + return verify + } + +// @Throws(PatchFailedException::class) + protected abstract fun applyTo(target: MutableList) + abstract fun restore(target: MutableList) + + /** + * Create a new delta of the actual instance with customized chunk data. + */ + abstract fun withChunks(original: Chunk, revised: Chunk): AbstractDelta + override fun hashCode(): Int { + return Triple(this.source, this.target, this.type).hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null) { + return false + } + if (this::class != other::class) { + return false + } + val other = other as AbstractDelta<*> + if (source != other.source) { + return false + } + return if (target != other.target) { + false + } else type == other.type + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/ChangeDelta.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/ChangeDelta.kt new file mode 100644 index 0000000..fb925ef --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/ChangeDelta.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Describes the change-delta between original and revised texts. + * + * @author [Dmitry Naumenko](dm.naumenko@gmail.com) + * @param The type of the compared elements in the data 'lines'. + */ +class ChangeDelta(source: Chunk, target: Chunk) : AbstractDelta(DeltaType.CHANGE, source, target) { +// @Throws(PatchFailedException::class) + override fun applyTo(target: MutableList) { + val position = source.position + val size = source.size() + for (i in 0 until size) { + target.removeAt(position) + } + var i = 0 + for (line in this.target.lines) { + target.add(position + i, line) + i++ + } + } + + override fun restore(target: MutableList) { + val position = this.target.position + val size = this.target.size() + for (i in 0 until size) { + target.removeAt(position) + } + var i = 0 + for (line in source.lines) { + target.add(position + i, line) + i++ + } + } + + override fun toString(): String { + return ("[ChangeDelta, position: " + source.position + ", lines: " + + source.lines + " to " + target.lines + "]") + } + + override fun withChunks(original: Chunk, revised: Chunk): AbstractDelta { + return ChangeDelta(original, revised) + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/Chunk.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/Chunk.kt new file mode 100644 index 0000000..6ff21b7 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/Chunk.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Holds the information about the part of text involved in the diff process + * + * + * + * Text is represented as `Object[]` because the diff engine is + * capable of handling more than plain ascci. In fact, arrays or lists of any + * type that implements [hashCode()][java.lang.Object.hashCode] and + * [equals()][java.lang.Object.equals] correctly can be subject to + * differencing using this library. + * + * + * @author [](dm.naumenko@gmail.com>Dmitry Naumenko +@param The type of the compared elements in the 'lines'. +) */ +class Chunk { + + /** + * @return the start position of chunk in the text + */ + val position: Int + + /** + * @return the affected lines + */ + var lines: List + + /** + * @return the positions of changed lines of chunk in the text + */ + val changePosition: List? + /** + * Creates a chunk and saves a copy of affected lines + * + * @param position the start position + * @param lines the affected lines + * @param changePosition the positions of changed lines + */ + /** + * Creates a chunk and saves a copy of affected lines + * + * @param position the start position + * @param lines the affected lines + */ +// @JvmOverloads + constructor(position: Int, lines: List, changePosition: List? = null) { + this.position = position + this.lines = ArrayList(lines) + this.changePosition = if (changePosition != null) ArrayList(changePosition) else null + } + /** + * Creates a chunk and saves a copy of affected lines + * + * @param position the start position + * @param lines the affected lines + * @param changePosition the positions of changed lines + */ + /** + * Creates a chunk and saves a copy of affected lines + * + * @param position the start position + * @param lines the affected lines + */ +// @JvmOverloads + constructor(position: Int, lines: Array, changePosition: List? = null) { + this.position = position + this.lines = lines.toList() + this.changePosition = if (changePosition != null) ArrayList(changePosition) else null + } + + /** + * Verifies that this chunk's saved text matches the corresponding text in + * the given sequence. + * + * @param target the sequence to verify against. + * @throws dev.gitlive.difflib.patch.PatchFailedException + */ +// @Throws(PatchFailedException::class) + fun verifyChunk(target: List): VerifyChunk { + if (position > target.size || last() > target.size) { + return VerifyChunk.POSITION_OUT_OF_TARGET + } + for (i in 0 until size()) { + if (target[position + i] != lines[i]) { + return VerifyChunk.CONTENT_DOES_NOT_MATCH_TARGET + } + } + return VerifyChunk.OK + } + + fun size(): Int { + return lines.size + } + + /** + * Returns the index of the last line of the chunk. + */ + fun last(): Int { + return position + size() - 1 + } + + override fun hashCode(): Int { + return Triple(lines, position, size()).hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null) { + return false + } + if (this::class != other::class) { + return false + } + val other = other as Chunk<*>? + if (lines != other!!.lines) { + return false + } + return position == other.position + } + + override fun toString(): String { + return "[position: " + position + ", size: " + size() + ", lines: " + lines + "]" + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/ConflictOutput.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/ConflictOutput.kt new file mode 100644 index 0000000..c5d89ed --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/ConflictOutput.kt @@ -0,0 +1,29 @@ +/*- + * #%L + * java-diff-utils + * %% + * Copyright (C) 2009 - 2017 java-diff-utils + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * #L% + */ +package dev.gitlive.difflib.patch + +/** + * + * @author tw + */ +interface ConflictOutput { +// @Throws(PatchFailedException::class) + fun processConflict(verifyChunk: VerifyChunk?, delta: AbstractDelta, result: MutableList) +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeleteDelta.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeleteDelta.kt new file mode 100644 index 0000000..c8b4c83 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeleteDelta.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Describes the delete-delta between original and revised texts. + * + * @author [Dmitry Naumenko](dm.naumenko@gmail.com) + * @param The type of the compared elements in the 'lines'. + */ +class DeleteDelta +/** + * Creates a change delta with the two given chunks. + * + * @param original The original chunk. Must not be `null`. + * @param revised The original chunk. Must not be `null`. + */ + (original: Chunk, revised: Chunk) : AbstractDelta(DeltaType.DELETE, original, revised) { +// @Throws(PatchFailedException::class) + protected override fun applyTo(target: MutableList) { + val position = source.position + val size = source.size() + for (i in 0 until size) { + target.removeAt(position) + } + } + + override fun restore(target: MutableList) { + val position = this.target.position + val lines: List = source.lines + for (i in lines.indices) { + target.add(position + i, lines[i]) + } + } + + override fun toString(): String { + return ("[DeleteDelta, position: " + source.position + ", lines: " + + source.lines + "]") + } + + override fun withChunks(original: Chunk, revised: Chunk): AbstractDelta { + return DeleteDelta(original, revised) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeltaType.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeltaType.kt new file mode 100644 index 0000000..edf9413 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DeltaType.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Specifies the type of the delta. There are three types of modifications from + * the original to get the revised text. + * + * CHANGE: a block of data of the original is replaced by another block of data. + * DELETE: a block of data of the original is removed + * INSERT: at a position of the original a block of data is inserted + * + * to be complete there is also + * + * EQUAL: a block of data of original and the revised text is equal + * + * which is no change at all. + * + */ +enum class DeltaType { + /** + * A change in the original. + */ + CHANGE, + + /** + * A delete from the original. + */ + DELETE, + + /** + * An insert into the original. + */ + INSERT, + + /** + * An do nothing. + */ + EQUAL +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/DiffException.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DiffException.kt new file mode 100644 index 0000000..ab5a677 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/DiffException.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Base class for all exceptions emanating from this package. + * + * @author [Juanco Anez](mailto:juanco@suigeneris.org) + */ +open class DiffException : Exception { + constructor() {} + constructor(msg: String?) : super(msg) {} + + companion object { + private const val serialVersionUID = 1L + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/EqualDelta.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/EqualDelta.kt new file mode 100644 index 0000000..152843f --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/EqualDelta.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2020 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * This delta contains equal lines of data. Therefore nothing is to do in applyTo and restore. + * @author tobens + */ +class EqualDelta(source: Chunk, target: Chunk) : AbstractDelta(DeltaType.EQUAL, source, target) { +// @Throws(PatchFailedException::class) + override fun applyTo(target: MutableList) { + } + + override fun restore(target: MutableList) {} + override fun toString(): String { + return ("[EqualDelta, position: " + source.position + ", lines: " + + source.lines + "]") + } + + override fun withChunks(original: Chunk, revised: Chunk): AbstractDelta { + return EqualDelta(original, revised) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/InsertDelta.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/InsertDelta.kt new file mode 100644 index 0000000..1455d7e --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/InsertDelta.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Describes the add-delta between original and revised texts. + * + * @author [Dmitry Naumenko](dm.naumenko@gmail.com) + * @param The type of the compared elements in the 'lines'. + */ +class InsertDelta +/** + * Creates an insert delta with the two given chunks. + * + * @param original The original chunk. Must not be `null`. + * @param revised The original chunk. Must not be `null`. + */ + (original: Chunk, revised: Chunk) : AbstractDelta(DeltaType.INSERT, original, revised) { +// @Throws(PatchFailedException::class) + protected override fun applyTo(target: MutableList) { + val position = source.position + val lines: List = this.target.lines + for (i in lines.indices) { + target.add(position + i, lines[i]) + } + } + + override fun restore(target: MutableList) { + val position = this.target.position + val size = this.target.size() + for (i in 0 until size) { + target.removeAt(position) + } + } + + override fun toString(): String { + return ("[InsertDelta, position: " + source.position + + ", lines: " + target.lines + "]") + } + + override fun withChunks(original: Chunk, revised: Chunk): AbstractDelta { + return InsertDelta(original, revised) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/Patch.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/Patch.kt new file mode 100644 index 0000000..0b2da4f --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/Patch.kt @@ -0,0 +1,198 @@ +/*- + * #%L + * java-diff-utils + * %% + * Copyright (C) 2009 - 2017 java-diff-utils + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * #L% + */ +package dev.gitlive.difflib.patch + +import dev.gitlive.difflib.algorithm.Change +import kotlin.jvm.JvmOverloads + +/** + * Describes the patch holding all deltas between the original and revised + * texts. + * + * @author [Dmitry Naumenko](dm.naumenko@gmail.com) + * @param The type of the compared elements in the 'lines'. + */ +class Patch @JvmOverloads constructor(estimatedPatchSize: Int = 10) { + private val deltas: MutableList> + + /** + * Apply this patch to the given target + * + * @return the patched text + * @throws PatchFailedException if can't apply patch + */ +// @Throws(PatchFailedException::class) + fun applyTo(target: List): List { + val result: MutableList = ArrayList(target) + val it = getDeltas().listIterator(deltas.size) + while (it.hasPrevious()) { + val delta = it.previous() + val valid = delta.verifyAntApplyTo(result) + if (valid != VerifyChunk.OK) { + conflictOutput.processConflict(valid, delta, result) + } + } + return result + } + + /** + * Standard Patch behaviour to throw an exception for pathching conflicts. + */ + val CONFLICT_PRODUCES_EXCEPTION = + object : ConflictOutput { + override fun processConflict(verifyChunk: VerifyChunk?, delta: AbstractDelta, result: MutableList) { + throw PatchFailedException( + "could not apply patch due to $verifyChunk" + ) + } + } + private var conflictOutput = CONFLICT_PRODUCES_EXCEPTION + + /** + * Alter normal conflict output behaviour to e.g. inclide some conflict + * statements in the result, like git does it. + */ + fun withConflictOutput(conflictOutput: ConflictOutput): Patch<*> { + this.conflictOutput = conflictOutput + return this + } + + /** + * Restore the text to original. Opposite to applyTo() method. + * + * @param target the given target + * @return the restored text + */ + fun restore(target: List): List { + val result: MutableList = ArrayList(target) + val it = getDeltas().listIterator(deltas.size) + while (it.hasPrevious()) { + val delta = it.previous() + delta.restore(result) + } + return result + } + + /** + * Add the given delta to this patch + * + * @param delta the given delta + */ + fun addDelta(delta: AbstractDelta) { + deltas.add(delta) + } + + /** + * Get the list of computed deltas + * + * @return the deltas + */ + fun getDeltas(): MutableList> { + deltas.sortBy { d -> d.source.position } + return deltas + } + + override fun toString(): String { + return "Patch{deltas=$deltas}" + } + + companion object { + /** + * Git like merge conflict output. + */ + @kotlin.jvm.JvmField + val CONFLICT_PRODUCES_MERGE_CONFLICT = + object : ConflictOutput { + override fun processConflict(verifyChunk: VerifyChunk?, delta: AbstractDelta, result: MutableList) { + if (result.size > delta.source.position) { + val orgData: MutableList = ArrayList() + for (i in 0 until delta.source.size()) { + orgData.add(result[delta.source.position]) + result.removeAt(delta.source.position) + } + orgData.add(0, "<<<<<< HEAD") + orgData.add("======") + orgData.addAll(delta.source.lines) + orgData.add(">>>>>>> PATCH") + result.addAll(delta.source.position, orgData) + } else { + throw UnsupportedOperationException("Not supported yet.") //To change body of generated methods, choose Tools | Templates. + } + } + } + + fun generate(original: List, revised: List, changes: List): Patch { + return generate(original, revised, changes, false) + } + + private fun buildChunk(start: Int, end: Int, data: List): Chunk { + return Chunk(start, ArrayList(data.subList(start, end))) + } + + fun generate( + original: List, + revised: List, + _changes: List, + includeEquals: Boolean + ): Patch { + val patch = Patch(_changes.size) + var startOriginal = 0 + var startRevised = 0 + var changes = _changes + if (includeEquals) { + changes = ArrayList(_changes) + changes.sortBy { d -> d.startOriginal } + } + for (change in changes) { + if (includeEquals && startOriginal < change.startOriginal) { + patch.addDelta( + EqualDelta( + buildChunk(startOriginal, change.startOriginal, original), + buildChunk(startRevised, change.startRevised, revised) + ) + ) + } + val orgChunk = buildChunk(change.startOriginal, change.endOriginal, original) + val revChunk = buildChunk(change.startRevised, change.endRevised, revised) + when (change.deltaType) { + DeltaType.DELETE -> patch.addDelta(DeleteDelta(orgChunk, revChunk)) + DeltaType.INSERT -> patch.addDelta(InsertDelta(orgChunk, revChunk)) + DeltaType.CHANGE -> patch.addDelta(ChangeDelta(orgChunk, revChunk)) + else -> {} + } + startOriginal = change.endOriginal + startRevised = change.endRevised + } + if (includeEquals && startOriginal < original.size) { + patch.addDelta( + EqualDelta( + buildChunk(startOriginal, original.size, original), + buildChunk(startRevised, revised.size, revised) + ) + ) + } + return patch + } + } + + init { + deltas = ArrayList(estimatedPatchSize) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/PatchFailedException.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/PatchFailedException.kt new file mode 100644 index 0000000..83c99ed --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/PatchFailedException.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * Thrown whenever a delta cannot be applied as a patch to a given text. + * + * @author [Juanco Anez](mailto:juanco@suigeneris.org) + */ +class PatchFailedException : DiffException { + constructor() {} + constructor(msg: String?) : super(msg) {} + + companion object { + private const val serialVersionUID = 1L + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/patch/VerifyChunk.kt b/src/commonMain/kotlin/dev/gitlive/difflib/patch/VerifyChunk.kt new file mode 100644 index 0000000..72b1d00 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/patch/VerifyChunk.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.patch + +/** + * + * @author tw + */ +enum class VerifyChunk { + OK, POSITION_OUT_OF_TARGET, CONTENT_DOES_NOT_MATCH_TARGET +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRow.kt b/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRow.kt new file mode 100644 index 0000000..734a973 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRow.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.text + +/** + * Describes the diff row in form [tag, oldLine, newLine) for showing the difference between two texts + * + * @author [Dmitry Naumenko](dm.naumenko@gmail.com) + */ +class DiffRow( + /** + * @param tag the tag to set + */ + var tag: Tag, + /** + * @return the oldLine + */ + val oldLine: String, + /** + * @return the newLine + */ + val newLine: String +) { + /** + * @return the tag + */ + + enum class Tag { + INSERT, DELETE, CHANGE, EQUAL + } + + override fun hashCode(): Int { + return Triple(newLine, oldLine, tag).hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null) { + return false + } + if (this::class != other::class) { + return false + } + val other = other as DiffRow + if (newLine != other.newLine) { + return false + } + if (oldLine != other.oldLine) { + return false + } + if (tag != other.tag) { + return false + } + return true + } + + override fun toString(): String { + return "[" + tag + "," + oldLine + "," + newLine + "]" + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRowGenerator.kt b/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRowGenerator.kt new file mode 100644 index 0000000..dc98666 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/text/DiffRowGenerator.kt @@ -0,0 +1,690 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.text + +import dev.gitlive.difflib.BiFunction +import dev.gitlive.difflib.BiPredicate +import dev.gitlive.difflib.DiffUtils +import dev.gitlive.difflib.Function +import dev.gitlive.difflib.patch.* +import kotlin.math.max + +/** + * This class for generating DiffRows for side-by-sidy view. You can customize + * the way of generating. For example, show inline diffs on not, ignoring white + * spaces or/and blank lines and so on. All parameters for generating are + * optional. If you do not specify them, the class will use the default values. + * + * These values are: showInlineDiffs = false; ignoreWhiteSpaces = true; + * ignoreBlankLines = true; ... + * + * For instantiating the DiffRowGenerator you should use the its builder. Like + * in example ` + * DiffRowGenerator generator = new DiffRowGenerator.Builder().showInlineDiffs(true). + * ignoreWhiteSpaces(true).columnWidth(100).build(); +` * + */ +class DiffRowGenerator private constructor(builder: Builder) { + private val columnWidth: Int + private var equalizer: BiPredicate? = null + private val ignoreWhiteSpaces: Boolean + private val inlineDiffSplitter: Function> + private val mergeOriginalRevised: Boolean + private val newTag: BiFunction + private val oldTag: BiFunction + private val reportLinesUnchanged: Boolean + private val lineNormalizer: Function + private val processDiffs: Function? + private val showInlineDiffs: Boolean + private val replaceOriginalLinefeedInChangesWithSpaces: Boolean + private val decompressDeltas: Boolean + + /** + * Get the DiffRows describing the difference between original and revised + * texts using the given patch. Useful for displaying side-by-side diff. + * + * @param original the original text + * @param revised the revised text + * @return the DiffRows between original and revised texts + */ + fun generateDiffRows(original: List, revised: List): List { + return generateDiffRows(original, DiffUtils.diff(original, revised, equalizer)) + } + + /** + * Generates the DiffRows describing the difference between original and + * revised texts using the given patch. Useful for displaying side-by-side + * diff. + * + * @param original the original text + * @param patch the given patch + * @return the DiffRows between original and revised texts + */ + fun generateDiffRows(original: List, patch: Patch): List { + val diffRows: MutableList = ArrayList() + var endPos = 0 + val deltaList: List> = patch.getDeltas() + if (decompressDeltas) { + for (originalDelta in deltaList) { + for (delta in decompressDeltas(originalDelta)) { + endPos = transformDeltaIntoDiffRow(original, endPos, diffRows, delta) + } + } + } else { + for (delta in deltaList) { + endPos = transformDeltaIntoDiffRow(original, endPos, diffRows, delta) + } + } + + // Copy the final matching chunk if any. + for (line in original.subList(endPos, original.size)) { + diffRows.add(buildDiffRow(DiffRow.Tag.EQUAL, line, line)) + } + return diffRows + } + + /** + * Transforms one patch delta into a DiffRow object. + */ + private fun transformDeltaIntoDiffRow( + original: List, + endPos: Int, + diffRows: MutableList, + delta: AbstractDelta + ): Int { + val orig: Chunk = delta.source + val rev: Chunk = delta.target + for (line in original.subList(endPos, orig.position)) { + diffRows.add(buildDiffRow(DiffRow.Tag.EQUAL, line, line)) + } + when (delta.type) { + DeltaType.INSERT -> for (line in rev.lines) { + diffRows.add(buildDiffRow(DiffRow.Tag.INSERT, "", line)) + } + DeltaType.DELETE -> for (line in orig.lines) { + diffRows.add(buildDiffRow(DiffRow.Tag.DELETE, line, "")) + } + else -> if (showInlineDiffs) { + diffRows.addAll(generateInlineDiffs(delta)) + } else { + var j = 0 + while (j < max(orig.size(), rev.size())) { + diffRows.add( + buildDiffRow( + DiffRow.Tag.CHANGE, + if (orig.lines.size > j) orig.lines[j] else "", + if (rev.lines.size > j) rev.lines[j] else "" + ) + ) + j++ + } + } + } + return orig.last() + 1 + } + + /** + * Decompresses ChangeDeltas with different source and target size to a + * ChangeDelta with same size and a following InsertDelta or DeleteDelta. + * With this problems of building DiffRows getting smaller. + * + * @param delta + */ + private fun decompressDeltas(delta: AbstractDelta): List> { + if (delta.type == DeltaType.CHANGE && delta.source.size() != delta.target.size()) { + val deltas: MutableList> = ArrayList() + //System.out.println("decompress this " + delta); + val minSize = delta.source.size().coerceAtMost(delta.target.size()) + val orig: Chunk = delta.source + val rev: Chunk = delta.target + deltas.add( + ChangeDelta( + Chunk(orig.position, orig.lines.subList(0, minSize)), + Chunk(rev.position, rev.lines.subList(0, minSize)) + ) + ) + if (orig.lines.size < rev.lines.size) { + deltas.add( + InsertDelta( + Chunk(orig.position + minSize, emptyList()), + Chunk(rev.position + minSize, rev.lines.subList(minSize, rev.lines.size)) + ) + ) + } else { + deltas.add( + DeleteDelta( + Chunk(orig.position + minSize, orig.lines.subList(minSize, orig.lines.size)), + Chunk(rev.position + minSize, emptyList()) + ) + ) + } + return deltas + } + return listOf(delta) + } + + private fun buildDiffRow(type: DiffRow.Tag, orgline: String, newline: String): DiffRow { + return if (reportLinesUnchanged) { + DiffRow(type, orgline, newline) + } else { + var wrapOrg = preprocessLine(orgline) + if (DiffRow.Tag.DELETE == type) { + if (mergeOriginalRevised || showInlineDiffs) { + wrapOrg = oldTag(type, true) + wrapOrg + oldTag(type, false) + } + } + var wrapNew = preprocessLine(newline) + if (DiffRow.Tag.INSERT == type) { + if (mergeOriginalRevised) { + wrapOrg = newTag(type, true) + wrapNew + newTag(type, false) + } else if (showInlineDiffs) { + wrapNew = newTag(type, true) + wrapNew + newTag(type, false) + } + } + DiffRow(type, wrapOrg, wrapNew) + } + } + + private fun buildDiffRowWithoutNormalizing(type: DiffRow.Tag, orgline: String, newline: String): DiffRow { + return DiffRow( + type, + StringUtils.wrapText(orgline, columnWidth), + StringUtils.wrapText(newline, columnWidth) + ) + } + + fun normalizeLines(list: List): List { + return if (reportLinesUnchanged) list else list.asSequence() + .map { t: String -> lineNormalizer(t) } + .toList() + } + + /** + * Add the inline diffs for given delta + * + * @param delta the given delta + */ + private fun generateInlineDiffs(delta: AbstractDelta): List { + val orig = normalizeLines(delta.source.lines) + val rev = normalizeLines(delta.target.lines) + val origList: MutableList + val revList: MutableList + val joinedOrig = orig.joinToString("\n") + val joinedRev = rev.joinToString("\n") + origList = inlineDiffSplitter(joinedOrig) + revList = inlineDiffSplitter(joinedRev) + val inlineDeltas: MutableList> = DiffUtils.diff(origList, revList, equalizer).getDeltas() + inlineDeltas.reverse() + for (inlineDelta in inlineDeltas) { + val inlineOrig: Chunk = inlineDelta.source + val inlineRev: Chunk = inlineDelta.target + if (inlineDelta.type == DeltaType.DELETE) { + wrapInTag( + origList, + inlineOrig.position, + inlineOrig + .position + + inlineOrig.size(), + DiffRow.Tag.DELETE, + oldTag, + processDiffs, + replaceOriginalLinefeedInChangesWithSpaces && mergeOriginalRevised + ) + } else if (inlineDelta.type == DeltaType.INSERT) { + if (mergeOriginalRevised) { + origList.addAll( + inlineOrig.position, + revList.subList( + inlineRev.position, + inlineRev.position + inlineRev.size() + ) + ) + wrapInTag( + origList, inlineOrig.position, + inlineOrig.position + inlineRev.size(), + DiffRow.Tag.INSERT, newTag, processDiffs, false + ) + } else { + wrapInTag( + revList, inlineRev.position, + inlineRev.position + inlineRev.size(), + DiffRow.Tag.INSERT, newTag, processDiffs, false + ) + } + } else if (inlineDelta.type == DeltaType.CHANGE) { + if (mergeOriginalRevised) { + origList.addAll( + inlineOrig.position + inlineOrig.size(), + revList.subList( + inlineRev.position, + inlineRev.position + inlineRev.size() + ) + ) + wrapInTag( + origList, inlineOrig.position + inlineOrig.size(), + inlineOrig.position + inlineOrig.size() + inlineRev.size(), + DiffRow.Tag.CHANGE, newTag, processDiffs, false + ) + } else { + wrapInTag( + revList, inlineRev.position, + inlineRev.position + inlineRev.size(), + DiffRow.Tag.CHANGE, newTag, processDiffs, false + ) + } + wrapInTag( + origList, + inlineOrig.position, + inlineOrig.position + inlineOrig.size(), + DiffRow.Tag.CHANGE, + oldTag, + processDiffs, + replaceOriginalLinefeedInChangesWithSpaces && mergeOriginalRevised + ) + } + } + val origResult = StringBuilder() + val revResult = StringBuilder() + for (character in origList) { + origResult.append(character) + } + for (character in revList) { + revResult.append(character) + } + val original = origResult.toString().trimEnd('\n').lines() + val revised = revResult.toString().trimEnd('\n').lines() + val diffRows: MutableList = ArrayList() + for (j in 0 until max(original.size, revised.size)) { + diffRows.add( + buildDiffRowWithoutNormalizing( + DiffRow.Tag.CHANGE, + if (original.size > j) original[j] else "", + if (revised.size > j) revised[j] else "" + ) + ) + } + return diffRows + } + + private fun preprocessLine(line: String): String { + return if (columnWidth == 0) { + lineNormalizer(line) + } else { + StringUtils.wrapText(lineNormalizer(line), columnWidth) + } + } + + /** + * This class used for building the DiffRowGenerator. + * + * @author dmitry + */ + class Builder { + var showInlineDiffs = false + var ignoreWhiteSpaces = false + var decompressDeltas = true + var oldTag = + { _: DiffRow.Tag, f: Boolean -> if (f) "" else "" } + var newTag = + { _: DiffRow.Tag, f: Boolean -> if (f) "" else "" } + var columnWidth = 0 + var mergeOriginalRevised = false + var reportLinesUnchanged = false + var inlineDiffSplitter = SPLITTER_BY_CHARACTER + var lineNormalizer = LINE_NORMALIZER_FOR_HTML + var processDiffs: Function? = null + var equalizer: BiPredicate? = null + var replaceOriginalLinefeedInChangesWithSpaces = false + + /** + * Show inline diffs in generating diff rows or not. + * + * @param val the value to set. Default: false. + * @return builder with configured showInlineDiff parameter + */ + fun showInlineDiffs(`val`: Boolean): Builder { + showInlineDiffs = `val` + return this + } + + /** + * Ignore white spaces in generating diff rows or not. + * + * @param val the value to set. Default: true. + * @return builder with configured ignoreWhiteSpaces parameter + */ + fun ignoreWhiteSpaces(`val`: Boolean): Builder { + ignoreWhiteSpaces = `val` + return this + } + + /** + * Give the originial old and new text lines to Diffrow without any + * additional processing and without any tags to highlight the change. + * + * @param val the value to set. Default: false. + * @return builder with configured reportLinesUnWrapped parameter + */ + fun reportLinesUnchanged(`val`: Boolean): Builder { + reportLinesUnchanged = `val` + return this + } + + /** + * Generator for Old-Text-Tags. + * + * @param generator the tag generator + * @return builder with configured ignoreBlankLines parameter + */ + fun oldTag(generator: BiFunction): Builder { + oldTag = generator + return this + } + + /** + * Generator for Old-Text-Tags. + * + * @param generator the tag generator + * @return builder with configured ignoreBlankLines parameter + */ + fun oldTag(generator: Function): Builder { + oldTag = { _: DiffRow.Tag?, f: Boolean? -> generator(f) } + return this + } + + /** + * Generator for New-Text-Tags. + * + * @param generator + * @return + */ + fun newTag(generator: BiFunction): Builder { + newTag = generator + return this + } + + /** + * Generator for New-Text-Tags. + * + * @param generator + * @return + */ + fun newTag(generator: Function): Builder { + newTag = { _: DiffRow.Tag?, f: Boolean? -> generator(f) } + return this + } + + /** + * Processor for diffed text parts. Here e.g. whitecharacters could be + * replaced by something visible. + * + * @param processDiffs + * @return + */ + fun processDiffs(processDiffs: Function?): Builder { + this.processDiffs = processDiffs + return this + } + + /** + * Set the column width of generated lines of original and revised + * texts. + * + * @param width the width to set. Making it < 0 doesn't make any + * sense. Default 80. + * @return builder with config of column width + */ + fun columnWidth(width: Int): Builder { + if (width >= 0) { + columnWidth = width + } + return this + } + + /** + * Build the DiffRowGenerator. If some parameters is not set, the + * default values are used. + * + * @return the customized DiffRowGenerator + */ + fun build(): DiffRowGenerator { + return DiffRowGenerator(this) + } + + /** + * Merge the complete result within the original text. This makes sense + * for one line display. + * + * @param mergeOriginalRevised + * @return + */ + fun mergeOriginalRevised(mergeOriginalRevised: Boolean): Builder { + this.mergeOriginalRevised = mergeOriginalRevised + return this + } + + /** + * Deltas could be in a state, that would produce some unreasonable + * results within an inline diff. So the deltas are decompressed into + * smaller parts and rebuild. But this could result in more differences. + * + * @param decompressDeltas + * @return + */ + fun decompressDeltas(decompressDeltas: Boolean): Builder { + this.decompressDeltas = decompressDeltas + return this + } + + /** + * Per default each character is separatly processed. This variant + * introduces processing by word, which does not deliver in word + * changes. Therefore the whole word will be tagged as changed: + * + *
+         * false:    (aBa : aba) --  changed: a(B)a : a(b)a
+         * true:     (aBa : aba) --  changed: (aBa) : (aba)
+        
* + */ + fun inlineDiffByWord(inlineDiffByWord: Boolean): Builder { + inlineDiffSplitter = if (inlineDiffByWord) SPLITTER_BY_WORD else SPLITTER_BY_CHARACTER + return this + } + + /** + * To provide some customized splitting a splitter can be provided. Here + * someone could think about sentence splitter, comma splitter or stuff + * like that. + * + * @param inlineDiffSplitter + * @return + */ + fun inlineDiffBySplitter(inlineDiffSplitter: Function>): Builder { + this.inlineDiffSplitter = inlineDiffSplitter + return this + } + + /** + * By default DiffRowGenerator preprocesses lines for HTML output. Tabs + * and special HTML characters like "<" are replaced with its encoded + * value. To change this you can provide a customized line normalizer + * here. + * + * @param lineNormalizer + * @return + */ + fun lineNormalizer(lineNormalizer: Function): Builder { + this.lineNormalizer = lineNormalizer + return this + } + + /** + * Provide an equalizer for diff processing. + * + * @param equalizer equalizer for diff processing. + * @return builder with configured equalizer parameter + */ + fun equalizer(equalizer: BiPredicate?): Builder { + this.equalizer = equalizer + return this + } + + /** + * Sometimes it happens that a change contains multiple lines. If there + * is no correspondence in old and new. To keep the merged line more + * readable the linefeeds could be replaced by spaces. + * + * @param replace + * @return + */ + fun replaceOriginalLinefeedInChangesWithSpaces(replace: Boolean): Builder { + replaceOriginalLinefeedInChangesWithSpaces = replace + return this + } + } + + companion object { + val DEFAULT_EQUALIZER = { obj1: String, obj2: String -> obj1 == obj2 } + val IGNORE_WHITESPACE_EQUALIZER = { original: String, revised: String -> adjustWhitespace(original) == adjustWhitespace(revised) } + val LINE_NORMALIZER_FOR_HTML = { obj: String -> StringUtils.normalize(obj) } + + /** + * Splitting lines by character to achieve char by char diff checking. + */ + val SPLITTER_BY_CHARACTER = { line: String -> + val list: MutableList = ArrayList(line.length) + for (character in line) { + list.add(character.toString()) + } + list + } + @kotlin.jvm.JvmField + val SPLIT_BY_WORD_PATTERN = Regex("\\s+|[,.\\[\\](){}/\\\\*+\\-#]") + + /** + * Splitting lines by word to achieve word by word diff checking. + */ + val SPLITTER_BY_WORD = { line: String -> + splitStringPreserveDelimiter( + line, + SPLIT_BY_WORD_PATTERN + ) + } + val WHITESPACE_PATTERN = Regex("\\s+") + @kotlin.jvm.JvmStatic + fun create(): Builder { + return Builder() + } + + private fun adjustWhitespace(raw: String): String { + return WHITESPACE_PATTERN.replace(raw.trim { it <= ' ' }, " ") + } + + @kotlin.jvm.JvmStatic + fun splitStringPreserveDelimiter(str: String?, SPLIT_PATTERN: Regex): MutableList { + val list = mutableListOf() + if (str != null) { + val results = SPLIT_PATTERN.findAll(str) + var pos = 0 + for (result in results) { + if (pos < result.range.first) { + list.add(str.substring(pos, result.range.first)) + } + list.add(result.value) + pos = result.range.last + 1 + } + if (pos < str.length) { + list.add(str.substring(pos)) + } + } + return list + } + + /** + * Wrap the elements in the sequence with the given tag + * + * @param startPosition the position from which tag should start. The + * counting start from a zero. + * @param endPosition the position before which tag should should be closed. + * @param tagGenerator the tag generator + */ + fun wrapInTag( + sequence: MutableList, startPosition: Int, + endPosition: Int, tag: DiffRow.Tag, tagGenerator: BiFunction, + processDiffs: Function?, replaceLinefeedWithSpace: Boolean + ) { + var endPos = endPosition + while (endPos >= startPosition) { + + //search position for end tag + while (endPos > startPosition) { + if ("\n" != sequence[endPos - 1]) { + break + } else if (replaceLinefeedWithSpace) { + sequence[endPos - 1] = " " + break + } + endPos-- + } + if (endPos == startPosition) { + break + } + sequence.add(endPos, tagGenerator(tag, false)) + if (processDiffs != null) { + sequence[endPos - 1] = processDiffs(sequence[endPos - 1]) + } + endPos-- + + //search position for end tag + while (endPos > startPosition) { + if ("\n" == sequence[endPos - 1]) { + if (replaceLinefeedWithSpace) { + sequence[endPos - 1] = " " + } else { + break + } + } + if (processDiffs != null) { + sequence[endPos - 1] = processDiffs(sequence[endPos - 1]) + } + endPos-- + } + sequence.add(endPos, tagGenerator(tag, true)) + endPos-- + } + } + } + + init { + showInlineDiffs = builder.showInlineDiffs + ignoreWhiteSpaces = builder.ignoreWhiteSpaces + oldTag = builder.oldTag + newTag = builder.newTag + columnWidth = builder.columnWidth + mergeOriginalRevised = builder.mergeOriginalRevised + inlineDiffSplitter = builder.inlineDiffSplitter + decompressDeltas = builder.decompressDeltas + equalizer = if (builder.equalizer != null) { + builder.equalizer + } else { + if (ignoreWhiteSpaces) IGNORE_WHITESPACE_EQUALIZER else DEFAULT_EQUALIZER + } + reportLinesUnchanged = builder.reportLinesUnchanged + lineNormalizer = builder.lineNormalizer + processDiffs = builder.processDiffs + replaceOriginalLinefeedInChangesWithSpaces = builder.replaceOriginalLinefeedInChangesWithSpaces + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/text/StringUtils.kt b/src/commonMain/kotlin/dev/gitlive/difflib/text/StringUtils.kt new file mode 100644 index 0000000..06a8127 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/text/StringUtils.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2009-2017 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.text + +internal object StringUtils { + /** + * Replaces all opening and closing tags with `<` or `>`. + * + * @param str + * @return str with some HTML meta characters escaped. + */ + @kotlin.jvm.JvmStatic + fun htmlEntites(str: String): String { + return str.replace("<", "<").replace(">", ">") + } + + @kotlin.jvm.JvmStatic + fun normalize(str: String): String { + return htmlEntites(str).replace("\t", " ") + } + + fun wrapText(list: List, columnWidth: Int): List { + return list.asSequence() + .map { line: String -> wrapText(line, columnWidth) } + .toList() + } + + /** + * Wrap the text with the given column width + * + * @param line the text + * @param columnWidth the given column + * @return the wrapped text + */ + @kotlin.jvm.JvmStatic + fun wrapText(line: String, columnWidth: Int): String { + require(columnWidth >= 0) { "columnWidth may not be less 0" } + if (columnWidth == 0) { + return line + } + val length = line.length + val delimiter = "
".length + var widthIndex = columnWidth + val b = StringBuilder(line) + var count = 0 + while (length > widthIndex) { + var breakPoint = widthIndex + delimiter * count + if (b[breakPoint - 1].isHighSurrogate() && + b[breakPoint].isLowSurrogate() + ) { + // Shift a breakpoint that would split a supplemental code-point. + breakPoint += 1 + if (breakPoint == b.length) { + // Break before instead of after if this is the last code-point. + breakPoint -= 2 + } + } + b.insert(breakPoint, "
") + widthIndex += columnWidth + count++ + } + return b.toString() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiff.kt b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiff.kt new file mode 100644 index 0000000..c2340df --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiff.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2019 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.unifieddiff + +import dev.gitlive.difflib.Predicate + +/** + * + * @author Tobias Warneke (t.warneke@gmx.net) + */ +class UnifiedDiff { + var header: String? = null + var tail: String? = null + private set + private val files: MutableList = ArrayList() + fun addFile(file: UnifiedDiffFile) { + files.add(file) + } + + fun getFiles(): List { + return files + } + + fun setTailTxt(tailTxt: String?) { + tail = tailTxt + } + +// @Throws(PatchFailedException::class) + fun applyPatchTo(findFile: Predicate, originalLines: List): List { + val file = files.asSequence() + .filter { diff: UnifiedDiffFile -> findFile(diff.fromFile!!) } + .firstOrNull() + return file?.patch?.applyTo(originalLines) ?: originalLines + } + + companion object { + @kotlin.jvm.JvmStatic + fun from(header: String?, tail: String?, vararg files: UnifiedDiffFile): UnifiedDiff { + val diff = UnifiedDiff() + diff.header = header + diff.setTailTxt(tail) + for (file in files) { + diff.addFile(file) + } + return diff + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffFile.kt b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffFile.kt new file mode 100644 index 0000000..2b0af34 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffFile.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2019 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.unifieddiff + +import dev.gitlive.difflib.patch.Patch + +/** + * Data structure for one patched file from a unified diff file. + * + * @author Tobias Warneke (t.warneke@gmx.net) + */ +class UnifiedDiffFile { + var diffCommand: String? = null + var fromFile: String? = null + var fromTimestamp: String? = null + var toFile: String? = null + var renameFrom: String? = null + var renameTo: String? = null + var toTimestamp: String? = null + var index: String? = null + var newFileMode: String? = null + var deletedFileMode: String? = null + var patch = Patch() + private set + var additions: Int? = null + var deletions: Int? = null + var isNoNewLineAtTheEndOfTheFile = false + var similarityIndex: Int? = null + + companion object { + @kotlin.jvm.JvmStatic + fun from(fromFile: String?, toFile: String?, patch: Patch): UnifiedDiffFile { + val file = UnifiedDiffFile() + file.fromFile = fromFile + file.toFile = toFile + file.patch = patch + return file + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffParserException.kt b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffParserException.kt new file mode 100644 index 0000000..c619e4f --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffParserException.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2019 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.gitlive.difflib.unifieddiff + +/** + * + * @author Tobias Warneke (t.warneke@gmx.net) + */ +class UnifiedDiffParserException : RuntimeException { + constructor() {} + constructor(message: String?) : super(message) {} + constructor(message: String?, cause: Throwable?) : super(message, cause) {} + constructor(cause: Throwable?) : super(cause) {} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffReader.kt b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffReader.kt new file mode 100644 index 0000000..94ef600 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/UnifiedDiffReader.kt @@ -0,0 +1,448 @@ +package dev.gitlive.difflib.unifieddiff + +import dev.gitlive.difflib.* +import dev.gitlive.difflib.patch.ChangeDelta +import dev.gitlive.difflib.patch.Chunk + + +/** + * + * @author Tobias Warneke (t.warneke@gmx.net) + */ +class UnifiedDiffReader internal constructor(lineReader: LineReader) { + private val nextLine: LineReader + private val data = UnifiedDiff() + private val DIFF_COMMAND: UnifiedDiffLine = UnifiedDiffLine(true, "^diff\\s") { match: MatchResult, line: String -> + processDiff( + match, + line + ) + } + private val SIMILARITY_INDEX: UnifiedDiffLine = UnifiedDiffLine(true, "^similarity index (\\d+)%$") { match: MatchResult, line: String -> + processSimilarityIndex( + match, + line + ) + } + private val INDEX: UnifiedDiffLine = UnifiedDiffLine(true, "^index\\s[\\da-zA-Z]+\\.\\.[\\da-zA-Z]+(\\s(\\d+))?$") { match: MatchResult, line: String -> + processIndex( + match, + line + ) + } + private val FROM_FILE: UnifiedDiffLine = UnifiedDiffLine(true, "^---\\s") { match: MatchResult, line: String -> + processFromFile( + match, + line + ) + } + private val TO_FILE: UnifiedDiffLine = UnifiedDiffLine(true, "^\\+\\+\\+\\s") { match: MatchResult, line: String -> + processToFile( + match, + line + ) + } + private val RENAME_FROM: UnifiedDiffLine = UnifiedDiffLine(true, "^rename\\sfrom\\s(.+)$") { match: MatchResult, line: String -> + processRenameFrom( + match, + line + ) + } + private val RENAME_TO: UnifiedDiffLine = UnifiedDiffLine(true, "^rename\\sto\\s(.+)$") { match: MatchResult, line: String -> + processRenameTo( + match, + line + ) + } + private val NEW_FILE_MODE: UnifiedDiffLine = UnifiedDiffLine(true, "^new\\sfile\\smode\\s(\\d+)") { match: MatchResult, line: String -> + processNewFileMode( + match, + line + ) + } + private val DELETED_FILE_MODE: UnifiedDiffLine = UnifiedDiffLine(true, "^deleted\\sfile\\smode\\s(\\d+)") { match: MatchResult, line: String -> + processDeletedFileMode( + match, + line + ) + } + private val BINARY_FILE_CHANGED: UnifiedDiffLine = UnifiedDiffLine(true, "Binary files (.*) and (.*) differ") { match: MatchResult, line: String -> + processBinaryFileChange(match, line) + } + private val CHUNK: UnifiedDiffLine = UnifiedDiffLine(false, UNIFIED_DIFF_CHUNK_REGEXP) { match: MatchResult, chunkStart: String -> + processChunk( + match, + chunkStart + ) + } + private val LINE_NORMAL = UnifiedDiffLine("^\\s") { match: MatchResult, line: String -> + processNormalLine( + match, + line + ) + } + private val LINE_DEL = UnifiedDiffLine("^-") { match: MatchResult, line: String -> + processDelLine( + match, + line + ) + } + private val LINE_ADD = UnifiedDiffLine("^\\+") { match: MatchResult, line: String -> + processAddLine( + match, + line + ) + } + private var actualFile: UnifiedDiffFile? = null + + // schema = [[/^\s+/, normal], [/^diff\s/, start], [/^new file mode \d+$/, new_file], + // [/^deleted file mode \d+$/, deleted_file], [/^index\s[\da-zA-Z]+\.\.[\da-zA-Z]+(\s(\d+))?$/, index], + // [/^---\s/, from_file], [/^\+\+\+\s/, to_file], [/^@@\s+\-(\d+),?(\d+)?\s+\+(\d+),?(\d+)?\s@@/, chunk], + // [/^-/, del], [/^\+/, add], [/^\\ No newline at end of file$/, eof]]; + private suspend fun parse(): UnifiedDiff { + var line: String? = nextLine() + while (line != null) { + var headerTxt = "" + while (line != null) { + headerTxt += if (validLine( + line, DIFF_COMMAND, SIMILARITY_INDEX, INDEX, + FROM_FILE, TO_FILE, + RENAME_FROM, RENAME_TO, + NEW_FILE_MODE, DELETED_FILE_MODE, + CHUNK + ) + ) { + break + } else { + """ + $line + + """.trimIndent() + } + line = nextLine() + } + if ("" != headerTxt) { + data.header = headerTxt + } + if (line != null && !CHUNK.validLine(line)) { + initFileIfNecessary() + while (line != null && !CHUNK.validLine(line)) { + if (!processLine( + line, DIFF_COMMAND, SIMILARITY_INDEX, INDEX, + FROM_FILE, TO_FILE, + RENAME_FROM, RENAME_TO, + NEW_FILE_MODE, DELETED_FILE_MODE, + BINARY_FILE_CHANGED + ) + ) { + throw UnifiedDiffParserException("expected file start line not found") + } + line = nextLine() + } + } + if (line != null) { + processLine(line, CHUNK) + line = nextLine() + var tempNewLine: String? = null + while (line != null) { + line = checkForNoNewLineAtTheEndOfTheFile(line) + if (!processLine(line, LINE_NORMAL, LINE_ADD, LINE_DEL)) { + // To help debugging tests + line = nextLine() + if (line != null && line.isNotEmpty()) { + throw UnifiedDiffParserException("unlikely empty string found in CHUNK before: $line") + } + throw UnifiedDiffParserException("expected data line not found") + } + if (originalTxt.size == old_size && revisedTxt.size == new_size + || old_size == 0 && new_size == 0 && originalTxt.size == old_ln && revisedTxt.size == new_ln + ) { + val isNormalLine = line?.let { NORMAL_LINE_TEXT.containsMatchIn(it) } ?: false + if (!isNormalLine) { + tempNewLine = nextLine() + if (NO_NEW_LINE_TEXT != tempNewLine && actualFile!!.isNoNewLineAtTheEndOfTheFile) { + revisedTxt.add("") + new_size++ + } else if (NO_NEW_LINE_TEXT == tempNewLine && !actualFile!!.isNoNewLineAtTheEndOfTheFile) { + originalTxt.add("") + old_size++ + } + } + + finalizeChunk() + break + } + line = nextLine() + } + line = tempNewLine ?: nextLine() + line = checkForNoNewLineAtTheEndOfTheFile(line) + } + if (line == null || line.startsWith("--") && !line.startsWith("---")) { + break + } + } + + line = nextLine() + if(line != null) { + var tailTxt = "" + while (line != null) { + if (tailTxt.isNotEmpty()) { + tailTxt += "\n" + } + tailTxt += line + line = nextLine() + } + data.setTailTxt(tailTxt) + } + return data + } + + private suspend fun checkForNoNewLineAtTheEndOfTheFile(line: String?): String? { + if (NO_NEW_LINE_TEXT == line) { + actualFile!!.isNoNewLineAtTheEndOfTheFile = true + return nextLine() + } + return line + } + + private suspend fun processLine(line: String?, vararg rules: UnifiedDiffLine): Boolean { + if (line == null) { + return false + } + for (rule in rules) { + if (rule.processLine(line)) { + return true + } + } + return false + } + + private fun validLine(line: String?, vararg rules: UnifiedDiffLine): Boolean { + if (line == null) { + return false + } + for (rule in rules) { + if (rule.validLine(line)) { + return true + } + } + return false + } + + private fun initFileIfNecessary() { + check(!(originalTxt.isNotEmpty() || revisedTxt.isNotEmpty())) + actualFile = null + if (actualFile == null) { + actualFile = UnifiedDiffFile() + data.addFile(actualFile!!) + } + } + + private fun processDiff(match: MatchResult, line: String) { + val fromTo = parseFileNames(line) + actualFile!!.fromFile = fromTo[0] + actualFile!!.toFile = fromTo[1] + actualFile!!.diffCommand = line + } + + private fun processSimilarityIndex(match: MatchResult, line: String) { + actualFile!!.similarityIndex = match.groupValues[1].toInt() + } + + private val originalTxt: MutableList = mutableListOf() + private val revisedTxt: MutableList = mutableListOf() + private val addLineIdxList: MutableList = mutableListOf() + private val delLineIdxList: MutableList = mutableListOf() + private var old_ln = 0 + private var old_size = 0 + private var new_ln = 0 + private var new_size = 0 + private var delLineIdx = 0 + private var addLineIdx = 0 + private var additions = 0 + private var deletions = 0 + + private fun finalizeChunk() { + if (originalTxt.isNotEmpty() || revisedTxt.isNotEmpty()) { + actualFile!!.patch.addDelta( + ChangeDelta( + Chunk(old_ln - 1, originalTxt, delLineIdxList), + Chunk(new_ln - 1, revisedTxt, addLineIdxList) + ) + ) + old_ln = 0 + new_ln = 0 + originalTxt.clear() + revisedTxt.clear() + addLineIdxList.clear() + delLineIdxList.clear() + actualFile!!.deletions = deletions + deletions = 0 + delLineIdx = 0 + actualFile!!.additions = additions + additions = 0 + addLineIdx = 0 + } + } + + private fun processNormalLine(match: MatchResult, line: String) { + val cline = line.substring(1) + originalTxt.add(cline) + revisedTxt.add(cline) + delLineIdx++ + addLineIdx++ + } + + private fun processAddLine(match: MatchResult, line: String) { + val cline = line.substring(1) + revisedTxt.add(cline) + addLineIdx++ + additions++ + addLineIdxList.add(new_ln - 1 + addLineIdx) + } + + private fun processDelLine(match: MatchResult, line: String) { + val cline = line.substring(1) + originalTxt.add(cline) + delLineIdx++ + deletions++ + delLineIdxList.add(old_ln - 1 + delLineIdx) + } + + private fun processChunk(match: MatchResult, chunkStart: String) { + // finalizeChunk(); + old_ln = toInteger(match, 1, 1) + old_size = toInteger(match, 2, 1) + new_ln = toInteger(match, 3, 1) + new_size = toInteger(match, 4, 1) + if (old_ln == 0) { + old_ln = 1 + } + if (new_ln == 0) { + new_ln = 1 + } + } + + private fun processIndex(match: MatchResult, line: String) { + actualFile!!.index = line.substring(6) + } + + private fun processFromFile(match: MatchResult, line: String) { + actualFile!!.fromFile = extractFileName(line) + actualFile!!.fromTimestamp = extractTimestamp(line) + } + + private fun processToFile(match: MatchResult, line: String) { + actualFile!!.toFile = extractFileName(line) + actualFile!!.toTimestamp = extractTimestamp(line) + } + + private fun processRenameFrom(match: MatchResult, line: String) { + actualFile!!.renameFrom = match.groupValues[1] + } + + private fun processRenameTo(match: MatchResult, line: String) { + actualFile!!.renameTo = match.groupValues[1] + } + + private fun processNewFileMode(match: MatchResult, line: String) { + actualFile!!.newFileMode = match.groupValues[1] + } + + private fun processDeletedFileMode(match: MatchResult, line: String) { + actualFile!!.deletedFileMode = match.groupValues[1] + } + + private fun processBinaryFileChange(match: MatchResult, line: String) { + // Nothing happens yet + } + + private fun extractFileName(_line: String): String { + var line = _line + if (TIMESTAMP_REGEXP.containsMatchIn(_line)) { + line = line.substring(0, TIMESTAMP_REGEXP.find(_line)!!.range.first) + } + line = line.split("\t").toTypedArray()[0] + return line.substring(4).replaceFirst("^(a|b|old|new)(\\/)?".toRegex(), "") + .trim { it <= ' ' } + } + + private fun extractTimestamp(line: String): String? { + return if (TIMESTAMP_REGEXP.containsMatchIn(line)) { + TIMESTAMP_REGEXP.find(line)?.groupValues?.firstOrNull() + } else null + } + + internal inner class UnifiedDiffLine { + private val pattern: Regex + private val command: BiConsumer + val isStopsHeaderParsing: Boolean + + constructor(pattern: String, command: BiConsumer) : this(false, pattern, command) {} + constructor(stopsHeaderParsing: Boolean, pattern: String, command: BiConsumer) { + this.pattern = Regex(pattern) + this.command = command + isStopsHeaderParsing = stopsHeaderParsing + } + + constructor(stopsHeaderParsing: Boolean, pattern: Regex, command: BiConsumer) { + this.pattern = pattern + this.command = command + isStopsHeaderParsing = stopsHeaderParsing + } + + fun validLine(line: String): Boolean { + return pattern.containsMatchIn(line) + } + + suspend fun processLine(line: String): Boolean { + return if (pattern.containsMatchIn(line)) { + command(pattern.find(line)!!, line) + true + } else { + false + } + } + + override fun toString(): String { + return "UnifiedDiffLine{pattern=$pattern, stopsHeaderParsing=$isStopsHeaderParsing}" + } + } + + companion object { + val UNIFIED_DIFF_CHUNK_REGEXP = """^@@\s+-(?:(\d+)(?:,(\d+))?)\s+\+(?:(\d+)(?:,(\d+))?)\s+@@""".toRegex() + val TIMESTAMP_REGEXP = """(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{3,})(?: [+-]\d+)?""".toRegex() + val NORMAL_LINE_TEXT = """^\s""".toRegex() + const val NO_NEW_LINE_TEXT = """\ No newline at end of file""" + + fun parseFileNames(line: String?): Array { + val split = line!!.split(" ").toTypedArray() + return arrayOf( + split[2].replace("""^a/""".toRegex(), ""), + split[3].replace("""^b/""".toRegex(), "") + ) + } + + /** + * To parse a diff file use this method. + * + * @param stream This is the diff file data. + * @return In a UnifiedDiff structure this diff file data is returned. + * @throws IOException + * @throws UnifiedDiffParserException + */ + + internal suspend fun parseUnifiedDiff(readLine: LineReader): UnifiedDiff { + val parser = UnifiedDiffReader(readLine) + return parser.parse() + } + + private fun toInteger(match: MatchResult, group: Int, defValue: Int): Int { + return match.groups[group]?.value?.toIntOrNull() ?: defValue + } + } + + init { + nextLine = lineReader + } +} diff --git a/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/package-info.kt b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/package-info.kt new file mode 100644 index 0000000..2683f57 --- /dev/null +++ b/src/commonMain/kotlin/dev/gitlive/difflib/unifieddiff/package-info.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 java-diff-utils. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * This is the new implementation of UnifiedDiff Tools. This version is multi file aware. + * + * + * To read a unified diff file you should use [UnifiedDiffReader.parseUnifiedDiff]. + * You will get a [UnifiedDiff] that holds all informations about the + * diffs and the files. + * + * + * To process the UnifiedDiff use [UnifiedDiffWriter.write]. + */ +package dev.gitlive.difflib.unifieddiff + +import dev.gitlive.difflib.unifieddiff.UnifiedDiff \ No newline at end of file diff --git a/src/commonMain/kotlin/net.sergeych.merge3/merge3.kt b/src/commonMain/kotlin/net.sergeych.merge3/merge3.kt new file mode 100644 index 0000000..f42a07b --- /dev/null +++ b/src/commonMain/kotlin/net.sergeych.merge3/merge3.kt @@ -0,0 +1,209 @@ +package net.sergeych.merge3 + +import dev.gitlive.difflib.DiffUtils.diff +import dev.gitlive.difflib.patch.AbstractDelta +import dev.gitlive.difflib.patch.ChangeDelta +import dev.gitlive.difflib.patch.DeleteDelta +import dev.gitlive.difflib.patch.InsertDelta +import net.sergeych.mp_logger.Log +import net.sergeych.mp_logger.LogTag +import net.sergeych.mp_logger.debug +import net.sergeych.sprintf.sprintf + +/** + * Merge result. This version does not report (yet) conflicts, just merges it all. + * @param merged the best merged resut. In the case of the conflict usually has both variants concatennated in place + * @param changedAreas ranges where data were altered. could be used to highlight changes + */ +class MergeResult( + val merged: List, + val changedAreas: List>, +) + +/** + * Perform 3-way merge. See [merge3] for details. + */ +private class Merge3(val source: List, val variantA: List, val variantB: List) : + LogTag("MRG3", Log.Level.INFO) { + + private val reindex = source.indices.toMutableList() + private val changeCount = MutableList(source.size) { 0 } + + val result = source.toMutableList() + + private fun trace() { + + debug { result.indices.joinToString("") { "%3d".sprintf(it) } } + debug { result.joinToString("") { "%3s".sprintf(it.toString()) } } + debug { + changeCount.joinToString("") { + when (it) { + 1 -> " *" + 0 -> " ." + 2 -> " !" + else -> " !$it" + } + } + } + debug { reindex.joinToString("") { if (it < 0) " x" else "%3d".sprintf(it) } } + + } + + private fun findPosition(sourcePosition: Int): Int { + var position = if (sourcePosition < reindex.size) reindex[sourcePosition] else reindex.last() + 1 + // found position could be already deleted + if (position < 0) { + // we look for leftmost position in the deleted block: + debug { "@$sourcePosition is DELETED, look for the next" } + var i = sourcePosition + while (++i < reindex.size) { + position = reindex[i] + if (position >= 0) break + } + // if we hit the end, lets append to the old end, but we still don't want to loose + // changes: + if (position < 0) position = reindex.lastOrNull { it >= 0 }?.let { it + 1 } ?: 0 + } + debug { "found position: $position" } + return position + } + + private fun insertAt(sourcePosition: Int, fragment: List) { + debug { "inserting $fragment @$sourcePosition" } + // position could be inside or after the end of the source string: + val position = findPosition(sourcePosition) + result.addAll(position, fragment) + for (k in sourcePosition until reindex.size) + if (reindex[k] >= 0) + reindex[k] += fragment.size + for (k in position until position + fragment.size) + changeCount.add(k, 1) + + } + + private fun deleteAt(sourcePosition: Int, count: Int) { + debug { "deleting $count elements @$sourcePosition" } + val position = findPosition(sourcePosition) + var sp = sourcePosition + for (i in position until position + count) { + // it this position is already removed, do nothing + if (reindex[sp] < 0) continue + reindex[sp++] = -1 + result.removeAt(position) + changeCount.removeAt(position) + } + while (sp < reindex.size) { + reindex[sp].let { if (it > 0) reindex[sp] = it - count } + sp++ + } + } + + private val conflicts = mutableListOf>() + private val updates = mutableListOf>() + + private fun applyDelta(delta: AbstractDelta) { + when (delta) { + is InsertDelta -> { + insertAt(delta.source.position, delta.target.lines) + } + + is ChangeDelta -> { + deleteAt(delta.source.position, delta.source.size()) + trace() + insertAt(delta.source.position, delta.target.lines) + } + + is DeleteDelta -> { + deleteAt(delta.source.position, delta.source.size()) + } + + else -> { + throw Exception("Can't apply: $delta") + } + } + debug { "Applied: $delta, result:" } + trace() + } + + + fun perform(): MergeResult { + val dA = diff(source, variantA).getDeltas() + val dB = diff(source, variantB).getDeltas() + + debug { "dA: $dA" } + debug { "dB: $dB" } + + + trace() + + debug { "adding first set" } + for (d in dA) { + debug { "adding dA $d" } + applyDelta(d) + } + for (d in dB) { + debug { "adding dB $d" } + applyDelta(d) + } + + // detect ranges + var conflictStart = -1 + var changeStart = -1 + fun closeConflict(index: Int) { + if (conflictStart >= 0) { + conflicts.add(conflictStart until index) + conflictStart = -1 + } + } + + fun closeUpdate(index: Int) { + if (changeStart >= 0) { + updates.add(changeStart until index) + changeStart = -1 + } + } + + for ((index, count) in changeCount.withIndex()) { + when (count) { + 0 -> { + // close conflict or update if was open + closeConflict(index) + closeUpdate(index) + } + + 1 -> { + // could be end of the conflict + closeConflict(index) + // open change if not opened + if (changeStart < 0) changeStart = index + } + + 2 -> { + // could be an end of the change + closeUpdate(index) + // could be opening of the conflict + if (conflictStart < 0) conflictStart = index + } + + else -> { + trace() + throw RuntimeException("internal error: invalud change counter @$index:$count") + } + } + } + closeConflict(changeCount.size) + closeUpdate(changeCount.size) + + return MergeResult(result, updates) + } +} + +/** + * Lossless (optimistically) 3-way merge. __this version does not report conflicts!__ + * Perform lossless smart merge of the [source] updated to [a] and to [b] respectively. + * See [MergeResult] for interpreting results + */ +fun merge3(source: List, a: List, b: List, showDebug: Boolean = false): MergeResult = + Merge3(source, a, b).also { + if( showDebug ) it.logLevel = Log.Level.DEBUG + }.perform() \ No newline at end of file diff --git a/src/jvmTest/kotlin/testMerge.kt b/src/jvmTest/kotlin/testMerge.kt new file mode 100644 index 0000000..e72d706 --- /dev/null +++ b/src/jvmTest/kotlin/testMerge.kt @@ -0,0 +1,49 @@ +import net.sergeych.merge3.merge3 +import net.sergeych.mp_logger.Log +import kotlin.test.Test +import kotlin.test.assertEquals + +val List.str: String get() = joinToString("") + +class BasicTest { + @Test + fun testMergeNoConflicts() { + val src = "Hello world".toList() + val a = "Hello cruel world!".toList() + val b = "Bye world!!".toList() + + val m = merge3(src, b, a) + println(m.merged.str) +// println(m.conflicts) + println(m.changedAreas) + assertEquals("Bye cruel world!!!", m.merged.str) +// assertTrue(m.noConflicts) + } + + @Test + fun testMergeNoConflicts2() { + val src = "Hello world".toList() + val a = "Hello friend".toList() + val b = "Bye world".toList() + + val m = merge3(src, b, a) + println(m.merged.str) +// println(m.conflicts) + println(m.changedAreas) + assertEquals("Bye friend", m.merged.str) +// assertTrue(m.noConflicts) + } + @Test + fun testMergeWithConflicts() { + Log.connectConsole() + val src = "Hello world".toList() + val a = "Hello 123".toList() + val b = "Hello 456".toList() + + val m = merge3(src, b, a, false) + println(m.merged.str) +// println(m.conflicts) + println(m.changedAreas) + assertEquals("Hello 123456", m.merged.str) + } +} \ No newline at end of file