feature: use myers's difference alogorithm to improve performance

This commit is contained in:
wzxjiang 2018-12-14 19:59:40 +08:00
parent 3246d71358
commit 26f1a8940e
13 changed files with 540 additions and 368 deletions

View File

@ -4,26 +4,56 @@
[![codecov](https://codecov.io/gh/Wzxhaha/Sdifft/branch/master/graph/badge.svg)](https://codecov.io/gh/Wzxhaha/Sdifft) [![codecov](https://codecov.io/gh/Wzxhaha/Sdifft/branch/master/graph/badge.svg)](https://codecov.io/gh/Wzxhaha/Sdifft)
[![codebeat badge](https://codebeat.co/badges/d37a19b5-3d38-45ae-a7c5-5e453826188d)](https://codebeat.co/projects/github-com-wzxhaha-sdifft-master) [![codebeat badge](https://codebeat.co/badges/d37a19b5-3d38-45ae-a7c5-5e453826188d)](https://codebeat.co/projects/github-com-wzxhaha-sdifft-master)
Using the LCS to compare differences between two strings Using [`the Myers's Difference Algorithm`](http://www.xmailserver.org/diff2.pdf) to compare differences between two equatable element
## Example ## Example(String)
```swift ```swift
impoort Sdifft impoort Sdifft
let to = "abcd" let source = "b"
let from = "b" let target = "abcd"
let diff = Diff(from: from, to: to) let diff = Diff(source: from, target: to)
/// Get diff modifications diff.scripts // [.insert(into: 3), .insert(into: 2), .same(into: 1), .insert(into: 0)]
diff.modifications // [(add: "a", delete: nil, same: "b"), (add: "cd", delete: nil, same: nil)]
/// Get same/add/delete
let same = diff.modifications.compactMap { $0.same }
...
/// Get diff attributedString /// Get diff attributedString
let diffAttributes = DiffAttributes(add: [.backgroundColor: UIColor.green]], delete: [.backgroundColor: UIColor.red], same: [.backgroundColor: UIColor.white]) let diffAttributes =
let attributedString = NSAttributedString.attributedString(with: diff, attributes: diffAttributes) DiffAttributes(
insert: [.backgroundColor: UIColor.green]],
delete: [.backgroundColor: UIColor.red],
same: [.backgroundColor: UIColor.white]
)
let attributedString = NSAttributedString(source: source, target: target, attributes: diffAttributes)
// output ->
// a{green}b{black}cd{green}
```
## Example(Line)
```swift
impoort Sdifft
let source = ["Hello"]
let target = ["Hello", "World", "!"]
let attributedString =
NSAttributedString(source: source, target: target, attributes: diffAttributes) {
let string = NSMutableAttributedString(attributedString: string)
string.append(NSAttributedString(string: "\n"))
switch script {
case .delete:
string.insert(NSAttributedString(string: "- "), at: 0)
case .insert:
string.insert(NSAttributedString(string: "+ "), at: 0)
case .same:
string.insert(NSAttributedString(string: " "), at: 0)
}
return string
}
// output ->
// Hello
// + World{green}
// + !{green}
``` ```
## Installation ## Installation

View File

@ -0,0 +1,40 @@
import UIKit
import Sdifft
// Difference between two strings
let source = "Hallo world"
let target = "typo: Hello World!"
let font = UIFont.systemFont(ofSize: 20)
let insertAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: UIColor.green,
.font: font
]
let deleteAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: UIColor.red,
.font: font,
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: UIColor.red,
.baselineOffset: 0
]
let sameAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.black,
.font: font
]
let attributedString1 =
NSAttributedString(
source: source, target: target,
attributes: DiffAttributes(insert: insertAttributes, delete: deleteAttributes, same: sameAttributes)
)
// Difference between two lines
let sourceLines = ["I'm coding with Swift"]
let targetLines = ["Today", "I'm coding with Swift", "lol"]
let attributedString2 =
NSAttributedString(
source: sourceLines, target: targetLines,
attributes: DiffAttributes(insert: insertAttributes, delete: deleteAttributes, same: sameAttributes)
)

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' executeOnSourceChanges='false'>
<timeline fileName='timeline.xctimeline'/>
</playground>

