• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

xmlunit / xmlunit / cd160610-9b67-4752-a2c4-e3a0d82d991e

21 Apr 2025 11:55AM UTC coverage: 91.756% (-0.02%) from 91.78%
cd160610-9b67-4752-a2c4-e3a0d82d991e

push

circleci

web-flow
Merge pull request #289 from xmlunit/circleci-project-setup

CircleCI project setup

3996 of 4698 branches covered (85.06%)

11754 of 12810 relevant lines covered (91.76%)

2.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.31
/xmlunit-placeholders/src/main/java/org/xmlunit/placeholder/PlaceholderDifferenceEvaluator.java
1
/*
2
  This file is licensed to You under the Apache License, Version 2.0
3
  (the "License"); you may not use this file except in compliance with
4
  the License.  You may obtain a copy of the License at
5

6
  http://www.apache.org/licenses/LICENSE-2.0
7

8
  Unless required by applicable law or agreed to in writing, software
9
  distributed under the License is distributed on an "AS IS" BASIS,
10
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
  See the License for the specific language governing permissions and
12
  limitations under the License.
13
*/
14
package org.xmlunit.placeholder;
15

16
import java.util.Collections;
17
import java.util.HashMap;
18
import java.util.Map;
19
import java.util.ServiceLoader;
20
import java.util.regex.Matcher;
21
import java.util.regex.Pattern;
22
import javax.xml.namespace.QName;
23
import org.w3c.dom.Node;
24
import org.xmlunit.diff.Comparison;
25
import org.xmlunit.diff.ComparisonResult;
26
import org.xmlunit.diff.ComparisonType;
27
import org.xmlunit.diff.DifferenceEvaluator;
28
import org.xmlunit.util.Nodes;
29

30
/**
31
 * This class is used to add placeholder feature to XML comparison.
32
 *
33
 * <p><b>This class and the whole module are considered experimental
34
 * and any API may change between releases of XMLUnit.</b></p>
35
 *
36
 * <p>To use it, just add it with {@link
37
 * org.xmlunit.builder.DiffBuilder} like below</p>
38
 *
39
 * <pre>
40
 * Diff diff = DiffBuilder.compare(control).withTest(test).withDifferenceEvaluator(new PlaceholderDifferenceEvaluator()).build();
41
 * </pre>
42
 *
43
 * <p>Supported scenarios are demonstrated in the unit tests
44
 * (PlaceholderDifferenceEvaluatorTest).</p>
45
 *
46
 * <p>Default delimiters for placeholder are <code>${</code> and
47
 * <code>}</code>. Arguments to placeholders are by default enclosed
48
 * in {@code (} and {@code )} and separated by {@code ,} - whitespace
49
 * is significant, arguments are not quoted.</p>
50
 *
51
 * <p>To use custom delimiters (in regular expression), create
52
 * instance with the {@link #PlaceholderDifferenceEvaluator(String,
53
 * String)} or {@link #PlaceholderDifferenceEvaluator(String, String,
54
 * String, String, String)} constructors.</p>
55
 *
56
 * @since 2.6.0
57
 */
