draft
This commit is contained in:
commit
011075d5ce
9 changed files with 873 additions and 0 deletions
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
47
src/main/java/com/github/shautvast/xmldiff/DiffEngine.java
Normal file
47
src/main/java/com/github/shautvast/xmldiff/DiffEngine.java
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
import org.xmlunit.builder.DiffBuilder;
|
||||
import org.xmlunit.diff.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Runs XMLUnit's {@link DiffBuilder} and indexes the resulting differences into two
|
||||
* XPath → {@link ComparisonType} maps (one per side).
|
||||
*
|
||||
* <p>Children are matched by element name ({@link ElementSelectors#byName}), so:
|
||||
* <ul>
|
||||
* <li>{@link ComparisonType#CHILD_LOOKUP} — a child has no name-match on the other side</li>
|
||||
* <li>{@link ComparisonType#TEXT_VALUE} — paired text nodes differ</li>
|
||||
* <li>{@link ComparisonType#ATTR_VALUE} — a named attribute's value differs</li>
|
||||
* <li>{@link ComparisonType#ATTR_NAME_LOOKUP} — an attribute exists only on one side</li>
|
||||
* </ul>
|
||||
*/
|
||||
class DiffEngine {
|
||||
|
||||
private DiffEngine() {}
|
||||
|
||||
record DiffMaps(Map<String, ComparisonType> left, Map<String, ComparisonType> right) {}
|
||||
|
||||
static DiffMaps compute(String leftXml, String rightXml) {
|
||||
Diff diff = DiffBuilder.compare(leftXml)
|
||||
.withTest(rightXml)
|
||||
.withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byName))
|
||||
.ignoreWhitespace()
|
||||
.build();
|
||||
|
||||
Map<String, ComparisonType> left = new HashMap<>();
|
||||
Map<String, ComparisonType> right = new HashMap<>();
|
||||
|
||||
for (Difference d : diff.getDifferences()) {
|
||||
Comparison c = d.getComparison();
|
||||
String lxp = c.getControlDetails().getXPath();
|
||||
String rxp = c.getTestDetails().getXPath();
|
||||
if (lxp != null) left.put(lxp, c.getType());
|
||||
if (rxp != null) right.put(rxp, c.getType());
|
||||
}
|
||||
|
||||
return new DiffMaps(left, right);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
public record DiffResult(String leftHtml, String rightHtml) {}
|
||||
59
src/main/java/com/github/shautvast/xmldiff/HtmlBuilder.java
Normal file
59
src/main/java/com/github/shautvast/xmldiff/HtmlBuilder.java
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
/**
|
||||
* Builds an HTML string of nested spans.
|
||||
* Consecutive spans with the same CSS class are merged for cleaner output.
|
||||
*/
|
||||
class HtmlBuilder {
|
||||
|
||||
private final StringBuilder sb = new StringBuilder();
|
||||
private String currentClass = null;
|
||||
private final StringBuilder currentContent = new StringBuilder();
|
||||
|
||||
void span(String cssClass, String content) {
|
||||
if (cssClass.equals(currentClass)) {
|
||||
currentContent.append(escape(content));
|
||||
} else {
|
||||
flush();
|
||||
currentClass = cssClass;
|
||||
currentContent.append(escape(content));
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits a newline + indent, always in a neutral span. */
|
||||
void newline(int indent) {
|
||||
if (!"neutral".equals(currentClass)) {
|
||||
flush();
|
||||
currentClass = "neutral";
|
||||
}
|
||||
currentContent.append("\n").append(" ".repeat(indent));
|
||||
}
|
||||
|
||||
/** Emits a bare empty span (placeholder for a node absent on this side). */
|
||||
void emptySpan() {
|
||||
flush();
|
||||
sb.append("<span></span>");
|
||||
}
|
||||
|
||||
String build() {
|
||||
flush();
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
if (currentClass != null && !currentContent.isEmpty()) {
|
||||
sb.append("<span class=\"").append(currentClass).append("\">");
|
||||
sb.append(currentContent);
|
||||
sb.append("</span>");
|
||||
}
|
||||
currentContent.setLength(0);
|
||||
currentClass = null;
|
||||
}
|
||||
|
||||
static String escape(String s) {
|
||||
return s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
348
src/main/java/com/github/shautvast/xmldiff/HtmlRenderer.java
Normal file
348
src/main/java/com/github/shautvast/xmldiff/HtmlRenderer.java
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
import org.w3c.dom.*;
|
||||
import org.xmlunit.diff.ComparisonType;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Walks two DOM trees in parallel and writes annotated HTML to two {@link HtmlBuilder}s,
|
||||
* guided by XMLUnit diff maps.
|
||||
*
|
||||
* <h3>What XMLUnit drives</h3>
|
||||
* <ul>
|
||||
* <li>{@code CHILD_LOOKUP} — identifies which children have no name-match on the other side.</li>
|
||||
* <li>{@code TEXT_VALUE} — drives correct/wrong on paired text nodes.</li>
|
||||
* <li>{@code ATTR_VALUE} — drives correct/wrong on attribute values when names match.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Child matching</h3>
|
||||
* <ol>
|
||||
* <li>XMLUnit {@code CHILD_LOOKUP} identifies structurally unmatched children.</li>
|
||||
* <li>Those are paired positionally (so {@code <expected>} vs {@code <actual>} renders as a
|
||||
* tag-name diff with {@code skipped} children, not as two independent missing nodes).</li>
|
||||
* <li>Remaining children are paired by element name, mirroring XMLUnit's own strategy.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Attributes</h3>
|
||||
* Matched by name (order is not significant). Values are compared via the XMLUnit diff maps.
|
||||
*/
|
||||
class HtmlRenderer {
|
||||
|
||||
private final HtmlBuilder left;
|
||||
private final HtmlBuilder right;
|
||||
private final Map<String, ComparisonType> leftDiffs;
|
||||
private final Map<String, ComparisonType> rightDiffs;
|
||||
|
||||
HtmlRenderer(HtmlBuilder left, HtmlBuilder right,
|
||||
Map<String, ComparisonType> leftDiffs,
|
||||
Map<String, ComparisonType> rightDiffs) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.leftDiffs = leftDiffs;
|
||||
this.rightDiffs = rightDiffs;
|
||||
}
|
||||
|
||||
void render(Element leftEl, Element rightEl) {
|
||||
String lxp = "/" + leftEl.getTagName() + "[1]";
|
||||
String rxp = "/" + rightEl.getTagName() + "[1]";
|
||||
renderElement(leftEl, rightEl, lxp, rxp, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Element
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void renderElement(Element leftEl, Element rightEl,
|
||||
String lxp, String rxp, int indent) {
|
||||
if (!leftEl.getTagName().equals(rightEl.getTagName())) {
|
||||
renderTagNameDiff(leftEl, rightEl, indent);
|
||||
return;
|
||||
}
|
||||
|
||||
String tag = leftEl.getTagName();
|
||||
left.span("neutral", "<" + tag);
|
||||
right.span("neutral", "<" + tag);
|
||||
|
||||
compareAttributes(leftEl, rightEl, lxp, rxp);
|
||||
|
||||
List<Node> leftChildren = significantChildren(leftEl);
|
||||
List<Node> rightChildren = significantChildren(rightEl);
|
||||
|
||||
if (leftChildren.isEmpty() && rightChildren.isEmpty()) {
|
||||
left.span("neutral", "/>");
|
||||
right.span("neutral", "/>");
|
||||
return;
|
||||
}
|
||||
|
||||
left.span("neutral", ">");
|
||||
right.span("neutral", ">");
|
||||
|
||||
compareChildren(leftChildren, rightChildren, lxp, rxp, indent + 1);
|
||||
|
||||
left.newline(indent);
|
||||
right.newline(indent);
|
||||
left.span("neutral", "</" + tag + ">");
|
||||
right.span("neutral", "</" + tag + ">");
|
||||
}
|
||||
|
||||
/** Tag names differ: name → correct/wrong, all content → skipped. */
|
||||
private void renderTagNameDiff(Element leftEl, Element rightEl, int indent) {
|
||||
left.span("correct", "<" + leftEl.getTagName());
|
||||
renderAttrsAsClass(left, leftEl, "skipped");
|
||||
renderBodyAsSkipped(left, leftEl, indent);
|
||||
|
||||
right.span("wrong", "<" + rightEl.getTagName());
|
||||
renderAttrsAsClass(right, rightEl, "skipped");
|
||||
renderBodyAsSkipped(right, rightEl, indent);
|
||||
}
|
||||
|
||||
private void renderBodyAsSkipped(HtmlBuilder builder, Element el, int indent) {
|
||||
List<Node> children = significantChildren(el);
|
||||
if (children.isEmpty()) {
|
||||
builder.span("skipped", "/>");
|
||||
} else {
|
||||
builder.span("skipped", ">");
|
||||
for (Node child : children) {
|
||||
builder.newline(indent + 1);
|
||||
renderSubtree(builder, child, "skipped", indent + 1);
|
||||
}
|
||||
builder.newline(indent);
|
||||
builder.span("skipped", "</" + el.getTagName() + ">");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Attributes — matched by name; values compared via XMLUnit diff maps
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void compareAttributes(Element leftEl, Element rightEl, String lxp, String rxp) {
|
||||
Map<String, String> leftAttrs = attrMap(leftEl);
|
||||
Map<String, String> rightAttrs = attrMap(rightEl);
|
||||
|
||||
for (Map.Entry<String, String> e : leftAttrs.entrySet()) {
|
||||
String name = e.getKey();
|
||||
String leftVal = e.getValue();
|
||||
String rightVal = rightAttrs.get(name);
|
||||
|
||||
if (rightVal == null) {
|
||||
left.span("correct", " " + name + "=\"" + leftVal + "\"");
|
||||
right.emptySpan();
|
||||
} else {
|
||||
boolean valueDiff = leftDiffs.get(lxp + "/@" + name) == ComparisonType.ATTR_VALUE
|
||||
|| rightDiffs.get(rxp + "/@" + name) == ComparisonType.ATTR_VALUE;
|
||||
if (valueDiff) {
|
||||
left.span("neutral", " " + name + "=\"");
|
||||
left.span("correct", leftVal);
|
||||
left.span("neutral", "\"");
|
||||
right.span("neutral", " " + name + "=\"");
|
||||
right.span("wrong", rightVal);
|
||||
right.span("neutral", "\"");
|
||||
} else {
|
||||
left.span("neutral", " " + name + "=\"" + leftVal + "\"");
|
||||
right.span("neutral", " " + name + "=\"" + rightVal + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> e : rightAttrs.entrySet()) {
|
||||
if (!leftAttrs.containsKey(e.getKey())) {
|
||||
left.emptySpan();
|
||||
right.span("wrong", " " + e.getKey() + "=\"" + e.getValue() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Children — paired using XMLUnit CHILD_LOOKUP + positional fallback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void compareChildren(List<Node> leftChildren, List<Node> rightChildren,
|
||||
String lParentXPath, String rParentXPath, int indent) {
|
||||
List<String> lxps = childXPaths(leftChildren, lParentXPath);
|
||||
List<String> rxps = childXPaths(rightChildren, rParentXPath);
|
||||
|
||||
// Step 1: identify structurally unmatched children per XMLUnit
|
||||
List<Integer> leftUnmatched = new ArrayList<>();
|
||||
List<Integer> rightUnmatched = new ArrayList<>();
|
||||
for (int i = 0; i < leftChildren.size(); i++) {
|
||||
if (leftDiffs.get(lxps.get(i)) == ComparisonType.CHILD_LOOKUP)
|
||||
leftUnmatched.add(i);
|
||||
}
|
||||
for (int i = 0; i < rightChildren.size(); i++) {
|
||||
if (rightDiffs.get(rxps.get(i)) == ComparisonType.CHILD_LOOKUP)
|
||||
rightUnmatched.add(i);
|
||||
}
|
||||
|
||||
// Step 2: pair unmatched children positionally so differently-named siblings
|
||||
// render as a tag-name diff (with skipped children) rather than independent missing nodes
|
||||
Map<Integer, Integer> leftToRight = new LinkedHashMap<>();
|
||||
Set<Integer> matchedRight = new LinkedHashSet<>();
|
||||
int positional = Math.min(leftUnmatched.size(), rightUnmatched.size());
|
||||
for (int i = 0; i < positional; i++) {
|
||||
leftToRight.put(leftUnmatched.get(i), rightUnmatched.get(i));
|
||||
matchedRight.add(rightUnmatched.get(i));
|
||||
}
|
||||
|
||||
// Step 3: name-based matching for non-CHILD_LOOKUP element children
|
||||
Set<Integer> leftUnmatchedSet = new HashSet<>(leftUnmatched);
|
||||
Set<Integer> rightUnmatchedSet = new HashSet<>(rightUnmatched);
|
||||
for (int li = 0; li < leftChildren.size(); li++) {
|
||||
if (leftUnmatchedSet.contains(li) || leftToRight.containsKey(li)) continue;
|
||||
if (!(leftChildren.get(li) instanceof Element le)) continue;
|
||||
for (int ri = 0; ri < rightChildren.size(); ri++) {
|
||||
if (rightUnmatchedSet.contains(ri) || matchedRight.contains(ri)) continue;
|
||||
if (rightChildren.get(ri) instanceof Element re
|
||||
&& re.getTagName().equals(le.getTagName())) {
|
||||
leftToRight.put(li, ri);
|
||||
matchedRight.add(ri);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: positional fallback for any remaining unmatched nodes (e.g. text)
|
||||
List<Integer> stillLeft = new ArrayList<>(), stillRight = new ArrayList<>();
|
||||
for (int li = 0; li < leftChildren.size(); li++)
|
||||
if (!leftToRight.containsKey(li)) stillLeft.add(li);
|
||||
for (int ri = 0; ri < rightChildren.size(); ri++)
|
||||
if (!matchedRight.contains(ri)) stillRight.add(ri);
|
||||
int extra = Math.min(stillLeft.size(), stillRight.size());
|
||||
for (int i = 0; i < extra; i++) {
|
||||
leftToRight.put(stillLeft.get(i), stillRight.get(i));
|
||||
matchedRight.add(stillRight.get(i));
|
||||
}
|
||||
|
||||
// Step 5: render left children in document order
|
||||
Set<Integer> renderedRight = new LinkedHashSet<>();
|
||||
for (int li = 0; li < leftChildren.size(); li++) {
|
||||
left.newline(indent);
|
||||
right.newline(indent);
|
||||
Integer ri = leftToRight.get(li);
|
||||
if (ri != null) {
|
||||
renderNode(leftChildren.get(li), rightChildren.get(ri),
|
||||
lxps.get(li), rxps.get(ri), indent);
|
||||
renderedRight.add(ri);
|
||||
} else {
|
||||
renderSubtree(left, leftChildren.get(li), "correct", indent);
|
||||
right.emptySpan();
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: render truly right-only children
|
||||
for (int ri = 0; ri < rightChildren.size(); ri++) {
|
||||
if (!renderedRight.contains(ri)) {
|
||||
left.newline(indent);
|
||||
right.newline(indent);
|
||||
left.emptySpan();
|
||||
renderSubtree(right, rightChildren.get(ri), "wrong", indent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderNode(Node ln, Node rn, String lxp, String rxp, int indent) {
|
||||
if (ln instanceof Element le && rn instanceof Element re) {
|
||||
renderElement(le, re, lxp, rxp, indent);
|
||||
} else if (ln instanceof Text lt && rn instanceof Text rt) {
|
||||
renderText(lt, rt, lxp, rxp);
|
||||
} else {
|
||||
renderSubtree(left, ln, "correct", indent);
|
||||
renderSubtree(right, rn, "wrong", indent);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Text — driven by XMLUnit TEXT_VALUE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void renderText(Text lt, Text rt, String lxp, String rxp) {
|
||||
boolean differs = leftDiffs.get(lxp) == ComparisonType.TEXT_VALUE
|
||||
|| rightDiffs.get(rxp) == ComparisonType.TEXT_VALUE;
|
||||
String lc = lt.getTextContent().strip();
|
||||
String rc = rt.getTextContent().strip();
|
||||
if (differs) {
|
||||
left.span("correct", lc);
|
||||
right.span("wrong", rc);
|
||||
} else {
|
||||
left.span("neutral", lc);
|
||||
right.span("neutral", rc);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Single-side subtree (one CSS class for all tokens)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void renderSubtree(HtmlBuilder builder, Node node, String cssClass, int indent) {
|
||||
if (node instanceof Element el) {
|
||||
builder.span(cssClass, "<" + el.getTagName());
|
||||
renderAttrsAsClass(builder, el, cssClass);
|
||||
List<Node> children = significantChildren(el);
|
||||
if (children.isEmpty()) {
|
||||
builder.span(cssClass, "/>");
|
||||
} else {
|
||||
builder.span(cssClass, ">");
|
||||
for (Node child : children) {
|
||||
builder.newline(indent + 1);
|
||||
renderSubtree(builder, child, cssClass, indent + 1);
|
||||
}
|
||||
builder.newline(indent);
|
||||
builder.span(cssClass, "</" + el.getTagName() + ">");
|
||||
}
|
||||
} else if (node instanceof Text text) {
|
||||
String content = text.getTextContent().strip();
|
||||
if (!content.isEmpty()) builder.span(cssClass, content);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void renderAttrsAsClass(HtmlBuilder builder, Element el, String cssClass) {
|
||||
attrMap(el).forEach((name, value) ->
|
||||
builder.span(cssClass, " " + name + "=\"" + value + "\""));
|
||||
}
|
||||
|
||||
private Map<String, String> attrMap(Element el) {
|
||||
NamedNodeMap nnm = el.getAttributes();
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < nnm.getLength(); i++) {
|
||||
Attr a = (Attr) nnm.item(i);
|
||||
map.put(a.getName(), a.getValue());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private List<Node> significantChildren(Element el) {
|
||||
NodeList nl = el.getChildNodes();
|
||||
List<Node> result = new ArrayList<>();
|
||||
for (int i = 0; i < nl.getLength(); i++) {
|
||||
Node n = nl.item(i);
|
||||
if (n.getNodeType() == Node.ELEMENT_NODE) {
|
||||
result.add(n);
|
||||
} else if (n.getNodeType() == Node.TEXT_NODE
|
||||
&& !n.getTextContent().strip().isEmpty()) {
|
||||
result.add(n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Generates XMLUnit-compatible XPaths for a list of child nodes. */
|
||||
private List<String> childXPaths(List<Node> children, String parentXPath) {
|
||||
Map<String, Integer> elementCounts = new LinkedHashMap<>();
|
||||
int textCount = 0;
|
||||
List<String> result = new ArrayList<>(children.size());
|
||||
for (Node child : children) {
|
||||
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
||||
String tag = ((Element) child).getTagName();
|
||||
int n = elementCounts.merge(tag, 1, Integer::sum);
|
||||
result.add(parentXPath + "/" + tag + "[" + n + "]");
|
||||
} else {
|
||||
result.add(parentXPath + "/text()[" + (++textCount) + "]");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
61
src/main/java/com/github/shautvast/xmldiff/XmlDiff.java
Normal file
61
src/main/java/com/github/shautvast/xmldiff/XmlDiff.java
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
import org.w3c.dom.Document;
|
||||
import org.xml.sax.InputSource;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.StringReader;
|
||||
|
||||
/**
|
||||
* Public API for XML diffing.
|
||||
*
|
||||
* <p>Takes two XML strings (left = expected, right = actual) and returns two HTML strings
|
||||
* representing a side-by-side diff. Each output is a tree of {@code <span>} elements
|
||||
* annotated with CSS classes:
|
||||
* <ul>
|
||||
* <li>{@code neutral} — token is identical on both sides</li>
|
||||
* <li>{@code correct} — token is on the left (expected) side and differs</li>
|
||||
* <li>{@code wrong} — token is on the right (actual) side and differs</li>
|
||||
* <li>{@code skipped} — child content of an element whose tag name differs</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Structural diffing is performed by <a href="https://www.xmlunit.org/">XMLUnit 2</a>
|
||||
* using name-based child matching. Attribute order is not significant.
|
||||
*/
|
||||
public class XmlDiff {
|
||||
|
||||
private XmlDiff() {}
|
||||
|
||||
/**
|
||||
* Compares two XML strings and returns annotated HTML for both sides.
|
||||
*
|
||||
* @param leftXml the expected XML
|
||||
* @param rightXml the actual XML
|
||||
* @return a {@link DiffResult} containing the left and right HTML strings
|
||||
* @throws XmlDiffException if either string cannot be parsed as XML
|
||||
*/
|
||||
public static DiffResult compare(String leftXml, String rightXml) {
|
||||
DiffEngine.DiffMaps maps = DiffEngine.compute(leftXml, rightXml);
|
||||
|
||||
Document leftDoc = parse(leftXml);
|
||||
Document rightDoc = parse(rightXml);
|
||||
|
||||
HtmlBuilder leftBuilder = new HtmlBuilder();
|
||||
HtmlBuilder rightBuilder = new HtmlBuilder();
|
||||
|
||||
new HtmlRenderer(leftBuilder, rightBuilder, maps.left(), maps.right())
|
||||
.render(leftDoc.getDocumentElement(), rightDoc.getDocumentElement());
|
||||
|
||||
return new DiffResult(leftBuilder.build(), rightBuilder.build());
|
||||
}
|
||||
|
||||
private static Document parse(String xml) {
|
||||
try {
|
||||
return DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
.parse(new InputSource(new StringReader(xml)));
|
||||
} catch (Exception e) {
|
||||
throw new XmlDiffException("Failed to parse XML: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
public class XmlDiffException extends RuntimeException {
|
||||
public XmlDiffException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
169
src/test/java/com/github/shautvast/xmldiff/XmlDiffTest.java
Normal file
169
src/test/java/com/github/shautvast/xmldiff/XmlDiffTest.java
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package com.github.shautvast.xmldiff;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class XmlDiffTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Identical elements — everything neutral
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void identicalSimple_allNeutral() {
|
||||
assertNoDiff(XmlDiff.compare("<root/>", "<root/>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalWithText_allNeutral() {
|
||||
assertNoDiff(XmlDiff.compare("<root>hello</root>", "<root>hello</root>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalNested_allNeutral() {
|
||||
String xml = "<root><child attr=\"val\">text</child></root>";
|
||||
assertNoDiff(XmlDiff.compare(xml, xml));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Differing text content
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void differingText() {
|
||||
DiffResult r = XmlDiff.compare("<root>expected</root>", "<root>actual</root>");
|
||||
assertContains(r.leftHtml(), "correct", "expected");
|
||||
assertContains(r.rightHtml(), "wrong", "actual");
|
||||
assertAbsent(r.leftHtml(), "wrong");
|
||||
assertAbsent(r.rightHtml(), "correct");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Differing attribute value — name neutral, value correct/wrong
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void differingAttributeValue() {
|
||||
DiffResult r = XmlDiff.compare(
|
||||
"<root attr=\"expected\"/>",
|
||||
"<root attr=\"actual\"/>");
|
||||
|
||||
assertContains(r.leftHtml(), "correct", "expected");
|
||||
assertContains(r.rightHtml(), "wrong", "actual");
|
||||
// The attribute name itself must not be marked correct/wrong
|
||||
assertFalse(r.leftHtml().contains("<span class=\"correct\">attr"),
|
||||
"attr name should not be marked correct");
|
||||
assertFalse(r.rightHtml().contains("<span class=\"wrong\">attr"),
|
||||
"attr name should not be marked wrong");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Attribute only on one side
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void attributeOnlyOnLeft() {
|
||||
DiffResult r = XmlDiff.compare("<root x=\"1\"/>", "<root/>");
|
||||
assertContains(r.leftHtml(), "correct", "x");
|
||||
assertTrue(r.rightHtml().contains("<span></span>"), "right should have placeholder");
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributeOnlyOnRight() {
|
||||
DiffResult r = XmlDiff.compare("<root/>", "<root x=\"1\"/>");
|
||||
assertTrue(r.leftHtml().contains("<span></span>"), "left should have placeholder");
|
||||
assertContains(r.rightHtml(), "wrong", "x");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Differing element name — tag correct/wrong, children skipped
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void differingElementName() {
|
||||
DiffResult r = XmlDiff.compare(
|
||||
"<root><expected>text</expected></root>",
|
||||
"<root><actual>text</actual></root>");
|
||||
|
||||
assertContains(r.leftHtml(), "correct", "expected");
|
||||
assertContains(r.rightHtml(), "wrong", "actual");
|
||||
assertTrue(r.leftHtml().contains("class=\"skipped\""), "left children should be skipped");
|
||||
assertTrue(r.rightHtml().contains("class=\"skipped\""), "right children should be skipped");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Extra child on left
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void extraChildOnLeft() {
|
||||
DiffResult r = XmlDiff.compare("<root><child/></root>", "<root/>");
|
||||
assertContains(r.leftHtml(), "correct", "child");
|
||||
assertTrue(r.rightHtml().contains("<span></span>"), "right should have placeholder");
|
||||
assertAbsent(r.rightHtml(), "correct");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Extra child on right
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void extraChildOnRight() {
|
||||
DiffResult r = XmlDiff.compare("<root/>", "<root><child/></root>");
|
||||
assertTrue(r.leftHtml().contains("<span></span>"), "left should have placeholder");
|
||||
assertContains(r.rightHtml(), "wrong", "child");
|
||||
assertAbsent(r.leftHtml(), "wrong");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Attribute order does not matter
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void attributeOrderDoesNotMatter() {
|
||||
DiffResult r = XmlDiff.compare(
|
||||
"<root x=\"1\" y=\"2\"/>",
|
||||
"<root y=\"2\" x=\"1\"/>");
|
||||
assertNoDiff(r);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. Nested elements — only the differing subtree is marked
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void nestedPartialDiff() {
|
||||
DiffResult r = XmlDiff.compare(
|
||||
"<root><same/><diff>expected</diff></root>",
|
||||
"<root><same/><diff>actual</diff></root>");
|
||||
|
||||
assertTrue(r.leftHtml().contains("class=\"neutral\""), "unchanged parts should be neutral");
|
||||
assertContains(r.leftHtml(), "correct", "expected");
|
||||
assertContains(r.rightHtml(), "wrong", "actual");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. Self-closing element, no diff
|
||||
// -------------------------------------------------------------------------
|
||||
@Test
|
||||
void selfClosingNoDiff() {
|
||||
assertNoDiff(XmlDiff.compare("<root><item id=\"1\"/></root>", "<root><item id=\"1\"/></root>"));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void assertNoDiff(DiffResult r) {
|
||||
assertAbsent(r.leftHtml(), "correct");
|
||||
assertAbsent(r.leftHtml(), "wrong");
|
||||
assertAbsent(r.leftHtml(), "skipped");
|
||||
assertAbsent(r.rightHtml(), "correct");
|
||||
assertAbsent(r.rightHtml(), "wrong");
|
||||
assertAbsent(r.rightHtml(), "skipped");
|
||||
}
|
||||
|
||||
private void assertContains(String html, String cssClass, String text) {
|
||||
assertTrue(html.contains("class=\"" + cssClass + "\""),
|
||||
"expected class=" + cssClass + " in: " + html);
|
||||
assertTrue(html.contains(text),
|
||||
"expected text '" + text + "' in: " + html);
|
||||
}
|
||||
|
||||
private void assertAbsent(String html, String cssClass) {
|
||||
assertFalse(html.contains("class=\"" + cssClass + "\""),
|
||||
"unexpected class=" + cssClass + " in: " + html);
|
||||
}
|
||||
}
|
||||
143
xmldiff.md
Normal file
143
xmldiff.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# xmldiff — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
A Java library that takes two XML strings (left = expected, right = actual) and produces two HTML strings suitable for rendering a side-by-side diff. Each output is a `<span>` tree with inner spans annotated with CSS classes.
|
||||
|
||||
## CSS Classes
|
||||
|
||||
| Class | Meaning |
|
||||
|------------|-----------------------------------------------------------|
|
||||
| `neutral` | This token is identical in both sides |
|
||||
| `correct` | This token is on the **left** side and differs from right |
|
||||
| `wrong` | This token is on the **right** side and differs from left |
|
||||
| `skipped` | Child content of an element whose **tag name** differs |
|
||||
|
||||
## Diff Granularity Rules
|
||||
|
||||
| Token | If equal | If different |
|
||||
|-------------------------------|------------|--------------------------------------------------------------------------------------|
|
||||
| Element name | `neutral` | Left → `correct`, right → `wrong`; all content (attrs, children, text) → `skipped` |
|
||||
| Attribute name | `neutral` | Left attr name → `correct`, right attr name → `wrong` |
|
||||
| Attribute value | `neutral` | Left attr name neutral, left value → `correct`; same on right → `wrong` |
|
||||
| Text content | `neutral` | Left text → `correct`, right text → `wrong` |
|
||||
| Element present only on left | — | Left subtree → `correct`, right → empty `<span></span>` |
|
||||
| Element present only on right | — | Right subtree → `wrong`, left → empty `<span></span>` |
|
||||
|
||||
Attribute **order is not significant**:
|
||||
|
||||
## Output Format
|
||||
|
||||
Each output string is pretty-printed HTML. XML special characters (`<`, `>`, `&`, `"`) inside span text are HTML-escaped. Indentation uses 2 spaces per level. Output does **not** include an XML declaration.
|
||||
|
||||
Example shape:
|
||||
|
||||
```html
|
||||
<span class="neutral"><root>
|
||||
<child </span><span class="correct">attr</span><span class="neutral">="</span><span class="correct">value</span><span class="neutral">">
|
||||
</span><span class="correct">text here</span><span class="neutral">
|
||||
</child>
|
||||
</root></span>
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```xml
|
||||
<!-- XML diffing -->
|
||||
<dependency>
|
||||
<groupId>org.xmlunit</groupId>
|
||||
<artifactId>xmlunit-core</artifactId>
|
||||
<version>2.10.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**XMLUnit 2.x** is the diffing engine. It produces a list of `Comparison` objects, each with:
|
||||
- `getType()` — `ComparisonType` enum: `ELEMENT_TAG_NAME`, `ATTR_VALUE`, `ATTR_NAME_LOOKUP`, `TEXT_VALUE`, `CHILD_NODELIST_LENGTH`, `HAS_CHILD_NODES`, etc.
|
||||
- `getControlDetails().getXPath()` — XPath of the affected node on the left side
|
||||
- `getTestDetails().getXPath()` — XPath of the affected node on the right side
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Step 1 — Diff (DiffEngine)
|
||||
|
||||
```
|
||||
Diff diff = DiffBuilder
|
||||
.compare(leftXml)
|
||||
.withTest(rightXml)
|
||||
.withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byName))
|
||||
.ignoreWhitespace()
|
||||
.build();
|
||||
|
||||
For each Comparison c in diff.getDifferences():
|
||||
record (c.getControlDetails().getXPath(), c.getTestDetails().getXPath(), c.getType())
|
||||
into two maps: leftDiffs: XPath → ComparisonType
|
||||
rightDiffs: XPath → ComparisonType
|
||||
```
|
||||
|
||||
### Step 2 — Render (HtmlRenderer)
|
||||
|
||||
Walk each DOM tree independently, pretty-printing to HTML. At each node, look up its XPath in the relevant diff map to determine its CSS class.
|
||||
|
||||
**Element node:**
|
||||
```
|
||||
xp = xpathOf(node)
|
||||
if leftDiffs contains xp with type ELEMENT_TAG_NAME:
|
||||
emit tag name as correct/wrong
|
||||
emit all attributes + children recursively as skipped
|
||||
else:
|
||||
emit tag name as neutral
|
||||
for each attribute (in document order):
|
||||
emit based on attr-level diff lookup
|
||||
recurse into children
|
||||
```
|
||||
|
||||
**Text node:**
|
||||
```
|
||||
xp = xpathOf(node)
|
||||
if leftDiffs/rightDiffs contains xp with type TEXT_VALUE:
|
||||
emit as correct / wrong
|
||||
else:
|
||||
emit as neutral
|
||||
```
|
||||
|
||||
**Missing child (CHILD_NODELIST_LENGTH or similar):**
|
||||
```
|
||||
emit present side as correct/wrong
|
||||
emit absent side as empty <span></span>
|
||||
```
|
||||
|
||||
XPaths are computed from the DOM tree as each node is visited, matching the XPaths that XMLUnit generates (e.g. `/root[1]/child[1]`).
|
||||
|
||||
### Step 3 — Output
|
||||
|
||||
`XmlDiff.compare()` calls `DiffEngine`, then calls `HtmlRenderer` once for the left tree and once for the right tree, returning a `DiffResult`.
|
||||
|
||||
## Test Cases
|
||||
|
||||
| # | Scenario | Left class | Right class |
|
||||
|---|---------------------------------------|------------|-------------|
|
||||
| 1 | Identical simple elements | all `neutral` | all `neutral` |
|
||||
| 2 | Differing text content | text `correct` | text `wrong` |
|
||||
| 3 | Differing attribute value | value `correct` | value `wrong` (name neutral) |
|
||||
| 4 | Differing attribute name | name `correct` | name `wrong` |
|
||||
| 5 | Differing element name | name `correct`, children `skipped` | name `wrong`, children `skipped` |
|
||||
| 6 | Extra child on left only | child `correct` | empty span |
|
||||
| 7 | Extra child on right only | empty span | child `wrong` |
|
||||
| 8 | Attribute order differs | first mismatch `correct` | first mismatch `wrong` |
|
||||
| 9 | Nested elements, partial diff | only differing subtree marked | same |
|
||||
| 10| Self-closing element, no diff | all `neutral` | all `neutral` |
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Comments, processing instructions, and CDATA sections are ignored.
|
||||
- Whitespace-only text nodes between elements are ignored (XMLUnit `ignoreWhitespace()`).
|
||||
- Namespace prefixes are treated as plain text; no namespace-aware comparison.
|
||||
- The library is stateless; `XmlDiff.compare()` is safe to call concurrently.
|
||||
Loading…
Add table
Reference in a new issue