View File

@ -21,7 +21,6 @@
/* End PBXAggregateTarget section */ /* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1E4BF61B21708396004C5E1F /* DiffSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4BF61A21708396004C5E1F /* DiffSequence.swift */; };
1EB1AD2720BD5E22004D0450 /* NSAttributedString+Diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */; }; 1EB1AD2720BD5E22004D0450 /* NSAttributedString+Diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */; };
1EB1AD2920BD640B004D0450 /* NSAttributedString+DiffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB1AD2820BD640B004D0450 /* NSAttributedString+DiffTests.swift */; }; 1EB1AD2920BD640B004D0450 /* NSAttributedString+DiffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB1AD2820BD640B004D0450 /* NSAttributedString+DiffTests.swift */; };
OBJ_21 /* Diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* Diff.swift */; }; OBJ_21 /* Diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* Diff.swift */; };
@ -48,7 +47,7 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
1E4BF61A21708396004C5E1F /* DiffSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffSequence.swift; sourceTree = "<group>"; }; 1E78630721C394C5006F4912 /* Sdifft.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Sdifft.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Diff.swift"; sourceTree = "<group>"; }; 1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Diff.swift"; sourceTree = "<group>"; };
1EB1AD2820BD640B004D0450 /* NSAttributedString+DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+DiffTests.swift"; sourceTree = "<group>"; }; 1EB1AD2820BD640B004D0450 /* NSAttributedString+DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+DiffTests.swift"; sourceTree = "<group>"; };
OBJ_12 /* DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffTests.swift; sourceTree = "<group>"; }; OBJ_12 /* DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffTests.swift; sourceTree = "<group>"; };
@ -107,6 +106,7 @@
OBJ_5 = { OBJ_5 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1E78630721C394C5006F4912 /* Sdifft.playground */,
OBJ_6 /* Package.swift */, OBJ_6 /* Package.swift */,
OBJ_7 /* Sources */, OBJ_7 /* Sources */,
OBJ_10 /* Tests */, OBJ_10 /* Tests */,
@ -126,7 +126,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
OBJ_9 /* Diff.swift */, OBJ_9 /* Diff.swift */,
1E4BF61A21708396004C5E1F /* DiffSequence.swift */,
1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */, 1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */,
); );
name = Sdifft; name = Sdifft;
@ -229,7 +228,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "if which swiftlint >/dev/null;\nthen\nswiftlint\n#cd Teambition&&swiftlint\nelse\necho \"SwiftLint does not exist, download from https://github.com/realm/SwiftLint\"\nfi"; shellScript = "if which swiftlint >/dev/null;\nthen\nswiftlint\nelse\necho \"SwiftLint does not exist, download from https://github.com/realm/SwiftLint\"\nfi\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
@ -238,7 +237,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 0; buildActionMask = 0;
files = ( files = (
1E4BF61B21708396004C5E1F /* DiffSequence.swift in Sources */,
1EB1AD2720BD5E22004D0450 /* NSAttributedString+Diff.swift in Sources */, 1EB1AD2720BD5E22004D0450 /* NSAttributedString+Diff.swift in Sources */,
OBJ_21 /* Diff.swift in Sources */, OBJ_21 /* Diff.swift in Sources */,
); );

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>DiffTests</key>
<dict>
<key>testTime()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>0.36825</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>DiffTests</key>
<dict>
<key>testTime()</key>
<dict>
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
<dict>
<key>baselineAverage</key>
<real>0.8</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>A8E95969-EF02-4FA2-9760-6D628059A4C2</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>400</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Intel Core i5</string>
<key>cpuSpeedInMHz</key>
<integer>2300</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>modelCode</key>
<string>MacBookPro15,2</string>
<key>physicalCPUCoresPerPackage</key>
<integer>4</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>x86_64</string>
<key>targetDevice</key>
<dict>
<key>modelCode</key>
<string>iPhone10,6</string>
<key>platformIdentifier</key>
<string>com.apple.platform.iphonesimulator</string>
</dict>
</dict>
<key>DCB9F5A5-E22D-4494-A0DC-4412307D88CA</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>400</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Intel Core i5</string>
<key>cpuSpeedInMHz</key>
<integer>2300</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>modelCode</key>
<string>MacBookPro15,2</string>
<key>physicalCPUCoresPerPackage</key>
<integer>4</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>x86_64</string>
<key>targetDevice</key>
<dict>
<key>modelCode</key>
<string>iPhone11,8</string>
<key>platformIdentifier</key>
<string>com.apple.platform.iphonesimulator</string>
</dict>
</dict>
</dict>
</dict>
</plist>

