Repository: jhy/jsoup
Pull request #1965 (2023-06-05 23:56:32)

Bump maven-release-plugin from 3.0.0 to 3.0.1
Bumps [maven-release-plugin](https://github.com/apache/maven-release) from 3.0.0 to 3.0.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/apache/maven-release/releases">maven-release-plugin's releases</a>.</em></p>
<blockquote>
<h2>3.0.1</h2>
<!-- raw HTML omitted -->
<h2>🐛 Bug Fixes</h2>
<ul>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1123">[MRELEASE-1123]</a> - Fix for Maven 4 compatibility (<a href="https://redirect.github.com/apache/maven-release/pull/186">#186</a>) <a href="https://github.com/slawekjaranowski"><code>@​slawekjaranowski</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1114">[MRELEASE-1114]</a> - Restore interactive mode for forked process (<a href="https://redirect.github.com/apache/maven-release/pull/189">#189</a>) <a href="https://github.com/slawekjaranowski"><code>@​slawekjaranowski</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1103">[MRELEASE-1103]</a> - Wrong file path used for master pw file (<a href="https://redirect.github.com/apache/maven-release/pull/181">#181</a>) <a href="https://github.com/cstamas"><code>@​cstamas</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-851">[MRELEASE-851]</a> - : javaHome argument is now honored in InvokerMavenExecutor (<a href="https://redirect.github.com/apache/maven-release/pull/182">#182</a>) <a href="https://github.com/prof-schnitzel"><code>@​prof-schnitzel</code></a></li>
</ul>
<h2>📦 Dependency updates</h2>
<ul>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1121">[MRELEASE-1121]</a> - Bump maven-shared-utils from 3.4.1 to 3.4.2 (<a href="https://redirect.github.com/apache/maven-release/pull/188">#188</a>) <a href="https://github.com/slawekjaranowski"><code>@​slawekjaranowski</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1121">[MRELEASE-1121]</a> - Bump maven-shared-utils from 3.3.4 to 3.4.1 (<a href="https://redirect.github.com/apache/maven-release/pull/183">#183</a>) <a href="https://github.com/dependabot"><code>@​dependabot</code></a></li>
<li>Bump hamcrest-core from 1.3 to 2.2 (<a href="https://redirect.github.com/apache/maven-release/pull/180">#180</a>) <a href="https://github.com/dependabot"><code>@​dependabot</code></a></li>
</ul>
<h2>👻 Maintenance</h2>
<ul>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1127">[MRELEASE-1127]</a> - Refresh download page (<a href="https://redirect.github.com/apache/maven-release/pull/191">#191</a>) <a href="https://github.com/slawekjaranowski"><code>@​slawekjaranowski</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1077">[MRELEASE-1077]</a> - Add the since tag to the documentation (<a href="https://redirect.github.com/apache/maven-release/pull/190">#190</a>) <a href="https://github.com/nielsbasjes"><code>@​nielsbasjes</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MNG-6829">[MNG-6829]</a> - Replace any StringUtils#isEmpty(String) and #isNotEmpty(String) (<a href="https://redirect.github.com/apache/maven-release/pull/185">#185</a>) <a href="https://github.com/timtebeek"><code>@​timtebeek</code></a></li>
<li><a href="https://issues.apache.org/jira/browse/MRELEASE-1122">[MRELEASE-1122]</a> - configure system requirements history (<a href="https://redirect.github.com/apache/maven-release/pull/184">#184</a>) <a href="https://github.com/hboutemy"><code>@​hboutemy</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/apache/maven-release/commit/0fae89df9706788902a1bc65d67cdbfae5f0d1b7"><code>0fae89d</code></a> [maven-release-plugin] prepare release maven-release-3.0.1</li>
<li><a href="https://github.com/apache/maven-release/commit/95cde3efa36b7fdf9631c1f740f9f50a469c044e"><code>95cde3e</code></a> [MRELEASE-1127] Refresh download page</li>
<li><a href="https://github.com/apache/maven-release/commit/33f0d91ab001174a8ee9b80ee4c9e06e0fbedf26"><code>33f0d91</code></a> [MRELEASE-1123] Fix for Maven 4 compatibility</li>
<li><a href="https://github.com/apache/maven-release/commit/e89d46ab308da10ee2001d6b8cd87ca90c48d20d"><code>e89d46a</code></a> [MRELEASE-1077] Add the since tag to the documentation</li>
<li><a href="https://github.com/apache/maven-release/commit/2ffacc8cd72cc0dbd22af58a146390b84543b4cb"><code>2ffacc8</code></a> [MRELEASE-1114] Restore interactive mode for forked process</li>
<li><a href="https://github.com/apache/maven-release/commit/e3bf3269b09bd80287c701e7b8164f3ca4a686a1"><code>e3bf326</code></a> [MRELEASE-1121] Bump maven-shared-utils from 3.4.1 to 3.4.2</li>
<li><a href="https://github.com/apache/maven-release/commit/7b9282c054227076360c71a13dc0b124a1f83c5a"><code>7b9282c</code></a> [MNG-6829] Replace any StringUtils#isEmpty(String) and #isNotEmpty(String) (#...</li>
<li><a href="https://github.com/apache/maven-release/commit/ad666d331c6d91a1af1b0b5d8d55bb2a103da117"><code>ad666d3</code></a> [MRELEASE-1122] configure system requirements history</li>
<li><a href="https://github.com/apache/maven-release/commit/6e152c973a49680ab02f87b6889cc2b6127ff5f0"><code>6e152c9</code></a> [MRELEASE-1121] Bump maven-shared-utils from 3.3.4 to 3.4.1 (<a href="https://redirect.github.com/apache/maven-release/issues/183">#183</a>)</li>
<li><a href="https://github.com/apache/maven-release/commit/78e2329b2aee142563d5fd93feda216e04662bdc"><code>78e2329</code></a> Auto-link MRELEASE Jira</li>
<li>Additional commits viewable in <a href="https://github.com/apache/maven-release/compare/maven-release-3.0.0...maven-release-3.0.1">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-release-plugin&package-manager=maven&previous-version=3.0.0&new-version=3.0.1)](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 46bb3a3c..989650e8 100644
--- a/CHANGES
+++ b/CHANGES
@@ -19,9 +19,37 @@ Release 1.16.2 [PENDING]
     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: if Document.OutputSettings was cloned from a clone, an NPE would be thrown when used.
+    <https://github.com/jhy/jsoup/pull/1964>
+
+  * 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>
+
+  * Bugfix: in Jsoup.connect(), a DELETE method request did not support a request body.
+    <https://github.com/jhy/jsoup/issues/1972>
+
   * 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..1d912a29 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>
@@ -172,7 +172,7 @@
       </plugin>
       <plugin>
         <artifactId>maven-release-plugin</artifactId>
-        <version>3.0.0</version>
+        <version>3.0.1</version>
       </plugin>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
@@ -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/Connection.java b/src/main/java/org/jsoup/Connection.java
index ef64faa4..c6afd7f4 100644
--- a/src/main/java/org/jsoup/Connection.java
+++ b/src/main/java/org/jsoup/Connection.java
@@ -43,7 +43,7 @@ public interface Connection {
      * GET and POST http methods.
      */
     enum Method {
-        GET(false), POST(true), PUT(true), DELETE(false), PATCH(true), HEAD(false), OPTIONS(false), TRACE(false);
+        GET(false), POST(true), PUT(true), DELETE(true), PATCH(true), HEAD(false), OPTIONS(false), TRACE(false);
 
         private final boolean hasBody;
 
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 eb258410..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);
     }
 
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/Document.java b/src/main/java/org/jsoup/nodes/Document.java
index 273e33aa..9930dc5e 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");
     }
@@ -379,9 +385,9 @@ public class Document extends Element {
         public enum Syntax {html, xml}
 
         private Entities.EscapeMode escapeMode = Entities.EscapeMode.base;
-        private Charset charset = DataUtil.UTF_8;
+        private Charset charset;
+        Entities.CoreCharset coreCharset; // fast encoders for ascii and utf8
         private final ThreadLocal<CharsetEncoder> encoderThreadLocal = new ThreadLocal<>(); // initialized by start of OuterHtmlVisitor
-        @Nullable Entities.CoreCharset coreCharset; // fast encoders for ascii and utf8
 
         private boolean prettyPrint = true;
         private boolean outline = false;
@@ -389,7 +395,9 @@ public class Document extends Element {
         private int maxPaddingWidth = 30;
         private Syntax syntax = Syntax.html;
 
-        public OutputSettings() {}
+        public OutputSettings() {
+            charset(DataUtil.UTF_8);
+        }
         
         /**
          * Get the document's current HTML escape mode: <code>base</code>, which provides a limited set of named HTML
@@ -433,6 +441,7 @@ public class Document extends Element {
          */
         public OutputSettings charset(Charset charset) {
             this.charset = charset;
+            coreCharset = Entities.CoreCharset.byName(charset.name());
             return this;
         }
 
@@ -450,7 +459,6 @@ public class Document extends Element {
             // created at start of OuterHtmlVisitor so each pass has own encoder, so OutputSettings can be shared among threads
             CharsetEncoder encoder = charset.newEncoder();
             encoderThreadLocal.set(encoder);
-            coreCharset = Entities.CoreCharset.byName(encoder.charset().name());
             return encoder;
         }
 
@@ -564,7 +572,7 @@ public class Document extends Element {
             } catch (CloneNotSupportedException e) {
                 throw new RuntimeException(e);
             }
-            clone.charset(charset.name()); // new charset and charset encoder
+            clone.charset(charset.name()); // new charset, coreCharset, and charset encoder
             clone.escapeMode = Entities.EscapeMode.valueOf(escapeMode.name());
             // indentAmount, maxPaddingWidth, and prettyPrint are primitives so object.clone() will handle
             return clone;
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/Evaluator.java b/src/main/java/org/jsoup/select/Evaluator.java
index bc33addd..d8929fc3 100644
--- a/src/main/java/org/jsoup/select/Evaluator.java
+++ b/src/main/java/org/jsoup/select/Evaluator.java
@@ -464,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.childrenSize()-1;
+			return p != null && !(p instanceof Document) && element == p.lastElementChild();
 		}
 
 		@Override
@@ -617,9 +617,11 @@ public abstract class Evaluator {
                 return 0;
 
             int pos = 0;
-            int size = parent.childrenSize();
-            for (int i = element.elementSiblingIndex(); i < size; i++) {
-                if (parent.child(i).normalName().equals(element.normalName())) pos++;
+            Element next = element;
+            while (next != null) {
+                if (next.normalName().equals(element.normalName()))
+                    pos++;
+                next = next.nextElementSibling();
             }
             return pos;
         }
@@ -637,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
@@ -654,7 +656,7 @@ 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;
     	}
 
@@ -687,12 +689,13 @@ public abstract class Evaluator {
 			if (p==null || p instanceof Document) return false;
 
 			int pos = 0;
-            int size = p.childrenSize();
-            for (int i = 0; i < size; i++) {
-                if (p.child(i).normalName().equals(element.normalName()))
+            Element next = p.firstElementChild();
+            while (next != null) {
+                if (next.normalName().equals(element.normalName()))
                     pos++;
                 if (pos > 1)
                     break;
+                next = next.nextElementSibling();
             }
         	return pos == 1;
 		}
@@ -707,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;
 		}
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 5396be33..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>


Test patch:
diff --git a/src/test/java/org/jsoup/helper/HttpConnectionTest.java b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
index 840d8a82..d7765880 100644
--- a/src/test/java/org/jsoup/helper/HttpConnectionTest.java
+++ b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
@@ -255,25 +255,47 @@ 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());
         assertEquals("https://test.com/foo%20bar/%5BOne%5D?q=white+space#frag%20ment", url3.toExternalForm());
     }
 
+    @Test void urlPathIsPreservedDoesntDoubleEncode() throws MalformedURLException {
+        URL url1 = new URL("https://test.com/[foo] bar+/%5BOne%5D?q=white space#frag ment");
+        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());
+        assertEquals("https://test.com/[foo]%20bar+/%5BOne%5D?q=white+space#frag%20ment", url3.toExternalForm());
+    }
+
     @Test void connectToEncodedUrl() {
         Connection connect = Jsoup.connect("https://example.com/a%20b%20c?query+string");
         URL url = connect.request().url();
         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/integration/ConnectTest.java b/src/test/java/org/jsoup/integration/ConnectTest.java
index 07f9d2eb..f02840a0 100644
--- a/src/test/java/org/jsoup/integration/ConnectTest.java
+++ b/src/test/java/org/jsoup/integration/ConnectTest.java
@@ -3,6 +3,7 @@ package org.jsoup.integration;
 import org.jsoup.Connection;
 import org.jsoup.HttpStatusException;
 import org.jsoup.Jsoup;
+import org.jsoup.Connection.Method;
 import org.jsoup.helper.DataUtil;
 import org.jsoup.helper.W3CDom;
 import org.jsoup.integration.servlets.*;
@@ -254,6 +255,31 @@ public class ConnectTest {
         assertEquals("auth=token", ihVal("Cookie", doc));
     }
 
+    @Test
+    public void doesDeleteWithBody() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1972
+        String body = "some body";
+        Connection.Response res = Jsoup.connect(echoUrl)
+            .requestBody(body)
+            .method(Method.DELETE)
+            .execute();
+
+        Document doc = res.parse();
+        assertEquals("DELETE", ihVal("Method", doc));
+        assertEquals(body, ihVal("Post Data", doc));
+    }
+
+    @Test
+    public void doesDeleteWithoutBody() throws IOException {
+        Connection.Response res = Jsoup.connect(echoUrl)
+            .method(Method.DELETE)
+            .execute();
+
+        Document doc = res.parse();
+        assertEquals("DELETE", ihVal("Method", doc));
+        assertEquals(null, ihVal("Post Data", doc));
+    }
+
     /**
      * Tests upload of content to a remote service.
      */
