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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user