58
public class PlaceholderDifferenceEvaluator implements DifferenceEvaluator {
59
    /**
60
     * Pattern used to find the start of a placeholder.
61
     */
62
    public static final String PLACEHOLDER_DEFAULT_OPENING_DELIMITER_REGEX = Pattern.quote("${");
1✔
63
    /**
64
     * Pattern used to find the end of a placeholder.
65
     */
66
    public static final String PLACEHOLDER_DEFAULT_CLOSING_DELIMITER_REGEX = Pattern.quote("}");
1✔
67
    /**
68
     * Pattern used to find the start of an argument list.
69
     * @since 2.7.0
70
     */
71
    public static final String PLACEHOLDER_DEFAULT_ARGS_OPENING_DELIMITER_REGEX = Pattern.quote("(");
1✔
72
    /**
73
     * Pattern used to find then end of an argument list.
74
     * @since 2.7.0
75
     */
76
    public static final String PLACEHOLDER_DEFAULT_ARGS_CLOSING_DELIMITER_REGEX = Pattern.quote(")");
1✔
77
    /**
78
     * Pattern used to find an argument separator.
79
     * @since 2.7.0
80
     */
81
    public static final String PLACEHOLDER_DEFAULT_ARGS_SEPARATOR_REGEX = Pattern.quote(",");
1✔
82

83
    private static final String PLACEHOLDER_PREFIX_REGEX = Pattern.quote("xmlunit.");
1✔
84
    private static final Map<String, PlaceholderHandler> KNOWN_HANDLERS;
85
    private static final String[] NO_ARGS = new String[0];
1✔
86

87
    static {
88
        Map<String, PlaceholderHandler> m = new HashMap<String, PlaceholderHandler>();
1✔
89
        for (PlaceholderHandler h : ServiceLoader.load(PlaceholderHandler.class)) {
1✔
90
            m.put(h.getKeyword(), h);
1✔
91
        }
1✔
92
        KNOWN_HANDLERS = Collections.unmodifiableMap(m);
1✔
93
    }
1✔
94

95
    private final Pattern placeholderRegex;
96
    private final Pattern argsRegex;
97
    private final String argsSplitter;
98

99
    /**
100
     * Creates a PlaceholderDifferenceEvaluator with default
101
     * delimiters {@link #PLACEHOLDER_DEFAULT_OPENING_DELIMITER_REGEX}
102
     * and {@link #PLACEHOLDER_DEFAULT_CLOSING_DELIMITER_REGEX}.
103
     */
104
    public PlaceholderDifferenceEvaluator() {
105
        this(null, null);
1✔
106
    }
1✔
107

108
    /**
109
     * Creates a PlaceholderDifferenceEvaluator with custom delimiters.
110
     * @param placeholderOpeningDelimiterRegex regular expression for
111
     * the opening delimiter of placeholder, defaults to {@link
112
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_OPENING_DELIMITER_REGEX}
113
     * if the parameter is null or blank
114
     * @param placeholderClosingDelimiterRegex regular expression for
115
     * the closing delimiter of placeholder, defaults to {@link
116
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_CLOSING_DELIMITER_REGEX}
117
     * if the parameter is null or blank
118
     */
119
    public PlaceholderDifferenceEvaluator(final String placeholderOpeningDelimiterRegex,
120
                                          final String placeholderClosingDelimiterRegex) {
121
        this(placeholderOpeningDelimiterRegex, placeholderClosingDelimiterRegex, null, null, null);
1✔
122
    }
1✔
123

124
    /**
125
     * Creates a PlaceholderDifferenceEvaluator with custom delimiters.
126
     * @param placeholderOpeningDelimiterRegex regular expression for
127
     * the opening delimiter of placeholder, defaults to {@link
128
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_OPENING_DELIMITER_REGEX}
129
     * if the parameter is null or blank
130
     * @param placeholderClosingDelimiterRegex regular expression for
131
     * the closing delimiter of placeholder, defaults to {@link
132
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_CLOSING_DELIMITER_REGEX}
133
     * if the parameter is null or blank
134
     * @param placeholderArgsOpeningDelimiterRegex regular expression for
135
     * the opening delimiter of the placeholder's argument list, defaults to {@link
136
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_ARGS_OPENING_DELIMITER_REGEX}
137
     * if the parameter is null or blank
138
     * @param placeholderArgsClosingDelimiterRegex regular expression for
139
     * the closing delimiter of the placeholder's argument list, defaults to {@link
140
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_ARGS_CLOSING_DELIMITER_REGEX}
141
     * if the parameter is null or blank
142
     * @param placeholderArgsSeparatorRegex regular expression for the
143
     * delimiter between arguments inside of the placeholder's
144
     * argument list, defaults to {@link
145
     * PlaceholderDifferenceEvaluator#PLACEHOLDER_DEFAULT_ARGS_SEPARATOR_REGEX}
146
     * if the parameter is null or blank
147
     *
148
     * @since 2.7.0
149
     */
150
    public PlaceholderDifferenceEvaluator(String placeholderOpeningDelimiterRegex,
151
        String placeholderClosingDelimiterRegex,
152
        String placeholderArgsOpeningDelimiterRegex,
153
        String placeholderArgsClosingDelimiterRegex,
154
        String placeholderArgsSeparatorRegex) {
1✔
155
        if (placeholderOpeningDelimiterRegex == null
1✔
156
            || placeholderOpeningDelimiterRegex.trim().length() == 0) {
1!
157
            placeholderOpeningDelimiterRegex = PLACEHOLDER_DEFAULT_OPENING_DELIMITER_REGEX;
1✔
158
        }
159
        if (placeholderClosingDelimiterRegex == null
1✔
160
            || placeholderClosingDelimiterRegex.trim().length() == 0) {
1!
161
            placeholderClosingDelimiterRegex = PLACEHOLDER_DEFAULT_CLOSING_DELIMITER_REGEX;
1✔
162
        }
163
        if (placeholderArgsOpeningDelimiterRegex == null
1✔
164
            || placeholderArgsOpeningDelimiterRegex.trim().length() == 0) {
1!
165
            placeholderArgsOpeningDelimiterRegex = PLACEHOLDER_DEFAULT_ARGS_OPENING_DELIMITER_REGEX;
1✔
166
        }
167
        if (placeholderArgsClosingDelimiterRegex == null
1✔
168
            || placeholderArgsClosingDelimiterRegex.trim().length() == 0) {
1!
169
            placeholderArgsClosingDelimiterRegex = PLACEHOLDER_DEFAULT_ARGS_CLOSING_DELIMITER_REGEX;
1✔
170
        }
171
        if (placeholderArgsSeparatorRegex == null
1!
172
            || placeholderArgsSeparatorRegex.trim().length() == 0) {
×
173
            placeholderArgsSeparatorRegex = PLACEHOLDER_DEFAULT_ARGS_SEPARATOR_REGEX;
1✔
174
        }
175

176
        placeholderRegex = Pattern.compile("(\\s*" + placeholderOpeningDelimiterRegex
1✔
177
            + "\\s*" + PLACEHOLDER_PREFIX_REGEX + "(.+)" + "\\s*"
178
            + placeholderClosingDelimiterRegex + "\\s*)");
179
        argsRegex = Pattern.compile("((.*)\\s*" + placeholderArgsOpeningDelimiterRegex
1✔
180
            + "(.+)"
181
            + "\\s*" + placeholderArgsClosingDelimiterRegex + "\\s*)");
182
        argsSplitter = placeholderArgsSeparatorRegex;
1✔
183
    }
1✔
184

185
    public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome) {
186
        if (outcome == ComparisonResult.EQUAL) {
1✔
187
            return outcome;
1✔
188
        }
189

190
        Comparison.Detail controlDetails = comparison.getControlDetails();
1✔
191
        Node controlTarget = controlDetails.getTarget();
1✔
192
        Comparison.Detail testDetails = comparison.getTestDetails();
1✔
193
        Node testTarget = testDetails.getTarget();
1✔
194

195
        // comparing textual content of elements
196
        if (comparison.getType() == ComparisonType.TEXT_VALUE) {
1✔
197
            return evaluateConsideringPlaceholders((String) controlDetails.getValue(),
1✔
198
                (String) testDetails.getValue(), outcome);
1✔
199

200
        // "test document has no text-like child node but control document has"
201
        } else if (isMissingTextNodeDifference(comparison)) {
1✔
202
            return evaluateMissingTextNodeConsideringPlaceholders(comparison, outcome);
1✔
203

204
        // may be comparing TEXT to CDATA
205
        } else if (isTextCDATAMismatch(comparison)) {
1✔
206
            return evaluateConsideringPlaceholders(controlTarget.getNodeValue(), testTarget.getNodeValue(), outcome);
1✔
207

208
        // comparing textual content of attributes
209
        } else if (comparison.getType() == ComparisonType.ATTR_VALUE) {
1✔
210
            return evaluateConsideringPlaceholders((String) controlDetails.getValue(),
1✔
211
                (String) testDetails.getValue(), outcome);
1✔
212

213
        // "test document has no attribute but control document has"
214
        } else if (isMissingAttributeDifference(comparison)) {
1✔
215
            return evaluateMissingAttributeConsideringPlaceholders(comparison, outcome);
1✔
216

217
        // default, don't apply any placeholders at all
218
        } else {
219
            return outcome;
1✔
220
        }
221
    }