diff --git a/src/test/java/org/jsoup/integration/servlets/BaseServlet.java b/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
index b1bf5cca..46d8e477 100644
--- a/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/BaseServlet.java
@@ -24,4 +24,9 @@ public abstract class BaseServlet extends HttpServlet {
     protected void doPut(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
         super.doPut(req, res);
     }
+
+    @Override
+    protected void doDelete(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
+        super.doPut(req, res);
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
index f5bf2ce3..c3a9c44a 100644
--- a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
@@ -40,6 +40,11 @@ public class EchoServlet extends BaseServlet {
         doIt(req, res);
     }
 
+    @Override
+    protected void doDelete(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
+        doIt(req, res);
+    }
+
     private void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
         int intCode = DefaultCode;
         String code = req.getHeader(CodeParam);
diff --git a/src/test/java/org/jsoup/nodes/EntitiesTest.java b/src/test/java/org/jsoup/nodes/EntitiesTest.java
index 886dfca5..7243c2f5 100644
--- a/src/test/java/org/jsoup/nodes/EntitiesTest.java
+++ b/src/test/java/org/jsoup/nodes/EntitiesTest.java
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
 
 import static org.jsoup.nodes.Document.OutputSettings;
 import static org.jsoup.nodes.Entities.EscapeMode.*;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class EntitiesTest {
@@ -163,4 +164,15 @@ public class EntitiesTest {
         Document xml = Jsoup.parse(input, "", Parser.xmlParser());
         assertEquals(input, xml.html());
     }
+    
+    @Test public void escapeByClonedOutputSettings() {
+        OutputSettings outputSettings = new OutputSettings();
+        String text = "Hello &<> Å å π 新 there ¾ © »";
+        OutputSettings clone1 = outputSettings.clone();
+        OutputSettings clone2 = outputSettings.clone();
+
+        String escaped1 = assertDoesNotThrow(() -> Entities.escape(text, clone1));
+        String escaped2 = assertDoesNotThrow(() -> Entities.escape(text, clone2));
+        assertEquals(escaped1, escaped2);
+    }
 }
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 5aa74a23..3196dc25 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -1164,4 +1164,21 @@ public class SelectorTest {
 
         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());
+    }
 }