View File

@ -27,104 +27,107 @@
import Foundation import Foundation
typealias Matrix = [[Int]] // swiftlint:disable identifier_name
public enum DiffScript {
case insert(into: Int)
case delete(at: Int)
case same(at: Int)
}
struct Vertice: Equatable {
let x, y: Int
static func == (lhs: Vertice, rhs: Vertice) -> Bool {
return lhs.x == rhs.x && lhs.y == rhs.y
}
}
struct Path {
let from, to: Vertice
let script: DiffScript
}
// swiftlint:disable identifier_name // swiftlint:disable identifier_name
/// Draw LCS matrix with two `DiffSequence` public class Diff<T: Equatable & Hashable> {
/// let scripts: [DiffScript]
/// - Parameters:
/// - from: DiffSequence public init(source: [T], target: [T]) {
/// - to: DiffSequence that be compared if source.isEmpty, target.isEmpty {
/// - Returns: matrix scripts = []
func drawMatrix<T: DiffSequence>(from: T, to: T) -> Matrix { } else if source.isEmpty, !target.isEmpty {
let row = from.count + 1 // Under normal circumstances, scripts is a reversed (index) array
let column = to.count + 1 // you need to reverse the array youself if need.
var result: [[Int]] = Array(repeating: Array(repeating: 0, count: column), count: row) scripts = (0..<target.count).reversed().compactMap { DiffScript.insert(into: $0) }
for i in 1..<row { } else if !source.isEmpty, target.isEmpty {
for j in 1..<column { scripts = (0..<source.count).reversed().compactMap { DiffScript.delete(at: $0) }
if from.index(of: i - 1) == to.index(of: j - 1) { } else {
result[i][j] = result[i - 1][j - 1] + 1 let paths = Diff.exploreEditGraph(source: source, target: target)
} else { scripts = Diff.reverseTree(paths: paths, sinkVertice: .init(x: target.count, y: source.count))
result[i][j] = max(result[i][j - 1], result[i - 1][j]) }
}
static func exploreEditGraph(source: [T], target: [T]) -> [Path] {
let max = source.count + target.count
var furthest = Array(repeating: 0, count: 2 * max + 1)
var paths: [Path] = []
let snake: (Int, Int, Int) -> Int = { x, d, k in
var _x = x
var y: Int { return _x - k }
while _x < target.count && y < source.count && source[y] == target[_x] {
_x += 1
paths.append(
Path(from: .init(x: _x - 1, y: y - 1), to: .init(x: _x, y: y), script: .same(at: _x - 1))
)
}
return _x
}
for d in 0...max {
for k in stride(from: -d, through: d, by: 2) {
let index = k + max
var x = 0
var y: Int { return x - k }
// swiftlint:disable statement_position
if d == 0 { }
else if k == -d || k != d && furthest[index - 1] < furthest[index + 1] {
// moving bottom
x = furthest[index + 1]
paths.append(
Path(
from: .init(x: x, y: y - 1), to: .init(x: x, y: y),
script: .delete(at: y - 1)
)
)
} else {
// moving right
x = furthest[index - 1] + 1
paths.append(
Path(
from: .init(x: x - 1, y: y), to: .init(x: x, y: y),
script: .insert(into: x - 1)
)
)
}
x = snake(x, d, k)
if x == target.count, y == source.count {
return paths
}
furthest[index] = x
} }
} }
return []
} }
return result
}
typealias Position = (row: Int, column: Int) // Search for the path from the back to the front
typealias DiffIndex = (from: Int, to: Int) static func reverseTree(paths: [Path], sinkVertice: Vertice) -> [DiffScript] {
var scripts: [DiffScript] = []
// swiftlint:disable line_length var next = sinkVertice
/// LCS paths.reversed().forEach {
/// guard $0.to == next else { return }
/// - Parameters: next = $0.from
/// - from: DiffSequence scripts.append($0.script)
/// - to: DiffSequence that be compared
/// - position: current position
/// - matrix: matrix
/// - same: same element's indexes
/// - Returns: same element's indexes
func lcs<T: DiffSequence>(from: T, to: T, position: Position, matrix: Matrix, same: [DiffIndex]) -> [DiffIndex] {
if position.row == 0 || position.column == 0 {
return same
}
if from.index(of: position.row - 1) == to.index(of: position.column - 1) {
return lcs(from: from, to: to, position: (position.row - 1, position.column - 1), matrix: matrix, same: same + [(position.row - 1, position.column - 1)])
} else if matrix[position.row - 1][position.column] >= matrix[position.row][position.column - 1] {
return lcs(from: from, to: to, position: (position.row - 1, position.column), matrix: matrix, same: same)
} else {
return lcs(from: from, to: to, position: (position.row, position.column - 1), matrix: matrix, same: same)
}
}
public struct Modification<Element: DiffSequence> {
public let add, delete, same: Element?
}
extension Array where Element == DiffIndex {
func modifications<T: DiffSequence>(from: T, to: T) -> [Modification<T>] {
var modifications: [Modification<T>] = []
var lastFrom = 0
var lastTo = 0
modifications += map {
let modification =
Modification<T>(
add: lastTo <= $0.to - 1 ? to.element(withRange: lastTo...$0.to - 1) : nil,
delete: lastFrom <= $0.from - 1 ? from.element(withRange: lastFrom...$0.from - 1) : nil,
same: to.element(withRange: $0.to...$0.to)
)
lastFrom = $0.from + 1
lastTo = $0.to + 1
return modification
} }
if lastFrom <= from.count - 1 || lastTo <= to.count - 1 { return scripts
modifications.append(
Modification<T>(
add: lastTo <= to.count - 1 ? to.element(withRange: lastTo...to.count - 1) : nil,
delete: lastFrom <= from.count - 1 ? from.element(withRange: lastFrom...from.count - 1) : nil,
same: nil
)
)
}
return modifications
}
}
public struct Diff<T: DiffSequence> {
public let modifications: [Modification<T>]
let matrix: Matrix
let from, to: T
public init(from: T, to: T) {
self.from = from
self.to = to
// because LCS is 'bottom-up'
// so them need be reversed to get the normal sequence
let reversedFrom = from.reversedElement()
let reversedTo = to.reversedElement()
matrix = drawMatrix(from: reversedFrom, to: reversedTo)
var same = lcs(from: reversedFrom, to: reversedTo, position: (from.count, to.count), matrix: matrix, same: [])
same = same.map { (from.count - 1 - $0, to.count - 1 - $1) }
modifications = same.modifications(from: from, to: to)
} }
} }

