├── difference
├── .gitignore
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── andrewbailey
│ │ │ └── diff
│ │ │ ├── impl
│ │ │ ├── MyersDiffOperation.kt
│ │ │ ├── Snake.kt
│ │ │ ├── Region.kt
│ │ │ ├── Point.kt
│ │ │ ├── CircularIntArray.kt
│ │ │ ├── Extensions.kt
│ │ │ └── MyersDiffAlgorithm.kt
│ │ │ ├── Difference.kt
│ │ │ ├── DiffOperation.kt
│ │ │ ├── DiffResult.kt
│ │ │ └── DiffGenerator.kt
│ ├── jvmTest
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── andrewbailey
│ │ │ └── diff
│ │ │ └── DiffReceiverTest.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── andrewbailey
│ │ │ └── diff
│ │ │ └── DiffReceiver.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── dev
│ │ └── andrewbailey
│ │ └── diff
│ │ ├── impl
│ │ └── MyersDiffAlgorithmTest.kt
│ │ └── DiffGeneratorTest.kt
└── build.gradle
├── android-perf-test
├── .gitignore
├── signing-key.jks
├── src
│ ├── main
│ │ └── AndroidManifest.xml
│ └── androidTest
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── dev
│ │ └── andrewbailey
│ │ └── diff
│ │ └── DiffBenchmarkTest.kt
├── proguard-rules.pro
├── benchmark-proguard-rules.pro
└── build.gradle
├── settings.gradle
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── publish.gradle
├── .gitignore
├── .editorconfig
├── .github
└── workflows
│ ├── commit-verifications.yml
│ └── publish.yml
├── LICENSE
├── gradle.properties
├── gradlew.bat
├── gradlew
└── README.md
/difference/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/android-perf-test/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name='Difference'
2 | include ':difference'
3 | include ':android-perf-test'
4 |
--------------------------------------------------------------------------------
/android-perf-test/signing-key.jks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewbailey/Difference/HEAD/android-perf-test/signing-key.jks
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewbailey/Difference/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/android-perf-test/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | *.klib
11 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Tue Jan 21 21:18:25 EST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.{kt,kts}]
4 | ij_kotlin_allow_trailing_comma_on_call_site=false
5 | ij_kotlin_allow_trailing_comma=false
6 | ktlint_code_style = android_studio
7 | ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than=2
8 | ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than=3
9 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/MyersDiffOperation.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | internal sealed class MyersDiffOperation {
4 |
5 | data class Insert(val value: T) : MyersDiffOperation()
6 |
7 | data object Delete : MyersDiffOperation()
8 |
9 | data object Skip : MyersDiffOperation()
10 | }
11 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/Snake.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | internal data class Snake(
4 | val start: Point,
5 | val end: Point
6 | ) : Comparable {
7 | override fun compareTo(other: Snake): Int = if (start.x == other.start.x) {
8 | start.y - other.start.y
9 | } else {
10 | start.x - other.start.x
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/Region.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | internal data class Region(
4 | val left: Int,
5 | val top: Int,
6 | val right: Int,
7 | val bottom: Int
8 | ) {
9 | val width: Int
10 | get() = right - left
11 |
12 | val height: Int
13 | get() = bottom - top
14 |
15 | val size: Int
16 | get() = width + height
17 |
18 | val delta: Int
19 | get() = width - height
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/commit-verifications.yml:
--------------------------------------------------------------------------------
1 | name: Difference CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v1
12 |
13 | - name: Set up JDK 17
14 | uses: actions/setup-java@v1
15 | with:
16 | java-version: 17
17 |
18 | - name: Run all unit tests
19 | run: ./gradlew allTests
20 |
21 | - name: Kotlin lint
22 | run: ./gradlew ktlintCheck
23 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/Point.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | import kotlin.jvm.JvmInline
4 |
5 | @JvmInline
6 | internal value class Point(private val packed: Long) {
7 | val x: Int get() = (packed and 0xFFFFFFFF).toInt()
8 | val y: Int get() = (packed shr 32).toInt()
9 |
10 | constructor(x: Int, y: Int) : this(
11 | (x.toLong() and 0xFFFFFFFF) or (y.toLong() shl 32)
12 | )
13 |
14 | inline operator fun component1() = x
15 | inline operator fun component2() = y
16 | }
17 |
--------------------------------------------------------------------------------
/android-perf-test/src/androidTest/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/CircularIntArray.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | import kotlin.jvm.JvmInline
4 |
5 | @JvmInline
6 | internal value class CircularIntArray(val array: IntArray) {
7 |
8 | constructor(size: Int) : this(IntArray(size))
9 |
10 | inline operator fun get(index: Int): Int = array[toLinearIndex(index)]
11 |
12 | inline operator fun set(index: Int, value: Int) {
13 | array[toLinearIndex(index)] = value
14 | }
15 |
16 | private inline fun toLinearIndex(index: Int): Int {
17 | val moddedIndex = index % array.size
18 | return if (moddedIndex < 0) {
19 | moddedIndex + array.size
20 | } else {
21 | moddedIndex
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/android-perf-test/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/Extensions.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | import kotlin.math.abs
4 |
5 | internal inline fun Int.isEven() = abs(this) % 2 == 0
6 |
7 | internal inline fun Int.isOdd() = abs(this) % 2 == 1
8 |
9 | internal inline fun MutableList.push(item: T) {
10 | add(item)
11 | }
12 |
13 | internal inline fun MutableList.pop(): T = removeAt(size - 1)
14 |
15 | internal inline fun List.fastForEach(action: (T) -> Unit) {
16 | @Suppress("ReplaceManualRangeWithIndicesCalls")
17 | for (i in 0 until size) {
18 | action(this[i])
19 | }
20 | }
21 |
22 | internal inline fun List.fastForEachIndexed(action: (Int, T) -> Unit) {
23 | @Suppress("ReplaceManualRangeWithIndicesCalls")
24 | for (i in 0 until size) {
25 | action(i, this[i])
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Andrew Bailey
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/android-perf-test/benchmark-proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
23 | -dontobfuscate
24 |
25 | -ignorewarnings
26 |
27 | -keepattributes *Annotation*
28 |
29 | -dontnote junit.framework.**
30 | -dontnote junit.runner.**
31 |
32 | -dontwarn androidx.test.**
33 | -dontwarn org.junit.**
34 | -dontwarn org.hamcrest.**
35 | -dontwarn com.squareup.javawriter.JavaWriter
36 |
37 | -keepclasseswithmembers @org.junit.runner.RunWith public class *
38 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types:
6 | - created
7 |
8 | jobs:
9 | publish:
10 | runs-on: macos-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v1
15 |
16 | - name: Set up JDK 17
17 | uses: actions/setup-java@v1
18 | with:
19 | java-version: 17
20 |
21 | - name: Publish JVM, JS, Linux, and Apple libraries to Maven
22 | run: ./gradlew publishAllPublicationsToSonatypeRepository
23 | env:
24 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
25 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
26 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
27 |
28 | publish-windows:
29 | runs-on: windows-latest
30 |
31 | steps:
32 | - name: Checkout code
33 | uses: actions/checkout@v1
34 |
35 | - name: Set up JDK 17
36 | uses: actions/setup-java@v1
37 | with:
38 | java-version: 17
39 |
40 | - name: Publish Windows x64 library to Maven
41 | run: ./gradlew publishMingwX64PublicationToSonatypeRepository
42 | env:
43 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
44 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
45 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
46 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 | android.enableAdditionalTestOutput=true
23 |
24 | GROUP=dev.andrewbailey.difference
25 | VERSION_NAME=1.1.1
26 |
27 | POM_ARTIFACT_ID=difference
28 | POM_NAME=Difference
29 |
30 | POM_DESCRIPTION=A Kotlin Multiplatform diffing library
31 |
32 | POM_URL=https://github.com/andrewbailey/difference/
33 | POM_SCM_URL=https://github.com/andrewbailey/difference/
34 | POM_SCM_CONNECTION=scm:git:git://github.com/andrewbailey/difference.git
35 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/andrewbailey/difference.git
36 |
37 | POM_LICENCE_NAME=MIT License
38 | POM_LICENCE_URL=https://opensource.org/licenses/MIT
39 | POM_LICENCE_DIST=repo
40 |
41 | POM_DEVELOPER_ID=andrewbailey
42 | POM_DEVELOPER_NAME=Andrew Bailey
43 |
--------------------------------------------------------------------------------
/gradle/publish.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'maven-publish'
2 | apply plugin: 'signing'
3 |
4 | publishing {
5 | publications.all {
6 | pom.withXml {
7 | def root = asNode()
8 |
9 | root.children().last() + {
10 | resolveStrategy = Closure.DELEGATE_FIRST
11 |
12 | description POM_DESCRIPTION
13 | name POM_NAME
14 | url POM_URL
15 | licenses {
16 | license {
17 | name POM_LICENCE_NAME
18 | url POM_LICENCE_URL
19 | distribution POM_LICENCE_DIST
20 | }
21 | }
22 | scm {
23 | url POM_SCM_URL
24 | connection POM_SCM_CONNECTION
25 | developerConnection POM_SCM_DEV_CONNECTION
26 | }
27 | developers {
28 | developer {
29 | id POM_DEVELOPER_ID
30 | name POM_DEVELOPER_NAME
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
37 | publications {
38 | kotlinMultiplatform {
39 | artifactId = POM_ARTIFACT_ID
40 | }
41 |
42 | jvm {
43 | artifact(javadocJar)
44 | }
45 | }
46 |
47 | repositories {
48 | maven {
49 | name 'sonatype'
50 | url 'https://oss.sonatype.org/service/local/staging/deploy/maven2/'
51 | credentials {
52 | username System.env.SONATYPE_USERNAME
53 | password System.env.SONATYPE_PASSWORD
54 | }
55 | }
56 | }
57 | }
58 |
59 | signing {
60 | def signingKey = System.env.SIGNING_KEY
61 | def signingPassword = ""
62 | useInMemoryPgpKeys(signingKey, signingPassword)
63 | publishing.publications.all {
64 | sign it
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/android-perf-test/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: "androidx.benchmark"
3 | apply plugin: 'kotlin-android'
4 |
5 | android {
6 | compileOptions {
7 | sourceCompatibility = 1.8
8 | targetCompatibility = 1.8
9 | }
10 |
11 | kotlinOptions {
12 | jvmTarget = "1.8"
13 | }
14 |
15 | defaultConfig {
16 | minSdkVersion 21
17 | targetSdkVersion 34
18 | compileSdk 34
19 | versionCode 1
20 | versionName "1.0"
21 | namespace "dev.andrewbailey.diff"
22 |
23 | testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner'
24 | testInstrumentationRunnerArgument 'androidx.benchmark.suppressErrors', 'UNLOCKED'
25 | }
26 |
27 | signingConfigs {
28 | release {
29 | storeFile file("signing-key.jks")
30 | storePassword "password"
31 | keyAlias "key0"
32 | keyPassword "password"
33 |
34 | // Optional, specify signing versions used
35 | v1SigningEnabled true
36 | v2SigningEnabled true
37 | }
38 | }
39 |
40 | testBuildType = "release"
41 | buildTypes {
42 | debug {
43 | debuggable false
44 | minifyEnabled true
45 | proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro"
46 | }
47 | release {
48 | isDefault = true
49 | debuggable false
50 | signingConfig signingConfigs.release
51 | }
52 | }
53 | }
54 |
55 | dependencies {
56 | implementation fileTree(dir: 'libs', include: ['*.jar'])
57 | implementation project(':difference')
58 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
59 | testImplementation 'junit:junit:4.13.2'
60 | androidTestImplementation 'androidx.test:runner:1.6.2'
61 | androidTestImplementation 'androidx.test.ext:junit:1.2.1'
62 | androidTestImplementation "androidx.benchmark:benchmark-junit4:1.3.3"
63 | }
64 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/Difference.kt:
--------------------------------------------------------------------------------
1 | @file:JvmName("Difference")
2 |
3 | package dev.andrewbailey.diff
4 |
5 | import kotlin.jvm.JvmName
6 |
7 | /**
8 | * Constructs a diff between the [original] and [updated] list inputs. The returned [DiffResult]
9 | * represents a sequence of operations which, if applied in order on the [original] list will
10 | * yield the [updated] list. The returned list always contains the minimum number of operations
11 | * required to transform the original input to the updated input.
12 | *
13 | * Optionally, move operations can be enabled or disabled by specifying [detectMoves]. If disabled,
14 | * the diff result will only include add and delete operations, which may cause the same item to
15 | * be deleted and re-inserted. If movement detection is enabled, all objects shared between the
16 | * two lists will either remain in place or be moved within the list instead of removing and
17 | * reinserting them.
18 | *
19 | * Internally, this function performs the Eugene-Myers diffing algorithm. the worst case runtime
20 | * of the algorithm takes on the order of O((M+N)×D + D log D) operations. M and N are the lengths
21 | * of the two input lists, and D is the smallest number of operations that it takes to modify the
22 | * original list into the updated one. If move detection is enabled, add another O(D²) to that
23 | * runtime.
24 | *
25 | * @param original The "before" state of the list to be diffed. For optimal performance, it is
26 | * critical that this data structure supports efficient random reads.
27 | * @param updated The "after" state of the list to be diffed. For optimal performance, it is
28 | * critical that this data structure supports efficient random reads.
29 | * @param detectMoves Whether or not to detect moved objects as such. When disabled, the returned
30 | * diff will only contain insert and delete operations. Enabled by default.
31 | * @return A [DiffResult] containing a shortest possible sequence of operations that can transform
32 | * the "before" state of the list into the "after" state of the list.
33 | */
34 | fun differenceOf(
35 | original: List,
36 | updated: List,
37 | detectMoves: Boolean = true
38 | ) = DiffGenerator.generateDiff(original, updated, detectMoves)
39 |
--------------------------------------------------------------------------------
/difference/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'org.jetbrains.kotlin.multiplatform'
2 | apply plugin: 'org.jetbrains.dokka'
3 |
4 | kotlin {
5 | jvm {
6 | withJava()
7 | mavenPublication {
8 | artifactId = 'difference-jvm'
9 | }
10 | }
11 |
12 | js {
13 | browser()
14 | nodejs()
15 | }
16 |
17 | iosX64 {
18 | mavenPublication {
19 | artifactId = 'difference-ios-x64'
20 | }
21 | }
22 | iosArm64 {
23 | mavenPublication {
24 | artifactId = 'difference-ios-arm64'
25 | }
26 | }
27 | iosSimulatorArm64 {
28 | mavenPublication {
29 | artifactId = 'difference-ios-simulator-arm64'
30 | }
31 | }
32 | linuxX64 {
33 | mavenPublication {
34 | artifactId = 'difference-linux-x64'
35 | }
36 | }
37 | macosX64 {
38 | mavenPublication {
39 | artifactId = 'difference-macos-x64'
40 | }
41 | }
42 | macosArm64 {
43 | mavenPublication {
44 | artifactId = 'difference-macos-arm64'
45 | }
46 | }
47 | mingwX64 {
48 | mavenPublication {
49 | artifactId = 'difference-mingw-x64'
50 | }
51 | }
52 |
53 | sourceSets {
54 | commonMain {
55 | dependencies {
56 | implementation kotlin('stdlib-common')
57 | }
58 | }
59 | commonTest {
60 | dependencies {
61 | implementation kotlin('test-common')
62 | implementation kotlin('test-annotations-common')
63 | }
64 | }
65 | jvmMain {
66 | dependencies {
67 | implementation kotlin('stdlib-jdk8')
68 | }
69 | }
70 | jvmTest {
71 | dependencies {
72 | implementation kotlin('test')
73 | implementation kotlin('test-junit')
74 | }
75 | }
76 | jsMain {
77 | dependencies {
78 | implementation kotlin('stdlib-js')
79 | }
80 | }
81 | jsTest {
82 | dependencies {
83 | implementation kotlin('test')
84 | implementation kotlin('test-js')
85 | }
86 | }
87 | }
88 | }
89 |
90 | dokkaHtml {
91 | outputDirectory = file("$rootDir/docs/1.x")
92 | }
93 |
94 | tasks.register('javadocJar', Jar) {
95 | dependsOn dokkaHtml
96 | archiveClassifier = 'javadoc'
97 | from file("$rootDir/docs/1.x")
98 | }
99 |
100 | apply from: "$rootDir/gradle/publish.gradle"
101 |
--------------------------------------------------------------------------------
/difference/src/jvmTest/kotlin/dev/andrewbailey/diff/DiffReceiverTest.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | import kotlin.random.Random
4 | import kotlin.test.assertEquals
5 | import org.junit.Test
6 |
7 | class DiffReceiverTest {
8 |
9 | @Test
10 | fun `applyDiff on original value returns updated value`() {
11 | val original = generateList()
12 | val updated = generateModifiedList(original)
13 |
14 | val diff = differenceOf(original, updated, false)
15 |
16 | val appliedResult = original.toMutableList()
17 |
18 | object : DiffReceiver() {
19 | override fun remove(index: Int) {
20 | appliedResult.removeAt(index)
21 | }
22 |
23 | override fun insert(item: String, index: Int) {
24 | appliedResult.add(index, item)
25 | }
26 |
27 | override fun move(oldIndex: Int, newIndex: Int) {
28 | appliedResult.add(
29 | element = appliedResult.removeAt(oldIndex),
30 | index = if (newIndex < oldIndex) {
31 | newIndex
32 | } else {
33 | newIndex - 1
34 | }
35 | )
36 | }
37 | }.applyDiff(diff)
38 |
39 | assertEquals(
40 | message = "Applying the diff to the input did not yield the updated value.",
41 | expected = updated,
42 | actual = appliedResult
43 | )
44 | }
45 |
46 | private fun generateList(): List {
47 | val random = Random(319163183995026179)
48 | return List(500) { random.nextInt().toString() }
49 | }
50 |
51 | private fun generateModifiedList(originalData: List): List {
52 | val random = Random(3260128955430943624)
53 | val modifiedList = originalData.toMutableList()
54 |
55 | repeat(250) {
56 | when (random.nextInt(0, 3)) {
57 | 0 -> { // Delete
58 | modifiedList.removeAt(
59 | index = random.nextInt(0, modifiedList.size)
60 | )
61 | }
62 | 1 -> { // Insert
63 | modifiedList.add(
64 | index = random.nextInt(0, modifiedList.size + 1),
65 | element = random.nextInt().toString()
66 | )
67 | }
68 | 2 -> { // Move
69 | val item = modifiedList.removeAt(
70 | index = random.nextInt(0, modifiedList.size)
71 | )
72 | modifiedList.add(
73 | index = random.nextInt(0, modifiedList.size + 1),
74 | element = item
75 | )
76 | }
77 | }
78 | }
79 |
80 | return modifiedList
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/difference/src/jvmMain/kotlin/dev/andrewbailey/diff/DiffReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | import dev.andrewbailey.diff.DiffOperation.Add
4 | import dev.andrewbailey.diff.DiffOperation.AddAll
5 | import dev.andrewbailey.diff.DiffOperation.Move
6 | import dev.andrewbailey.diff.DiffOperation.MoveRange
7 | import dev.andrewbailey.diff.DiffOperation.Remove
8 | import dev.andrewbailey.diff.DiffOperation.RemoveRange
9 | import dev.andrewbailey.diff.impl.fastForEach
10 | import dev.andrewbailey.diff.impl.fastForEachIndexed
11 |
12 | /**
13 | * This class serves as a convenience class for Java users who may find it tedious to call
14 | * [DiffResult.applyDiff], since Java users have to rely on Kotlin's `FunctionN` interfaces and
15 | * don't have access to Kotlin's named arguments and default arguments.
16 | *
17 | * If you're using Difference directly in Kotlin, then there's no reason for you to use this class
18 | * since the [DiffResult.applyDiff] is a more idiomatic way to use a difference result.
19 | */
20 | abstract class DiffReceiver {
21 |
22 | fun applyDiff(diff: DiffResult) {
23 | diff.operations.fastForEach { operation ->
24 | when (operation) {
25 | is Remove -> {
26 | remove(operation.index)
27 | }
28 | is RemoveRange -> {
29 | removeRange(operation.startIndex, operation.endIndex)
30 | }
31 | is Add -> {
32 | insert(operation.item, operation.index)
33 | }
34 | is AddAll -> {
35 | insertAll(operation.items, operation.index)
36 | }
37 | is Move -> {
38 | move(operation.fromIndex, operation.toIndex)
39 | }
40 | is MoveRange -> {
41 | moveRange(operation.fromIndex, operation.toIndex, operation.itemCount)
42 | }
43 | }
44 | }
45 | }
46 |
47 | abstract fun remove(index: Int)
48 |
49 | open fun removeRange(start: Int, end: Int) {
50 | repeat(times = end - start) {
51 | remove(start)
52 | }
53 | }
54 |
55 | abstract fun insert(item: T, index: Int)
56 |
57 | open fun insertAll(items: List, index: Int) {
58 | items.fastForEachIndexed { itemIndex, item ->
59 | insert(item, index + itemIndex)
60 | }
61 | }
62 |
63 | open fun move(oldIndex: Int, newIndex: Int): Unit = throw UnsupportedOperationException(
64 | "The received diff included move operations, but this receiver does not support moving " +
65 | "elements. You should either disable movement detection when generating the " +
66 | "diff, or override the `DiffReceiver.move()` function."
67 | )
68 |
69 | open fun moveRange(
70 | oldIndex: Int,
71 | newIndex: Int,
72 | count: Int
73 | ) {
74 | when {
75 | newIndex < oldIndex -> {
76 | (0 until count).forEach { item ->
77 | move(oldIndex + item, newIndex + item)
78 | }
79 | }
80 | newIndex > oldIndex -> {
81 | repeat(count) {
82 | move(oldIndex, newIndex)
83 | }
84 | }
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto init
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto init
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :init
68 | @rem Get command-line arguments, handling Windows variants
69 |
70 | if not "%OS%" == "Windows_NT" goto win9xME_args
71 |
72 | :win9xME_args
73 | @rem Slurp the command line arguments.
74 | set CMD_LINE_ARGS=
75 | set _SKIP=2
76 |
77 | :win9xME_args_slurp
78 | if "x%~1" == "x" goto execute
79 |
80 | set CMD_LINE_ARGS=%*
81 |
82 | :execute
83 | @rem Setup the command line
84 |
85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
86 |
87 |
88 | @rem Execute Gradle
89 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
90 |
91 | :end
92 | @rem End local scope for the variables with windows NT shell
93 | if "%ERRORLEVEL%"=="0" goto mainEnd
94 |
95 | :fail
96 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
97 | rem the _cmd.exe /c_ return code!
98 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
99 | exit /b 1
100 |
101 | :mainEnd
102 | if "%OS%"=="Windows_NT" endlocal
103 |
104 | :omega
105 |
--------------------------------------------------------------------------------
/android-perf-test/src/androidTest/java/dev/andrewbailey/diff/DiffBenchmarkTest.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | import androidx.benchmark.junit4.BenchmarkRule
4 | import androidx.benchmark.junit4.measureRepeated
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import kotlin.random.Random
7 | import org.junit.Rule
8 | import org.junit.Test
9 | import org.junit.runner.RunWith
10 |
11 | @RunWith(AndroidJUnit4::class)
12 | class DiffBenchmarkTest {
13 |
14 | @get:Rule
15 | val benchmarkRule = BenchmarkRule()
16 |
17 | @Test
18 | fun verySmallDiffWithoutMoves() = runBenchmarkScenario(
19 | seed = 4459894567797046261,
20 | numberOfItems = 100,
21 | numberOfOperations = 10,
22 | detectMoves = false
23 | )
24 |
25 | @Test
26 | fun verySmallDiffWithMoves() = runBenchmarkScenario(
27 | seed = 4459894567797046261,
28 | numberOfItems = 100,
29 | numberOfOperations = 10,
30 | detectMoves = true
31 | )
32 |
33 | @Test
34 | fun smallDiffWithoutMoves() = runBenchmarkScenario(
35 | seed = -5791837575014754264,
36 | numberOfItems = 1000,
37 | numberOfOperations = 100,
38 | detectMoves = false
39 | )
40 |
41 | @Test
42 | fun smallDiffWithMoves() = runBenchmarkScenario(
43 | seed = -5791837575014754264,
44 | numberOfItems = 1000,
45 | numberOfOperations = 100,
46 | detectMoves = true
47 | )
48 |
49 | @Test
50 | fun mediumDiffWithoutMoves() = runBenchmarkScenario(
51 | seed = -797670750388632780,
52 | numberOfItems = 5000,
53 | numberOfOperations = 500,
54 | detectMoves = false
55 | )
56 |
57 | @Test
58 | fun mediumDiffWithMoves() = runBenchmarkScenario(
59 | seed = -797670750388632780,
60 | numberOfItems = 5000,
61 | numberOfOperations = 500,
62 | detectMoves = true
63 | )
64 |
65 | @Test
66 | fun largeDiffWithoutMoves() = runBenchmarkScenario(
67 | seed = 8208385239328551378,
68 | numberOfItems = 10000,
69 | numberOfOperations = 1000,
70 | detectMoves = false
71 | )
72 |
73 | @Test
74 | fun largeDiffWithMoves() = runBenchmarkScenario(
75 | seed = 8208385239328551378,
76 | numberOfItems = 10000,
77 | numberOfOperations = 1000,
78 | detectMoves = true
79 | )
80 |
81 | private fun runBenchmarkScenario(
82 | seed: Long,
83 | numberOfItems: Int,
84 | numberOfOperations: Int,
85 | detectMoves: Boolean
86 | ) {
87 | val original = generateList(
88 | numberOfItems = numberOfItems,
89 | seed = seed
90 | )
91 |
92 | val updated = generateModifiedList(
93 | originalData = original,
94 | numberOfOperations = numberOfOperations,
95 | seed = seed
96 | )
97 |
98 | benchmarkRule.measureRepeated {
99 | differenceOf(
100 | original = original,
101 | updated = updated,
102 | detectMoves = detectMoves
103 | )
104 | }
105 | }
106 |
107 | private fun generateList(numberOfItems: Int, seed: Long): List {
108 | val random = Random(seed)
109 | return List(numberOfItems) { random.nextInt() }
110 | }
111 |
112 | private fun generateModifiedList(
113 | originalData: List,
114 | numberOfOperations: Int,
115 | seed: Long
116 | ): List {
117 | val random = Random(seed)
118 | val modifiedList = originalData.toMutableList()
119 |
120 | repeat(numberOfOperations) {
121 | when (random.nextInt(0, 3)) {
122 | 0 -> { // Delete
123 | modifiedList.removeAt(
124 | index = random.nextInt(0, modifiedList.size)
125 | )
126 | }
127 | 1 -> { // Insert
128 | modifiedList.add(
129 | index = random.nextInt(0, modifiedList.size + 1),
130 | element = random.nextInt()
131 | )
132 | }
133 | 2 -> { // Move
134 | val item = modifiedList.removeAt(
135 | index = random.nextInt(0, modifiedList.size)
136 | )
137 | modifiedList.add(
138 | index = random.nextInt(0, modifiedList.size + 1),
139 | element = item
140 | )
141 | }
142 | }
143 | }
144 |
145 | return modifiedList
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/difference/src/commonTest/kotlin/dev/andrewbailey/diff/impl/MyersDiffAlgorithmTest.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Delete
4 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Insert
5 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Skip
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class MyersDiffAlgorithmTest {
10 |
11 | @Test
12 | fun generateDiff_withEmptyInputs_returnsEmptySequence() {
13 | assertEquals(
14 | expected = emptyList(),
15 | actual = MyersDiffAlgorithm(
16 | original = emptyList(),
17 | updated = emptyList()
18 | ).generateDiff().toList()
19 | )
20 | }
21 |
22 | @Test
23 | fun generateDiff_withEmptyStart_returnsAdditions() {
24 | assertEquals(
25 | expected = listOf(Insert("A"), Insert("B"), Insert("C")),
26 | actual = MyersDiffAlgorithm(
27 | original = emptyList(),
28 | updated = listOf("A", "B", "C")
29 | ).generateDiff().toList()
30 | )
31 | }
32 |
33 | @Test
34 | fun generateDiff_withEmptyEnd_returnsDeletions() {
35 | assertEquals(
36 | expected = listOf(Delete, Delete, Delete),
37 | actual = MyersDiffAlgorithm(
38 | original = listOf("A", "B", "C"),
39 | updated = emptyList()
40 | ).generateDiff().toList()
41 | )
42 | }
43 |
44 | @Test
45 | fun generateDiff_withSameStartAndEnd_returnsSkips() {
46 | assertEquals(
47 | expected = listOf(Skip, Skip, Skip, Skip),
48 | actual = MyersDiffAlgorithm(
49 | original = listOf("A", "B", "C", "D"),
50 | updated = listOf("A", "B", "C", "D")
51 | ).generateDiff().toList()
52 | )
53 | }
54 |
55 | @Test
56 | fun generateDiff_withSimpleExample() {
57 | val original = "ABCABAC".toList()
58 | val updated = "CBABAC".toList()
59 |
60 | val diff = MyersDiffAlgorithm(original, updated).generateDiff().toList()
61 |
62 | assertEquals(
63 | expected = listOf(
64 | Delete,
65 | Delete,
66 | Skip,
67 | Insert(value = 'B'),
68 | Skip,
69 | Skip,
70 | Skip,
71 | Skip
72 | ),
73 | actual = diff
74 | )
75 | }
76 |
77 | @Test
78 | fun generateDiff_withDnaExample() {
79 | val original = "tgtcgctctcaagatggcgtcttattacgaaaggagccagtccgggttgc".toList()
80 | val updated = "ggctggggttttcgcacggcgctccctccgcggttgtatctcaggcgaca".toList()
81 |
82 | val diff = MyersDiffAlgorithm(original, updated).generateDiff()
83 |
84 | assertEquals(
85 | message = "Applying the diff to the input did not yield the updated value.",
86 | expected = updated,
87 | actual = applyDiff(original, diff)
88 | )
89 |
90 | assertEquals(
91 | message = "The number of operations in the diff did not match the expected value.",
92 | expected = 40,
93 | actual = diff.count { it !is Skip }
94 | )
95 | }
96 |
97 | @Test
98 | fun generateDiff_withLoremIpsumExample() {
99 | val original = (
100 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " +
101 | "tempor incididunt ut labore et dolore magna aliqua. Quis auctor elit sed " +
102 | "vulputate mi sit amet mauris commodo. Nec dui nunc mattis enim ut tellus " +
103 | "elementum. Ultricies integer quis auctor elit sed vulputate mi sit amet. " +
104 | "Ullamcorper velit sed ullamcorper morbi tincidunt."
105 | ).lowercase().split(" ")
106 | val updated = (
107 | "Malesuada fames ac turpis egestas. Varius sit amet mattis vulputate enim. Nisl " +
108 | "nisi scelerisque eu ultrices vitae auctor eu augue. Sit amet volutpat consequat " +
109 | "mauris nunc congue nisi vitae. Egestas purus viverra accumsan in nisl nisi " +
110 | "scelerisque eu. Lobortis elementum nibh tellus molestie. Nulla at volutpat diam " +
111 | "ut venenatis tellus in metus. Ac turpis egestas sed tempus urna et pharetra " +
112 | "pharetra massa. Etiam sit amet nisl purus in mollis. Vivamus arcu felis " +
113 | "bibendum ut tristique et egestas quis. Vestibulum lorem sed risus ultricies " +
114 | "tristique nulla aliquet. Nunc scelerisque viverra mauris in aliquam. Facilisis " +
115 | "magna etiam tempor orci eu lobortis elementum nibh. Purus faucibus ornare " +
116 | "suspendisse sed nisi. Dui accumsan sit amet nulla."
117 | ).lowercase().split(" ")
118 |
119 | val diff = MyersDiffAlgorithm(original, updated).generateDiff()
120 |
121 | assertEquals(
122 | message = "Applying the diff to the input did not yield the updated value.",
123 | expected = updated,
124 | actual = applyDiff(original, diff)
125 | )
126 |
127 | assertEquals(
128 | message = "The number of operations in the diff did not match the expected value.",
129 | expected = 143,
130 | actual = diff.count { it !is Skip }
131 | )
132 | }
133 |
134 | private fun applyDiff(original: List, diff: List>): List =
135 | original.toMutableList().apply {
136 | var index = 0
137 | diff.forEach { operation ->
138 | when (operation) {
139 | is Skip -> index++
140 | is Insert -> add(index++, operation.value)
141 | is Delete -> removeAt(index)
142 | }
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/DiffOperation.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | /**
4 | * An individual operation contained in a [DiffResult] between two collections.
5 | */
6 | sealed class DiffOperation {
7 |
8 | /**
9 | * An operation in a diff to indicate the removal of an object between the before and after
10 | * states of the collections.
11 | *
12 | * @param index The index of the removal, expressed as the index to remove from the collection
13 | * if you had applied all previous operations in this diff.
14 | * @param item The item that was removed from the original collection
15 | */
16 | data class Remove(
17 | val index: Int,
18 | val item: T
19 | ) : DiffOperation()
20 |
21 | /**
22 | * An operation in a diff to indicate the removal of a range of objects between the before and
23 | * after states of the collections.
24 | *
25 | * @param startIndex The start index of the removal (inclusive), expressed as the index to
26 | * remove from the collection if you had applied all previous operations in this diff.
27 | * @param endIndex The end index of the removal (exclusive), expressed as the index to remove
28 | * from the collection if you had applied all previous operations in this diff.
29 | */
30 | data class RemoveRange(
31 | val startIndex: Int,
32 | val endIndex: Int
33 | ) : DiffOperation()
34 |
35 | /**
36 | * An operation in a diff to indicate the insertion of an object between the before and after
37 | * states of the collections.
38 | *
39 | * @param index The index of the insertion, expressed as the index to add the given object to
40 | * the collection if you had applied all previous operations in this diff.
41 | * @param item The object to insert at the specified location.
42 | */
43 | data class Add(
44 | val index: Int,
45 | val item: T
46 | ) : DiffOperation()
47 |
48 | /**
49 | * An operation in a diff to indicate the insertion of several objects between the before and
50 | * after states of the collections.
51 | *
52 | * @param index The index of the insertions, expressed as the index to add the given object to
53 | * the collection if you had applied all previous operations in this diff. The first object in
54 | * the given items list should be inserted at this index, with all subsequent operations
55 | * following consecutively.
56 | * @param items The objects to insert at the specified location. Always contains at least two
57 | * values.
58 | */
59 | data class AddAll(
60 | val index: Int,
61 | val items: List
62 | ) : DiffOperation()
63 |
64 | /**
65 | * An operation in a diff to indicate the movement of one object to a new index between the
66 | * before and after states of the collections. Only included in a diff if movement detection
67 | * was enabled.
68 | *
69 | * The indices expressed in this object assume applying the move operation as an atomic
70 | * operation to a collection that has had all other operations in this diff applied. To
71 | * implement as a remove-then-add call, correct the [toIndex] as shown:
72 | *
73 | * ```kotlin
74 | * add(removeAt(fromIndex), if (toIndex < fromIndex) toIndex else toIndex - 1)
75 | * ```
76 | *
77 | * @param fromIndex The index of the object as it would appear if you had applied all previous
78 | * operations in this diff.
79 | * @param toIndex The new index that this object should appear at, not accounting for the
80 | * deletion of this object from its current location.
81 | */
82 | data class Move(
83 | val fromIndex: Int,
84 | val toIndex: Int
85 | ) : DiffOperation()
86 |
87 | /**
88 | * An operation in a diff to indicate the movement of a range of objects to a new location
89 | * between the before and after states of the collections. Only included in a diff if movement
90 | * detection was enabled.
91 | *
92 | * The indices expressed in this object assume applying the move operation as an atomic
93 | * operation to a collection that has had all other operations in this diff applied. To
94 | * implement as a removeRange-then-add call, correct the [toIndex] as shown:
95 | *
96 | * ```kotlin
97 | * addAll(
98 | * values = removeRange(fromIndex, itemCount),
99 | * index = if (toIndex < fromIndex) toIndex else toIndex - 1
100 | * )
101 | * ```
102 | *
103 | * Alternatively, to implement as a loop of remove-then-add calls, correct the destination
104 | * indices as shown:
105 | *
106 | * ```kotlin
107 | * when {
108 | * toIndex < fromIndex -> {
109 | * (0 until count).forEach { item ->
110 | * val oldIndex = fromIndex + item
111 | * val newIndex = toIndex + item
112 | * add(
113 | * item = removeAt(oldIndex),
114 | * index = if (newIndex < oldIndex) newIndex else newIndex - 1
115 | * )
116 | * }
117 | * }
118 | * toIndex > fromIndex -> {
119 | * repeat(count) {
120 | * add(
121 | * item = removeAt(fromIndex),
122 | * index = newIndex
123 | * )
124 | * }
125 | * }
126 | * }
127 | * ```
128 | *
129 | * @param fromIndex The index of the object as it would appear if you had applied all previous
130 | * operations in this diff.
131 | * @param toIndex The new index that this object should appear at, not accounting for the
132 | * deletion of this object from its current location.
133 | */
134 | data class MoveRange(
135 | val fromIndex: Int,
136 | val toIndex: Int,
137 | val itemCount: Int
138 | ) : DiffOperation()
139 | }
140 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Difference
2 | Difference is a Kotlin multiplatform differencing library.
3 | Given two lists, Difference will compute the insert and delete operations required to transform the starting list into the final list.
4 | Difference can also optionally detect items that have moved to new indices in the list.
5 |
6 | Behind the scenes, Difference uses Eugene Myer's Differencing Algorithm.
7 | This is the same algorithm used by Android's [DiffUtil](https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil) class, which you may be familiar with if you're an Android developer.
8 | Difference is very similar to Android's DiffUtil, but is completely platform agnostic and has more receiver-agnostic APIs to consume the diff result.
9 |
10 | ## Setup
11 | Gradle can automatically resolve which variation of Difference is appropriate for the platform and architecture you're compiling for.
12 | To use the universal dependency, add this dependency to your module's `build.gradle`:
13 |
14 | ```groovy
15 | dependencies {
16 | implementation 'dev.andrewbailey.difference:difference:1.1.1'
17 | }
18 | ```
19 |
20 | If you want to explicitly specify which platform variant of the library you want to depend on, you can use any of the following dependencies as appropriate:
21 |
22 | ```
23 | dev.andrewbailey.difference:difference-jvm:1.1.1
24 | dev.andrewbailey.difference:difference-js:1.1.1
25 | dev.andrewbailey.difference:difference-linux-x64:1.1.1
26 | dev.andrewbailey.difference:difference-macos-x64:1.1.1
27 | dev.andrewbailey.difference:difference-macos-arm64:1.1.1
28 | dev.andrewbailey.difference:difference-ios-x64:1.1.1
29 | dev.andrewbailey.difference:difference-ios-arm64:1.1.1
30 | dev.andrewbailey.difference:difference-ios-simulator-arm64:1.1.1
31 | dev.andrewbailey.difference:difference-mingw-x64:1.1.1
32 | ```
33 |
34 | ## Generating a diff
35 | To generate a diff, you can call `differenceOf` with the following arguments:
36 |
37 | ```kotlin
38 | val listOfBffs = listOf("Merengue", "Sherb", "Tammy")
39 | val revisedListOfBffs = listOf("Merengue", "Sprinkle", "Sherb")
40 |
41 | val diff = differenceOf(
42 | original = listOfBffs,
43 | updated = revisedListOfBffs,
44 | detectMoves = false
45 | )
46 | ```
47 |
48 | In Java, you can write this code as:
49 |
50 | ```java
51 | List listOfBffs = Arrays.asList("Merengue", "Sherb", "Tammy");
52 | List revisedListOfBffs = Arrays.asList("Merengue", "Sprinkle", "Sherb");
53 |
54 | DiffResult diff = Difference.differenceOf(listOfBffs, revisedListOfBffs, false);
55 | ```
56 |
57 | The diff that will be generated for this input will look like this:
58 | ```
59 | Insert(index = 1, item = "Sprinkle")
60 | Remove(index = 3)
61 | ```
62 |
63 | Please keep in mind that calling `differenceOf` is an expensive operation, so it's recommended to offload this call to a background thread.
64 | See the [Performance](#performance) section for more info.
65 |
66 | ## Using a `DiffResult`
67 |
68 | `differenceOf` returns a `DiffResult` object, typed based on the input lists.
69 | To consume a diff, you can call `DiffResult.applyDiff`.
70 | There are several overloads that can increase the performance of how your diff is applied, but the most basic call looks something like this:
71 |
72 | ```kotlin
73 | ...
74 |
75 | val diff = differenceOf(
76 | original = listOfBffs,
77 | updated = revisedListOfBffs,
78 | detectMoves = true
79 | )
80 |
81 | // When `applyDiff` returns, `output` will be equal to `revisedListOfBffs`
82 | val output = listOfBffs.toMutableList()
83 | diff.applyDiff(
84 | remove = { index: Int ->
85 | output.removeAt(index)
86 | },
87 | insert = { item: T, index: Int ->
88 | output.add(index, item)
89 | },
90 | move = { oldIndex: Int, newIndex: Int ->
91 | // You can leave this blank if you set `detectMoves` to false
92 | output.add(
93 | element = removeAt(oldIndex),
94 | index = if (newIndex < oldIndex) {
95 | newIndex
96 | } else {
97 | newIndex - 1
98 | }
99 | )
100 | }
101 | )
102 | ```
103 |
104 | If you're using Difference in Java, using `applyDiff` can be a bit awkward to use because it exposes Kotlin's `FunctionN` interfaces, named parameters, and default arguments to be easy to call.
105 | You can alternatively use `DiffReceiver` to improve the readability of your diff callbacks in a Java project.
106 |
107 | The same receiver logic can be expressed like this in Java:
108 |
109 | ```java
110 | DiffResult diff = differenceOf(...);
111 |
112 | // When `applyDiff` returns, `output` will be equal to `revisedListOfBffs`
113 | List output = ArrayList<>(listOfBffs);
114 | DiffReceiver receiver = new DiffReceiver<>() {
115 | @Override
116 | public void remove(int index) {
117 | output.removeAt(index);
118 | }
119 |
120 | @Override
121 | public void insert(String item, int index) {
122 | output.add(index, item);
123 | }
124 |
125 | @Override
126 | public void move(int oldIndex, int newIndex) {
127 | // You can omit this override if you set `detectMoves` to false
128 | int index = newIndex;
129 | if (newIndex >= oldIndex) {
130 | index--;
131 | }
132 |
133 | output.add(index, output.removeAt(oldIndex));
134 | }
135 | };
136 |
137 | receiver.applyDiff(diff);
138 | ```
139 |
140 | Note that `DiffReceiver` is only available in Java projects.
141 | If your project is a 100% Kotlin project, then you should stick to `applyDiff` and ignore `DiffReceiver`.
142 |
143 | ## Performance
144 | Difference uses a linear-space non-recursive implementation of Eugene Myer's Differencing algorithm.
145 | The algorithm takes O((M+N)×D + D log D) operations, where M and N are the lengths of the input lists, and D is the minimum number of edits it takes to transform the original list into the final list.
146 | If you enable movement detection, this runtime increases by O(D²).
147 |
148 | Diff generation can take a while, so for UI-centric applications, you should avoid calling `differenceOf` on your main thread.
149 | Depending on conditions and the client's hardware, diff generation for arbitrary lists can easily cause your application to block the main thread and cause frame drops if you aren't careful.
150 |
151 | ### Benchmarks
152 |
153 | Difference has benchmark tests that can run on an Android device.
154 | Below is the measured time it takes to compute diffs of various sizes on a Pixel 3 running Android Q.
155 |
156 | These values **should not** be used to estimate how long any call to `differenceOf` will take.
157 | Real-world performance will vary device-to-device, and can be influenced by conditions such as the device's hardware, CPU load, temperature, and battery level, among other factors.
158 | You should take these values with a grain of salt and make note of how the time taken to generate a diff grows exponentially based on the size of the inputs.
159 |
160 | #### Pixel 3 Benchmark (without movement detection)
161 | | Starting list size | Number of operations | Time taken |
162 | |:------------------:|:--------------------:|:-----------|
163 | | 100 | 10 | 0.059 ms |
164 | | 1000 | 100 | 0.983 ms |
165 | | 5000 | 500 | 17.6 ms |
166 | | 10000 | 1000 | 55.6 ms |
167 |
168 | #### Pixel 3 Benchmark (with movement detection)
169 | | Starting list size | Number of operations | Time taken |
170 | |:------------------:|:--------------------:|:-----------|
171 | | 100 | 10 | 0.055 ms |
172 | | 1000 | 100 | 1.158 ms |
173 | | 5000 | 500 | 22.0 ms |
174 | | 10000 | 1000 | 73.5 ms |
175 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/DiffResult.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | /**
4 | * Stores the result of a diff calculated by [differenceOf].
5 | */
6 | class DiffResult internal constructor(
7 | /**
8 | * The sequence of operations in the diff. When applied in order, these operations will
9 | * transform the original collection into the target collection. This list always coalesces
10 | * ranges of the same operation together, and prefers to use the minimum number of
11 | * [DiffOperation] objects required to express the diff.
12 | */
13 | val operations: List>
14 | ) {
15 |
16 | /**
17 | * Executes the operations in this diff-in order. This function is generally intended to
18 | * mirror the state changes expressed in this difference to a parallel data structure derived
19 | * by the diff-ed collection.
20 | *
21 | * The lambdas passed into this method are invoked to respond to each operation. When an index
22 | * is specified, it expresses the index of the in-flight data structure. That is, an index
23 | * which is only valid when all previously dispatched operations have been applied to the
24 | * original collection.
25 | *
26 | * This overload applies operations on an element-by-element basis. Use the overload with
27 | * the additional "Range" lambdas to support bulk operations, which can improve performance.
28 | *
29 | * @param remove Called to remove the object at the specified `index` from the in-flight
30 | * collection as a step towards the final collection.
31 | * @param insert Called to insert the given `item` at the specified `index` to the in-flight
32 | * collection as a step towards the final collection.
33 | * @param move If this diff was generated with moves detected, this function is called to move
34 | * the item at `oldIndex` to `newIndex`. If this diff was not generated with moves detected,
35 | * this lambda will never be invoked. These indexes are written assuming an atomic operation
36 | * to perform the move. To implement a move operation as a remove and re-insert operation, the
37 | * `newIndex` may need to be corrected with an offset. In practice, this looks like
38 | * `add(removeAt(oldIndex), if (newIndex < oldIndex) else newIndex - 1)`.
39 | */
40 | inline fun applyDiff(
41 | crossinline remove: (index: Int) -> Unit,
42 | crossinline insert: (item: T, index: Int) -> Unit,
43 | crossinline move: (oldIndex: Int, newIndex: Int) -> Unit
44 | ) {
45 | applyDiff(
46 | remove = remove,
47 | insert = insert,
48 | move = move,
49 | removeRange = { start, end ->
50 | repeat(times = end - start) {
51 | remove(start)
52 | }
53 | },
54 | insertAll = { items, index ->
55 | items.forEachIndexed { itemIndex, item ->
56 | insert(item, index + itemIndex)
57 | }
58 | },
59 | moveRange = { oldIndex, newIndex, count ->
60 | when {
61 | newIndex < oldIndex -> {
62 | (0 until count).forEach { item ->
63 | move(oldIndex + item, newIndex + item)
64 | }
65 | }
66 | newIndex > oldIndex -> {
67 | repeat(count) {
68 | move(oldIndex, newIndex)
69 | }
70 | }
71 | }
72 | }
73 | )
74 | }
75 |
76 | /**
77 | * Executes the operations in this diff-in order, with support for applying operations in bulk
78 | * when a large span of items experience the same transformation. This function is generally
79 | * intended to mirror the state changes expressed in this difference to a parallel data
80 | * structure derived by the diff-ed collection.
81 | *
82 | * The lambdas passed into this method are invoked to respond to each aggregated operation.
83 | * When an index or range is specified, it is indexed to the in-flight data structure. That is,
84 | * an index which is only valid when all previously dispatched operations have been applied
85 | * to the original collection.
86 | *
87 | * @param remove Called to remove the object at the specified `index` from the in-flight
88 | * collection as a step towards the final collection.
89 | * @param removeRange Called to remove all objects between index `start` (inclusive) and `end`
90 | * (exclusive) from the in-flight collection as a step towards the final collection.
91 | * @param insert Called to insert the given `item` at the specified `index` to the in-flight
92 | * collection as a step towards the final collection.
93 | * @param insertAll Called to insert all items in the provided collection to the in-flight
94 | * collection. The given list of items should be added in-order with the inserted sequence
95 | * starting at the provided `index` in the in-flight collection.
96 | * @param move If this diff was generated with moves detected, this function is called to move
97 | * the item at `oldIndex` to `newIndex`. If this diff was not generated with moves detected,
98 | * this lambda will never be invoked. These indexes are written assuming an atomic operation
99 | * to perform the move. To implement a move operation as a remove and re-insert operation, the
100 | * `newIndex` may need to be corrected with an offset. In practice, this looks like
101 | * `add(removeAt(oldIndex), if (newIndex < oldIndex) newIndex else newIndex - 1)`.
102 | * @param moveRange This lambda is used in the same way as [move] is, but operates on a
103 | * contiguous span of two or more items to be moved. The indexing works in the same way as it
104 | * does for the single-item variant, in that the `newIndex` and `oldIndex` arguments provided
105 | * are both specified with respect to the current state of the in-flight array. If implementing
106 | * this expression as an addRange and removeRange operation, or by breaking this operation into
107 | * multiple move operations, you will need to correct the destination index when it is before
108 | * the source index.
109 | */
110 | inline fun applyDiff(
111 | crossinline remove: (index: Int) -> Unit,
112 | crossinline removeRange: (start: Int, end: Int) -> Unit,
113 | crossinline insert: (item: T, index: Int) -> Unit,
114 | crossinline insertAll: (items: List, index: Int) -> Unit,
115 | crossinline move: (oldIndex: Int, newIndex: Int) -> Unit,
116 | crossinline moveRange: (oldIndex: Int, newIndex: Int, count: Int) -> Unit
117 | ) {
118 | operations.forEach { operation ->
119 | when (operation) {
120 | is DiffOperation.Remove -> {
121 | remove(operation.index)
122 | }
123 | is DiffOperation.RemoveRange -> {
124 | removeRange(operation.startIndex, operation.endIndex)
125 | }
126 | is DiffOperation.Add -> {
127 | insert(operation.item, operation.index)
128 | }
129 | is DiffOperation.AddAll -> {
130 | insertAll(operation.items, operation.index)
131 | }
132 | is DiffOperation.Move -> {
133 | move(operation.fromIndex, operation.toIndex)
134 | }
135 | is DiffOperation.MoveRange -> {
136 | moveRange(operation.fromIndex, operation.toIndex, operation.itemCount)
137 | }
138 | }
139 | }
140 | }
141 |
142 | override fun equals(other: Any?) = other is DiffResult<*> && other.operations == operations
143 |
144 | override fun hashCode() = operations.hashCode()
145 |
146 | override fun toString() = "DiffResult(operations = $operations)"
147 | }
148 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/DiffGenerator.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | import dev.andrewbailey.diff.DiffOperation.Add
4 | import dev.andrewbailey.diff.DiffOperation.AddAll
5 | import dev.andrewbailey.diff.DiffOperation.Move
6 | import dev.andrewbailey.diff.DiffOperation.MoveRange
7 | import dev.andrewbailey.diff.DiffOperation.Remove
8 | import dev.andrewbailey.diff.DiffOperation.RemoveRange
9 | import dev.andrewbailey.diff.impl.MyersDiffAlgorithm
10 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Delete
11 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Insert
12 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Skip
13 | import dev.andrewbailey.diff.impl.fastForEach
14 |
15 | internal object DiffGenerator {
16 |
17 | fun generateDiff(
18 | original: List,
19 | updated: List,
20 | detectMoves: Boolean
21 | ): DiffResult {
22 | val diff = MyersDiffAlgorithm(original, updated).generateDiff()
23 |
24 | var index = 0
25 | var indexInOriginalSequence = 0
26 | val operations = mutableListOf>()
27 | diff.fastForEach { operation ->
28 | when (operation) {
29 | is Insert -> {
30 | operations += Add(
31 | index = index,
32 | item = operation.value
33 | )
34 | index++
35 | }
36 | is Delete -> {
37 | operations += Remove(
38 | index = index,
39 | item = original[indexInOriginalSequence]
40 | )
41 | indexInOriginalSequence++
42 | }
43 | is Skip -> {
44 | index++
45 | indexInOriginalSequence++
46 | }
47 | }
48 | }
49 |
50 | if (detectMoves) reduceDeletesAndAddsToMoves(operations)
51 | reduceSequences(operations)
52 | return DiffResult(operations)
53 | }
54 |
55 | /**
56 | * Given a diff, this function performs a reduction step that converts pairs of adds and removes
57 | * for the same item into move operations.
58 | *
59 | * This function takes O(n^2) time. There's an optimization in place here that avoids rescanning
60 | * the entire array for possible matches, and technically adds a coefficient of 1/2 to this
61 | * runtime, but alas big-O isn't concerned with this detail.
62 | *
63 | * The way this algorithm runs is a bit unintuitive and depends on the inputted list of
64 | * operations being sorted by index from smallest to largest. When this function is called, the
65 | * [operations] only contains add and remove operations. We iterate over the entire array. For
66 | * each operation (whether it's an add or remove) we try and find an operation that undoes the
67 | * operation later on in the array (we don't need to rescan the earlier part for reasons that
68 | * will become clear in a moment).
69 | *
70 | * If we find a pair of operations that can be reduced to a move (i.e. if we find a remove that
71 | * undoes an add or vice versa), we replace the operation that came first with the move
72 | * operation. This keeps the array in a sorted order when we're done. We then remove the
73 | * opposite operation from the diff and check the next item until we've checked all operations.
74 | */
75 | private fun reduceDeletesAndAddsToMoves(operations: MutableList>) {
76 | var index = 0
77 | while (index < operations.size) {
78 | val operation = operations[index]
79 |
80 | var indexOfOppositeAction = index + 1
81 | var endIndexDifference = 0
82 |
83 | while (indexOfOppositeAction < operations.size &&
84 | !canBeReducedToMove(operation, operations[indexOfOppositeAction])
85 | ) {
86 | val rejectedOperation = operations[indexOfOppositeAction]
87 | if (rejectedOperation is Add) {
88 | endIndexDifference++
89 | } else {
90 | endIndexDifference--
91 | }
92 | indexOfOppositeAction++
93 | }
94 |
95 | val oppositeAction = operations.getOrNull(indexOfOppositeAction)
96 |
97 | if (oppositeAction != null) {
98 | val deleteFromIndex = if (operation is Remove) {
99 | operation.index
100 | } else {
101 | (oppositeAction as Remove).index - endIndexDifference - 1
102 | }
103 |
104 | val addToIndex = if (operation is Add) {
105 | operation.index
106 | } else {
107 | (oppositeAction as Add).index - endIndexDifference + 1
108 | }
109 |
110 | operations[index] = Move(
111 | fromIndex = deleteFromIndex,
112 | toIndex = addToIndex
113 | )
114 |
115 | operations.removeAt(indexOfOppositeAction)
116 | }
117 |
118 | index++
119 | }
120 | }
121 |
122 | private fun canBeReducedToMove(
123 | operation1: DiffOperation,
124 | operation2: DiffOperation
125 | ): Boolean = when (operation1) {
126 | is Add -> operation2 is Remove && operation1.item == operation2.item
127 | is Remove -> operation2 is Add && operation1.item == operation2.item
128 | else -> false
129 | }
130 |
131 | private fun reduceSequences(operations: MutableList>) {
132 | var index = 0
133 |
134 | while (index < operations.size) {
135 | val operationToReduce = operations[index]
136 | var sequenceEndIndex = index + 1
137 | var sequenceLength = 1
138 | while (sequenceEndIndex < operations.size &&
139 | operationToReduce.canBeCombinedWith(operations[sequenceEndIndex], sequenceLength)
140 | ) {
141 | sequenceEndIndex++
142 | sequenceLength++
143 | }
144 |
145 | if (sequenceLength > 1) {
146 | operations[index] = reduceSequence(
147 | operations = operations,
148 | sequenceStartIndex = index,
149 | sequenceLength = sequenceLength
150 | )
151 |
152 | repeat(sequenceLength - 1) { operations.removeAt(index + 1) }
153 | }
154 |
155 | index++
156 | }
157 | }
158 |
159 | private fun reduceSequence(
160 | operations: MutableList>,
161 | sequenceStartIndex: Int,
162 | sequenceLength: Int
163 | ): DiffOperation = when (val startOperation = operations[sequenceStartIndex]) {
164 | is Remove -> {
165 | RemoveRange(
166 | startIndex = startOperation.index,
167 | endIndex = startOperation.index + sequenceLength
168 | )
169 | }
170 | is Add -> {
171 | AddAll(
172 | index = startOperation.index,
173 | items = List(sequenceLength) { i ->
174 | (operations[sequenceStartIndex + i] as Add).item
175 | }
176 | )
177 | }
178 | is Move -> {
179 | MoveRange(
180 | fromIndex = startOperation.fromIndex,
181 | toIndex = startOperation.toIndex,
182 | itemCount = sequenceLength
183 | )
184 | }
185 | else -> throw IllegalArgumentException(
186 | "Cannot reduce sequence starting with $startOperation"
187 | )
188 | }
189 |
190 | private fun DiffOperation.canBeCombinedWith(
191 | otherOperation: DiffOperation,
192 | offset: Int
193 | ): Boolean = when (this) {
194 | is Remove -> otherOperation is Remove && index == otherOperation.index
195 | is Add -> otherOperation is Add && index + offset == otherOperation.index
196 | is Move ->
197 | otherOperation is Move &&
198 | when {
199 | toIndex < fromIndex -> {
200 | // Move backwards case
201 | toIndex + offset == otherOperation.toIndex &&
202 | fromIndex + offset == otherOperation.fromIndex
203 | }
204 | else -> {
205 | // Move forwards case
206 | toIndex == otherOperation.toIndex &&
207 | fromIndex == otherOperation.fromIndex
208 | }
209 | }
210 | else -> false
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/difference/src/commonMain/kotlin/dev/andrewbailey/diff/impl/MyersDiffAlgorithm.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff.impl
2 |
3 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Delete
4 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Insert
5 | import dev.andrewbailey.diff.impl.MyersDiffOperation.Skip
6 | import kotlin.math.ceil
7 |
8 | /**
9 | * This class implements a variation of Eugene Myers' Diffing Algorithm that uses linear space. This
10 | * algorithm has a worst-case runtime of O(M + N + D^2), where N and M are the lengths of [original]
11 | * and [updated], and D is the length of the edit script (i.e. the number of `Insert`s and `Delete`s
12 | * that appear in the result of [generateDiff]). This algorithm has an expected runtime of
13 | * O((M + N) D) and always uses O(D) space.
14 | *
15 | * The basic variation of this algorithm (which uses quadratic space) is a greedy algorithm. In this
16 | * introductory variation, the algorithm places the inputs on a grid. At (0, 0), no inputs from
17 | * either [original] or [updated] have been accepted. Moving to the right (in the positive X
18 | * direction) indicates that the next item in [original] should be removed. Moving downward (in the
19 | * positive Y direction) indicates that the next item in [updated] should be inserted. Moving
20 | * diagonally (in both the positive X and Y direction) indicates that [original] and [updated] share
21 | * a value, so there is no difference.
22 | *
23 | * The greedy algorithm makes multiple passes to find the shortest path from (0, 0) to (N, M). When
24 | * the algorithm runs through the inputs, it can move diagonally for free. Long chains of diagonals
25 | * where both inputs have a matching subsequence are called a [Snake]. In each iteration, the
26 | * possible paths expand either by one unit in a single direction or, if there is a snake,
27 | * diagonally until the end of the snake.
28 | *
29 | * The linear-space implementation of this algorithm is a divide and conquer algorithm that follows
30 | * the same basic principles as the greedy algorithm. The input is traversed forwards and backwards
31 | * simultaneously in a subset of the grid. When the paths in both directions intersect, a shortest
32 | * path has been found and a diff can be outputted.
33 | *
34 | * You can read the technical paper by Eugene Myers here: http://xmailserver.org/diff2.pdf
35 | */
36 | internal class MyersDiffAlgorithm(
37 | private val original: List,
38 | private val updated: List
39 | ) {
40 |
41 | fun generateDiff(): List> {
42 | val path = findPath()
43 | val regions = mutableListOf>()
44 |
45 | path.fastForEach { (p1, p2) ->
46 | var (x1, y1) = walkDiagonal(p1, p2, regions)
47 | val (x2, y2) = p2
48 |
49 | val dY = y2 - y1
50 | val dX = x2 - x1
51 | when {
52 | dY > dX -> {
53 | regions += interpretRegion(x1, y1, x1, y1 + 1)
54 | y1++
55 | }
56 | dY < dX -> {
57 | regions += interpretRegion(x1, y1, x1 + 1, y1)
58 | x1++
59 | }
60 | }
61 |
62 | walkDiagonal(Point(x1, y1), p2, regions)
63 | }
64 |
65 | return regions
66 | }
67 |
68 | private fun walkDiagonal(
69 | start: Point,
70 | end: Point,
71 | regionsOutput: MutableList>
72 | ): Point {
73 | var (x1, y1) = start
74 | val (x2, y2) = end
75 | while (x1 < x2 && y1 < y2 && original[x1] == updated[y1]) {
76 | regionsOutput += interpretRegion(x1, y1, x1 + 1, y1 + 1)
77 | x1++
78 | y1++
79 | }
80 |
81 | return Point(x1, y1)
82 | }
83 |
84 | private fun interpretRegion(
85 | x1: Int,
86 | y1: Int,
87 | x2: Int,
88 | y2: Int
89 | ): MyersDiffOperation = when {
90 | x1 == x2 -> Insert(value = updated[y1])
91 | y1 == y2 -> Delete
92 | else -> Skip
93 | }
94 |
95 | private fun findPath(): List {
96 | val snakes = mutableListOf()
97 | val stack = mutableListOf()
98 |
99 | stack.push(
100 | Region(
101 | left = 0,
102 | top = 0,
103 | right = original.size,
104 | bottom = updated.size
105 | )
106 | )
107 |
108 | while (stack.isNotEmpty()) {
109 | val region = stack.pop()
110 |
111 | val snake = midpoint(region)
112 | if (snake != null) {
113 | snakes += snake
114 | val (start, finish) = snake
115 |
116 | stack.push(
117 | region.copy(
118 | right = start.x,
119 | bottom = start.y
120 | )
121 | )
122 |
123 | stack.push(
124 | region.copy(
125 | left = finish.x,
126 | top = finish.y
127 | )
128 | )
129 | }
130 | }
131 |
132 | snakes.sort()
133 | return snakes
134 | }
135 |
136 | private fun midpoint(region: Region): Snake? {
137 | if (region.size == 0) {
138 | return null
139 | }
140 |
141 | val max = ceil(region.size / 2.0f).toInt()
142 |
143 | val vForwards = CircularIntArray(2 * max + 1)
144 | vForwards[1] = region.left
145 | val vBackwards = CircularIntArray(2 * max + 1)
146 | vBackwards[1] = region.bottom
147 |
148 | for (depth in 0..max) {
149 | forwards(region, vForwards, vBackwards, depth)?.let { return it }
150 | backwards(region, vForwards, vBackwards, depth)?.let { return it }
151 | }
152 |
153 | return null
154 | }
155 |
156 | private fun forwards(
157 | region: Region,
158 | vForwards: CircularIntArray,
159 | vBackwards: CircularIntArray,
160 | depth: Int
161 | ): Snake? {
162 | // This loop is effectively `for (k in (-depth..depth step 2).reversed())`, but avoids
163 | // allocating a Range object.
164 | var k = depth
165 | while (k >= -depth) {
166 | val c = k - region.delta
167 |
168 | var endX: Int
169 | val startX: Int
170 |
171 | if (k == -depth || (k != -depth && vForwards[k - 1] < vForwards[k + 1])) {
172 | startX = vForwards[k + 1]
173 | endX = startX
174 | } else {
175 | startX = vForwards[k - 1]
176 | endX = startX + 1
177 | }
178 |
179 | var endY = region.top + (endX - region.left) - k
180 | val startY = if (depth == 0 || endX != startX) endY else endY - 1
181 |
182 | while (endX < region.right &&
183 | endY < region.bottom &&
184 | original[endX] == updated[endY]
185 | ) {
186 | endX++
187 | endY++
188 | }
189 |
190 | vForwards[k] = endX
191 |
192 | if (region.delta.isOdd() && c in -(depth - 1) until depth && endY >= vBackwards[c]) {
193 | return Snake(
194 | start = Point(startX, startY),
195 | end = Point(endX, endY)
196 | )
197 | }
198 |
199 | k -= 2
200 | }
201 |
202 | return null
203 | }
204 |
205 | private fun backwards(
206 | region: Region,
207 | vForwards: CircularIntArray,
208 | vBackwards: CircularIntArray,
209 | depth: Int
210 | ): Snake? {
211 | // This loop is effectively `for (c in (-depth..depth step 2).reversed())`, but avoids
212 | // allocating a Range object.
213 | var c = depth
214 | while (c >= -depth) {
215 | val k = c + region.delta
216 |
217 | val endY: Int
218 | var startY: Int
219 |
220 | if (c == -depth || (c != depth && vBackwards[c - 1] > vBackwards[c + 1])) {
221 | endY = vBackwards[c + 1]
222 | startY = endY
223 | } else {
224 | endY = vBackwards[c - 1]
225 | startY = endY - 1
226 | }
227 |
228 | var startX = region.left + (startY - region.top) + k
229 | val endX = if (depth == 0 || startY != endY) startX else startX + 1
230 |
231 | while (startX > region.left &&
232 | startY > region.top &&
233 | original[startX - 1] == updated[startY - 1]
234 | ) {
235 | startX--
236 | startY--
237 | }
238 |
239 | vBackwards[c] = startY
240 |
241 | if (region.delta.isEven() && k in -depth..depth && startX <= vForwards[k]) {
242 | return Snake(
243 | start = Point(startX, startY),
244 | end = Point(endX, endY)
245 | )
246 | }
247 |
248 | c -= 2
249 | }
250 |
251 | return null
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/difference/src/commonTest/kotlin/dev/andrewbailey/diff/DiffGeneratorTest.kt:
--------------------------------------------------------------------------------
1 | package dev.andrewbailey.diff
2 |
3 | import dev.andrewbailey.diff.DiffOperation.Add
4 | import dev.andrewbailey.diff.DiffOperation.AddAll
5 | import dev.andrewbailey.diff.DiffOperation.Move
6 | import dev.andrewbailey.diff.DiffOperation.MoveRange
7 | import dev.andrewbailey.diff.DiffOperation.Remove
8 | import dev.andrewbailey.diff.DiffOperation.RemoveRange
9 | import kotlin.test.Test
10 | import kotlin.test.assertEquals
11 |
12 | class DiffGeneratorTest {
13 |
14 | @Test
15 | fun generateDiff_withEmptyInput_returnsEmptyResult() {
16 | val original = emptyList()
17 | val updated = emptyList()
18 |
19 | val diff = DiffGenerator.generateDiff(original, updated, false)
20 |
21 | assertEquals(
22 | message = "The returned diff did not match the expected value.",
23 | expected = DiffResult(emptyList()),
24 | actual = diff
25 | )
26 |
27 | assertEquals(
28 | message = "Applying the diff to the input did not yield the updated value.",
29 | expected = updated,
30 | actual = applyDiff(original, diff)
31 | )
32 | }
33 |
34 | @Test
35 | fun generateDiff_withEmptyStart_returnsAdditions() {
36 | val original = emptyList()
37 | val updated = listOf("A", "B", "C")
38 |
39 | val diff = DiffGenerator.generateDiff(original, updated, false)
40 |
41 | assertEquals(
42 | message = "The returned diff did not match the expected value.",
43 | expected = DiffResult(
44 | listOf(
45 | AddAll(
46 | index = 0,
47 | items = listOf("A", "B", "C")
48 | )
49 | )
50 | ),
51 | actual = diff
52 | )
53 |
54 | assertEquals(
55 | message = "Applying the diff to the input did not yield the updated value.",
56 | expected = updated,
57 | actual = applyDiff(original, diff)
58 | )
59 | }
60 |
61 | @Test
62 | fun generateDiff_withEmptyEnd_returnsDeletions() {
63 | val original = listOf("A", "B", "C")
64 | val updated = emptyList()
65 |
66 | val diff = DiffGenerator.generateDiff(original, updated, false)
67 |
68 | assertEquals(
69 | message = "The returned diff did not match the expected value.",
70 | expected = DiffResult(
71 | listOf(
72 | RemoveRange(
73 | startIndex = 0,
74 | endIndex = 3
75 | )
76 | )
77 | ),
78 | actual = diff
79 | )
80 |
81 | assertEquals(
82 | message = "Applying the diff to the input did not yield the updated value.",
83 | expected = updated,
84 | actual = applyDiff(original, diff)
85 | )
86 | }
87 |
88 | @Test
89 | fun generateDiff_withSameStartAndEnd_returnsEmptyDiff() {
90 | val original = listOf("A", "B", "C")
91 | val updated = listOf("A", "B", "C")
92 |
93 | val diff = DiffGenerator.generateDiff(original, updated, false)
94 |
95 | assertEquals(
96 | message = "The returned diff did not match the expected value.",
97 | expected = DiffResult(emptyList()),
98 | actual = diff
99 | )
100 |
101 | assertEquals(
102 | message = "Applying the diff to the input did not yield the updated value.",
103 | expected = updated,
104 | actual = applyDiff(original, diff)
105 | )
106 | }
107 |
108 | @Test
109 | fun generateDiff_withoutMoves_calculatesComplexDiff() {
110 | val original = "ABCDEFGHJKLPQR".toList()
111 | val updated = "BCAGHIJLMNOPQR".toList()
112 |
113 | val diff = DiffGenerator.generateDiff(
114 | original = original,
115 | updated = updated,
116 | detectMoves = false
117 | )
118 |
119 | assertEquals(
120 | message = "The returned diff did not match the expected value.",
121 | expected = DiffResult(
122 | listOf(
123 | Remove(index = 0, item = 'A'),
124 | RemoveRange(startIndex = 2, endIndex = 5),
125 | Add(index = 2, item = 'A'),
126 | Add(index = 5, item = 'I'),
127 | Remove(index = 7, item = 'K'),
128 | AddAll(index = 8, items = "MNO".toList())
129 | )
130 | ),
131 | actual = diff
132 | )
133 |
134 | assertEquals(
135 | message = "Applying the diff to the input did not yield the updated value.",
136 | expected = updated,
137 | actual = applyDiff(original, diff)
138 | )
139 | }
140 |
141 | @Test
142 | fun generateDiff_detectsForwardsAndBackwardsMovements() {
143 | val original = "CADEFB".toList()
144 | val updated = "ABCDEF".toList()
145 |
146 | val diff = DiffGenerator.generateDiff(
147 | original = original,
148 | updated = updated,
149 | detectMoves = true
150 | )
151 |
152 | assertEquals(
153 | message = "The returned diff did not match the expected value.",
154 | expected = DiffResult(
155 | listOf(
156 | Move(
157 | fromIndex = 0,
158 | toIndex = 2
159 | ),
160 | Move(
161 | fromIndex = 5,
162 | toIndex = 1
163 | )
164 | )
165 | ),
166 | actual = diff
167 | )
168 |
169 | assertEquals(
170 | message = "Applying the diff to the input did not yield the updated value.",
171 | expected = updated,
172 | actual = applyDiff(original, diff)
173 | )
174 | }
175 |
176 | @Test
177 | fun generateDiff_detectsMoveForwardsSequences() {
178 | val original = "ABCDEFGHIJKL".toList()
179 | val updated = "ABCGHIJKLDEF".toList()
180 |
181 | val diff = DiffGenerator.generateDiff(
182 | original = original,
183 | updated = updated,
184 | detectMoves = true
185 | )
186 |
187 | assertEquals(
188 | message = "The returned diff did not match the expected value.",
189 | expected = DiffResult(
190 | listOf(
191 | MoveRange(
192 | fromIndex = 3,
193 | toIndex = 12,
194 | itemCount = 3
195 | )
196 | )
197 | ),
198 | actual = diff
199 | )
200 |
201 | assertEquals(
202 | message = "Applying the diff to the input did not yield the updated value.",
203 | expected = updated,
204 | actual = applyDiff(original, diff)
205 | )
206 | }
207 |
208 | @Test
209 | fun generateDiff_DetectsMoveBackwardsSequences() {
210 | val original = "ABCDEFGHIJKL".toList()
211 | val updated = "HIJABCDEFGKL".toList()
212 |
213 | val diff = DiffGenerator.generateDiff(
214 | original = original,
215 | updated = updated,
216 | detectMoves = true
217 | )
218 |
219 | assertEquals(
220 | message = "The returned diff did not match the expected value.",
221 | expected = DiffResult(
222 | listOf(
223 | MoveRange(
224 | fromIndex = 7,
225 | toIndex = 0,
226 | itemCount = 3
227 | )
228 | )
229 | ),
230 | actual = diff
231 | )
232 |
233 | assertEquals(
234 | message = "Applying the diff to the input did not yield the updated value.",
235 | expected = updated,
236 | actual = applyDiff(original, diff)
237 | )
238 | }
239 |
240 | @Test
241 | fun generateDiff_DetectsAdjacentMovesToDifferentDestinations() {
242 | val original = "ABCDEFGHIJKL".toList()
243 | val updated = "DABCGHEIJKLF".toList()
244 |
245 | val diff = DiffGenerator.generateDiff(
246 | original = original,
247 | updated = updated,
248 | detectMoves = true
249 | )
250 |
251 | assertEquals(
252 | message = "The returned diff did not match the expected value.",
253 | expected = DiffResult(
254 | listOf(
255 | Move(
256 | fromIndex = 3,
257 | toIndex = 0
258 | ),
259 | Move(
260 | fromIndex = 4,
261 | toIndex = 8
262 | ),
263 | Move(
264 | fromIndex = 4,
265 | toIndex = 12
266 | )
267 | )
268 | ),
269 | actual = diff
270 | )
271 |
272 | assertEquals(
273 | message = "Applying the diff to the input did not yield the updated value.",
274 | expected = updated,
275 | actual = applyDiff(original, diff)
276 | )
277 | }
278 |
279 | @Test
280 | fun generateDiff_withMoves_excludesOppositeOperationsFromMerging() {
281 | val original = listOf(3, 2, 3, 0, 0, 3, 1, 0, 1, 2)
282 | val updated = listOf(1, 3, 2, 0, 12, 0, 15, 0, 1, 2, 3)
283 |
284 | val diff = DiffGenerator.generateDiff(
285 | original = original,
286 | updated = updated,
287 | detectMoves = true
288 | )
289 |
290 | assertEquals(
291 | message = "The returned diff did not match the expected value.",
292 | expected = DiffResult(
293 | listOf(
294 | Move(
295 | fromIndex = 6,
296 | toIndex = 0
297 | ),
298 | Move(
299 | fromIndex = 3,
300 | toIndex = 10
301 | ),
302 | Add(
303 | index = 4,
304 | item = 12
305 | ),
306 | Remove(
307 | index = 6,
308 | item = 3
309 | ),
310 | Add(
311 | index = 6,
312 | item = 15
313 | )
314 | )
315 | ),
316 | actual = diff
317 | )
318 |
319 | assertEquals(
320 | message = "Applying the diff to the input did not yield the updated value.",
321 | expected = updated,
322 | actual = applyDiff(original, diff)
323 | )
324 | }
325 |
326 | private fun applyDiff(original: List, diff: DiffResult): List =
327 | original.toMutableList().apply {
328 | diff.applyDiff(
329 | remove = { index -> removeAt(index) },
330 | insert = { item, index -> add(index, item) },
331 | move = { oldIndex, newIndex ->
332 | add(
333 | element = removeAt(oldIndex),
334 | index = if (newIndex < oldIndex) {
335 | newIndex
336 | } else {
337 | newIndex - 1
338 | }
339 | )
340 | }
341 | )
342 | }
343 | }
344 |
--------------------------------------------------------------------------------