Say what the difference actually is, and expand from either side

A changed line only said 'changed'. Now the engine classifies it and the view
colours it accordingly: whitespace-only (teal), comment-only (indigo) or real
content (orange), with a legend counting each kind present in the file. Comment
detection uses the syntax inferred from the extension and only claims a comment
change when the code either side of it is identical; an unknown file type claims
nothing, since hiding a real change is worse than showing a cosmetic one.

Within a changed line the differing characters are now highlighted rather than
tinting the whole row, so a 21 -> 18 reads as a one-character edit instead of
looking like a rewritten line.

The tree no longer uses List's outline, which only ever puts a disclosure
triangle on the leading column: rows are flattened with their own expansion
state, so both sides carry a chevron and the tree can be driven from whichever
side you're reading. Expand all / collapse all added to the context menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
This commit is contained in:
alexandrev-tibco
2026-07-28 10:08:54 +02:00
parent e0bff88225
commit 4c0f55c32d
8 changed files with 460 additions and 46 deletions
+19
View File
@@ -29,6 +29,18 @@ final class ComparisonModel: Identifiable {
/// difference" can walk them without the view rebuilding the tree.
private(set) var visibleRows: [DiffNode] = []
/// Which folders are open. Held here rather than by List's outline so both
/// sides of a row can carry a disclosure control.
private var expanded: Set<String> = []
func isExpanded(_ id: String) -> Bool { expanded.contains(id) }
func toggleExpansion(_ id: String) {
if expanded.contains(id) { expanded.remove(id) } else { expanded.insert(id) }
}
func collapseAll() { expanded.removeAll() }
var selection: DiffNode?
var isReady: Bool { leftURL != nil && rightURL != nil }
@@ -108,6 +120,12 @@ final class ComparisonModel: Identifiable {
if isReady { compare() }
}
/// Opens every folder currently shown.
func expandAll(from root: Any?) {
guard let root = root as? DisplayNodeExpanding else { return }
expanded.formUnion(root.expandableIDs())
}
func clear() {
leftURL = nil
rightURL = nil
@@ -116,6 +134,7 @@ final class ComparisonModel: Identifiable {
errorMessage = nil
visibleRows = []
searchText = ""
expanded.removeAll()
}
func compare() {
+83 -30
View File
@@ -2,8 +2,7 @@ import SwiftUI
import AppKit
import KotejEngine
/// A row ready to render: filters are applied up front so the outline can use a
/// plain key path for its children.
/// A row ready to render: filters are applied up front.
struct DisplayNode: Identifiable {
let id: String
let node: DiffNode
@@ -27,20 +26,38 @@ struct DisplayNode: Identifiable {
return node.path.localizedCaseInsensitiveContains(search)
}
/// Depth-first flattening, used for "next/previous difference".
func flattened() -> [DiffNode] {
[node] + (children ?? []).flatMap { $0.flattened() }
}
}
/// Lets the model collect the ids to open without knowing about the view layer.
protocol DisplayNodeExpanding {
func expandableIDs() -> Set<String>
}
extension DisplayNode: DisplayNodeExpanding {
func expandableIDs() -> Set<String> {
guard let children, !children.isEmpty else { return [] }
return children.reduce(into: Set([id])) { $0.formUnion($1.expandableIDs()) }
}
}
/// A row as actually drawn: the tree is flattened with its own expansion state
/// rather than using List's outline, because that only ever puts a disclosure
/// triangle on the leading column and here either side should expand.
struct FlatRow: Identifiable {
let id: String
let node: DiffNode
let depth: Int
let isExpandable: Bool
let isExpanded: Bool
}
/// The side-by-side tree. Archives appear as folders.
struct DiffTreeView: View {
@Bindable var model: ComparisonModel
/// The two name columns split whatever is left evenly, so each side gets half
/// the window. They carry only a small floor a large one would become the
/// list's ideal width and stop it from filling.
private let nameMinWidth: CGFloat = 120
private let sizeWidth: CGFloat = 84
private let statusWidth: CGFloat = 104
@@ -57,7 +74,7 @@ struct DiffTreeView: View {
}
@ViewBuilder private var content: some View {
if rows.isEmpty {
if flatRows.isEmpty {
VStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.largeTitle)
@@ -66,11 +83,11 @@ struct DiffTreeView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(rows, children: \.children, selection: selectionBinding) { row in
DiffRow(node: row.node,
nameMinWidth: nameMinWidth,
List(flatRows, selection: selectionBinding) { row in
DiffRow(row: row,
sizeWidth: sizeWidth,
statusWidth: statusWidth)
statusWidth: statusWidth,
onToggle: { model.toggleExpansion(row.id) })
.contentShape(Rectangle())
.onTapGesture { select(row.node) }
.contextMenu { rowMenu(row.node) }
@@ -85,10 +102,23 @@ struct DiffTreeView: View {
return DisplayNode.build(root, filter: model.filter, search: model.searchText)
}
private var rows: [DisplayNode] { displayRoot?.children ?? [] }
/// Depth-first walk that stops at collapsed folders.
private var flatRows: [FlatRow] {
var out: [FlatRow] = []
func walk(_ display: DisplayNode, depth: Int) {
let expandable = (display.children?.isEmpty == false)
let expanded = model.isExpanded(display.id)
out.append(FlatRow(id: display.id, node: display.node, depth: depth,
isExpandable: expandable, isExpanded: expanded))
guard expandable, expanded else { return }
for child in display.children ?? [] { walk(child, depth: depth + 1) }
}
for child in displayRoot?.children ?? [] { walk(child, depth: 0) }
return out
}
private func publishVisibleRows() {
model.setVisibleRows(rows.flatMap { $0.flattened() })
model.setVisibleRows((displayRoot?.children ?? []).flatMap { $0.flattened() })
}
private var header: some View {
@@ -96,11 +126,11 @@ struct DiffTreeView: View {
Group {
if let name = model.leftURL?.lastPathComponent { Text(verbatim: name) } else { Text("Left") }
}
.frame(minWidth: nameMinWidth, maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
Group {
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
}
.frame(minWidth: nameMinWidth, maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Size").frame(width: sizeWidth, alignment: .trailing)
Text("Status").frame(width: statusWidth, alignment: .trailing)
}
@@ -112,7 +142,7 @@ struct DiffTreeView: View {
.background(.bar)
}
private var selectionBinding: Binding<DisplayNode.ID?> {
private var selectionBinding: Binding<FlatRow.ID?> {
Binding(get: { model.selection?.path }, set: { _ in })
}
@@ -122,6 +152,9 @@ struct DiffTreeView: View {
}
@ViewBuilder private func rowMenu(_ node: DiffNode) -> some View {
Button("Expand all") { model.expandAll(from: displayRoot) }
Button("Collapse all") { model.collapseAll() }
Divider()
Button("Reveal left in Finder") { reveal(node.left) }
.disabled(!canReveal(node.left))
Button("Reveal right in Finder") { reveal(node.right) }
@@ -147,17 +180,20 @@ struct DiffTreeView: View {
}
}
/// One row: the name on each side, its size, and the verdict.
/// One row: each side carries its own disclosure control, so the tree can be
/// navigated from whichever side you're reading.
struct DiffRow: View {
let node: DiffNode
let nameMinWidth: CGFloat
let row: FlatRow
let sizeWidth: CGFloat
let statusWidth: CGFloat
let onToggle: () -> Void
private var node: DiffNode { row.node }
var body: some View {
HStack(spacing: 0) {
side(name: node.left == nil ? nil : node.name, missing: node.left == nil, isLeft: true)
side(name: node.right == nil ? nil : node.name, missing: node.right == nil, isLeft: false)
side(present: node.left != nil)
side(present: node.right != nil)
Text(verbatim: sizeText)
.font(.caption.monospacedDigit())
@@ -172,16 +208,33 @@ struct DiffRow: View {
.padding(.vertical, 2)
}
private func side(name: String?, missing: Bool, isLeft: Bool) -> some View {
HStack(spacing: 6) {
Image(systemName: missing ? "minus" : icon)
.foregroundStyle(missing ? Color.secondary.opacity(0.5)
: (node.isArchive ? Color.orange : Color.secondary))
private func side(present: Bool) -> some View {
HStack(spacing: 4) {
Color.clear.frame(width: CGFloat(row.depth) * 14, height: 1)
Group {
if row.isExpandable {
Button(action: onToggle) {
Image(systemName: row.isExpanded ? "chevron.down" : "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 12)
}
.buttonStyle(.borderless)
} else {
Color.clear.frame(width: 12, height: 1)
}
}
Image(systemName: present ? icon : "minus")
.foregroundStyle(present ? (node.isArchive ? Color.orange : Color.secondary)
: Color.secondary.opacity(0.5))
.frame(width: 16)
Text(verbatim: name ?? "")
.foregroundStyle(missing ? Color.secondary : Color.primary)
Text(verbatim: present ? node.name : "")
.foregroundStyle(present ? Color.primary : Color.secondary)
Spacer(minLength: 0)
}
.frame(minWidth: nameMinWidth, maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var icon: String {
+89 -7
View File
@@ -39,6 +39,7 @@ struct FileDiffView: View {
Spacer()
if isLoading { ProgressView().controlSize(.small) }
if !rows.isEmpty {
DiffLegend(rows: rows)
Toggle("Wrap lines", isOn: $wrapLines)
.toggleStyle(.checkbox)
.font(.caption)
@@ -114,8 +115,9 @@ struct FileDiffView: View {
let leftText = String(decoding: leftData, as: UTF8.self)
let rightText = String(decoding: rightData, as: UTF8.self)
let syntax = CommentSyntax.forPath(node.name)
rows = await Task.detached(priority: .userInitiated) {
TextDiff.rows(left: leftText, right: rightText)
TextDiff.rows(left: leftText, right: rightText, syntax: syntax)
}.value
if node.status == .equivalent {
@@ -132,19 +134,19 @@ struct DiffLine: View {
var body: some View {
HStack(alignment: .top, spacing: 0) {
cell(number: row.leftNumber, text: row.left, tint: leftTint)
cell(number: row.leftNumber, text: row.left, highlight: row.leftHighlight, tint: leftTint)
Divider()
cell(number: row.rightNumber, text: row.right, tint: rightTint)
cell(number: row.rightNumber, text: row.right, highlight: row.rightHighlight, tint: rightTint)
}
.font(.system(.caption, design: .monospaced))
}
private func cell(number: Int?, text: String, tint: Color) -> some View {
private func cell(number: Int?, text: String, highlight: Range<Int>?, tint: Color) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(verbatim: number.map(String.init) ?? "")
.frame(width: 44, alignment: .trailing)
.foregroundStyle(.tertiary)
Text(verbatim: text.isEmpty ? " " : text)
Text(attributed(text, highlight: highlight))
.lineLimit(wrapLines ? nil : 1)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: wrapLines)
@@ -157,10 +159,51 @@ struct DiffLine: View {
.background(tint)
}
/// Paints just the characters that differ, so a one-character change doesn't
/// look the same as a rewritten line.
private func attributed(_ text: String, highlight: Range<Int>?) -> AttributedString {
var result = AttributedString(text.isEmpty ? " " : text)
guard let highlight, !highlight.isEmpty, !text.isEmpty else { return result }
// Split the line so the differing middle can be styled on its own; the
// offsets come from the engine as character counts.
let characters = Array(text)
let lower = min(highlight.lowerBound, characters.count)
let upper = min(highlight.upperBound, characters.count)
guard lower < upper else { return result }
var before = AttributedString(String(characters[0..<lower]))
var middle = AttributedString(String(characters[lower..<upper]))
let after = AttributedString(String(characters[upper...]))
middle.backgroundColor = accent.opacity(0.55)
middle.font = .system(.caption, design: .monospaced).bold()
before.append(middle)
before.append(after)
result = before
return result
}
/// A changed line is tinted by what actually changed, so cosmetic edits are
/// visibly not the same thing as a change in behaviour.
var accent: Color {
switch row.kind {
case .added: return .green
case .removed: return .red
case .equal: return .clear
case .changed:
switch row.changeKind {
case .whitespace: return .teal
case .comment: return .indigo
case .content: return .orange
}
}
}
private var leftTint: Color {
switch row.kind {
case .equal: return .clear
case .changed: return .yellow.opacity(0.18)
case .changed: return accent.opacity(0.12)
case .removed: return .red.opacity(0.16)
case .added: return .secondary.opacity(0.06)
}
@@ -169,9 +212,48 @@ struct DiffLine: View {
private var rightTint: Color {
switch row.kind {
case .equal: return .clear
case .changed: return .yellow.opacity(0.18)
case .changed: return accent.opacity(0.12)
case .added: return .green.opacity(0.16)
case .removed: return .secondary.opacity(0.06)
}
}
}
/// Tells you at a glance what kind of differences this file has.
struct DiffLegend: View {
let rows: [TextDiff.Row]
var body: some View {
HStack(spacing: 10) {
ForEach(present, id: \.0) { item in
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 2)
.fill(item.1.opacity(0.55))
.frame(width: 8, height: 8)
Text(item.2) + Text(verbatim: " \(item.3)")
}
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
/// Only the kinds actually present, with how many lines each accounts for.
private var present: [(String, Color, LocalizedStringKey, Int)] {
let changed = rows.filter { $0.kind == .changed }
var out: [(String, Color, LocalizedStringKey, Int)] = []
let content = changed.filter { $0.changeKind == .content }.count
let comments = changed.filter { $0.changeKind == .comment }.count
let spacing = changed.filter { $0.changeKind == .whitespace }.count
let added = rows.filter { $0.kind == .added }.count
let removed = rows.filter { $0.kind == .removed }.count
if content > 0 { out.append(("content", .orange, "content", content)) }
if comments > 0 { out.append(("comment", .indigo, "comments", comments)) }
if spacing > 0 { out.append(("space", .teal, "whitespace", spacing)) }
if added > 0 { out.append(("added", .green, "added", added)) }
if removed > 0 { out.append(("removed", .red, "removed", removed)) }
return out
}
}
+7
View File
@@ -78,3 +78,10 @@
"Ignore blank lines" = "Ignore blank lines";
"Treat CRLF and LF as equal" = "Treat CRLF and LF as equal";
"Applies to text files; JSON and XML also compare structurally." = "Applies to text files; JSON and XML also compare structurally.";
/* Difference detail */
"content" = "content";
"comments" = "comments";
"whitespace" = "whitespace";
"added" = "added";
"removed" = "removed";
+7
View File
@@ -78,3 +78,10 @@
"Ignore blank lines" = "Ignorar líneas en blanco";
"Treat CRLF and LF as equal" = "Tratar CRLF y LF como iguales";
"Applies to text files; JSON and XML also compare structurally." = "Se aplica a ficheros de texto; JSON y XML además se comparan estructuralmente.";
/* Detalle de diferencias */
"content" = "contenido";
"comments" = "comentarios";
"whitespace" = "espacios";
"added" = "añadidas";
"removed" = "borradas";
+68
View File
@@ -0,0 +1,68 @@
import Foundation
/// How a file marks comments, so a change can be told apart from a change in
/// what the code actually does.
public struct CommentSyntax: Sendable, Equatable {
public let linePrefixes: [String]
public let blockOpen: String?
public let blockClose: String?
public init(linePrefixes: [String], blockOpen: String? = nil, blockClose: String? = nil) {
self.linePrefixes = linePrefixes
self.blockOpen = blockOpen
self.blockClose = blockClose
}
public static let cLike = CommentSyntax(linePrefixes: ["//"], blockOpen: "/*", blockClose: "*/")
public static let hash = CommentSyntax(linePrefixes: ["#"])
public static let sql = CommentSyntax(linePrefixes: ["--"], blockOpen: "/*", blockClose: "*/")
public static let markup = CommentSyntax(linePrefixes: [], blockOpen: "<!--", blockClose: "-->")
/// Best guess from the file extension. Unknown types get nil, and then a
/// change is never reported as comment-only better to under-claim than to
/// hide a real difference.
public static func forPath(_ path: String) -> CommentSyntax? {
switch (path as NSString).pathExtension.lowercased() {
case "swift", "java", "js", "ts", "jsx", "tsx", "go", "c", "h", "cpp", "hpp",
"cs", "kt", "scala", "rs", "css", "json5", "gradle", "groovy":
return .cLike
case "py", "rb", "sh", "bash", "zsh", "yml", "yaml", "properties", "conf",
"cfg", "ini", "toml", "dockerfile", "makefile", "pl", "r":
return .hash
case "sql":
return .sql
case "xml", "html", "htm", "xsd", "xsl", "xslt", "wsdl", "svg", "vue":
return .markup
default:
return nil
}
}
/// The line with its comments removed. Block comments are handled only when
/// they open and close on the same line a multi-line block would need real
/// parsing, and guessing there risks calling a code change a comment.
func stripping(_ line: String) -> String {
var result = line
if let open = blockOpen, let close = blockClose {
while let start = result.range(of: open),
let end = result.range(of: close, range: start.upperBound..<result.endIndex) {
result.removeSubrange(start.lowerBound..<end.upperBound)
}
}
for prefix in linePrefixes {
if let start = result.range(of: prefix) {
result = String(result[result.startIndex..<start.lowerBound])
break
}
}
return result
}
/// True when the line has nothing but a comment (or nothing at all).
func isEntirelyComment(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return false }
return stripping(line).trimmingCharacters(in: .whitespaces).isEmpty
}
}
+93 -9
View File
@@ -6,6 +6,17 @@ public enum TextDiff {
case equal, changed, added, removed
}
/// What a changed line actually differs in. Knowing that a difference is only
/// whitespace or only a comment is usually enough to skip it.
public enum ChangeKind: Sendable, Equatable {
/// Only spacing or line endings changed.
case whitespace
/// Only the comments changed; the code is the same.
case comment
/// A real change.
case content
}
/// 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
@@ -13,13 +24,72 @@ public enum TextDiff {
public let rightNumber: Int?
public let left: String
public let right: String
/// Only meaningful for `.changed` rows.
public let changeKind: ChangeKind
/// Character offsets of the part that actually differs, so the exact
/// characters can be highlighted rather than the whole line.
public let leftHighlight: Range<Int>?
public let rightHighlight: Range<Int>?
init(kind: Kind, leftNumber: Int?, rightNumber: Int?, left: String, right: String,
changeKind: ChangeKind = .content,
leftHighlight: Range<Int>? = nil, rightHighlight: Range<Int>? = nil) {
self.kind = kind
self.leftNumber = leftNumber
self.rightNumber = rightNumber
self.left = left
self.right = right
self.changeKind = changeKind
self.leftHighlight = leftHighlight
self.rightHighlight = rightHighlight
}
}
/// Classifies a changed pair and locates the differing characters.
static func describe(left: String, right: String, syntax: CommentSyntax?)
-> (ChangeKind, Range<Int>?, Range<Int>?) {
let (leftRange, rightRange) = inlineRanges(left, right)
// Whitespace first: it subsumes the others when nothing else moved.
if left.filter({ !$0.isWhitespace }) == right.filter({ !$0.isWhitespace }) {
return (.whitespace, leftRange, rightRange)
}
if let syntax {
let strippedLeft = syntax.stripping(left).trimmingCharacters(in: .whitespaces)
let strippedRight = syntax.stripping(right).trimmingCharacters(in: .whitespaces)
if strippedLeft == strippedRight {
return (.comment, leftRange, rightRange)
}
}
return (.content, leftRange, rightRange)
}
/// The differing middle of two lines, after trimming what they share at each
/// end. Nil when one side is empty, where highlighting adds nothing.
static func inlineRanges(_ left: String, _ right: String) -> (Range<Int>?, Range<Int>?) {
let l = Array(left), r = Array(right)
guard !l.isEmpty, !r.isEmpty else { return (nil, nil) }
var prefix = 0
while prefix < l.count, prefix < r.count, l[prefix] == r[prefix] { prefix += 1 }
var suffix = 0
while suffix < l.count - prefix, suffix < r.count - prefix,
l[l.count - 1 - suffix] == r[r.count - 1 - suffix] { suffix += 1 }
let leftRange = prefix..<(l.count - suffix)
let rightRange = prefix..<(r.count - suffix)
// Identical lines have nothing to point at.
if leftRange.isEmpty && rightRange.isEmpty { return (nil, nil) }
return (leftRange, rightRange)
}
/// 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] {
public static func rows(left: String, right: String,
syntax: CommentSyntax? = nil, limit: Int = 3_000) -> [Row] {
let leftLines = split(left)
let rightLines = split(right)
@@ -43,9 +113,11 @@ public enum TextDiff {
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))
rows.append(contentsOf: blockRows(leftMiddle, rightMiddle, leftStart: prefix,
rightStart: prefix, syntax: syntax))
} else {
rows.append(contentsOf: align(leftMiddle, rightMiddle, leftStart: prefix, rightStart: prefix))
rows.append(contentsOf: align(leftMiddle, rightMiddle, leftStart: prefix,
rightStart: prefix, syntax: syntax))
}
for i in 0..<suffix {
@@ -63,7 +135,7 @@ public enum TextDiff {
/// Longest common subsequence over the differing middle section.
private static func align(_ left: [String], _ right: [String],
leftStart: Int, rightStart: Int) -> [Row] {
leftStart: Int, rightStart: Int, syntax: CommentSyntax?) -> [Row] {
guard !left.isEmpty || !right.isEmpty else { return [] }
// lengths[i][j] = LCS length of left[i...] and right[j...]
@@ -88,9 +160,13 @@ public enum TextDiff {
func flush() {
let shared = min(removed.count, added.count)
for k in 0..<shared {
let (change, leftRange, rightRange) = describe(left: removed[k].1,
right: added[k].1, syntax: syntax)
rows.append(Row(kind: .changed,
leftNumber: removed[k].0, rightNumber: added[k].0,
left: removed[k].1, right: added[k].1))
left: removed[k].1, right: added[k].1,
changeKind: change,
leftHighlight: leftRange, rightHighlight: rightRange))
}
for k in shared..<removed.count {
rows.append(Row(kind: .removed, leftNumber: removed[k].0, rightNumber: nil,
@@ -124,16 +200,24 @@ public enum TextDiff {
/// Fallback for very large differing sections: pair the lines positionally.
private static func blockRows(_ left: [String], _ right: [String],
leftStart: Int, rightStart: Int) -> [Row] {
leftStart: Int, rightStart: Int, syntax: CommentSyntax?) -> [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))
if l == r {
rows.append(Row(kind: .equal,
leftNumber: leftStart + index + 1, rightNumber: rightStart + index + 1,
left: l, right: r))
} else {
let (change, leftRange, rightRange) = describe(left: l, right: r, syntax: syntax)
rows.append(Row(kind: .changed,
leftNumber: leftStart + index + 1, rightNumber: rightStart + index + 1,
left: l, right: r, changeKind: change,
leftHighlight: leftRange, rightHighlight: rightRange))
}
case let (l?, nil):
rows.append(Row(kind: .removed, leftNumber: leftStart + index + 1, rightNumber: nil,
left: l, right: ""))
@@ -0,0 +1,94 @@
import XCTest
@testable import KotejEngine
final class ChangeDetailTests: XCTestCase {
private func changedRow(_ left: String, _ right: String, syntax: CommentSyntax? = nil) throws -> TextDiff.Row {
let rows = TextDiff.rows(left: left, right: right, syntax: syntax)
return try XCTUnwrap(rows.first { $0.kind == .changed })
}
// MARK: Classification
func testIndentationOnlyIsWhitespace() throws {
let row = try changedRow(" let x = 1", "\t\tlet x = 1", syntax: .cLike)
XCTAssertEqual(row.changeKind, .whitespace)
}
func testSpacingInsideTheLineIsWhitespace() throws {
let row = try changedRow("a = b+c", "a = b + c", syntax: .cLike)
XCTAssertEqual(row.changeKind, .whitespace)
}
func testCommentOnlyChangeIsRecognised() throws {
let row = try changedRow("let total = 3 // suma vieja",
"let total = 3 // suma nueva", syntax: .cLike)
XCTAssertEqual(row.changeKind, .comment, "the code is identical, only the comment moved")
}
func testHashCommentsToo() throws {
let row = try changedRow("PORT=8080 # antes", "PORT=8080 # después", syntax: .hash)
XCTAssertEqual(row.changeKind, .comment)
}
func testCodeChangeBesideACommentIsContent() throws {
let row = try changedRow("let total = 3 // nota", "let total = 4 // nota", syntax: .cLike)
XCTAssertEqual(row.changeKind, .content, "the value changed; that's not a comment change")
}
func testWithoutKnownSyntaxNothingIsClaimedAsComment() throws {
// No syntax means we can't be sure, and hiding a real change is worse.
let row = try changedRow("valor = 3 // nota", "valor = 3 // otra", syntax: nil)
XCTAssertEqual(row.changeKind, .content)
}
func testRealChangeIsContent() throws {
let row = try changedRow("if user.isAdmin {", "if user.isOwner {", syntax: .cLike)
XCTAssertEqual(row.changeKind, .content)
}
// MARK: Character-level highlighting
func testHighlightsOnlyTheDifferingCharacters() throws {
let row = try changedRow("let total = 3", "let total = 4", syntax: .cLike)
let left = try XCTUnwrap(row.leftHighlight)
let right = try XCTUnwrap(row.rightHighlight)
XCTAssertEqual(String(Array(row.left)[left]), "3")
XCTAssertEqual(String(Array(row.right)[right]), "4")
}
func testHighlightSpansAnInsertedWord() throws {
let row = try changedRow("if user.isAdmin {", "if user.isSuperAdmin {", syntax: .cLike)
let right = try XCTUnwrap(row.rightHighlight)
XCTAssertEqual(String(Array(row.right)[right]), "Super")
// Nothing was removed on the left, so its range is empty at the same spot.
XCTAssertEqual(row.leftHighlight?.isEmpty, true)
}
func testIdenticalLinesHaveNoHighlight() {
let (left, right) = TextDiff.inlineRanges("igual", "igual")
XCTAssertNil(left)
XCTAssertNil(right)
}
func testEmptySideIsNotHighlighted() {
let (left, right) = TextDiff.inlineRanges("", "algo")
XCTAssertNil(left)
XCTAssertNil(right)
}
// MARK: Comment syntax
func testSyntaxIsInferredFromTheExtension() {
XCTAssertEqual(CommentSyntax.forPath("App.swift"), .cLike)
XCTAssertEqual(CommentSyntax.forPath("deploy.sh"), .hash)
XCTAssertEqual(CommentSyntax.forPath("query.sql"), .sql)
XCTAssertEqual(CommentSyntax.forPath("pom.xml"), .markup)
XCTAssertNil(CommentSyntax.forPath("data.bin"), "unknown types claim nothing")
}
func testStripsBlockCommentsOnOneLine() {
XCTAssertEqual(CommentSyntax.cLike.stripping("let a = 1 /* nota */ + 2"), "let a = 1 + 2")
XCTAssertTrue(CommentSyntax.markup.isEntirelyComment(" <!-- solo comentario --> "))
XCTAssertFalse(CommentSyntax.cLike.isEntirelyComment("let a = 1 // nota"))
}
}