222

223
    private boolean isMissingTextNodeDifference(Comparison comparison) {
224
        return controlHasOneTextChildAndTestHasNone(comparison)
1✔
225
            || cantFindControlTextChildInTest(comparison);
1✔
226
    }
227

228
    private boolean controlHasOneTextChildAndTestHasNone(Comparison comparison) {
229
        Comparison.Detail controlDetails = comparison.getControlDetails();
1✔
230
        Node controlTarget = controlDetails.getTarget();
1✔
231
        Comparison.Detail testDetails = comparison.getTestDetails();
1✔
232
        return comparison.getType() == ComparisonType.CHILD_NODELIST_LENGTH &&
1✔
233
            Integer.valueOf(1).equals(controlDetails.getValue()) &&
1✔
234
            Integer.valueOf(0).equals(testDetails.getValue()) &&
1!
235
            isTextLikeNode(controlTarget.getFirstChild());
1!
236
    }
237

238
    private boolean cantFindControlTextChildInTest(Comparison comparison) {
239
        Node controlTarget = comparison.getControlDetails().getTarget();
1✔
240
        return comparison.getType() == ComparisonType.CHILD_LOOKUP
1✔
241
            && controlTarget != null && isTextLikeNode(controlTarget);
1!
242
    }
243

244
    private ComparisonResult evaluateMissingTextNodeConsideringPlaceholders(Comparison comparison, ComparisonResult outcome) {
245
        Node controlTarget = comparison.getControlDetails().getTarget();
1✔
246
        String value;
247
        if (controlHasOneTextChildAndTestHasNone(comparison)) {
1✔
248
            value = controlTarget.getFirstChild().getNodeValue();
1✔
249
        } else {
250
            value = controlTarget.getNodeValue();
1✔
251
        }
252
        return evaluateConsideringPlaceholders(value, null, outcome);
1✔
253
    }
