Files
alexandrev-tibco 82525da917 XML: repeated sibling elements compare as a set
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
2026-07-31 13:30:25 +02:00

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 + "</>"
}
}