82525da917
A list of NameValuePair entries says the same thing whichever order it's written in, but the comparison demanded positional equality and flagged a pure reorder as a difference — which makes it useless on configuration files. Elements are now reduced to a canonical string: attributes sorted, whitespace normalised, and siblings grouped by name with *repeated* groups sorted, so their order stops mattering. Differently named siblings keep their order, since a sequence of distinct elements can carry meaning. Comparing canonical strings also avoids matching children pairwise, which would be quadratic on exactly the long repeated lists this is for — covered by a test with 400 reversed entries. Reordering no longer hides real changes: a changed value, a dropped entry and a collapsed duplicate are all still caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
266 lines
11 KiB
Swift
266 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// How two files should be considered equal.
|
|
public enum ComparisonMode: String, CaseIterable, Sendable {
|
|
/// Byte for byte. Always available and always exact.
|
|
case binary
|
|
/// Text, with configurable tolerance for whitespace and line endings.
|
|
case text
|
|
/// Structure-aware: two JSON/XML documents are equal when they mean the same
|
|
/// thing, regardless of key order, insignificant whitespace or formatting.
|
|
case semantic
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .binary: return "Byte for byte"
|
|
case .text: return "Text"
|
|
case .semantic: return "Semantic (JSON/XML)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tolerances applied in text mode.
|
|
public struct TextOptions: Sendable, Equatable {
|
|
public var ignoreTrailingWhitespace: Bool
|
|
public var ignoreAllWhitespace: Bool
|
|
public var ignoreCase: Bool
|
|
public var ignoreBlankLines: Bool
|
|
/// Treat CRLF and LF as the same, so files that crossed platforms still match.
|
|
public var normaliseLineEndings: Bool
|
|
|
|
public init(ignoreTrailingWhitespace: Bool = true,
|
|
ignoreAllWhitespace: Bool = false,
|
|
ignoreCase: Bool = false,
|
|
ignoreBlankLines: Bool = false,
|
|
normaliseLineEndings: Bool = true) {
|
|
self.ignoreTrailingWhitespace = ignoreTrailingWhitespace
|
|
self.ignoreAllWhitespace = ignoreAllWhitespace
|
|
self.ignoreCase = ignoreCase
|
|
self.ignoreBlankLines = ignoreBlankLines
|
|
self.normaliseLineEndings = normaliseLineEndings
|
|
}
|
|
|
|
public static let strict = TextOptions(ignoreTrailingWhitespace: false, normaliseLineEndings: false)
|
|
}
|
|
|
|
/// The detected nature of a file, which decides what the default comparison is.
|
|
public enum ContentKind: String, Sendable {
|
|
case json, xml, text, binary
|
|
|
|
/// Guesses from the extension first (cheap) and falls back to sniffing.
|
|
public static func detect(path: String, data: @autoclosure () -> Data?) -> ContentKind {
|
|
switch (path as NSString).pathExtension.lowercased() {
|
|
case "json": return .json
|
|
case "xml", "xsd", "xsl", "xslt", "wsdl", "pom", "svg", "plist", "storyboard": return .xml
|
|
case "txt", "md", "yml", "yaml", "properties", "csv", "log", "java", "swift", "go", "js", "ts", "sh", "sql", "html", "css":
|
|
return .text
|
|
default: break
|
|
}
|
|
guard let bytes = data() else { return .binary }
|
|
return sniff(bytes)
|
|
}
|
|
|
|
/// A NUL byte in the first block is the classic "this is binary" signal.
|
|
static func sniff(_ data: Data) -> ContentKind {
|
|
let sample = data.prefix(8_000)
|
|
if sample.contains(0) { return .binary }
|
|
guard let text = String(data: sample, encoding: .utf8) else { return .binary }
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.hasPrefix("{") || trimmed.hasPrefix("[") { return .json }
|
|
if trimmed.hasPrefix("<") { return .xml }
|
|
return .text
|
|
}
|
|
|
|
/// The comparison that makes sense by default for this kind.
|
|
public var defaultMode: ComparisonMode {
|
|
switch self {
|
|
case .json, .xml: return .semantic
|
|
case .text: return .text
|
|
case .binary: return .binary
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decides whether two blobs are equal under a given mode.
|
|
public enum ContentComparer {
|
|
public enum Result: Equatable {
|
|
case equal
|
|
/// Bytes differ but the documents mean the same thing (JSON/XML only).
|
|
case equivalent
|
|
case different
|
|
/// Semantic comparison was asked for but the content doesn't parse.
|
|
case unsupported(String)
|
|
}
|
|
|
|
public static func compare(_ left: Data, _ right: Data,
|
|
mode: ComparisonMode,
|
|
kind: ContentKind,
|
|
textOptions: TextOptions = TextOptions()) -> Result {
|
|
if left == right { return .equal }
|
|
|
|
switch mode {
|
|
case .binary:
|
|
return .different
|
|
|
|
case .text:
|
|
guard let leftText = String(data: left, encoding: .utf8),
|
|
let rightText = String(data: right, encoding: .utf8) else { return .different }
|
|
return normalise(leftText, textOptions) == normalise(rightText, textOptions) ? .equivalent : .different
|
|
|
|
case .semantic:
|
|
switch kind {
|
|
case .json:
|
|
return compareJSON(left, right)
|
|
case .xml:
|
|
return compareXML(left, right)
|
|
case .text:
|
|
return compare(left, right, mode: .text, kind: .text, textOptions: textOptions)
|
|
case .binary:
|
|
return .different
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Text
|
|
|
|
static func normalise(_ text: String, _ options: TextOptions) -> String {
|
|
var value = text
|
|
if options.normaliseLineEndings {
|
|
value = value.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
|
}
|
|
if options.ignoreCase { value = value.lowercased() }
|
|
|
|
var lines = value.components(separatedBy: "\n")
|
|
if options.ignoreAllWhitespace {
|
|
lines = lines.map { $0.filter { !$0.isWhitespace } }
|
|
} else if options.ignoreTrailingWhitespace {
|
|
lines = lines.map { line in
|
|
var trimmed = line
|
|
while let last = trimmed.last, last.isWhitespace { trimmed.removeLast() }
|
|
return trimmed
|
|
}
|
|
}
|
|
if options.ignoreBlankLines {
|
|
lines = lines.filter { !$0.isEmpty }
|
|
} else {
|
|
// A missing final newline shouldn't count as a difference on its own.
|
|
while lines.last == "" { lines.removeLast() }
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
// MARK: JSON
|
|
|
|
static func compareJSON(_ left: Data, _ right: Data) -> Result {
|
|
guard let leftValue = try? JSONSerialization.jsonObject(with: left, options: [.fragmentsAllowed]) else {
|
|
return .unsupported("The left file is not valid JSON")
|
|
}
|
|
guard let rightValue = try? JSONSerialization.jsonObject(with: right, options: [.fragmentsAllowed]) else {
|
|
return .unsupported("The right file is not valid JSON")
|
|
}
|
|
return jsonEqual(leftValue, rightValue) ? .equivalent : .different
|
|
}
|
|
|
|
/// Structural equality: object key order and formatting are irrelevant, but
|
|
/// array order is significant (it carries meaning in JSON).
|
|
static func jsonEqual(_ left: Any, _ right: Any) -> Bool {
|
|
switch (left, right) {
|
|
case let (l as [String: Any], r as [String: Any]):
|
|
guard l.count == r.count else { return false }
|
|
for (key, leftChild) in l {
|
|
guard let rightChild = r[key], jsonEqual(leftChild, rightChild) else { return false }
|
|
}
|
|
return true
|
|
|
|
case let (l as [Any], r as [Any]):
|
|
guard l.count == r.count else { return false }
|
|
return zip(l, r).allSatisfy { jsonEqual($0, $1) }
|
|
|
|
case let (l as NSNumber, r as NSNumber):
|
|
// Distinguish booleans from 0/1, which NSNumber otherwise conflates.
|
|
let leftIsBool = CFGetTypeID(l) == CFBooleanGetTypeID()
|
|
let rightIsBool = CFGetTypeID(r) == CFBooleanGetTypeID()
|
|
guard leftIsBool == rightIsBool else { return false }
|
|
return l == r
|
|
|
|
case let (l as String, r as String):
|
|
return l == r
|
|
|
|
case (is NSNull, is NSNull):
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: XML
|
|
|
|
static func compareXML(_ left: Data, _ right: Data) -> Result {
|
|
// Tidying normalises entities and insignificant whitespace for us.
|
|
let options: XMLNode.Options = [.documentTidyXML]
|
|
guard let leftDocument = try? XMLDocument(data: left, options: options) else {
|
|
return .unsupported("The left file is not valid XML")
|
|
}
|
|
guard let rightDocument = try? XMLDocument(data: right, options: options) else {
|
|
return .unsupported("The right file is not valid XML")
|
|
}
|
|
guard let leftRoot = leftDocument.rootElement(), let rightRoot = rightDocument.rootElement() else {
|
|
return .different
|
|
}
|
|
return xmlEqual(leftRoot, rightRoot) ? .equivalent : .different
|
|
}
|
|
|
|
/// Canonical comparison. Attribute order, insignificant whitespace and the
|
|
/// order of *repeated* sibling elements don't matter; the order of differently
|
|
/// named siblings still does, so documents whose sequence carries meaning
|
|
/// aren't quietly flattened.
|
|
///
|
|
/// The repeated-sibling rule is what makes configuration files usable: a list
|
|
/// of `NameValuePair` entries says the same thing whichever order it's
|
|
/// written in.
|
|
static func xmlEqual(_ left: XMLElement, _ right: XMLElement) -> Bool {
|
|
canonical(left) == canonical(right)
|
|
}
|
|
|
|
/// A normalised rendering of the element, built so that two documents which
|
|
/// mean the same thing render identically. Comparing the strings is also
|
|
/// cheaper than matching children pairwise, which would be quadratic on the
|
|
/// long repeated lists this is meant to handle.
|
|
static func canonical(_ element: XMLElement) -> String {
|
|
var out = "<\(element.uri ?? "")|\(element.localName ?? "")"
|
|
|
|
// Namespace declarations are structure, not content.
|
|
let attributes = (element.attributes ?? [])
|
|
.compactMap { attribute -> String? in
|
|
guard let name = attribute.name, !name.hasPrefix("xmlns") else { return nil }
|
|
return "\(name)=\(attribute.stringValue ?? "")"
|
|
}
|
|
.sorted()
|
|
out += " " + attributes.joined(separator: " ") + ">"
|
|
|
|
let children = (element.children ?? []).compactMap { $0 as? XMLElement }
|
|
if children.isEmpty {
|
|
out += (element.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return out + "</>"
|
|
}
|
|
|
|
// Group siblings by name, keeping the order names first appear so that a
|
|
// reordering of *different* elements is still a difference.
|
|
var order: [String] = []
|
|
var groups: [String: [XMLElement]] = [:]
|
|
for child in children {
|
|
let key = "\(child.uri ?? "")|\(child.localName ?? "")"
|
|
if groups[key] == nil { order.append(key) }
|
|
groups[key, default: []].append(child)
|
|
}
|
|
|
|
for key in order {
|
|
let rendered = (groups[key] ?? []).map(canonical)
|
|
// Repeated entries are a set: sorting makes their order irrelevant.
|
|
out += rendered.count > 1 ? rendered.sorted().joined() : rendered.joined()
|
|
}
|
|
return out + "</>"
|
|
}
|
|
}
|