From 26f1a8940e057d410ae564f56a7813cabe026075 Mon Sep 17 00:00:00 2001 From: wzxjiang Date: Fri, 14 Dec 2018 19:59:40 +0800 Subject: [PATCH] feature: use myers's difference alogorithm to improve performance --- README.md | 56 ++++-- Sdifft.playground/Contents.swift | 40 ++++ Sdifft.playground/contents.xcplayground | 4 + Sdifft.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/IDEWorkspaceChecks.plist | 8 + ...A8E95969-EF02-4FA2-9760-6D628059A4C2.plist | 22 +++ ...DCB9F5A5-E22D-4494-A0DC-4412307D88CA.plist | 22 +++ .../Sdifft::SdifftTests.xcbaseline/Info.plist | 71 +++++++ Sources/Sdifft/Diff.swift | 175 +++++++++--------- Sources/Sdifft/DiffSequence.swift | 61 ------ Sources/Sdifft/NSAttributedString+Diff.swift | 127 +++++++++---- Tests/SdifftTests/DiffTests.swift | 162 ++++------------ .../NSAttributedString+DiffTests.swift | 128 +++++++++---- 13 files changed, 528 insertions(+), 356 deletions(-) create mode 100644 Sdifft.playground/Contents.swift create mode 100644 Sdifft.playground/contents.xcplayground create mode 100644 Sdifft.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/A8E95969-EF02-4FA2-9760-6D628059A4C2.plist create mode 100644 Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/DCB9F5A5-E22D-4494-A0DC-4412307D88CA.plist create mode 100644 Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/Info.plist delete mode 100644 Sources/Sdifft/DiffSequence.swift diff --git a/README.md b/README.md index 1dd97ea..d90a33d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sdifft.playground/Contents.swift b/Sdifft.playground/Contents.swift new file mode 100644 index 0000000..cf64635 --- /dev/null +++ b/Sdifft.playground/Contents.swift @@ -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) + ) diff --git a/Sdifft.playground/contents.xcplayground b/Sdifft.playground/contents.xcplayground new file mode 100644 index 0000000..9f5f2f4 --- /dev/null +++ b/Sdifft.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Sdifft.xcodeproj/project.pbxproj b/Sdifft.xcodeproj/project.pbxproj index f7e0045..3653b70 100644 --- a/Sdifft.xcodeproj/project.pbxproj +++ b/Sdifft.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1E78630721C394C5006F4912 /* Sdifft.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Sdifft.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 1EB1AD2620BD5E22004D0450 /* NSAttributedString+Diff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Diff.swift"; sourceTree = ""; }; 1EB1AD2820BD640B004D0450 /* NSAttributedString+DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+DiffTests.swift"; sourceTree = ""; }; OBJ_12 /* DiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffTests.swift; sourceTree = ""; }; @@ -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 */, ); diff --git a/Sdifft.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Sdifft.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Sdifft.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/A8E95969-EF02-4FA2-9760-6D628059A4C2.plist b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/A8E95969-EF02-4FA2-9760-6D628059A4C2.plist new file mode 100644 index 0000000..ef8329f --- /dev/null +++ b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/A8E95969-EF02-4FA2-9760-6D628059A4C2.plist @@ -0,0 +1,22 @@ + + + + + classNames + + DiffTests + + testTime() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.36825 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/DCB9F5A5-E22D-4494-A0DC-4412307D88CA.plist b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/DCB9F5A5-E22D-4494-A0DC-4412307D88CA.plist new file mode 100644 index 0000000..b9d9122 --- /dev/null +++ b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/DCB9F5A5-E22D-4494-A0DC-4412307D88CA.plist @@ -0,0 +1,22 @@ + + + + + classNames + + DiffTests + + testTime() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.8 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/Info.plist b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/Info.plist new file mode 100644 index 0000000..4a34ded --- /dev/null +++ b/Sdifft.xcodeproj/xcshareddata/xcbaselines/Sdifft::SdifftTests.xcbaseline/Info.plist @@ -0,0 +1,71 @@ + + + + + runDestinationsByUUID + + A8E95969-EF02-4FA2-9760-6D628059A4C2 + + localComputer + + busSpeedInMHz + 400 + cpuCount + 1 + cpuKind + Intel Core i5 + cpuSpeedInMHz + 2300 + logicalCPUCoresPerPackage + 8 + modelCode + MacBookPro15,2 + physicalCPUCoresPerPackage + 4 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + x86_64 + targetDevice + + modelCode + iPhone10,6 + platformIdentifier + com.apple.platform.iphonesimulator + + + DCB9F5A5-E22D-4494-A0DC-4412307D88CA + + localComputer + + busSpeedInMHz + 400 + cpuCount + 1 + cpuKind + Intel Core i5 + cpuSpeedInMHz + 2300 + logicalCPUCoresPerPackage + 8 + modelCode + MacBookPro15,2 + physicalCPUCoresPerPackage + 4 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + x86_64 + targetDevice + + modelCode + iPhone11,8 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/Sources/Sdifft/Diff.swift b/Sources/Sdifft/Diff.swift index f29ea16..c3e0c70 100644 --- a/Sources/Sdifft/Diff.swift +++ b/Sources/Sdifft/Diff.swift @@ -27,104 +27,107 @@ import Foundation -typealias Matrix = [[Int]] - // swiftlint:disable identifier_name -/// Draw LCS matrix with two `DiffSequence` -/// -/// - Parameters: -/// - from: DiffSequence -/// - to: DiffSequence that be compared -/// - Returns: matrix -func drawMatrix(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..(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) +struct Vertice: Equatable { + let x, y: Int + static func == (lhs: Vertice, rhs: Vertice) -> Bool { + return lhs.x == rhs.x && lhs.y == rhs.y } } -public struct Modification { - public let add, delete, same: Element? +struct Path { + let from, to: Vertice + let script: DiffScript } -extension Array where Element == DiffIndex { - func modifications(from: T, to: T) -> [Modification] { - var modifications: [Modification] = [] - var lastFrom = 0 - var lastTo = 0 - modifications += map { - let modification = - Modification( - 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 +// swiftlint:disable identifier_name +public class Diff { + 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..( - 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 + } + + 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 } - return modifications + + 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 [] } -} -public struct Diff { - public let modifications: [Modification] - 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) + // 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) + } + return scripts } } diff --git a/Sources/Sdifft/DiffSequence.swift b/Sources/Sdifft/DiffSequence.swift deleted file mode 100644 index 7b45923..0000000 --- a/Sources/Sdifft/DiffSequence.swift +++ /dev/null @@ -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) -> 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) -> 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) -> [Element] { - return Array(self[range]) - } - public func reversedElement() -> [Element] { - return reversed() - } -} diff --git a/Sources/Sdifft/NSAttributedString+Diff.swift b/Sources/Sdifft/NSAttributedString+Diff.swift index af36e9c..c0a1a80 100644 --- a/Sources/Sdifft/NSAttributedString+Diff.swift +++ b/Sources/Sdifft/NSAttributedString+Diff.swift @@ -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 Array where Element == DiffScript { + func reverseIndex(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) + } + } + } +} + extension NSAttributedString { - /// Get attributedString with `Diff` and attributes - /// - /// - Parameters: - /// - diff: Diff - /// - attributes: DiffAttributes - /// - Returns: NSAttributedString - public static func attributedString(with diff: Diff, attributes: DiffAttributes) -> NSAttributedString { + private static func script(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() - 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)) + 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)) } } - return attributedString + + self.init(attributedString: attributedString) } - public static func attributedString(with diff: Diff<[String]>, attributes: DiffAttributes) -> NSAttributedString { + + /// 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() - diff.modifications.forEach { - if let add = $0.add { - attributedString.append(NSAttributedString(string: add.joined(), attributes: attributes.add)) + 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 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)) + if let handler = handler { + scriptAttributedString = handler($0, scriptAttributedString) } + attributedString.append(scriptAttributedString) } - return attributedString + + self.init(attributedString: attributedString) } } diff --git a/Tests/SdifftTests/DiffTests.swift b/Tests/SdifftTests/DiffTests.swift index 029c197..45896af 100644 --- a/Tests/SdifftTests/DiffTests.swift +++ b/Tests/SdifftTests/DiffTests.swift @@ -1,146 +1,60 @@ import XCTest @testable import Sdifft -extension String { - subscript(idx: Int) -> String { - return index(of: idx) - } - subscript(range: CountableClosedRange) -> String { - return element(withRange: range) - } -} - -extension Array where Element == Modification { - 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 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 == [] - ) + func testDiff() { + let scripts = Diff(source: .init("b"), target: .init("abcd")).scripts + + + 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) ] } diff --git a/Tests/SdifftTests/NSAttributedString+DiffTests.swift b/Tests/SdifftTests/NSAttributedString+DiffTests.swift index 5d3ef41..4ba2405 100644 --- a/Tests/SdifftTests/NSAttributedString+DiffTests.swift +++ b/Tests/SdifftTests/NSAttributedString+DiffTests.swift @@ -8,24 +8,33 @@ import UIKit typealias Color = UIColor #endif +extension String { + subscript(range: CountableClosedRange) -> 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) ] }