View File

@ -1,61 +0,0 @@
//
// DiffSequence.swift
// Sdifft
//
// Created by WzxJiang on 18/5/23.
// Copyright © 2018 WzxJiang. All rights reserved.
//
// https://github.com/Wzxhaha/Sdifft
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
public protocol DiffSequence: Equatable {
func index(of idx: Int) -> Self
func element(withRange range: CountableClosedRange<Int>) -> Self
func reversedElement() -> Self
var count: Int { get }
}
extension String: DiffSequence {
public func index(of idx: Int) -> String {
return String(self[index(startIndex, offsetBy: idx)])
}
public func element(withRange range: CountableClosedRange<Int>) -> String {
let start = index(startIndex, offsetBy: range.lowerBound)
let end = index(startIndex, offsetBy: range.upperBound)
return String(self[start...end])
}
public func reversedElement() -> String {
return String(reversed())
}
}
extension Array: DiffSequence where Element == String {
public func index(of idx: Int) -> [Element] {
return [self[idx]]
}
public func element(withRange range: CountableClosedRange<Int>) -> [Element] {
return Array(self[range])
}
public func reversedElement() -> [Element] {
return reversed()
}
}

View File

@ -27,51 +27,116 @@
import Foundation import Foundation
extension String {
subscript(_ idx: Int) -> String {
return String(self[index(startIndex, offsetBy: idx)])
}
}
public struct DiffAttributes { public struct DiffAttributes {
public let add, delete, same: [NSAttributedString.Key: Any] public let insert, delete, same: [NSAttributedString.Key: Any]
// swiftlint:disable line_length public init(
public init(add: [NSAttributedString.Key: Any], delete: [NSAttributedString.Key: Any], same: [NSAttributedString.Key: Any]) { insert: [NSAttributedString.Key: Any],
self.add = add delete: [NSAttributedString.Key: Any],
same: [NSAttributedString.Key: Any]
) {
self.insert = insert
self.delete = delete self.delete = delete
self.same = same self.same = same
} }
} }
extension NSAttributedString { extension Array where Element == DiffScript {
/// Get attributedString with `Diff` and attributes func reverseIndex<T>(source: [T], target: [T]) -> [DiffScript] {
/// return map {
/// - Parameters: switch $0 {
/// - diff: Diff case .delete(at: let idx):
/// - attributes: DiffAttributes return DiffScript.delete(at: source.count - 1 - idx)
/// - Returns: NSAttributedString case .insert(into: let idx):
public static func attributedString(with diff: Diff<String>, attributes: DiffAttributes) -> NSAttributedString { return DiffScript.insert(into: target.count - 1 - idx)
let attributedString = NSMutableAttributedString() case .same(at: let idx):
diff.modifications.forEach { return DiffScript.same(at: target.count - 1 - idx)
if let add = $0.add {
attributedString.append(NSAttributedString(string: add, attributes: attributes.add))
}
if let delete = $0.delete {
attributedString.append(NSAttributedString(string: delete, attributes: attributes.delete))
}
if let same = $0.same {
attributedString.append(NSAttributedString(string: same, attributes: attributes.same))
} }
} }
return attributedString }
} }
public static func attributedString(with diff: Diff<[String]>, attributes: DiffAttributes) -> NSAttributedString {
let attributedString = NSMutableAttributedString() extension NSAttributedString {
diff.modifications.forEach { private static func script<T: Equatable & Hashable>(withSource source: [T], target: [T]) -> [DiffScript] {
if let add = $0.add { // The results under normal conditions aren't humanable
attributedString.append(NSAttributedString(string: add.joined(), attributes: attributes.add)) // because it's `Right-Left`
} // example:
if let delete = $0.delete { // source: "hallo"
attributedString.append(NSAttributedString(string: delete.joined(), attributes: attributes.delete)) // target: "typo hello"
} // result: "h{delete}type: he{insert}a{delete}llo{same}"
if let same = $0.same { //
attributedString.append(NSAttributedString(string: same.joined(), attributes: attributes.same)) // If we reverse source and target, we will get the results that humanable
} // example:
} // source: "hallo"
return attributedString // target: "typo hello"
// result: "type: {insert}h{same}a{delete}e{insert}llo"
return
Diff(source: source.reversed(), target: target.reversed())
.scripts
.reverseIndex(source: source, target: target)
}
public convenience init(source: String, target: String, attributes: DiffAttributes) {
let attributedString = NSMutableAttributedString()
let scripts = NSAttributedString.script(withSource: .init(source), target: .init(target))
scripts.forEach {
switch $0 {
case .insert(into: let idx):
attributedString.append(NSAttributedString(string: target[idx], attributes: attributes.insert))
case .delete(at: let idx):
attributedString.append(NSAttributedString(string: source[idx], attributes: attributes.delete))
case .same(at: let idx):
attributedString.append(NSAttributedString(string: target[idx], attributes: attributes.same))
}
}
self.init(attributedString: attributedString)
}
/// Difference between two lines
///
/// - Parameters:
/// - source: source
/// - target: target
/// - attributes: attributes
/// - handler: handler of each script's attributedString
///
/// For example:
/// let attributedString =
/// NSAttributedString(source: source, target: target, attributes: attributes) { script, string in
/// let string = NSMutableAttributedString(attributedString: string)
/// string.append(NSAttributedString(string: "\n"))
/// return string
/// }
public convenience init(
source: [String], target: [String],
attributes: DiffAttributes,
handler: ((DiffScript, NSAttributedString) -> NSAttributedString)? = nil
) {
let attributedString = NSMutableAttributedString()
let scripts = NSAttributedString.script(withSource: source, target: target)
scripts.forEach {
var scriptAttributedString: NSAttributedString
switch $0 {
case .insert(into: let idx):
scriptAttributedString = NSAttributedString(string: target[idx], attributes: attributes.insert)
case .delete(at: let idx):
scriptAttributedString = NSAttributedString(string: source[idx], attributes: attributes.delete)
case .same(at: let idx):
scriptAttributedString = NSAttributedString(string: target[idx], attributes: attributes.same)
}
if let handler = handler {
scriptAttributedString = handler($0, scriptAttributedString)
}
attributedString.append(scriptAttributedString)
}
self.init(attributedString: attributedString)
} }
} }

