Files
kotej/Sources/KotejEngine/DirectoryComparer.swift
alexandrev-tibco e3cf1df73e Nested archives by signature, per-side sizes, applied-mode icon, manual pairing
Archives are detected by their signature, not just their extension, so a ZIP
renamed to anything still opens as a folder — and nesting keeps going, verified
on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are
skipped without inflating, since checking the signature inside an archive means
decompressing, and an EAR holds thousands of .class files.

The comparison now records which mode settled each file, so Automatic shows an
icon per row (byte / text / structural) instead of leaving you to guess when it
went semantic.

Sizes are shown on both sides and highlighted when they disagree, which is often
the fastest hint of what changed.

Two files whose names don't match can now be compared: mark one side, pick the
other, and it becomes the new base in this tab or a new one. Entries living
inside an archive are extracted to a temporary file first, since they have no
path of their own — that's what lets a file inside a JAR be paired with a loose
one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
2026-07-30 15:53:33 +02:00

223 lines
9.6 KiB
Swift

import Foundation
/// Outcome of comparing one pair of nodes.
public enum DiffStatus: Equatable, Sendable {
/// Byte-identical (proved by size + CRC or by content).
case identical
/// Bytes differ but the documents mean the same under the chosen rules.
case equivalent
case different
case onlyLeft
case onlyRight
/// Cheap checks couldn't prove equality; content hasn't been read yet.
case pending
public var isDifference: Bool {
switch self {
case .identical, .equivalent: return false
case .different, .onlyLeft, .onlyRight, .pending: return true
}
}
}
/// A row in the side-by-side tree: the same path on both sides, plus a verdict.
public final class DiffNode: @unchecked Sendable {
public let name: String
public let path: String
public let isDirectory: Bool
public let isArchive: Bool
public let left: Node?
public let right: Node?
public var status: DiffStatus
/// Which comparison actually settled this file, so "Automatic" can show what
/// it decided rather than leaving you to guess.
public var appliedMode: ComparisonMode?
public internal(set) var children: [DiffNode]
init(name: String, path: String, isDirectory: Bool, isArchive: Bool,
left: Node?, right: Node?, status: DiffStatus, children: [DiffNode] = []) {
self.name = name
self.path = path
self.isDirectory = isDirectory
self.isArchive = isArchive
self.left = left
self.right = right
self.status = status
self.children = children
}
/// Counts, for the summary bar.
public struct Totals: Equatable, Sendable {
public init() {}
public var identical = 0, different = 0, onlyLeft = 0, onlyRight = 0, pending = 0
public var differences: Int { different + onlyLeft + onlyRight + pending }
}
public func totals() -> Totals {
var totals = Totals()
func walk(_ node: DiffNode) {
if node.isDirectory {
node.children.forEach(walk)
return
}
switch node.status {
case .identical, .equivalent: totals.identical += 1
case .different: totals.different += 1
case .onlyLeft: totals.onlyLeft += 1
case .onlyRight: totals.onlyRight += 1
case .pending: totals.pending += 1
}
}
walk(self)
return totals
}
}
/// Pairs two trees and decides what differs.
///
/// The comparison is layered so big trees stay fast: sizes and (for archive
/// entries) CRC32s prove equality without reading a byte, and only what those
/// checks can't settle is read. Note the asymmetry cheap checks can prove two
/// files *identical*, but never that they *differ*, because a JSON or XML with
/// different bytes may still be semantically equal.
public enum DirectoryComparer {
public struct Options: Sendable {
/// Read file contents during the scan. When false the undecided pairs are
/// left `.pending` and resolved on demand, which keeps huge trees snappy.
public var resolveContentEagerly: Bool
/// Cap on eagerly-read files; larger ones stay `.pending`.
public var eagerSizeLimit: UInt64
public var mode: ComparisonMode?
public var textOptions: TextOptions
public init(resolveContentEagerly: Bool = true,
eagerSizeLimit: UInt64 = 4 * 1024 * 1024,
mode: ComparisonMode? = nil,
textOptions: TextOptions = TextOptions()) {
self.resolveContentEagerly = resolveContentEagerly
self.eagerSizeLimit = eagerSizeLimit
self.mode = mode
self.textOptions = textOptions
}
}
public static func compare(left: Node, right: Node, options: Options = Options()) -> DiffNode {
let root = pair(left: left, right: right, name: left.name, path: "", options: options)
return root
}
private static func pair(left: Node?, right: Node?, name: String, path: String,
options: Options) -> DiffNode {
// Present on one side only: the whole subtree is a difference.
guard let left, let right else {
let present = left ?? right!
let node = DiffNode(name: name, path: path, isDirectory: present.isDirectory,
isArchive: present.isArchive, left: left, right: right,
status: left == nil ? .onlyRight : .onlyLeft)
node.children = present.children.map { child in
pair(left: left == nil ? nil : child, right: left == nil ? child : nil,
name: child.name, path: child.path, options: options)
}
return node
}
if left.isDirectory && right.isDirectory {
let node = DiffNode(name: name, path: path, isDirectory: true,
isArchive: left.isArchive || right.isArchive,
left: left, right: right, status: .identical)
node.children = pairChildren(left: left, right: right, options: options)
// A folder differs when anything inside it does.
node.status = node.children.contains { $0.status.isDifference } ? .different : .identical
return node
}
// A folder on one side and a file on the other is a difference in itself.
if left.isDirectory != right.isDirectory {
return DiffNode(name: name, path: path, isDirectory: left.isDirectory,
isArchive: left.isArchive || right.isArchive,
left: left, right: right, status: .different)
}
let outcome = fileOutcome(left: left, right: right, options: options)
let node = DiffNode(name: name, path: path, isDirectory: false,
isArchive: false, left: left, right: right,
status: outcome.status)
node.appliedMode = outcome.mode
return node
}
private static func pairChildren(left: Node, right: Node, options: Options) -> [DiffNode] {
var rightByName = Dictionary(grouping: right.children, by: \.name).mapValues { $0[0] }
var rows: [DiffNode] = []
for leftChild in left.children {
let match = rightByName.removeValue(forKey: leftChild.name)
rows.append(pair(left: leftChild, right: match, name: leftChild.name,
path: leftChild.path, options: options))
}
for orphan in rightByName.values {
rows.append(pair(left: nil, right: orphan, name: orphan.name,
path: orphan.path, options: options))
}
rows.sort { a, b in
if a.isDirectory != b.isDirectory { return a.isDirectory }
return a.name.localizedStandardCompare(b.name) == .orderedAscending
}
return rows
}
/// The status plus which comparison produced it.
static func fileOutcome(left: Node, right: Node,
options: Options) -> (status: DiffStatus, mode: ComparisonMode?) {
// Level 1 CRC32 straight from the archive directory: free and decisive.
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
if leftCRC == rightCRC && left.size == right.size { return (.identical, nil) }
} else if left.size == right.size, left.size == 0 {
return (.identical, nil)
}
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
guard mustRead else { return (.pending, nil) }
return resolveDetailed(left: left, right: right, options: options)
}
/// The layered check for a single pair of files.
static func fileStatus(left: Node, right: Node, options: Options) -> DiffStatus {
// Level 1 CRC32 straight from the archive directory: free and decisive.
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
if leftCRC == rightCRC && left.size == right.size { return .identical }
// Different CRCs still might be semantically equal, so fall through.
} else if left.size == right.size, left.size == 0 {
return .identical
}
// Level 2 read and compare, unless we're staying lazy.
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
guard mustRead else { return .pending }
return resolve(left: left, right: right, options: options)
}
/// Reads both sides and applies the chosen (or inferred) comparison mode.
public static func resolve(left: Node, right: Node, options: Options = Options()) -> DiffStatus {
resolveDetailed(left: left, right: right, options: options).status
}
/// Same, but also reports which mode was used.
public static func resolveDetailed(left: Node, right: Node,
options: Options = Options()) -> (status: DiffStatus, mode: ComparisonMode?) {
guard let leftData = try? left.data(), let rightData = try? right.data() else { return (.different, nil) }
let kind = ContentKind.detect(path: left.name, data: leftData)
let mode = options.mode ?? kind.defaultMode
if leftData == rightData { return (.identical, mode) }
switch ContentComparer.compare(leftData, rightData, mode: mode, kind: kind,
textOptions: options.textOptions) {
case .equal: return (.identical, mode)
case .equivalent: return (.equivalent, mode)
case .different, .unsupported: return (.different, mode)
}
}
}