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
This commit is contained in:
alexandrev-tibco
2026-07-31 13:30:25 +02:00
parent 139f650d64
commit 82525da917
2 changed files with 148 additions and 25 deletions
+44 -25
View File
@@ -211,36 +211,55 @@ public enum ContentComparer {
return xmlEqual(leftRoot, rightRoot) ? .equivalent : .different
}
/// Canonical comparison: attribute order and insignificant whitespace don't
/// matter, but element order does (it is significant in XML).
/// 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 {
guard left.localName == right.localName, left.uri == right.uri else { return false }
canonical(left) == canonical(right)
}
let attributesOf = { (element: XMLElement) -> [String: String] in
var map: [String: String] = [:]
for attribute in element.attributes ?? [] {
// Namespace declarations are structure, not content.
guard let name = attribute.name, !name.hasPrefix("xmlns") else { continue }
map[name] = attribute.stringValue ?? ""
/// 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 ?? "")"
}
return map
}
guard attributesOf(left) == attributesOf(right) else { return false }
.sorted()
out += " " + attributes.joined(separator: " ") + ">"
let childrenOf = { (element: XMLElement) -> [XMLElement] in
(element.children ?? []).compactMap { $0 as? XMLElement }
}
let leftChildren = childrenOf(left), rightChildren = childrenOf(right)
// A leaf's text is its value; whitespace around it is formatting.
if leftChildren.isEmpty && rightChildren.isEmpty {
let textOf = { (element: XMLElement) in
(element.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
return textOf(left) == textOf(right)
let children = (element.children ?? []).compactMap { $0 as? XMLElement }
if children.isEmpty {
out += (element.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return out + "</>"
}
guard leftChildren.count == rightChildren.count else { return false }
return zip(leftChildren, rightChildren).allSatisfy { xmlEqual($0, $1) }
// 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 + "</>"
}
}
@@ -0,0 +1,104 @@
import XCTest
@testable import KotejEngine
final class XMLCanonicalTests: XCTestCase {
private func compare(_ left: String, _ right: String) -> ContentComparer.Result {
ContentComparer.compare(Data(left.utf8), Data(right.utf8), mode: .semantic, kind: .xml)
}
/// The case that matters for configuration: the same list of entries, written
/// in a different order.
func testRepeatedElementsAreASetNotASequence() {
let left = """
<Config>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
</Config>
"""
let right = """
<Config>
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
</Config>
"""
XCTAssertEqual(compare(left, right), .equivalent,
"reordered repeated entries mean the same thing")
}
func testAChangedValueInsideARepeatedElementIsStillCaught() {
let left = """
<Config>
<NameValuePair><name>port</name><value>8080</value></NameValuePair>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
</Config>
"""
let right = """
<Config>
<NameValuePair><name>host</name><value>localhost</value></NameValuePair>
<NameValuePair><name>port</name><value>9090</value></NameValuePair>
</Config>
"""
XCTAssertEqual(compare(left, right), .different, "8080 -> 9090 must not be hidden by reordering")
}
func testAMissingRepeatedEntryIsCaught() {
let left = """
<Config>
<Pair><k>a</k></Pair><Pair><k>b</k></Pair><Pair><k>c</k></Pair>
</Config>
"""
let right = "<Config><Pair><k>c</k></Pair><Pair><k>a</k></Pair></Config>"
XCTAssertEqual(compare(left, right), .different, "a dropped entry is a difference")
}
func testDuplicatesAreNotCollapsed() {
let left = "<Config><Pair><k>a</k></Pair><Pair><k>a</k></Pair></Config>"
let right = "<Config><Pair><k>a</k></Pair></Config>"
XCTAssertEqual(compare(left, right), .different, "two entries are not one")
}
/// Reordering elements with *different* names is still a difference: their
/// sequence can carry meaning.
func testOrderOfDifferentlyNamedSiblingsStillMatters() {
XCTAssertEqual(compare("<root><a/><b/></root>", "<root><b/><a/></root>"), .different)
}
func testReorderingWorksSeveralLevelsDown() {
let left = """
<app>
<module name="one">
<property><key>x</key><val>1</val></property>
<property><key>y</key><val>2</val></property>
</module>
</app>
"""
let right = """
<app>
<module name="one">
<property><key>y</key><val>2</val></property>
<property><key>x</key><val>1</val></property>
</module>
</app>
"""
XCTAssertEqual(compare(left, right), .equivalent)
}
func testAttributeOrderAndWhitespaceStillIgnored() {
let left = #"<item id="1" name="uno">valor</item>"#
let right = "<item name=\"uno\" id=\"1\">\n valor\n</item>"
XCTAssertEqual(compare(left, right), .equivalent)
}
func testAttributeValueChangeIsCaught() {
XCTAssertEqual(compare(#"<item id="1"/>"#, #"<item id="2"/>"#), .different)
}
/// Long lists are the point of the canonical-string approach; this would be
/// quadratic with pairwise matching.
func testLargeReorderedListIsHandled() {
let entries = (0..<400).map { "<Pair><k>key\($0)</k><v>\($0)</v></Pair>" }
let left = "<Config>" + entries.joined() + "</Config>"
let right = "<Config>" + entries.reversed().joined() + "</Config>"
XCTAssertEqual(compare(left, right), .equivalent)
}
}