View File

@ -1,146 +1,60 @@
import XCTest import XCTest
@testable import Sdifft @testable import Sdifft
extension String { extension Array where Element == DiffScript {
subscript(idx: Int) -> String { var description: String {
return index(of: idx) var description = ""
} forEach {
subscript(range: CountableClosedRange<Int>) -> String { switch $0 {
return element(withRange: range) case .delete(at: let idx):
} description += "D{\(idx)}"
} case .insert(into: let idx):
description += "I{\(idx)}"
extension Array where Element == Modification<String> { case .same(at: let idx):
var sames: [String] { description += "U{\(idx)}"
return compactMap { $0.same } }
} }
var adds: [String] { return description
return compactMap { $0.add }
}
var deletes: [String] {
return compactMap { $0.delete }
}
}
extension Array where Element == Modification<[String]> {
var sames: [[String]] {
return compactMap { $0.same }
}
var adds: [[String]] {
return compactMap { $0.add }
}
var deletes: [[String]] {
return compactMap { $0.delete }
} }
} }
class DiffTests: XCTestCase { class DiffTests: XCTestCase {
func testMatrix() { func testDiff() {
assert( let scripts = Diff(source: .init("b"), target: .init("abcd")).scripts
drawMatrix(from: "abcd", to: "acd") == [
[0, 0, 0, 0],
[0, 1, 1, 1],
[0, 1, 1, 1],
[0, 1, 2, 2],
[0, 1, 2, 3]
]
)
assert(
drawMatrix(from: "abcdegh", to: "ae") == [
[0, 0, 0],
[0, 1, 1],
[0, 1, 1],
[0, 1, 1],
[0, 1, 1],
[0, 1, 2],
[0, 1, 2],
[0, 1, 2]
]
)
assert(
drawMatrix(from: "adf", to: "d") == [
[0, 0],
[0, 0],
[0, 1],
[0, 1]
]
)
assert(
drawMatrix(from: "d", to: "adf") == [
[0, 0, 0, 0],
[0, 0, 1, 1]
]
)
assert(
drawMatrix(from: "", to: "") == [
[0]
]
)
}
func testStringRange() {
assert( let expectations = [
"abc"[0] == "a" ("abc", "abc", "U{2}U{1}U{0}"),
) ("abc", "ab", "D{2}U{1}U{0}"),
assert( ("ab", "abc", "I{2}U{1}U{0}"),
"abc"[1] == "b" ("", "abc", "I{2}I{1}I{0}"),
) ("abc", "", "D{2}D{1}D{0}"),
assert( ("b", "ac", "D{0}I{1}I{0}"),
"abc"[2] == "c" ("", "", "")
) ]
} expectations.forEach {
let scripts = Diff(source: .init($0.0), target: .init($0.1)).scripts
func testModification() { XCTAssertTrue(
let to1 = "abcd" scripts.description == $0.2,
let from1 = "b" "\(scripts.description) is no equal to \($0.2)"
let diff1 = Diff(from: from1, to: to1) )
assert( }
diff1.modifications.sames == ["b"] &&
diff1.modifications.adds == ["a", "cd"] &&
diff1.modifications.deletes == []
)
let to2 = "abcd"
let from2 = "bx"
let diff2 = Diff(from: from2, to: to2)
assert(
diff2.modifications.sames == ["b"] &&
diff2.modifications.adds == ["a", "cd"] &&
diff2.modifications.deletes == ["x"]
)
let to3 = "A\r\nB\r\nC"
let from3 = "A\r\n\r\nB\r\n\r\nC"
let diff3 = Diff(from: from3, to: to3)
assert(
diff3.modifications.sames == ["A", "\r\n", "B", "\r\n", "C"] &&
diff3.modifications.adds == [] &&
diff3.modifications.deletes == ["\r\n", "\r\n"]
)
let to4 = ["a", "bc", "d", "c"]
let from4 = ["d"]
let diff4 = Diff(from: from4, to: to4)
assert(
diff4.modifications.sames == [["d"]] &&
diff4.modifications.adds == [["a", "bc"], ["c"]] &&
diff4.modifications.deletes == []
)
} }
// swiftlint:disable line_length // swiftlint:disable line_length
func testTime() { func testTime() {
// 1000 character * 1000 character: 3.540s // 1000 character * 1000 character: 0.8s
measure { measure {
_ = _ =
Diff( Diff(
from: "abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkbexjabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk123abcdhijkabcdhijkabcdhijkabcdhijk12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123", source: .init("abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkbexjabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk123abcdhijkabcdhijkabcdhijkabcdhijk12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123"),
to: "abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk" target: .init("abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk")
) )
} }
} }
static var allTests = [ static var allTests = [
("testMatrix", testMatrix), ("testDiff", testDiff),
("testStringRange", testStringRange),
("testModification", testModification),
("testTime", testTime) ("testTime", testTime)
] ]
} }

