Why our XPath tester doesn’t use document.evaluate()
Browsers ship a complete XPath engine, and this site’s XPath tester does not use it. The constraint that forced a hand-written evaluator, what it deliberately leaves out, and the two spec details it got wrong first.
Every modern browser has a complete XPath 1.0 engine built in: document.evaluate(). The obvious way to build an online XPath tester is to parse the user’s XML with DOMParser and hand the expression to it. The XPath tester on this site does not do that, and the reason is not about the browser at all.
The constraint: the page is built before any browser sees it
Every page here is rendered to static HTML at build time, in Node, so that the explanation and worked examples exist in the HTML a search engine or a reader without JavaScript receives. The XPath page shows example expressions with their results, and the test suite checks those results. Both of those run in Node, where there is no document and no DOMParser.
There were two honest options. One was to pull in a DOM implementation for Node — a package such as @xmldom/xmldom together with an XPath library that runs on top of it — and accept that the build and the browser would be using different engines. The other was to evaluate XPath over the XML parser this site already had for its XML to JSON converter, so that the build, the tests and the browser all run the same code. We chose the second, which only works if the evaluator is small. So it is deliberately not complete.
What a tester actually needs
XPath 1.0 has thirteen axes and a function library of a few dozen functions. What people type into a tester is a much smaller set, and that is what the evaluator implements:
- Location paths — absolute (
/a/b), relative,//at any position,*,.and... - Node tests for elements,
@name,@*andtext(). - The predicates that come up constantly: position (
[1],[last()]), attribute presence and equality,not(...)andcontains(...). count(...)as a whole expression.
Everything else — the union operator |, the following and preceding axes, namespace resolution, the rest of the function library — fails with a parse error that names the problem. The rule was that an unsupported expression must never produce a plausible wrong answer; an error is better than a list of matches that looks right.
Spec detail #1: the root is not the top of the tree
The first version returned nothing for every absolute path. The parser produces a tree whose top node is the root element, <bookstore>, and the evaluator treated / as "start at that node". So /bookstore/book looked for a <bookstore> among the root element’s children, found none, and stopped.
In XPath, / is the document node — an invisible parent whose single child is the root element. The fix was to make the first step of an absolute path test the root element itself instead of its children, which is exactly what the document-node rule produces. A useful side effect of following the spec rather than intuition: a relative path evaluated from the document correctly finds nothing, because the document node’s only child is bookstore, not book.
/bookstore/book
<book category="cooking"> — 1 child element
<book category="web"> — 1 child elementbook
(no matches)Spec detail #2: @name is an axis, not a child lookup
The second bug was subtler. After the first fix, element steps expanded to the context node’s children, and attribute steps went through the same expansion. So /bookstore/book[1]/@category read the category attribute of the first book’s children — and returned another element’s attribute, or nothing, depending on the document.
@category is shorthand for attribute::category: it reads attributes of the current node, without stepping down. text() is the same kind of step. Only when it is written as //@category does it search descendants, and that is a different, broader query — the difference is easiest to see side by side:
/bookstore/book[1]/@category
@category = "cooking"//@category
@category = "cooking"
@category = "web"Both bugs are now pinned by regression tests named after the rule they enforce, not after the symptom, so the next person to touch the step logic reads the spec detail before they read the code.
The detail people get wrong most: predicate order
Writing the predicate tests surfaced the rule that trips up users more than either bug above. Predicates are applied left to right, and each one filters the result of the one before it — like chained .filter() calls, not like conditions joined with and. That makes their order significant whenever one of them is positional:
/bookstore/book[@category='web'][1]
<book category="web"> — 1 child element/bookstore/book[1][@category='web']
(no matches)The first expression asks for "the first web book" and finds one. The second asks for "the first book, if it is a web book" — and the first book is a cookbook, so the answer is nothing. Both are correct XPath; they are different questions. When an expression with [1] or [last()] returns less than you expected, swapping the predicates is the first thing to try, and the tester makes that a two-second experiment.
The same reading explains why an empty result is not an error in the tester. /bookstore/nonexistent is a well-formed question whose answer is "no nodes", the same way a database query that matches no rows is not a database error. The red error state is reserved for expressions the evaluator cannot parse or does not support, so the two situations never look alike.
Was it worth it?
For a general XPath library, no — use one that implements the whole specification. For a tester whose examples must be rendered and verified outside a browser, a few hundred lines that do one subset correctly, and refuse the rest loudly, turned out simpler to trust than two engines that are supposed to agree. The cost is the "Known limitations" list on the XPath tester page, which is the price of writing that list down instead of discovering it.