Repository: jhy/jsoup
Pull request #1959 (2023-05-22 23:56:43)

Bump maven-source-plugin from 3.2.1 to 3.3.0
Bumps [maven-source-plugin](https://github.com/apache/maven-source-plugin) from 3.2.1 to 3.3.0.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/apache/maven-source-plugin/commit/02a984779ed6157698fdb9235e5e66154b3d24e7"><code>02a9847</code></a> [maven-release-plugin] prepare release maven-source-plugin-3.3.0</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/f186993414e076abcf70b960b487813a6b1df8b7"><code>f186993</code></a> [MSOURCES-135] Cleanup project code</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/021af551aedf361dec82a5f6808054030ac47e8b"><code>021af55</code></a> [MSOURCES-134] Refresh download page</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/b11a457ba99a83fb5808b132967b4ddcc9523e89"><code>b11a457</code></a> Use shared GitHub actions v3</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/7caf2b0debe39afa2d1aff4fa38fa38b8988acad"><code>7caf2b0</code></a> [MSOURCES-133] Upgrade Parent to 39 - ignore git blame</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/dee4c1000d57c8c385cfe1147c81950371437d4b"><code>dee4c10</code></a> [MSOURCES-133] Upgrade Parent to 39</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/452111f29c36260640ae2cebc2afd8ec5f6279a0"><code>452111f</code></a> Add dependabot configuration</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/e691ac3b7f0f451e9da973e7734e07671b21efa9"><code>e691ac3</code></a> s/MSOURCE/MSOURCES/</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/1ddffd8923b65e37b28abf60120f979ea6238a23"><code>1ddffd8</code></a> Auto-link MSOURCE Jira</li>
<li><a href="https://github.com/apache/maven-source-plugin/commit/37ffefea6f479bbd78e89a2df97b4acd5b57f5b3"><code>37ffefe</code></a> Add pull request template</li>
<li>Additional commits viewable in <a href="https://github.com/apache/maven-source-plugin/compare/maven-source-plugin-3.2.1...maven-source-plugin-3.3.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.maven.plugins:maven-source-plugin&package-manager=maven&previous-version=3.2.1&new-version=3.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.



Patch:
diff --git a/.gitignore b/.gitignore
index c696abb0..b22249a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ target/
 .settings/
 *Thrash*
 bin/
+.vscode/
diff --git a/CHANGES b/CHANGES
index f48e5ddd..e24b64ad 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,14 +1,49 @@
 jsoup changelog
 
 Release 1.16.2 [PENDING]
+  * Improvement: optimized the performance of complex CSS selectors, by adding a cost-based query planner. Evaluators
+    are sorted by their relative execution cost, and executed in order of lower to higher cost. This speeds the
+    matching process by ensuring that simpler evaluations (such as a tag name match) are conducted prior to more
+    complex evaluations (such as an attribute regex, or a deep child scan with a :has).
+
   * Improvement: when converting jsoup Documents to W3C Documents in W3CDom, HTML documents will be placed in the
     `http://www.w3.org/1999/xhtml` namespace by default, per the HTML5 spec. This can be controlled by setting
     `W3CDom#namespaceAware(false)`.
     <https://github.com/jhy/jsoup/pull/1848>
 
+  * Improvement: speed optimized the Structural Evaluators by memoizing previous evaluations. Particularly the `~`
+    (any preceeding sibling) and `:nth-of-type` selectors are improved.
+    <https://github.com/jhy/jsoup/issues/1956>
+
+  * Improvement: tweaked the performance of the Element nextElementSibling, previousElementSibling, firstElementSibling,
+    lastElementSibling, firstElementChild, and lastElementChild. They now inplace filter/skip in the child-node list, vs
+    having to allocate and scan a complete Element filtered list.
+
+  * Improvement: optimized internal methods that previously called Element.children() to use filter/skip child-node list
+    accessors instead, reducing new Element List allocations.
+
+  * Improvement: tweaked the performance of parsing :pseudo selectors.
+
+  * Improvement: when using the `:empty` pseudo-selector, blank textnodes are now considered empty. Previously,
+    an element containing any whitespace was not considered empty.
+    <https://github.com/jhy/jsoup/issues/1976>
+
   * Bugfix: `form` elements and empty elements (such as `img`) did not have their attributes de-duplicated.
     <https://github.com/jhy/jsoup/pull/1950>
 
+  * Bugfix: in Jsoup.connect(url), URL paths containing a %2B were incorrectly recoded to a '+', or a '+' was recoded
+    to a ' '. Fixed by reverting to the previous behavior of not encoding supplied paths, other than normalizing to
+    ASCII.
+    <https://github.com/jhy/jsoup/issues/1952>
+
+  * Bugfix: in Jsoup.connect(url), the ConstrainableInputStream would clear Thread interrupts when reading the body.
+    This precluded callers from spawning a thread, running a number of requests for a length of time, then joining that
+    thread after interrupting it.
+    <https://github.com/jhy/jsoup/issues/1991>
+
+  * Bugfix: when tracking HTML source positions, the closing tags for H1...H6 elements were not tracked correctly.
+    <https://github.com/jhy/jsoup/issues/1987>
+
   * Change: removed previously deprecated methods Document#normalise, Element#forEach(org.jsoup.helper.Consumer<>),
     Node#forEach(org.jsoup.helper.Consumer<>), and the org.jsoup.helper.Consumer interface; the latter being a
     previously required compatibility shim prior to Android's de-sugaring support.
diff --git a/pom.xml b/pom.xml
index 48ec01bb..110c3375 100644
--- a/pom.xml
+++ b/pom.xml
@@ -109,7 +109,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-source-plugin</artifactId>
-        <version>3.2.1</version>
+        <version>3.3.0</version>
         <configuration>
           <excludes>
             <exclude>org/jsoup/examples/**</exclude>
@@ -147,7 +147,7 @@
       <plugin>
         <groupId>org.apache.felix</groupId>
         <artifactId>maven-bundle-plugin</artifactId>
-        <version>5.1.8</version>
+        <version>5.1.9</version>
         <executions>
           <execution>
             <id>bundle-manifest</id>
@@ -185,7 +185,7 @@
       </plugin>
       <plugin>
         <artifactId>maven-failsafe-plugin</artifactId>
-        <version>3.1.0</version>
+        <version>3.1.2</version>
         <executions>
           <execution>
             <goals>
@@ -307,7 +307,7 @@
         <plugins>
           <plugin>
             <artifactId>maven-failsafe-plugin</artifactId>
-            <version>3.1.0</version>
+            <version>3.1.2</version>
             <executions>
               <execution>
                 <goals>
diff --git a/src/main/java/org/jsoup/helper/UrlBuilder.java b/src/main/java/org/jsoup/helper/UrlBuilder.java
index 89f46a13..4deda367 100644
--- a/src/main/java/org/jsoup/helper/UrlBuilder.java
+++ b/src/main/java/org/jsoup/helper/UrlBuilder.java
@@ -38,24 +38,20 @@ final class UrlBuilder {
                 u.getUserInfo(),
                 IDN.toASCII(decodePart(u.getHost())), // puny-code
                 u.getPort(),
-                decodePart(u.getPath()),
-                null, null // query and fragment appended later so as not to encode
+                null, null, null // path, query and fragment appended later so as not to encode
             );
 
-            String normUrl = uri.toASCIIString();
-            if (q != null || u.getRef() != null) {
-                StringBuilder sb = StringUtil.borrowBuilder().append(normUrl);
-                if (q != null) {
-                    sb.append('?');
-                    appendToAscii(StringUtil.releaseBuilder(q), true, sb);
-                }
-                if (u.getRef() != null) {
-                    sb.append('#');
-                    appendToAscii(u.getRef(), false, sb);
-                }
-                normUrl = StringUtil.releaseBuilder(sb);
+            StringBuilder normUrl = StringUtil.borrowBuilder().append(uri.toASCIIString());
+            appendToAscii(u.getPath(), false, normUrl);
+            if (q != null) {
+                normUrl.append('?');
+                appendToAscii(StringUtil.releaseBuilder(q), true, normUrl);
             }
-            u =  new URL(normUrl);
+            if (u.getRef() != null) {
+                normUrl.append('#');
+                appendToAscii(u.getRef(), false, normUrl);
+            }
+            u = new URL(StringUtil.releaseBuilder(normUrl));
             return u;
         } catch (MalformedURLException | URISyntaxException | UnsupportedEncodingException e) {
             // we assert here so that any incomplete normalization issues can be caught in devel. but in practise,
diff --git a/src/main/java/org/jsoup/helper/W3CDom.java b/src/main/java/org/jsoup/helper/W3CDom.java
index 29296b1a..231410b0 100644
--- a/src/main/java/org/jsoup/helper/W3CDom.java
+++ b/src/main/java/org/jsoup/helper/W3CDom.java
@@ -211,7 +211,7 @@ public class W3CDom {
             }
             out.setXmlStandalone(true);
             // if in is Document, use the root element, not the wrapping document, as the context:
-            org.jsoup.nodes.Element context = (in instanceof org.jsoup.nodes.Document) ? in.child(0) : in;
+            org.jsoup.nodes.Element context = (in instanceof org.jsoup.nodes.Document) ? in.firstElementChild() : in;
             out.setUserData(ContextProperty, context, null);
             convert(inDoc != null ? inDoc : in, out);
             return out;
@@ -251,7 +251,7 @@ public class W3CDom {
             }
             builder.syntax = inDoc.outputSettings().syntax();
         }
-        org.jsoup.nodes.Element rootEl = in instanceof org.jsoup.nodes.Document ? in.child(0) : in; // skip the #root node if a Document
+        org.jsoup.nodes.Element rootEl = in instanceof org.jsoup.nodes.Document ? in.firstElementChild() : in; // skip the #root node if a Document
         NodeTraversor.traverse(builder, rootEl);
     }
 
@@ -287,7 +287,8 @@ public class W3CDom {
             nodeList = (NodeList) expression.evaluate(contextNode, XPathConstants.NODESET); // love the strong typing here /s
             Validate.notNull(nodeList);
         } catch (XPathExpressionException | XPathFactoryConfigurationException e) {
-            throw new Selector.SelectorParseException("Could not evaluate XPath query [%s]: %s", xpath, e.getMessage());
+            throw new Selector.SelectorParseException(
+                e, "Could not evaluate XPath query [%s]: %s", xpath, e.getMessage());
         }
         return nodeList;
     }
diff --git a/src/main/java/org/jsoup/internal/ConstrainableInputStream.java b/src/main/java/org/jsoup/internal/ConstrainableInputStream.java
index 54928f4e..aa2c2751 100644
--- a/src/main/java/org/jsoup/internal/ConstrainableInputStream.java
+++ b/src/main/java/org/jsoup/internal/ConstrainableInputStream.java
@@ -49,8 +49,8 @@ public final class ConstrainableInputStream extends BufferedInputStream {
     public int read(byte[] b, int off, int len) throws IOException {
         if (interrupted || capped && remaining <= 0)
             return -1;
-        if (Thread.interrupted()) {
-            // interrupted latches, because parse() may call twice (and we still want the thread interupt to clear)
+        if (Thread.currentThread().isInterrupted()) {
+            // interrupted latches, because parse() may call twice
             interrupted = true;
             return -1;
         }
diff --git a/src/main/java/org/jsoup/nodes/Comment.java b/src/main/java/org/jsoup/nodes/Comment.java
index f7fc9f3c..77585bc1 100644
--- a/src/main/java/org/jsoup/nodes/Comment.java
+++ b/src/main/java/org/jsoup/nodes/Comment.java
@@ -88,7 +88,7 @@ public class Comment extends LeafNode {
         String fragment = "<" + declContent + ">";
         // use the HTML parser not XML, so we don't get into a recursive XML Declaration on contrived data
         Document doc = Parser.htmlParser().settings(ParseSettings.preserveCase).parseInput(fragment, baseUri());
-        if (doc.body().children().size() > 0) {
+        if (doc.body().childrenSize() > 0) {
             Element el = doc.body().child(0);
             decl = new XmlDeclaration(NodeUtils.parser(doc).settings().normalizeTag(el.tagName()), data.startsWith("!"));
             decl.attributes().addAll(el.attributes());
diff --git a/src/main/java/org/jsoup/nodes/Document.java b/src/main/java/org/jsoup/nodes/Document.java
index 273e33aa..70c571f8 100644
--- a/src/main/java/org/jsoup/nodes/Document.java
+++ b/src/main/java/org/jsoup/nodes/Document.java
@@ -102,9 +102,11 @@ public class Document extends Element {
      @return the root HTML element.
      */
     private Element htmlEl() {
-        for (Element el: childElementsList()) {
+        Element el = firstElementChild();
+        while (el != null) {
             if (el.normalName().equals("html"))
                 return el;
+            el = el.nextElementSibling();
         }
         return appendElement("html");
     }
@@ -118,10 +120,12 @@ public class Document extends Element {
      @return {@code head} element.
      */
     public Element head() {
-        Element html = htmlEl();
-        for (Element el: html.childElementsList()) {
+        final Element html = htmlEl();
+        Element el = html.firstElementChild();
+        while (el != null) {
             if (el.normalName().equals("head"))
                 return el;
+            el = el.nextElementSibling();
         }
         return html.prependElement("head");
     }
@@ -136,10 +140,12 @@ public class Document extends Element {
      had no contents, or the outermost {@code <frameset> element} for frameset documents.
      */
     public Element body() {
-        Element html = htmlEl();
-        for (Element el: html.childElementsList()) {
+        final Element html = htmlEl();
+        Element el = html.firstElementChild();
+        while (el != null) {
             if ("body".equals(el.normalName()) || "frameset".equals(el.normalName()))
                 return el;
+            el = el.nextElementSibling();
         }
         return html.appendElement("body");
     }
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index c0646dd1..3d5f8c15 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -889,13 +889,11 @@ public class Element extends Node {
      * @see #previousElementSibling()
      */
     public @Nullable Element nextElementSibling() {
-        if (parentNode == null) return null;
-        List<Element> siblings = parent().childElementsList();
-        int index = indexInList(this, siblings);
-        if (siblings.size() > index+1)
-            return siblings.get(index+1);
-        else
-            return null;
+        Node next = this;
+        while ((next = next.nextSibling()) != null) {
+            if (next instanceof Element) return (Element) next;
+        }
+        return null;
     }
 
     /**
@@ -913,13 +911,11 @@ public class Element extends Node {
      * @see #nextElementSibling()
      */
     public @Nullable Element previousElementSibling() {
-        if (parentNode == null) return null;
-        List<Element> siblings = parent().childElementsList();
-        int index = indexInList(this, siblings);
-        if (index > 0)
-            return siblings.get(index-1);
-        else
-            return null;
+        Node prev = this;
+        while ((prev = prev.previousSibling()) != null) {
+            if (prev instanceof Element) return (Element) prev;
+        }
+        return null;
     }
 
     /**
@@ -945,8 +941,8 @@ public class Element extends Node {
      */
     public Element firstElementSibling() {
         if (parent() != null) {
-            List<Element> siblings = parent().childElementsList();
-            return siblings.size() > 1 ? siblings.get(0) : this;
+            //noinspection DataFlowIssue (not nullable, would be this is no other sibs)
+            return parent().firstElementChild();
         } else
             return this; // orphan is its own first sibling
     }
@@ -967,8 +963,8 @@ public class Element extends Node {
      */
     public Element lastElementSibling() {
         if (parent() != null) {
-            List<Element> siblings = parent().childElementsList();
-            return siblings.size() > 1 ? siblings.get(siblings.size() - 1) : this;
+            //noinspection DataFlowIssue (not nullable, would be this if no other sibs)
+            return parent().lastElementChild();
         } else
             return this;
     }
@@ -990,12 +986,10 @@ public class Element extends Node {
      @since 1.15.2
      */
     public @Nullable Element firstElementChild() {
-        final int size = childNodeSize();
-        if (size == 0) return null;
-        List<Node> children = ensureChildNodes();
-        for (int i = 0; i < size; i++) {
-            Node node = children.get(i);
-            if (node instanceof Element) return (Element) node;
+        Node child = firstChild();
+        while (child != null) {
+            if (child instanceof Element) return (Element) child;
+            child = child.nextSibling();
         }
         return null;
     }
@@ -1008,12 +1002,10 @@ public class Element extends Node {
      @since 1.15.2
      */
     public @Nullable Element lastElementChild() {
-        final int size = childNodeSize();
-        if (size == 0) return null;
-        List<Node> children = ensureChildNodes();
-        for (int i = size -1; i >= 0; i--) {
-            Node node = children.get(i);
-            if (node instanceof Element) return (Element) node;
+        Node child = lastChild();
+        while (child != null) {
+            if (child instanceof Element) return (Element) child;
+            child = child.previousSibling();
         }
         return null;
     }
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index fe30fb7b..3c55690f 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -456,8 +456,10 @@ public abstract class Node implements Cloneable {
     }
 
     private Element getDeepChild(Element el) {
-        while (el.childrenSize() > 0) {
-            el = el.childElementsList().get(0);
+        Element child = el.firstElementChild();
+        while (child != null) {
+            el = child;
+            child = child.firstElementChild();
         }
         return el;
     }
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
index be0498c5..2e49e605 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
@@ -424,20 +424,12 @@ public class HtmlTreeBuilder extends TreeBuilder {
     // elnames is sorted, comes from Constants
     void popStackToClose(String... elNames) {
         for (int pos = stack.size() -1; pos >= 0; pos--) {
-            Element next = stack.get(pos);
+            Element el = stack.get(pos);
             stack.remove(pos);
-            if (inSorted(next.normalName(), elNames))
-                break;
-        }
-    }
-
-    void popStackToBefore(String elName) {
-        for (int pos = stack.size() -1; pos >= 0; pos--) {
-            Element next = stack.get(pos);
-            if (next.normalName().equals(elName)) {
+            if (inSorted(el.normalName(), elNames)) {
+                if (currentToken instanceof Token.EndTag)
+                    onNodeClosed(el, currentToken);
                 break;
-            } else {
-                stack.remove(pos);
             }
         }
     }
diff --git a/src/main/java/org/jsoup/select/Collector.java b/src/main/java/org/jsoup/select/Collector.java
index a3820da0..00f384b6 100644
--- a/src/main/java/org/jsoup/select/Collector.java
+++ b/src/main/java/org/jsoup/select/Collector.java
@@ -24,6 +24,7 @@ public class Collector {
      @return list of matches; empty if none
      */
     public static Elements collect (Evaluator eval, Element root) {
+        eval.reset();
         Elements elements = new Elements();
         NodeTraversor.traverse((node, depth) -> {
             if (node instanceof Element) {
@@ -43,6 +44,7 @@ public class Collector {
      @return the first match; {@code null} if none
      */
     public static @Nullable Element findFirst(Evaluator eval, Element root) {
+        eval.reset();
         FirstFinder finder = new FirstFinder(eval);
         return finder.find(root, root);
     }
diff --git a/src/main/java/org/jsoup/select/CombiningEvaluator.java b/src/main/java/org/jsoup/select/CombiningEvaluator.java
index 5a4f5999..3af498e6 100644
--- a/src/main/java/org/jsoup/select/CombiningEvaluator.java
+++ b/src/main/java/org/jsoup/select/CombiningEvaluator.java
@@ -7,23 +7,39 @@ import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
 
 /**
  * Base combining (and, or) evaluator.
  */
 public abstract class CombiningEvaluator extends Evaluator {
-    final ArrayList<Evaluator> evaluators;
+    final ArrayList<Evaluator> evaluators; // maintain original order so that #toString() is sensible
+    final ArrayList<Evaluator> sortedEvaluators; // cost ascending order
     int num = 0;
+    int cost = 0;
 
     CombiningEvaluator() {
         super();
         evaluators = new ArrayList<>();
+        sortedEvaluators = new ArrayList<>();
     }
 
     CombiningEvaluator(Collection<Evaluator> evaluators) {
         this();
         this.evaluators.addAll(evaluators);
-        updateNumEvaluators();
+        updateEvaluators();
+    }
+
+    @Override protected void reset() {
+        for (Evaluator evaluator : evaluators) {
+            evaluator.reset();
+        }
+        super.reset();
+    }
+
+    @Override protected int cost() {
+        return cost;
     }
 
     @Nullable Evaluator rightMostEvaluator() {
@@ -32,13 +48,26 @@ public abstract class CombiningEvaluator extends Evaluator {
     
     void replaceRightMostEvaluator(Evaluator replacement) {
         evaluators.set(num - 1, replacement);
+        updateEvaluators();
     }
 
-    void updateNumEvaluators() {
+    void updateEvaluators() {
         // used so we don't need to bash on size() for every match test
         num = evaluators.size();
+
+        // sort the evaluators by lowest cost first, to optimize the evaluation order
+        cost = 0;
+        for (Evaluator evaluator : evaluators) {
+            cost += evaluator.cost();
+        }
+        sortedEvaluators.clear();
+        sortedEvaluators.addAll(evaluators);
+        Collections.sort(sortedEvaluators, costComparator);
     }
 
+    private static final Comparator<Evaluator> costComparator = (o1, o2) -> o1.cost() - o2.cost();
+    // ^ comparingInt, sortedEvaluators.sort not available in targeted version
+
     public static final class And extends CombiningEvaluator {
         And(Collection<Evaluator> evaluators) {
             super(evaluators);
@@ -49,10 +78,10 @@ public abstract class CombiningEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element node) {
-            for (int i = num - 1; i >= 0; i--) { // process backwards so that :matchText is evaled earlier, to catch parent query. todo - should redo matchText to virtually expand during match, not pre-match (see SelectorTest#findBetweenSpan)
-                Evaluator s = evaluators.get(i);
-                if (!s.matches(root, node))
+        public boolean matches(Element root, Element element) {
+            for (int i = 0; i < num; i++) {
+                Evaluator s = sortedEvaluators.get(i);
+                if (!s.matches(root, element))
                     return false;
             }
             return true;
@@ -75,7 +104,7 @@ public abstract class CombiningEvaluator extends Evaluator {
                 this.evaluators.add(new And(evaluators));
             else // 0 or 1
                 this.evaluators.addAll(evaluators);
-            updateNumEvaluators();
+            updateEvaluators();
         }
 
         Or(Evaluator... evaluators) { this(Arrays.asList(evaluators)); }
@@ -86,13 +115,13 @@ public abstract class CombiningEvaluator extends Evaluator {
 
         public void add(Evaluator e) {
             evaluators.add(e);
-            updateNumEvaluators();
+            updateEvaluators();
         }
 
         @Override
         public boolean matches(Element root, Element node) {
             for (int i = 0; i < num; i++) {
-                Evaluator s = evaluators.get(i);
+                Evaluator s = sortedEvaluators.get(i);
                 if (s.matches(root, node))
                     return true;
             }
diff --git a/src/main/java/org/jsoup/select/Evaluator.java b/src/main/java/org/jsoup/select/Evaluator.java
index 33036847..d8929fc3 100644
--- a/src/main/java/org/jsoup/select/Evaluator.java
+++ b/src/main/java/org/jsoup/select/Evaluator.java
@@ -37,6 +37,20 @@ public abstract class Evaluator {
      */
     public abstract boolean matches(Element root, Element element);
 
+    /**
+     Reset any internal state in this Evaluator before executing a new Collector evaluation.
+     */
+    protected void reset() {
+    }
+
+    /**
+     A relative evaluator cost function. During evaluation, Evaluators are sorted by ascending cost as an optimization.
+     * @return the relative cost of this Evaluator
+     */
+    protected int cost() {
+        return 5; // a nominal default cost
+    }
+
     /**
      * Evaluator for tag name
      */
@@ -52,6 +66,10 @@ public abstract class Evaluator {
             return (element.normalName().equals(tagName));
         }
 
+        @Override protected int cost() {
+            return 1;
+        }
+
         @Override
         public String toString() {
             return String.format("%s", tagName);
@@ -95,11 +113,13 @@ public abstract class Evaluator {
             return (id.equals(element.id()));
         }
 
+        @Override protected int cost() {
+            return 2;
+        }
         @Override
         public String toString() {
             return String.format("#%s", id);
         }
-
     }
 
     /**
@@ -117,6 +137,10 @@ public abstract class Evaluator {
             return (element.hasClass(className));
         }
 
+        @Override protected int cost() {
+            return 6; // does whitespace scanning
+        }
+
         @Override
         public String toString() {
             return String.format(".%s", className);
@@ -139,11 +163,14 @@ public abstract class Evaluator {
             return element.hasAttr(key);
         }
 
+        @Override protected int cost() {
+            return 2;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s]", key);
         }
-
     }
 
     /**
@@ -167,6 +194,10 @@ public abstract class Evaluator {
             return false;
         }
 
+        @Override protected int cost() {
+            return 6;
+        }
+
         @Override
         public String toString() {
             return String.format("[^%s]", keyPrefix);
@@ -187,6 +218,10 @@ public abstract class Evaluator {
             return element.hasAttr(key) && value.equalsIgnoreCase(element.attr(key).trim());
         }
 
+        @Override protected int cost() {
+            return 3;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s=%s]", key, value);
@@ -207,6 +242,10 @@ public abstract class Evaluator {
             return !value.equalsIgnoreCase(element.attr(key));
         }
 
+        @Override protected int cost() {
+            return 3;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s!=%s]", key, value);
@@ -227,11 +266,14 @@ public abstract class Evaluator {
             return element.hasAttr(key) && lowerCase(element.attr(key)).startsWith(value); // value is lower case already
         }
 
+        @Override protected int cost() {
+            return 4;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s^=%s]", key, value);
         }
-
     }
 
     /**
@@ -247,11 +289,14 @@ public abstract class Evaluator {
             return element.hasAttr(key) && lowerCase(element.attr(key)).endsWith(value); // value is lower case
         }
 
+        @Override protected int cost() {
+            return 4;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s$=%s]", key, value);
         }
-
     }
 
     /**
@@ -267,6 +312,10 @@ public abstract class Evaluator {
             return element.hasAttr(key) && lowerCase(element.attr(key)).contains(value); // value is lower case
         }
 
+        @Override protected int cost() {
+            return 6;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s*=%s]", key, value);
@@ -291,6 +340,10 @@ public abstract class Evaluator {
             return element.hasAttr(key) && pattern.matcher(element.attr(key)).find();
         }
 
+        @Override protected int cost() {
+            return 8;
+        }
+
         @Override
         public String toString() {
             return String.format("[%s~=%s]", key, pattern.toString());
@@ -334,6 +387,10 @@ public abstract class Evaluator {
             return true;
         }
 
+        @Override protected int cost() {
+            return 10;
+        }
+
         @Override
         public String toString() {
             return "*";
@@ -407,7 +464,7 @@ public abstract class Evaluator {
 		@Override
 		public boolean matches(Element root, Element element) {
 			final Element p = element.parent();
-			return p != null && !(p instanceof Document) && element.elementSiblingIndex() == p.children().size()-1;
+			return p != null && !(p instanceof Document) && element == p.lastElementChild();
 		}
 
 		@Override
@@ -508,7 +565,7 @@ public abstract class Evaluator {
         protected int calculatePosition(Element root, Element element) {
     	    if (element.parent() == null)
     	        return 0;
-        	return element.parent().children().size() - element.elementSiblingIndex();
+        	return element.parent().childrenSize()- element.elementSiblingIndex();
         }
 
 		@Override
@@ -522,50 +579,57 @@ public abstract class Evaluator {
      *
      */
     public static class IsNthOfType extends CssNthEvaluator {
-    	public IsNthOfType(int a, int b) {
-    		super(a,b);
-    	}
+        public IsNthOfType(int a, int b) {
+            super(a, b);
+        }
 
-		protected int calculatePosition(Element root, Element element) {
-			int pos = 0;
-            if (element.parent() == null)
+        protected int calculatePosition(Element root, Element element) {
+            Element parent = element.parent();
+            if (parent == null)
                 return 0;
-        	Elements family = element.parent().children();
-            for (Element el : family) {
-                if (el.tag().equals(element.tag())) pos++;
-                if (el == element) break;
+
+            int pos = 0;
+            final int size = parent.childNodeSize();
+            for (int i = 0; i < size; i++) {
+                Node node = parent.childNode(i);
+                if (node.normalName().equals(element.normalName())) pos++;
+                if (node == element) break;
             }
-			return pos;
-		}
+            return pos;
+        }
 
-		@Override
-		protected String getPseudoClass() {
-			return "nth-of-type";
-		}
+        @Override
+        protected String getPseudoClass() {
+            return "nth-of-type";
+        }
     }
 
     public static class IsNthLastOfType extends CssNthEvaluator {
 
-		public IsNthLastOfType(int a, int b) {
-			super(a, b);
-		}
+        public IsNthLastOfType(int a, int b) {
+            super(a, b);
+        }
 
-		@Override
-		protected int calculatePosition(Element root, Element element) {
-			int pos = 0;
-            if (element.parent() == null)
+        @Override
+        protected int calculatePosition(Element root, Element element) {
+            Element parent = element.parent();
+            if (parent == null)
                 return 0;
-        	Elements family = element.parent().children();
-        	for (int i = element.elementSiblingIndex(); i < family.size(); i++) {
-        		if (family.get(i).tag().equals(element.tag())) pos++;
-        	}
-			return pos;
-		}
 
-		@Override
-		protected String getPseudoClass() {
-			return "nth-last-of-type";
-		}
+            int pos = 0;
+            Element next = element;
+            while (next != null) {
+                if (next.normalName().equals(element.normalName()))
+                    pos++;
+                next = next.nextElementSibling();
+            }
+            return pos;
+        }
+
+        @Override
+        protected String getPseudoClass() {
+            return "nth-last-of-type";
+        }
     }
 
     /**
@@ -575,7 +639,7 @@ public abstract class Evaluator {
     	@Override
     	public boolean matches(Element root, Element element) {
     		final Element p = element.parent();
-    		return p != null && !(p instanceof Document) && element.elementSiblingIndex() == 0;
+    		return p != null && !(p instanceof Document) && element == p.firstElementChild();
     	}
 
     	@Override
@@ -592,9 +656,14 @@ public abstract class Evaluator {
     public static final class IsRoot extends Evaluator {
     	@Override
     	public boolean matches(Element root, Element element) {
-    		final Element r = root instanceof Document?root.child(0):root;
+    		final Element r = root instanceof Document ? root.firstElementChild() : root;
     		return element == r;
     	}
+
+        @Override protected int cost() {
+            return 1;
+        }
+
     	@Override
     	public String toString() {
     		return ":root";
@@ -620,9 +689,13 @@ public abstract class Evaluator {
 			if (p==null || p instanceof Document) return false;
 
 			int pos = 0;
-        	Elements family = p.children();
-            for (Element el : family) {
-                if (el.tag().equals(element.tag())) pos++;
+            Element next = p.firstElementChild();
+            while (next != null) {
+                if (next.normalName().equals(element.normalName()))
+                    pos++;
+                if (pos > 1)
+                    break;
+                next = next.nextElementSibling();
             }
         	return pos == 1;
 		}
@@ -637,7 +710,10 @@ public abstract class Evaluator {
 		public boolean matches(Element root, Element element) {
         	List<Node> family = element.childNodes();
             for (Node n : family) {
-                if (!(n instanceof Comment || n instanceof XmlDeclaration || n instanceof DocumentType)) return false;
+                if (n instanceof TextNode)
+                    return ((TextNode)n).isBlank();
+                if (!(n instanceof Comment || n instanceof XmlDeclaration || n instanceof DocumentType))
+                    return false;
             }
         	return true;
 		}
@@ -675,6 +751,10 @@ public abstract class Evaluator {
             return lowerCase(element.text()).contains(searchText);
         }
 
+        @Override protected int cost() {
+            return 10;
+        }
+
         @Override
         public String toString() {
             return String.format(":contains(%s)", searchText);
@@ -698,6 +778,10 @@ public abstract class Evaluator {
             return element.wholeText().contains(searchText);
         }
 
+        @Override protected int cost() {
+            return 10;
+        }
+
         @Override
         public String toString() {
             return String.format(":containsWholeText(%s)", searchText);
@@ -785,6 +869,10 @@ public abstract class Evaluator {
             return m.find();
         }
 
+        @Override protected int cost() {
+            return 8;
+        }
+
         @Override
         public String toString() {
             return String.format(":matches(%s)", pattern);
@@ -807,6 +895,10 @@ public abstract class Evaluator {
             return m.find();
         }
 
+        @Override protected int cost() {
+            return 7;
+        }
+
         @Override
         public String toString() {
             return String.format(":matchesOwn(%s)", pattern);
@@ -830,6 +922,10 @@ public abstract class Evaluator {
             return m.find();
         }
 
+        @Override protected int cost() {
+            return 8;
+        }
+
         @Override
         public String toString() {
             return String.format(":matchesWholeText(%s)", pattern);
@@ -853,6 +949,10 @@ public abstract class Evaluator {
             return m.find();
         }
 
+        @Override protected int cost() {
+            return 7;
+        }
+
         @Override
         public String toString() {
             return String.format(":matchesWholeOwnText(%s)", pattern);
@@ -876,6 +976,10 @@ public abstract class Evaluator {
             return false;
         }
 
+        @Override protected int cost() {
+            return -1; // forces first evaluation, which prepares the DOM for later evaluator matches
+        }
+
         @Override
         public String toString() {
             return ":matchText";
diff --git a/src/main/java/org/jsoup/select/QueryParser.java b/src/main/java/org/jsoup/select/QueryParser.java
index b049df3d..1e512c02 100644
--- a/src/main/java/org/jsoup/select/QueryParser.java
+++ b/src/main/java/org/jsoup/select/QueryParser.java
@@ -15,8 +15,8 @@ import static org.jsoup.internal.Normalizer.normalize;
  * Parses a CSS selector into an Evaluator tree.
  */
 public class QueryParser {
-    private final static char[] combinators = {',', '>', '+', '~', ' '};
-    private static final String[] AttributeEvals = new String[]{"=", "!=", "^=", "$=", "*=", "~="};
+    private final static char[] Combinators = {',', '>', '+', '~', ' '};
+    private final static String[] AttributeEvals = new String[]{"=", "!=", "^=", "$=", "*=", "~="};
 
     private final TokenQueue tq;
     private final String query;
@@ -55,23 +55,23 @@ public class QueryParser {
     Evaluator parse() {
         tq.consumeWhitespace();
 
-        if (tq.matchesAny(combinators)) { // if starts with a combinator, use root as elements
+        if (tq.matchesAny(Combinators)) { // if starts with a combinator, use root as elements
             evals.add(new StructuralEvaluator.Root());
             combinator(tq.consume());
         } else {
-            findElements();
+            evals.add(consumeEvaluator());
         }
 
         while (!tq.isEmpty()) {
             // hierarchy and extras
             boolean seenWhite = tq.consumeWhitespace();
 
-            if (tq.matchesAny(combinators)) {
+            if (tq.matchesAny(Combinators)) {
                 combinator(tq.consume());
             } else if (seenWhite) {
                 combinator(' ');
             } else { // E.class, E#id, E[attr] etc. AND
-                findElements(); // take next el, #. etc off queue
+                evals.add(consumeEvaluator()); // take next el, #. etc off queue
             }
         }
 
@@ -146,7 +146,7 @@ public class QueryParser {
                 sq.append("(").append(tq.chompBalanced('(', ')')).append(")");
             else if (tq.matches("["))
                 sq.append("[").append(tq.chompBalanced('[', ']')).append("]");
-            else if (tq.matchesAny(combinators))
+            else if (tq.matchesAny(Combinators))
                 if (sq.length() > 0)
                     break;
                 else
@@ -157,175 +157,166 @@ public class QueryParser {
         return StringUtil.releaseBuilder(sq);
     }
 
-    private void findElements() {
+    private Evaluator consumeEvaluator() {
         if (tq.matchChomp("#"))
-            byId();
+            return byId();
         else if (tq.matchChomp("."))
-            byClass();
+            return byClass();
         else if (tq.matchesWord() || tq.matches("*|"))
-            byTag();
+            return byTag();
         else if (tq.matches("["))
-            byAttribute();
+            return byAttribute();
         else if (tq.matchChomp("*"))
-            allElements();
-        else if (tq.matchChomp(":lt("))
-            indexLessThan();
-        else if (tq.matchChomp(":gt("))
-            indexGreaterThan();
-        else if (tq.matchChomp(":eq("))
-            indexEquals();
-        else if (tq.matches(":has("))
-            has();
-        else if (tq.matches(":contains("))
-            contains(false);
-        else if (tq.matches(":containsOwn("))
-            contains(true);
-        else if (tq.matches(":containsWholeText("))
-            containsWholeText(false);
-        else if (tq.matches(":containsWholeOwnText("))
-            containsWholeText(true);
-        else if (tq.matches(":containsData("))
-            containsData();
-        else if (tq.matches(":matches("))
-            matches(false);
-        else if (tq.matches(":matchesOwn("))
-            matches(true);
-        else if (tq.matches(":matchesWholeText("))
-            matchesWholeText(false);
-        else if (tq.matches(":matchesWholeOwnText("))
-            matchesWholeText(true);
-        else if (tq.matches(":not("))
-            not();
-		else if (tq.matchChomp(":nth-child("))
-        	cssNthChild(false, false);
-        else if (tq.matchChomp(":nth-last-child("))
-        	cssNthChild(true, false);
-        else if (tq.matchChomp(":nth-of-type("))
-        	cssNthChild(false, true);
-        else if (tq.matchChomp(":nth-last-of-type("))
-        	cssNthChild(true, true);
-        else if (tq.matchChomp(":first-child"))
-        	evals.add(new Evaluator.IsFirstChild());
-        else if (tq.matchChomp(":last-child"))
-        	evals.add(new Evaluator.IsLastChild());
-        else if (tq.matchChomp(":first-of-type"))
-        	evals.add(new Evaluator.IsFirstOfType());
-        else if (tq.matchChomp(":last-of-type"))
-        	evals.add(new Evaluator.IsLastOfType());
-        else if (tq.matchChomp(":only-child"))
-        	evals.add(new Evaluator.IsOnlyChild());
-        else if (tq.matchChomp(":only-of-type"))
-        	evals.add(new Evaluator.IsOnlyOfType());
-        else if (tq.matchChomp(":empty"))
-        	evals.add(new Evaluator.IsEmpty());
-        else if (tq.matchChomp(":root"))
-        	evals.add(new Evaluator.IsRoot());
-        else if (tq.matchChomp(":matchText"))
-            evals.add(new Evaluator.MatchText());
+            return new Evaluator.AllElements();
+        else if (tq.matchChomp(":"))
+            return parsePseudoSelector();
 		else // unhandled
             throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder());
+    }
 
+    private Evaluator parsePseudoSelector() {
+        final String pseudo = tq.consumeCssIdentifier();
+        switch (pseudo) {
+            case "lt":
+                return new Evaluator.IndexLessThan(consumeIndex());
+            case "gt":
+                return new Evaluator.IndexGreaterThan(consumeIndex());
+            case "eq":
+                return new Evaluator.IndexEquals(consumeIndex());
+            case "has":
+                return has();
+            case "contains":
+                return contains(false);
+            case "containsOwn":
+                return contains(true);
+            case "containsWholeText":
+                return containsWholeText(false);
+            case "containsWholeOwnText":
+                return containsWholeText(true);
+            case "containsData":
+                return containsData();
+            case "matches":
+                return matches(false);
+            case "matchesOwn":
+                return matches(true);
+            case "matchesWholeText":
+                return matchesWholeText(false);
+            case "matchesWholeOwnText":
+                return matchesWholeText(true);
+            case "not":
+                return not();
+            case "nth-child":
+                return cssNthChild(false, false);
+            case "nth-last-child":
+                return cssNthChild(true, false);
+            case "nth-of-type":
+                return cssNthChild(false, true);
+            case "nth-last-of-type":
+                return cssNthChild(true, true);
+            case "first-child":
+                return new Evaluator.IsFirstChild();
+            case "last-child":
+                return new Evaluator.IsLastChild();
+            case "first-of-type":
+                return new Evaluator.IsFirstOfType();
+            case "last-of-type":
+                return new Evaluator.IsLastOfType();
+            case "only-child":
+                return new Evaluator.IsOnlyChild();
+            case "only-of-type":
+                return new Evaluator.IsOnlyOfType();
+            case "empty":
+                return new Evaluator.IsEmpty();
+            case "root":
+                return new Evaluator.IsRoot();
+            case "matchText":
+                return new Evaluator.MatchText();
+            default:
+                throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query, tq.remainder());
+        }
     }
 
-    private void byId() {
+    private Evaluator byId() {
         String id = tq.consumeCssIdentifier();
         Validate.notEmpty(id);
-        evals.add(new Evaluator.Id(id));
+        return new Evaluator.Id(id);
     }
 
-    private void byClass() {
+    private Evaluator byClass() {
         String className = tq.consumeCssIdentifier();
         Validate.notEmpty(className);
-        evals.add(new Evaluator.Class(className.trim()));
+        return new Evaluator.Class(className.trim());
     }
 
-    private void byTag() {
+    private Evaluator byTag() {
         // todo - these aren't dealing perfectly with case sensitivity. For case sensitive parsers, we should also make
         // the tag in the selector case-sensitive (and also attribute names). But for now, normalize (lower-case) for
         // consistency - both the selector and the element tag
         String tagName = normalize(tq.consumeElementSelector());
         Validate.notEmpty(tagName);
+        final Evaluator eval;
 
         // namespaces: wildcard match equals(tagName) or ending in ":"+tagName
         if (tagName.startsWith("*|")) {
             String plainTag = tagName.substring(2); // strip *|
-            evals.add(new CombiningEvaluator.Or(
+            eval = new CombiningEvaluator.Or(
                 new Evaluator.Tag(plainTag),
-                new Evaluator.TagEndsWith(tagName.replace("*|", ":")))
+                new Evaluator.TagEndsWith(tagName.replace("*|", ":"))
             );
         } else {
             // namespaces: if element name is "abc:def", selector must be "abc|def", so flip:
             if (tagName.contains("|"))
                 tagName = tagName.replace("|", ":");
 
-            evals.add(new Evaluator.Tag(tagName));
+            eval = new Evaluator.Tag(tagName);
         }
+        return eval;
     }
 
-    private void byAttribute() {
+    private Evaluator byAttribute() {
         TokenQueue cq = new TokenQueue(tq.chompBalanced('[', ']')); // content queue
         String key = cq.consumeToAny(AttributeEvals); // eq, not, start, end, contain, match, (no val)
         Validate.notEmpty(key);
         cq.consumeWhitespace();
+        final Evaluator eval;
 
         if (cq.isEmpty()) {
             if (key.startsWith("^"))
-                evals.add(new Evaluator.AttributeStarting(key.substring(1)));
+                eval = new Evaluator.AttributeStarting(key.substring(1));
             else
-                evals.add(new Evaluator.Attribute(key));
+                eval = new Evaluator.Attribute(key);
         } else {
             if (cq.matchChomp("="))
-                evals.add(new Evaluator.AttributeWithValue(key, cq.remainder()));
-
+                eval = new Evaluator.AttributeWithValue(key, cq.remainder());
             else if (cq.matchChomp("!="))
-                evals.add(new Evaluator.AttributeWithValueNot(key, cq.remainder()));
-
+                eval = new Evaluator.AttributeWithValueNot(key, cq.remainder());
             else if (cq.matchChomp("^="))
-                evals.add(new Evaluator.AttributeWithValueStarting(key, cq.remainder()));
-
+                eval = new Evaluator.AttributeWithValueStarting(key, cq.remainder());
             else if (cq.matchChomp("$="))
-                evals.add(new Evaluator.AttributeWithValueEnding(key, cq.remainder()));
-
+                eval = new Evaluator.AttributeWithValueEnding(key, cq.remainder());
             else if (cq.matchChomp("*="))
-                evals.add(new Evaluator.AttributeWithValueContaining(key, cq.remainder()));
-
+                eval = new Evaluator.AttributeWithValueContaining(key, cq.remainder());
             else if (cq.matchChomp("~="))
-                evals.add(new Evaluator.AttributeWithValueMatching(key, Pattern.compile(cq.remainder())));
+                eval = new Evaluator.AttributeWithValueMatching(key, Pattern.compile(cq.remainder()));
             else
                 throw new Selector.SelectorParseException("Could not parse attribute query '%s': unexpected token at '%s'", query, cq.remainder());
         }
+        return eval;
     }
 
-    private void allElements() {
-        evals.add(new Evaluator.AllElements());
-    }
-
-    // pseudo selectors :lt, :gt, :eq
-    private void indexLessThan() {
-        evals.add(new Evaluator.IndexLessThan(consumeIndex()));
-    }
-
-    private void indexGreaterThan() {
-        evals.add(new Evaluator.IndexGreaterThan(consumeIndex()));
-    }
-
-    private void indexEquals() {
-        evals.add(new Evaluator.IndexEquals(consumeIndex()));
-    }
-    
     //pseudo selectors :first-child, :last-child, :nth-child, ...
     private static final Pattern NTH_AB = Pattern.compile("(([+-])?(\\d+)?)n(\\s*([+-])?\\s*\\d+)?", Pattern.CASE_INSENSITIVE);
     private static final Pattern NTH_B  = Pattern.compile("([+-])?(\\d+)");
 
-	private void cssNthChild(boolean backwards, boolean ofType) {
-		String argS = normalize(tq.chompTo(")"));
-		Matcher mAB = NTH_AB.matcher(argS);
-		Matcher mB = NTH_B.matcher(argS);
+	private Evaluator cssNthChild(boolean backwards, boolean ofType) {
+		String arg = normalize(consumeParens());
+		Matcher mAB = NTH_AB.matcher(arg);
+		Matcher mB = NTH_B.matcher(arg);
 		final int a, b;
-		if ("odd".equals(argS)) {
+		if ("odd".equals(arg)) {
 			a = 2;
 			b = 1;
-		} else if ("even".equals(argS)) {
+		} else if ("even".equals(arg)) {
 			a = 2;
 			b = 0;
 		} else if (mAB.matches()) {
@@ -335,101 +326,99 @@ public class QueryParser {
 			a = 0;
 			b = Integer.parseInt(mB.group().replaceFirst("^\\+", ""));
 		} else {
-			throw new Selector.SelectorParseException("Could not parse nth-index '%s': unexpected format", argS);
+			throw new Selector.SelectorParseException("Could not parse nth-index '%s': unexpected format", arg);
 		}
+
+        final Evaluator eval;
 		if (ofType)
 			if (backwards)
-				evals.add(new Evaluator.IsNthLastOfType(a, b));
+				eval = new Evaluator.IsNthLastOfType(a, b);
 			else
-				evals.add(new Evaluator.IsNthOfType(a, b));
+				eval = new Evaluator.IsNthOfType(a, b);
 		else {
 			if (backwards)
-				evals.add(new Evaluator.IsNthLastChild(a, b));
+				eval = (new Evaluator.IsNthLastChild(a, b));
 			else
-				evals.add(new Evaluator.IsNthChild(a, b));
+				eval = new Evaluator.IsNthChild(a, b);
 		}
+        return eval;
 	}
 
+    private String consumeParens() {
+        return tq.chompBalanced('(', ')');
+    }
+
     private int consumeIndex() {
-        String indexS = tq.chompTo(")").trim();
-        Validate.isTrue(StringUtil.isNumeric(indexS), "Index must be numeric");
-        return Integer.parseInt(indexS);
+        String index = consumeParens().trim();
+        Validate.isTrue(StringUtil.isNumeric(index), "Index must be numeric");
+        return Integer.parseInt(index);
     }
 
     // pseudo selector :has(el)
-    private void has() {
-        tq.consume(":has");
-        String subQuery = tq.chompBalanced('(', ')');
+    private Evaluator has() {
+        String subQuery = consumeParens();
         Validate.notEmpty(subQuery, ":has(selector) sub-select must not be empty");
-        evals.add(new StructuralEvaluator.Has(parse(subQuery)));
+        return new StructuralEvaluator.Has(parse(subQuery));
     }
 
     // pseudo selector :contains(text), containsOwn(text)
-    private void contains(boolean own) {
+    private Evaluator contains(boolean own) {
         String query = own ? ":containsOwn" : ":contains";
-        tq.consume(query);
-        String searchText = TokenQueue.unescape(tq.chompBalanced('(', ')'));
+        String searchText = TokenQueue.unescape(consumeParens());
         Validate.notEmpty(searchText, query + "(text) query must not be empty");
-        evals.add(own
+        return own
             ? new Evaluator.ContainsOwnText(searchText)
-            : new Evaluator.ContainsText(searchText));
+            : new Evaluator.ContainsText(searchText);
     }
 
-    private void containsWholeText(boolean own) {
+    private Evaluator containsWholeText(boolean own) {
         String query = own ? ":containsWholeOwnText" : ":containsWholeText";
-        tq.consume(query);
-        String searchText = TokenQueue.unescape(tq.chompBalanced('(', ')'));
+        String searchText = TokenQueue.unescape(consumeParens());
         Validate.notEmpty(searchText, query + "(text) query must not be empty");
-        evals.add(own
+        return own
             ? new Evaluator.ContainsWholeOwnText(searchText)
-            : new Evaluator.ContainsWholeText(searchText));
+            : new Evaluator.ContainsWholeText(searchText);
     }
 
     // pseudo selector :containsData(data)
-    private void containsData() {
-        tq.consume(":containsData");
-        String searchText = TokenQueue.unescape(tq.chompBalanced('(', ')'));
+    private Evaluator containsData() {
+        String searchText = TokenQueue.unescape(consumeParens());
         Validate.notEmpty(searchText, ":containsData(text) query must not be empty");
-        evals.add(new Evaluator.ContainsData(searchText));
+        return new Evaluator.ContainsData(searchText);
     }
 
     // :matches(regex), matchesOwn(regex)
-    private void matches(boolean own) {
+    private Evaluator matches(boolean own) {
         String query = own ? ":matchesOwn" : ":matches";
-        tq.consume(query);
-        String regex = tq.chompBalanced('(', ')'); // don't unescape, as regex bits will be escaped
+        String regex = consumeParens(); // don't unescape, as regex bits will be escaped
         Validate.notEmpty(regex, query + "(regex) query must not be empty");
 
-        evals.add(own
+        return own
             ? new Evaluator.MatchesOwn(Pattern.compile(regex))
-            : new Evaluator.Matches(Pattern.compile(regex)));
+            : new Evaluator.Matches(Pattern.compile(regex));
     }
 
     // :matches(regex), matchesOwn(regex)
-    private void matchesWholeText(boolean own) {
+    private Evaluator matchesWholeText(boolean own) {
         String query = own ? ":matchesWholeOwnText" : ":matchesWholeText";
-        tq.consume(query);
-        String regex = tq.chompBalanced('(', ')'); // don't unescape, as regex bits will be escaped
+        String regex = consumeParens(); // don't unescape, as regex bits will be escaped
         Validate.notEmpty(regex, query + "(regex) query must not be empty");
 
-        evals.add(own
+        return own
             ? new Evaluator.MatchesWholeOwnText(Pattern.compile(regex))
-            : new Evaluator.MatchesWholeText(Pattern.compile(regex)));
+            : new Evaluator.MatchesWholeText(Pattern.compile(regex));
     }
 
     // :not(selector)
-    private void not() {
-        tq.consume(":not");
-        String subQuery = tq.chompBalanced('(', ')');
+    private Evaluator not() {
+        String subQuery = consumeParens();
         Validate.notEmpty(subQuery, ":not(selector) subselect must not be empty");
 
-        evals.add(new StructuralEvaluator.Not(parse(subQuery)));
+        return new StructuralEvaluator.Not(parse(subQuery));
     }
 
     @Override
     public String toString() {
         return query;
     }
-
-
 }
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 71d0bc2a..95c17560 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -4,7 +4,6 @@ import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Element;
 
 import javax.annotation.Nullable;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.IdentityHashMap;
 
@@ -17,8 +16,8 @@ import java.util.IdentityHashMap;
  * elements, attributes, and attribute values).
  * </p>
  * <p>
- * The universal selector (*) is implicit when no element selector is supplied (i.e. {@code *.header} and {@code .header}
- * is equivalent).
+ * The universal selector {@code *} is implicit when no element selector is supplied (i.e. {@code .header} and {@code *.header}
+ * are equivalent).
  * </p>
  * <style>table.syntax tr td {vertical-align: top; padding-right: 2em; padding-top:0.5em; padding-bottom:0.5em; } table.syntax tr:hover{background-color: #eee;} table.syntax {border-spacing: 0px 0px;}</style>
  * <table summary="" class="syntax"><colgroup><col span="1" style="width: 20%;"><col span="1" style="width: 40%;"><col span="1" style="width: 40%;"></colgroup>
@@ -170,8 +169,12 @@ public class Selector {
             super(msg);
         }
 
-        public SelectorParseException(String msg, Object... params) {
-            super(String.format(msg, params));
+        public SelectorParseException(String msg, Object... msgArgs) {
+            super(String.format(msg, msgArgs));
+        }
+
+        public SelectorParseException(Throwable cause, String msg, Object... msgArgs) {
+            super(String.format(msg, msgArgs), cause);
         }
     }
 }
diff --git a/src/main/java/org/jsoup/select/StructuralEvaluator.java b/src/main/java/org/jsoup/select/StructuralEvaluator.java
index 52d8697a..08831802 100644
--- a/src/main/java/org/jsoup/select/StructuralEvaluator.java
+++ b/src/main/java/org/jsoup/select/StructuralEvaluator.java
@@ -3,24 +3,64 @@ package org.jsoup.select;
 import org.jsoup.nodes.Element;
 import org.jsoup.nodes.Node;
 
+import java.util.IdentityHashMap;
+
 /**
  * Base structural evaluator.
  */
 abstract class StructuralEvaluator extends Evaluator {
-    Evaluator evaluator;
+    final Evaluator evaluator;
+
+    public StructuralEvaluator(Evaluator evaluator) {
+        this.evaluator = evaluator;
+    }
+
+    // Memoize inner matches, to save repeated re-evaluations of parent, sibling etc.
+    // root + element: Boolean matches. ThreadLocal in case the Evaluator is compiled then reused across multi threads
+    final ThreadLocal<IdentityHashMap<Element, IdentityHashMap<Element, Boolean>>>
+        threadMemo = ThreadLocal.withInitial(IdentityHashMap::new);
+
+    boolean memoMatches(final Element root, final Element element) {
+        // not using computeIfAbsent, as the lambda impl requires a new Supplier closure object on every hit: tons of GC
+        IdentityHashMap<Element, IdentityHashMap<Element, Boolean>> rootMemo = threadMemo.get();
+        IdentityHashMap<Element, Boolean> memo = rootMemo.get(root);
+        if (memo == null) {
+            memo = new IdentityHashMap<>();
+            rootMemo.put(root, memo);
+        }
+        Boolean matches = memo.get(element);
+        if (matches == null) {
+            matches = evaluator.matches(root, element);
+            memo.put(element, matches);
+        }
+        return matches;
+    }
+
+    @Override protected void reset() {
+        threadMemo.get().clear();
+        super.reset();
+    }
 
     static class Root extends Evaluator {
         @Override
         public boolean matches(Element root, Element element) {
             return root == element;
         }
+
+        @Override protected int cost() {
+            return 1;
+        }
+
+        @Override public String toString() {
+            return "";
+        }
     }
 
     static class Has extends StructuralEvaluator {
         final Collector.FirstFinder finder;
 
         public Has(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
             finder = new Collector.FirstFinder(evaluator);
         }
 
@@ -38,6 +78,10 @@ abstract class StructuralEvaluator extends Evaluator {
             return false;
         }
 
+        @Override protected int cost() {
+            return 10 * evaluator.cost();
+        }
+
         @Override
         public String toString() {
             return String.format(":has(%s)", evaluator);
@@ -46,12 +90,16 @@ abstract class StructuralEvaluator extends Evaluator {
 
     static class Not extends StructuralEvaluator {
         public Not(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
         }
 
         @Override
-        public boolean matches(Element root, Element node) {
-            return !evaluator.matches(root, node);
+        public boolean matches(Element root, Element element) {
+            return !memoMatches(root, element);
+        }
+
+        @Override protected int cost() {
+            return 2 + evaluator.cost();
         }
 
         @Override
@@ -62,7 +110,7 @@ abstract class StructuralEvaluator extends Evaluator {
 
     static class Parent extends StructuralEvaluator {
         public Parent(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
         }
 
         @Override
@@ -72,7 +120,7 @@ abstract class StructuralEvaluator extends Evaluator {
 
             Element parent = element.parent();
             while (parent != null) {
-                if (evaluator.matches(root, parent))
+                if (memoMatches(root, parent))
                     return true;
                 if (parent == root)
                     break;
@@ -81,6 +129,10 @@ abstract class StructuralEvaluator extends Evaluator {
             return false;
         }
 
+        @Override protected int cost() {
+            return 2 * evaluator.cost();
+        }
+
         @Override
         public String toString() {
             return String.format("%s ", evaluator);
@@ -89,7 +141,7 @@ abstract class StructuralEvaluator extends Evaluator {
 
     static class ImmediateParent extends StructuralEvaluator {
         public ImmediateParent(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
         }
 
         @Override
@@ -98,7 +150,11 @@ abstract class StructuralEvaluator extends Evaluator {
                 return false;
 
             Element parent = element.parent();
-            return parent != null && evaluator.matches(root, parent);
+            return parent != null && memoMatches(root, parent);
+        }
+
+        @Override protected int cost() {
+            return 1 + evaluator.cost();
         }
 
         @Override
@@ -109,25 +165,27 @@ abstract class StructuralEvaluator extends Evaluator {
 
     static class PreviousSibling extends StructuralEvaluator {
         public PreviousSibling(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
         }
 
         @Override
         public boolean matches(Element root, Element element) {
-            if (root == element)
-                return false;
-
-            Element prev = element.previousElementSibling();
+            if (root == element) return false;
 
-            while (prev != null) {
-                if (evaluator.matches(root, prev))
-                    return true;
-
-                prev = prev.previousElementSibling();
+            Element sibling = element.firstElementSibling();
+            while (sibling != null) {
+                if (sibling == element) break;
+                if (memoMatches(root, sibling)) return true;
+                sibling = sibling.nextElementSibling();
             }
+
             return false;
         }
 
+        @Override protected int cost() {
+            return 3 * evaluator.cost();
+        }
+
         @Override
         public String toString() {
             return String.format("%s ~ ", evaluator);
@@ -136,7 +194,7 @@ abstract class StructuralEvaluator extends Evaluator {
 
     static class ImmediatePreviousSibling extends StructuralEvaluator {
         public ImmediatePreviousSibling(Evaluator evaluator) {
-            this.evaluator = evaluator;
+            super(evaluator);
         }
 
         @Override
@@ -145,7 +203,11 @@ abstract class StructuralEvaluator extends Evaluator {
                 return false;
 
             Element prev = element.previousElementSibling();
-            return prev != null && evaluator.matches(root, prev);
+            return prev != null && memoMatches(root, prev);
+        }
+
+        @Override protected int cost() {
+            return 2 + evaluator.cost();
         }
 
         @Override


Test patch:
diff --git a/src/test/java/org/jsoup/helper/HttpConnectionTest.java b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
index 840d8a82..9834f72a 100644
--- a/src/test/java/org/jsoup/helper/HttpConnectionTest.java
+++ b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
@@ -255,13 +255,13 @@ public class HttpConnectionTest {
     }
 
     @Test public void encodeUrl() throws MalformedURLException {
-        URL url1 = new URL("https://test.com/foo bar/[One]?q=white space#frag");
+        URL url1 = new URL("https://test.com/foo%20bar/%5BOne%5D?q=white+space#frag");
         URL url2 = new UrlBuilder(url1).build();
         assertEquals("https://test.com/foo%20bar/%5BOne%5D?q=white+space#frag", url2.toExternalForm());
     }
 
     @Test void encodedUrlDoesntDoubleEncode() throws MalformedURLException {
-        URL url1 = new URL("https://test.com/foo bar/[One]?q=white space#frag ment");
+        URL url1 = new URL("https://test.com/foo%20bar/%5BOne%5D?q=white+space#frag%20ment");
         URL url2 = new UrlBuilder(url1).build();
         URL url3 = new UrlBuilder(url2).build();
         assertEquals("https://test.com/foo%20bar/%5BOne%5D?q=white+space#frag%20ment", url2.toExternalForm());
@@ -274,6 +274,20 @@ public class HttpConnectionTest {
         assertEquals("https://example.com/a%20b%20c?query+string", url.toExternalForm());
     }
 
+    @Test void encodedUrlPathIsPreserved() {
+        // https://github.com/jhy/jsoup/issues/1952
+        Connection connect = Jsoup.connect("https://example.com/%2B32");
+        URL url = connect.request().url();
+        assertEquals("https://example.com/%2B32", url.toExternalForm());
+    }
+
+    @Test void urlPathPlusIsPreserved() {
+        // https://github.com/jhy/jsoup/issues/1952
+        Connection connect = Jsoup.connect("https://example.com/123+456");
+        URL url = connect.request().url();
+        assertEquals("https://example.com/123+456", url.toExternalForm());
+    }
+
     @Test public void noUrlThrowsValidationError() throws IOException {
         HttpConnection con = new HttpConnection();
         boolean threw = false;
diff --git a/src/test/java/org/jsoup/integration/ConnectIT.java b/src/test/java/org/jsoup/integration/ConnectIT.java
index 84315b4a..c3789357 100644
--- a/src/test/java/org/jsoup/integration/ConnectIT.java
+++ b/src/test/java/org/jsoup/integration/ConnectIT.java
@@ -14,6 +14,7 @@ import java.io.IOException;
 import java.net.SocketTimeoutException;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -70,7 +71,29 @@ public class ConnectIT {
         assertTrue(runner.isInterrupted());
         runner.join();
 
-        assertEquals(0, body[0].length()); // doesn't ready a failed doc
+        assertEquals(0, body[0].length()); // doesn't read a failed doc
+    }
+
+    @Test public void canInterruptThenJoinASpawnedThread() throws InterruptedException {
+        // https://github.com/jhy/jsoup/issues/1991
+        AtomicBoolean ioException = new AtomicBoolean();
+        Thread runner = new Thread(() -> {
+            try {
+                while (!Thread.currentThread().isInterrupted()) {
+                    Document doc  = Jsoup.connect(SlowRider.Url)
+                        .timeout(4000)
+                        .get();
+                }
+            } catch (IOException e) {
+                ioException.set(true); // don't expect to catch, because the outer sleep will complete before this timeout
+            }
+        });
+
+        runner.start();
+        Thread.sleep(2 * 1000);
+        runner.interrupt();
+        runner.join();
+        assertFalse(ioException.get());
     }
 
     @Test
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index 5ff30415..41809af7 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -217,6 +217,28 @@ public class ElementTest {
         assertEquals("this", p.nextElementSibling().text());
         assertEquals("Hello", p.firstElementSibling().text());
         assertEquals("element", p.lastElementSibling().text());
+        assertNull(p.lastElementSibling().nextElementSibling());
+        assertNull(p.firstElementSibling().previousElementSibling());
+    }
+
+    @Test public void nextElementSibling() {
+        Document doc = Jsoup.parse("<p>One</p>Two<p>Three</p>");
+        Element el = doc.expectFirst("p");
+        assertNull(el.previousElementSibling());
+        Element next = el.nextElementSibling();
+        assertNotNull(next);
+        assertEquals("Three", next.text());
+        assertNull(next.nextElementSibling());
+    }
+
+    @Test public void prevElementSibling() {
+        Document doc = Jsoup.parse("<p>One</p>Two<p>Three</p>");
+        Element el = doc.expectFirst("p:contains(Three)");
+        assertNull(el.nextElementSibling());
+        Element prev = el.previousElementSibling();
+        assertNotNull(prev);
+        assertEquals("One", prev.text());
+        assertNull(prev.previousElementSibling());
     }
 
     @Test
@@ -242,7 +264,7 @@ public class ElementTest {
     @Test
     public void testFirstAndLastSiblings() {
         Document doc = Jsoup.parse("<div><p>One<p>Two<p>Three");
-        Element div = doc.selectFirst("div");
+        Element div = doc.expectFirst("div");
         Element one = div.child(0);
         Element two = div.child(1);
         Element three = div.child(2);
@@ -251,6 +273,8 @@ public class ElementTest {
         assertSame(one, two.firstElementSibling());
         assertSame(three, three.lastElementSibling());
         assertSame(three, two.lastElementSibling());
+        assertNull(one.previousElementSibling());
+        assertNull(three.nextElementSibling());
     }
 
     @Test
diff --git a/src/test/java/org/jsoup/nodes/PositionTest.java b/src/test/java/org/jsoup/nodes/PositionTest.java
index 069d5c6a..1db73c9e 100644
--- a/src/test/java/org/jsoup/nodes/PositionTest.java
+++ b/src/test/java/org/jsoup/nodes/PositionTest.java
@@ -203,4 +203,20 @@ class PositionTest {
         }
     }
 
+    @Test void tracksClosingHeadingTags() {
+        // https://github.com/jhy/jsoup/issues/1987
+        String html = "<h1>One</h1><h2>Two</h2><h10>Ten</h10>";
+        Document doc = Jsoup.parse(html, TrackingParser);
+
+        Elements els = doc.body().children();
+        for (Element el : els) {
+            assertTrue(el.sourceRange().isTracked());
+            assertTrue(el.endSourceRange().isTracked());
+        }
+
+        Element h2 = doc.expectFirst("h2");
+        assertEquals("1,13:12-1,17:16", h2.sourceRange().toString());
+        assertEquals("1,20:19-1,25:24", h2.endSourceRange().toString());
+    }
+
 }
\ No newline at end of file
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index 4a0076ac..3196dc25 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -8,6 +8,7 @@ import org.jsoup.nodes.Element;
 import org.jsoup.parser.Parser;
 import org.junit.jupiter.api.Test;
 
+import java.util.IdentityHashMap;
 import java.util.Locale;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -1143,4 +1144,41 @@ public class SelectorTest {
         Selector.SelectorParseException ex = new Selector.SelectorParseException("%&");
         assertEquals("%&", ex.getMessage());
     }
+
+    @Test public void evaluatorMemosAreReset() {
+        Evaluator eval = QueryParser.parse("p ~ p");
+        CombiningEvaluator.And andEval = (CombiningEvaluator.And) eval;
+        StructuralEvaluator.PreviousSibling prevEval = (StructuralEvaluator.PreviousSibling) andEval.evaluators.get(0);
+        IdentityHashMap<Element, IdentityHashMap<Element, Boolean>> map = prevEval.threadMemo.get();
+        assertEquals(0, map.size()); // no memo yet
+
+        Document doc1 = Jsoup.parse("<p>One<p>Two<p>Three");
+        Document doc2 = Jsoup.parse("<p>One2<p>Two2<p>Three2");
+
+        Elements s1 = doc1.select(eval);
+        assertEquals(2, s1.size());
+        assertEquals("Two", s1.first().text());
+        Elements s2 = doc2.select(eval);
+        assertEquals(2, s2.size());
+        assertEquals("Two2", s2.first().text());
+
+        assertEquals(1, map.size()); // root of doc 2
+    }
+
+    @Test public void blankTextNodesAreConsideredEmpty() {
+        // https://github.com/jhy/jsoup/issues/1976
+        String html = "<li id=1>\n </li><li id=2></li><li id=3> </li><li id=4>One</li><li id=5><span></li>";
+        Document doc = Jsoup.parse(html);
+        Elements empty = doc.select("li:empty");
+        Elements notEmpty = doc.select("li:not(:empty)");
+
+        assertEquals(3, empty.size());
+        assertEquals(2, notEmpty.size());
+
+        assertEquals("1", empty.get(0).id());
+        assertEquals("2", empty.get(1).id());
+        assertEquals("3", empty.get(2).id());
+        assertEquals("4", notEmpty.get(0).id());
+        assertEquals("5", notEmpty.get(1).id());
+    }
 }
diff --git a/src/test/java/org/jsoup/select/XpathTest.java b/src/test/java/org/jsoup/select/XpathTest.java
index 274800ee..ae278322 100644
--- a/src/test/java/org/jsoup/select/XpathTest.java
+++ b/src/test/java/org/jsoup/select/XpathTest.java
@@ -69,6 +69,11 @@ public class XpathTest {
             // checks exception message within jsoup's control, rest may be JDK impl specific
             // was - Could not evaluate XPath query [//???]: javax.xml.transform.TransformerException: A location step was expected following the '/' or '//' token.
             assertTrue(e.getMessage().startsWith("Could not evaluate XPath query [//???]:"));
+
+            // check we have a root cause
+            Throwable cause = e.getCause();
+            assertNotNull(cause);
+            assertNotSame(cause, e);
         }
         assertTrue(threw);
     }