254

255
    private boolean isTextCDATAMismatch(Comparison comparison) {
256
        return comparison.getType() == ComparisonType.NODE_TYPE
1✔
257
            && isTextLikeNode(comparison.getControlDetails().getTarget())
1!
258
            && isTextLikeNode(comparison.getTestDetails().getTarget());
1!
259
    }
260

261
    private boolean isTextLikeNode(Node node) {
262
        short nodeType = node.getNodeType();
1✔
263
        return nodeType == Node.TEXT_NODE || nodeType == Node.CDATA_SECTION_NODE;
1!
264
    }
265

266
    private boolean isMissingAttributeDifference(Comparison comparison) {
267
        return comparison.getType() == ComparisonType.ELEMENT_NUM_ATTRIBUTES
1✔
268
            || (comparison.getType() == ComparisonType.ATTR_NAME_LOOKUP
1✔
269
                && comparison.getControlDetails().getTarget() != null
1!
270
                && comparison.getControlDetails().getValue() != null);
1✔
271
    }
272

273
    private ComparisonResult evaluateMissingAttributeConsideringPlaceholders(Comparison comparison, ComparisonResult outcome) {
274
        if (comparison.getType() == ComparisonType.ELEMENT_NUM_ATTRIBUTES) {
1✔
275
            return evaluateAttributeListLengthConsideringPlaceholders(comparison, outcome);
1✔
276
        }
277
        String controlAttrValue = Nodes.getAttributes(comparison.getControlDetails().getTarget())
1✔
278
            .get((QName) comparison.getControlDetails().getValue());
1✔
279
        return evaluateConsideringPlaceholders(controlAttrValue, null, outcome);
1✔
280
    }
281

282
    private ComparisonResult evaluateAttributeListLengthConsideringPlaceholders(Comparison comparison,
283
        ComparisonResult outcome) {
284
        Map<QName, String> controlAttrs = Nodes.getAttributes(comparison.getControlDetails().getTarget());
1✔
285
        Map<QName, String> testAttrs = Nodes.getAttributes(comparison.getTestDetails().getTarget());
1✔
286

287
        int cAttrsMatched = 0;
1✔
288
        for (Map.Entry<QName, String> cAttr : controlAttrs.entrySet()) {
1✔
289
            String testValue = testAttrs.get(cAttr.getKey());
1✔
290
            if (testValue == null) {
1✔
291
                ComparisonResult o = evaluateConsideringPlaceholders(cAttr.getValue(), null, outcome);
1✔
292
                if (o != ComparisonResult.EQUAL) {
1!
293
                    return outcome;
×
294
                }
295
            } else {
1✔
296
                cAttrsMatched++;
1✔
297
            }
298
        }
1✔
299
        if (cAttrsMatched != testAttrs.size()) {
1✔
300
            // there are unmatched test attributes
301
            return outcome;
1✔
302
        }
303
        return ComparisonResult.EQUAL;
1✔
304
    }
305

306
    private ComparisonResult evaluateConsideringPlaceholders(String controlText, String testText,
307
        ComparisonResult outcome) {
308
        final Matcher placeholderMatcher = placeholderRegex.matcher(controlText);
1✔
309
        if (placeholderMatcher.find()) {
1✔
310
            final String content = placeholderMatcher.group(2).trim();
1✔
311
            final Matcher argsMatcher = argsRegex.matcher(content);
1✔
312
            final String keyword;
313
            final String[] args;
314
            if (argsMatcher.find()) {
1✔
315
                keyword = argsMatcher.group(2).trim();
1✔
316
                args = argsMatcher.group(3).split(argsSplitter);
1✔
317
            } else {
318
                keyword = content;
1✔
319
                args = NO_ARGS;
1✔
320
            }
321
            if (isKnown(keyword)) {
1✔
322
                if (!placeholderMatcher.group(1).trim().equals(controlText.trim())) {
1✔
323
                    throw new RuntimeException("The placeholder must exclusively occupy the text node.");
1✔
324
                }
325
                return evaluate(keyword, testText, args);
1✔
326
            }
327
        }
328

329
        // no placeholder at all or unknown keyword
330
        return outcome;
1✔
331
    }
332

333
    private boolean isKnown(final String keyword) {
334
        return KNOWN_HANDLERS.containsKey(keyword);
1✔
335
    }
336

337
    private ComparisonResult evaluate(final String keyword, final String testText, final String[] args) {
338
        return KNOWN_HANDLERS.get(keyword).evaluate(testText, args);
1✔
339
    }
340
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc