f85c746af0
"Cotejo" means nothing outside Spanish and is hard to pronounce elsewhere, so the project is now Kotej: same root, but it reads and sounds the same in English and Spanish. Renamed throughout — engine, targets, bundle ids and the URL scheme, which is now kotej://compare. Icon generated with Codex: two facing panels split by a seam, content lines on each side and one amber line marking a difference — the tool's job at a glance, still legible at 16pt. 34 tests still green; kotej:// verified end to end against the demo JARs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
150 lines
6.4 KiB
Swift
150 lines
6.4 KiB
Swift
import Foundation
|
|
|
|
/// Line-by-line alignment of two texts, for the side-by-side file view.
|
|
public enum TextDiff {
|
|
public enum Kind: Sendable, Equatable {
|
|
case equal, changed, added, removed
|
|
}
|
|
|
|
/// One row of the side-by-side view. A nil number means "no line on that side".
|
|
public struct Row: Sendable, Equatable {
|
|
public let kind: Kind
|
|
public let leftNumber: Int?
|
|
public let rightNumber: Int?
|
|
public let left: String
|
|
public let right: String
|
|
}
|
|
|
|
/// Aligns both texts. Common prefixes and suffixes are matched cheaply first,
|
|
/// so a one-line change in a big file costs almost nothing; only the middle
|
|
/// needs the quadratic pass, and beyond `limit` lines that middle is reported
|
|
/// as one changed block rather than hanging the UI.
|
|
public static func rows(left: String, right: String, limit: Int = 3_000) -> [Row] {
|
|
let leftLines = split(left)
|
|
let rightLines = split(right)
|
|
|
|
var prefix = 0
|
|
while prefix < leftLines.count, prefix < rightLines.count,
|
|
leftLines[prefix] == rightLines[prefix] { prefix += 1 }
|
|
|
|
var suffix = 0
|
|
while suffix < leftLines.count - prefix, suffix < rightLines.count - prefix,
|
|
leftLines[leftLines.count - 1 - suffix] == rightLines[rightLines.count - 1 - suffix] {
|
|
suffix += 1
|
|
}
|
|
|
|
var rows: [Row] = []
|
|
for i in 0..<prefix {
|
|
rows.append(Row(kind: .equal, leftNumber: i + 1, rightNumber: i + 1,
|
|
left: leftLines[i], right: rightLines[i]))
|
|
}
|
|
|
|
let leftMiddle = Array(leftLines[prefix..<(leftLines.count - suffix)])
|
|
let rightMiddle = Array(rightLines[prefix..<(rightLines.count - suffix)])
|
|
|
|
if leftMiddle.count > limit || rightMiddle.count > limit {
|
|
rows.append(contentsOf: blockRows(leftMiddle, rightMiddle, leftStart: prefix, rightStart: prefix))
|
|
} else {
|
|
rows.append(contentsOf: align(leftMiddle, rightMiddle, leftStart: prefix, rightStart: prefix))
|
|
}
|
|
|
|
for i in 0..<suffix {
|
|
let leftIndex = leftLines.count - suffix + i
|
|
let rightIndex = rightLines.count - suffix + i
|
|
rows.append(Row(kind: .equal, leftNumber: leftIndex + 1, rightNumber: rightIndex + 1,
|
|
left: leftLines[leftIndex], right: rightLines[rightIndex]))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
private static func split(_ text: String) -> [String] {
|
|
text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
|
}
|
|
|
|
/// Longest common subsequence over the differing middle section.
|
|
private static func align(_ left: [String], _ right: [String],
|
|
leftStart: Int, rightStart: Int) -> [Row] {
|
|
guard !left.isEmpty || !right.isEmpty else { return [] }
|
|
|
|
// lengths[i][j] = LCS length of left[i...] and right[j...]
|
|
var lengths = [[Int]](repeating: [Int](repeating: 0, count: right.count + 1), count: left.count + 1)
|
|
if !left.isEmpty && !right.isEmpty {
|
|
for i in stride(from: left.count - 1, through: 0, by: -1) {
|
|
for j in stride(from: right.count - 1, through: 0, by: -1) {
|
|
lengths[i][j] = left[i] == right[j]
|
|
? lengths[i + 1][j + 1] + 1
|
|
: max(lengths[i + 1][j], lengths[i][j + 1])
|
|
}
|
|
}
|
|
}
|
|
|
|
var rows: [Row] = []
|
|
var i = 0, j = 0
|
|
// Pending one-sided runs are paired up as "changed" so a modified line
|
|
// shows opposite its old version instead of as a delete plus an insert.
|
|
var removed: [(Int, String)] = []
|
|
var added: [(Int, String)] = []
|
|
|
|
func flush() {
|
|
let shared = min(removed.count, added.count)
|
|
for k in 0..<shared {
|
|
rows.append(Row(kind: .changed,
|
|
leftNumber: removed[k].0, rightNumber: added[k].0,
|
|
left: removed[k].1, right: added[k].1))
|
|
}
|
|
for k in shared..<removed.count {
|
|
rows.append(Row(kind: .removed, leftNumber: removed[k].0, rightNumber: nil,
|
|
left: removed[k].1, right: ""))
|
|
}
|
|
for k in shared..<added.count {
|
|
rows.append(Row(kind: .added, leftNumber: nil, rightNumber: added[k].0,
|
|
left: "", right: added[k].1))
|
|
}
|
|
removed.removeAll()
|
|
added.removeAll()
|
|
}
|
|
|
|
while i < left.count && j < right.count {
|
|
if left[i] == right[j] {
|
|
flush()
|
|
rows.append(Row(kind: .equal, leftNumber: leftStart + i + 1, rightNumber: rightStart + j + 1,
|
|
left: left[i], right: right[j]))
|
|
i += 1; j += 1
|
|
} else if lengths[i + 1][j] >= lengths[i][j + 1] {
|
|
removed.append((leftStart + i + 1, left[i])); i += 1
|
|
} else {
|
|
added.append((rightStart + j + 1, right[j])); j += 1
|
|
}
|
|
}
|
|
while i < left.count { removed.append((leftStart + i + 1, left[i])); i += 1 }
|
|
while j < right.count { added.append((rightStart + j + 1, right[j])); j += 1 }
|
|
flush()
|
|
return rows
|
|
}
|
|
|
|
/// Fallback for very large differing sections: pair the lines positionally.
|
|
private static func blockRows(_ left: [String], _ right: [String],
|
|
leftStart: Int, rightStart: Int) -> [Row] {
|
|
var rows: [Row] = []
|
|
for index in 0..<max(left.count, right.count) {
|
|
let leftLine = index < left.count ? left[index] : nil
|
|
let rightLine = index < right.count ? right[index] : nil
|
|
switch (leftLine, rightLine) {
|
|
case let (l?, r?):
|
|
rows.append(Row(kind: l == r ? .equal : .changed,
|
|
leftNumber: leftStart + index + 1, rightNumber: rightStart + index + 1,
|
|
left: l, right: r))
|
|
case let (l?, nil):
|
|
rows.append(Row(kind: .removed, leftNumber: leftStart + index + 1, rightNumber: nil,
|
|
left: l, right: ""))
|
|
case let (nil, r?):
|
|
rows.append(Row(kind: .added, leftNumber: nil, rightNumber: rightStart + index + 1,
|
|
left: "", right: r))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
}
|