-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathNESDiffBuilder.swift
More file actions
136 lines (128 loc) · 5.17 KB
/
NESDiffBuilder.swift
File metadata and controls
136 lines (128 loc) · 5.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import Foundation
struct DiffSegment {
enum Change {
case unchanged
case added
case removed
}
let text: String
let change: Change
}
enum DiffBuilder {
static func inlineSegments(oldLine: String, newLine: String) -> [DiffSegment] {
let oldTokens = tokenizePreservingWhitespace(oldLine)
let newTokens = tokenizePreservingWhitespace(newLine)
let condensed = condensedSegments(oldTokens: oldTokens, newTokens: newTokens)
return mergeInlineWhitespaceSegments(condensed)
}
static func lineSegments(oldContent: String, newContent: String) -> [DiffSegment] {
let oldLines = oldContent.components(separatedBy: .newlines)
let newLines = newContent.components(separatedBy: .newlines)
return diff(tokensInOld: oldLines, tokensInNew: newLines)
}
private static func tokenizePreservingWhitespace(_ text: String) -> [String] {
guard !text.isEmpty else { return [] }
// This pattern matches either:
// - a sequence of non-whitespace characters (\\S+) followed by optional whitespace (\\s*), or
// - a sequence of whitespace characters (\\s+)
// This ensures that tokens preserve trailing whitespace, or capture standalone whitespace sequences.
let pattern = "\\S+\\s*|\\s+"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return [text]
}
let nsText = text as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
let matches = regex.matches(in: text, range: fullRange)
if matches.isEmpty {
return [text]
}
return matches.map { nsText.substring(with: $0.range) }
}
private static func condensedSegments(oldTokens: [String], newTokens: [String]) -> [DiffSegment] {
let raw = diff(tokensInOld: oldTokens, tokensInNew: newTokens)
guard var last = raw.first else { return [] }
var condensed: [DiffSegment] = []
for segment in raw.dropFirst() {
if segment.change == last.change {
last = DiffSegment(text: last.text + segment.text, change: last.change)
} else {
condensed.append(last)
last = segment
}
}
condensed.append(last)
return condensed
}
private static func diff(tokensInOld oldTokens: [String], tokensInNew newTokens: [String]) -> [DiffSegment] {
let m = oldTokens.count
let n = newTokens.count
if m == 0 { return newTokens.map { DiffSegment(text: $0, change: .added) } }
if n == 0 { return oldTokens.map { DiffSegment(text: $0, change: .removed) } }
var lcs = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
for i in 1...m {
for j in 1...n {
if oldTokens[i - 1] == newTokens[j - 1] {
lcs[i][j] = lcs[i - 1][j - 1] + 1
} else {
lcs[i][j] = max(lcs[i - 1][j], lcs[i][j - 1])
}
}
}
var i = m
var j = n
var result: [DiffSegment] = []
while i > 0 && j > 0 {
if oldTokens[i - 1] == newTokens[j - 1] {
result.append(DiffSegment(text: oldTokens[i - 1], change: .unchanged))
i -= 1
j -= 1
} else if lcs[i - 1][j] > lcs[i][j - 1] {
result.append(DiffSegment(text: oldTokens[i - 1], change: .removed))
i -= 1
} else {
result.append(DiffSegment(text: newTokens[j - 1], change: .added))
j -= 1
}
}
while i > 0 {
result.append(DiffSegment(text: oldTokens[i - 1], change: .removed))
i -= 1
}
while j > 0 {
result.append(DiffSegment(text: newTokens[j - 1], change: .added))
j -= 1
}
return result.reversed()
}
private static func mergeInlineWhitespaceSegments(_ segments: [DiffSegment]) -> [DiffSegment] {
guard !segments.isEmpty else { return segments }
var merged: [DiffSegment] = []
var index = 0
while index < segments.count {
let current = segments[index]
switch current.change {
case .added, .removed:
var combinedText = current.text
var lookahead = index + 1
while lookahead + 1 < segments.count,
segments[lookahead].change == .unchanged,
segments[lookahead].text.isWhitespaceOnly,
segments[lookahead + 1].change == current.change {
combinedText += segments[lookahead].text + segments[lookahead + 1].text
lookahead += 2
}
merged.append(DiffSegment(text: combinedText, change: current.change))
index = lookahead
case .unchanged:
merged.append(current)
index += 1
}
}
return merged
}
}
private extension String {
var isWhitespaceOnly: Bool {
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}