View File

@ -8,24 +8,33 @@ import UIKit
typealias Color = UIColor typealias Color = UIColor
#endif #endif
extension String {
subscript(range: CountableClosedRange<Int>) -> String {
let start = index(startIndex, offsetBy: range.lowerBound)
let end = index(startIndex, offsetBy: range.upperBound)
return String(self[start...end])
}
}
extension CGColor: CustomStringConvertible { extension CGColor: CustomStringConvertible {
public var description: String { public var description: String {
let comps: [CGFloat] = components ?? [0, 0, 0, 0] let comps: [CGFloat] = components ?? [0, 0, 0, 0]
if comps == [1, 0, 0, 1] { if comps == [1, 0, 0, 1] {
return "{red}" return "{D}"
} else if comps == [0, 1, 0, 1] { } else if comps == [0, 1, 0, 1] {
return "{green}" return "{I}"
} else { } else {
return "{black}" return "{U}"
} }
} }
} }
extension NSAttributedString { extension NSAttributedString {
// swiftlint:disable line_length
open override var description: String { open override var description: String {
var description = "" var description = ""
enumerateAttributes(in: NSRange(location: 0, length: string.count), options: .longestEffectiveRangeNotRequired) { (attributes, range, _) in enumerateAttributes(
in: NSRange(location: 0, length: string.count),
options: .longestEffectiveRangeNotRequired) { (attributes, range, _) in
let color = attributes[NSAttributedString.Key.backgroundColor] as? Color ?? Color.black let color = attributes[NSAttributedString.Key.backgroundColor] as? Color ?? Color.black
description += string[range.location...range.location + range.length - 1] + color.cgColor.description description += string[range.location...range.location + range.length - 1] + color.cgColor.description
} }
@ -34,40 +43,87 @@ extension NSAttributedString {
} }
class NSAttributedStringDiffTests: XCTestCase { class NSAttributedStringDiffTests: XCTestCase {
// swiftlint:disable line_length let insertAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: UIColor.green
]
let deleteAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: UIColor.red,
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.strikethroughColor: UIColor.red,
.baselineOffset: 0
]
let sameAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.black
]
func testAttributedString() { func testAttributedString() {
let diffAttributes = DiffAttributes(add: [.backgroundColor: Color.green], delete: [.backgroundColor: Color.red], same: [.backgroundColor: Color.black]) let expectations = [
let to1 = "abcdhijk" ("abc", "abc", "abc{U}"),
let from1 = "bexj" ("abc", "ab", "ab{U}c{D}"),
let diff1 = Diff(from: from1, to: to1) ("ab", "abc", "ab{U}c{I}"),
let attributedString1 = NSAttributedString.attributedString(with: diff1, attributes: diffAttributes) ("", "abc", "abc{I}"),
assert( ("abc", "", "abc{D}"),
attributedString1.debugDescription == "a{green}b{black}cdhi{green}ex{red}j{black}k{green}" ("b", "ac", "b{D}ac{I}"),
) ("abc", "ac", "a{U}b{D}c{U}"),
let to2 = "bexj" ("", "", "")
let from2 = "abcdhijk" ]
let diff2 = Diff(from: from2, to: to2) expectations.forEach {
let attributedString2 = NSAttributedString.attributedString(with: diff2, attributes: diffAttributes) let string =
assert( NSAttributedString(
attributedString2.debugDescription == "a{red}b{black}ex{green}cdhi{red}j{black}k{red}" source: $0.0, target: $0.1,
) attributes: DiffAttributes(insert: insertAttributes, delete: deleteAttributes, same: sameAttributes)
let to3 = ["bexj", "abc", "c"] )
let from3 = ["abcdhijk"] XCTAssertTrue(
let diff3 = Diff(from: from3, to: to3) string.description == $0.2,
let attributedString3 = NSAttributedString.attributedString(with: diff3, attributes: diffAttributes) "\(string.description) is no equal to \($0.2)"
assert( )
attributedString3.debugDescription == "bexjabcc{green}abcdhijk{red}" }
) }
let to4 = ["bexj", "abc", "c", "abc"]
let from4 = ["abcdhijk", "abc"] func testLines() {
let diff4 = Diff(from: from4, to: to4) let expectations: [([String], [String], [String])] = [
let attributedString4 = NSAttributedString.attributedString(with: diff4, attributes: diffAttributes) (["a", "b", "c"], ["a", "b", "c"], ["a", "b", "c"]),
assert( (["a", "b", "c"], ["a", "b"], ["a", "b", "-c"]),
attributedString4.debugDescription == "bexj{green}abcdhijk{red}abc{black}cabc{green}" (["a", "b"], ["a", "b", "c"], ["a", "b", "+c"]),
) ([], ["a", "b", "c"], ["+a", "+b", "+c"]),
(["a", "b", "c"], [], ["-a", "-b", "-c"]),
(["b"], ["a", "c"], ["-b", "+a", "+c"]),
(["a", "b", "c"], ["a", "c"], ["a", "-b", "c"]),
([], [], [])
]
expectations.forEach {
let string =
NSAttributedString(
source: $0.0, target: $0.1,
attributes: DiffAttributes(
insert: insertAttributes,
delete: deleteAttributes,
same: sameAttributes)
) { script, string in
let string = NSMutableAttributedString(attributedString: string)
string.append(NSAttributedString(string: "\n"))
switch script {
case .delete:
string.insert(NSAttributedString(string: "-"), at: 0)
case .insert:
string.insert(NSAttributedString(string: "+"), at: 0)
case .same:
break
}
return string
}
let result = string.string.split(separator: "\n").map { String($0) }
XCTAssertTrue(
result == $0.2,
"\(result) is no equal to \($0.2)"
)
}
} }
static var allTests = [ static var allTests = [
("testAttributedString", testAttributedString) ("testAttributedString", testAttributedString),
("testLines", testLines)
] ]
} }