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)
[![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
impoort Sdifft
let to = "abcd"
let from = "b"
let diff = Diff(from: from, to: to)
/// Get diff modifications
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 }
...
let source = "b"
let target = "abcd"
let diff = Diff(source: from, target: to)
diff.scripts // [.insert(into: 3), .insert(into: 2), .same(into: 1), .insert(into: 0)]
/// Get diff attributedString
let diffAttributes = DiffAttributes(add: [.backgroundColor: UIColor.green]], delete: [.backgroundColor: UIColor.red], same: [.backgroundColor: UIColor.white])
let attributedString = NSAttributedString.attributedString(with: diff, attributes: diffAttributes)
let 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

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 */
/* 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 */; };
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 */; };
@ -48,7 +47,7 @@
/* End PBXContainerItemProxy 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>"; };
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>"; };
@ -107,6 +106,7 @@
OBJ_5 = {
isa = PBXGroup;
children = (
1E78630721C394C5006F4912 /* Sdifft.playground */,
OBJ_6 /* Package.swift */,
OBJ_7 /* Sources */,
OBJ_10 /* Tests */,
@ -126,7 +126,6 @@
isa = PBXGroup;
children = (
OBJ_9 /* Diff.swift */,
1E4BF61A21708396004C5E1F /* DiffSequence.swift */,
1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */,
);
name = Sdifft;
@ -229,7 +228,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
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 */
@ -238,7 +237,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 0;
files = (
1E4BF61B21708396004C5E1F /* DiffSequence.swift in Sources */,
1EB1AD2720BD5E22004D0450 /* NSAttributedString+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
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
/// Draw LCS matrix with two `DiffSequence`
///
/// - Parameters:
/// - from: DiffSequence
/// - to: DiffSequence that be compared
/// - Returns: matrix
func drawMatrix<T: DiffSequence>(from: T, to: T) -> Matrix {
let row = from.count + 1
let column = to.count + 1
var result: [[Int]] = Array(repeating: Array(repeating: 0, count: column), count: row)
for i in 1..<row {
for j in 1..<column {
if from.index(of: i - 1) == to.index(of: j - 1) {
result[i][j] = result[i - 1][j - 1] + 1
} else {
result[i][j] = max(result[i][j - 1], result[i - 1][j])
public class Diff<T: Equatable & Hashable> {
let scripts: [DiffScript]
public init(source: [T], target: [T]) {
if source.isEmpty, target.isEmpty {
scripts = []
} else if source.isEmpty, !target.isEmpty {
// Under normal circumstances, scripts is a reversed (index) array
// you need to reverse the array youself if need.
scripts = (0..<target.count).reversed().compactMap { DiffScript.insert(into: $0) }
} else if !source.isEmpty, target.isEmpty {
scripts = (0..<source.count).reversed().compactMap { DiffScript.delete(at: $0) }
} else {
let paths = Diff.exploreEditGraph(source: source, target: target)
scripts = Diff.reverseTree(paths: paths, sinkVertice: .init(x: target.count, y: source.count))
}
}
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)
typealias DiffIndex = (from: Int, to: Int)
// swiftlint:disable line_length
/// LCS
///
/// - Parameters:
/// - from: DiffSequence
/// - 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
// Search for the path from the back to the front
static func reverseTree(paths: [Path], sinkVertice: Vertice) -> [DiffScript] {
var scripts: [DiffScript] = []
var next = sinkVertice
paths.reversed().forEach {
guard $0.to == next else { return }
next = $0.from
scripts.append($0.script)
}
if lastFrom <= from.count - 1 || lastTo <= to.count - 1 {
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)
return scripts
}
}

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
extension String {
subscript(_ idx: Int) -> String {
return String(self[index(startIndex, offsetBy: idx)])
}
}
public struct DiffAttributes {
public let add, delete, same: [NSAttributedString.Key: Any]
// swiftlint:disable line_length
public init(add: [NSAttributedString.Key: Any], delete: [NSAttributedString.Key: Any], same: [NSAttributedString.Key: Any]) {
self.add = add
public let insert, delete, same: [NSAttributedString.Key: Any]
public init(
insert: [NSAttributedString.Key: Any],
delete: [NSAttributedString.Key: Any],
same: [NSAttributedString.Key: Any]
) {
self.insert = insert
self.delete = delete
self.same = same
}
}
extension NSAttributedString {
/// Get attributedString with `Diff` and attributes
///
/// - Parameters:
/// - diff: Diff
/// - attributes: DiffAttributes
/// - Returns: NSAttributedString
public static func attributedString(with diff: Diff<String>, attributes: DiffAttributes) -> NSAttributedString {
let attributedString = NSMutableAttributedString()
diff.modifications.forEach {
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))
extension Array where Element == DiffScript {
func reverseIndex<T>(source: [T], target: [T]) -> [DiffScript] {
return map {
switch $0 {
case .delete(at: let idx):
return DiffScript.delete(at: source.count - 1 - idx)
case .insert(into: let idx):
return DiffScript.insert(into: target.count - 1 - idx)
case .same(at: let idx):
return DiffScript.same(at: target.count - 1 - idx)
}
}
return attributedString
}
public static func attributedString(with diff: Diff<[String]>, attributes: DiffAttributes) -> NSAttributedString {
let attributedString = NSMutableAttributedString()
diff.modifications.forEach {
if let add = $0.add {
attributedString.append(NSAttributedString(string: add.joined(), attributes: attributes.add))
}
if let delete = $0.delete {
attributedString.append(NSAttributedString(string: delete.joined(), attributes: attributes.delete))
}
if let same = $0.same {
attributedString.append(NSAttributedString(string: same.joined(), attributes: attributes.same))
}
}
return attributedString
}
}
extension NSAttributedString {
private static func script<T: Equatable & Hashable>(withSource source: [T], target: [T]) -> [DiffScript] {
// The results under normal conditions aren't humanable
// because it's `Right-Left`
// example:
// source: "hallo"
// target: "typo hello"
// result: "h{delete}type: he{insert}a{delete}llo{same}"
//
// If we reverse source and target, we will get the results that humanable
// example:
// source: "hallo"
// 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
@testable import Sdifft
extension String {
subscript(idx: Int) -> String {
return index(of: idx)
}
subscript(range: CountableClosedRange<Int>) -> String {
return element(withRange: range)
}
}
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 }
}
}
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 }
extension Array where Element == DiffScript {
var description: String {
var description = ""
forEach {
switch $0 {
case .delete(at: let idx):
description += "D{\(idx)}"
case .insert(into: let idx):
description += "I{\(idx)}"
case .same(at: let idx):
description += "U{\(idx)}"
}
}
return description
}
}
class DiffTests: XCTestCase {
func testMatrix() {
assert(
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 testDiff() {
let scripts = Diff(source: .init("b"), target: .init("abcd")).scripts
func testStringRange() {
assert(
"abc"[0] == "a"
)
assert(
"abc"[1] == "b"
)
assert(
"abc"[2] == "c"
)
}
func testModification() {
let to1 = "abcd"
let from1 = "b"
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 == []
)
let expectations = [
("abc", "abc", "U{2}U{1}U{0}"),
("abc", "ab", "D{2}U{1}U{0}"),
("ab", "abc", "I{2}U{1}U{0}"),
("", "abc", "I{2}I{1}I{0}"),
("abc", "", "D{2}D{1}D{0}"),
("b", "ac", "D{0}I{1}I{0}"),
("", "", "")
]
expectations.forEach {
let scripts = Diff(source: .init($0.0), target: .init($0.1)).scripts
XCTAssertTrue(
scripts.description == $0.2,
"\(scripts.description) is no equal to \($0.2)"
)
}
}
// swiftlint:disable line_length
func testTime() {
// 1000 character * 1000 character: 3.540s
// 1000 character * 1000 character: 0.8s
measure {
_ =
Diff(
from: "abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkbexjabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk123abcdhijkabcdhijkabcdhijkabcdhijk12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123",
to: "abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk"
source: .init("abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkbexjabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk123abcdhijkabcdhijkabcdhijkabcdhijk12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123k12213123"),
target: .init("abcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijkabcdhijk")
)
}
}
static var allTests = [
("testMatrix", testMatrix),
("testStringRange", testStringRange),
("testModification", testModification),
("testDiff", testDiff),
("testTime", testTime)
]
}

View File

@ -8,24 +8,33 @@ import UIKit
typealias Color = UIColor
#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 {
public var description: String {
let comps: [CGFloat] = components ?? [0, 0, 0, 0]
if comps == [1, 0, 0, 1] {
return "{red}"
return "{D}"
} else if comps == [0, 1, 0, 1] {
return "{green}"
return "{I}"
} else {
return "{black}"
return "{U}"
}
}
}
extension NSAttributedString {
// swiftlint:disable line_length
open override var description: String {
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
description += string[range.location...range.location + range.length - 1] + color.cgColor.description
}
@ -34,40 +43,87 @@ extension NSAttributedString {
}
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() {
let diffAttributes = DiffAttributes(add: [.backgroundColor: Color.green], delete: [.backgroundColor: Color.red], same: [.backgroundColor: Color.black])
let to1 = "abcdhijk"
let from1 = "bexj"
let diff1 = Diff(from: from1, to: to1)
let attributedString1 = NSAttributedString.attributedString(with: diff1, attributes: diffAttributes)
assert(
attributedString1.debugDescription == "a{green}b{black}cdhi{green}ex{red}j{black}k{green}"
)
let to2 = "bexj"
let from2 = "abcdhijk"
let diff2 = Diff(from: from2, to: to2)
let attributedString2 = NSAttributedString.attributedString(with: diff2, attributes: diffAttributes)
assert(
attributedString2.debugDescription == "a{red}b{black}ex{green}cdhi{red}j{black}k{red}"
)
let to3 = ["bexj", "abc", "c"]
let from3 = ["abcdhijk"]
let diff3 = Diff(from: from3, to: to3)
let attributedString3 = NSAttributedString.attributedString(with: diff3, attributes: diffAttributes)
assert(
attributedString3.debugDescription == "bexjabcc{green}abcdhijk{red}"
)
let to4 = ["bexj", "abc", "c", "abc"]
let from4 = ["abcdhijk", "abc"]
let diff4 = Diff(from: from4, to: to4)
let attributedString4 = NSAttributedString.attributedString(with: diff4, attributes: diffAttributes)
assert(
attributedString4.debugDescription == "bexj{green}abcdhijk{red}abc{black}cabc{green}"
)
let expectations = [
("abc", "abc", "abc{U}"),
("abc", "ab", "ab{U}c{D}"),
("ab", "abc", "ab{U}c{I}"),
("", "abc", "abc{I}"),
("abc", "", "abc{D}"),
("b", "ac", "b{D}ac{I}"),
("abc", "ac", "a{U}b{D}c{U}"),
("", "", "")
]
expectations.forEach {
let string =
NSAttributedString(
source: $0.0, target: $0.1,
attributes: DiffAttributes(insert: insertAttributes, delete: deleteAttributes, same: sameAttributes)
)
XCTAssertTrue(
string.description == $0.2,
"\(string.description) is no equal to \($0.2)"
)
}
}
func testLines() {
let expectations: [([String], [String], [String])] = [
(["a", "b", "c"], ["a", "b", "c"], ["a", "b", "c"]),
(["a", "b", "c"], ["a", "b"], ["a", "b", "-c"]),
(["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 = [
("testAttributedString", testAttributedString)
("testAttributedString", testAttributedString),
("testLines", testLines)
]
}