Repository: jhy/jsoup
Pull request #2324 (2025-05-15 00:59:11)

Selectors can match against Nodes
This extends the QueryParser and Evaluators to support matching nodes (including specific node types, and their nodeValue contents)

Allows finding e.g. Elements following a particular comment or text node.

Also allows selecting nodes directly via 
- `Element#selectNodes(String css)` and 
- `Element#selectNodes(String css, Class nodeType)`

E.g.: `::comment:contains(prices) + p` will find `p` Elements directly after a `<!-- prices: -->` comment.

Or: `div:has(>::comment)` will find `div` Elements that directly have comment children.

Available operators:

<table>

 <tr><td colspan="3"><h3>Node pseudo selectors</h3></td></tr>
 <tr><td colspan="3">These selectors enable matching specific leaf nodes, including Comments, TextNodes. When used with `Element#select(String)`, these can be used with structural selectors such as <code>:has()</code> to refine which Elements are matched. To retrieve matching Nodes directly, use `Element#selectNodes(String)`.</td></tr>
 <tr><td>::node</td><td>Matches any node</td><td></td></tr>
 <tr><td>::leafnode</td><td>Matches any leaf-node (this is, a Node which is not an Element)</td><td></td></tr>
 <tr><td>::comment</td><td>Matches a Comment node</td><td></td></tr>
 <tr><td>::text</td><td>Matches a TextNode</td><td></td></tr>
 <tr><td>::data</td><td>Matches a DataNode</td><td></td></tr>
 <tr><td>::node:contains(text)</td><td>Matches a node that has a (normalized, case-insensitive) value containing <i>text</i>.</td><td>::comment:contains(foo bar)</td></tr>
 <tr><td>::node:matches(regex)</td><td>Matches a node that has a value matching the regex.</td><td>::comment:matches(\\d+)</td></tr>
 <tr><td>::node:blank</td><td>Matches a node that has either no value, or a value of only whitespace.</td><td>::comment:not(:blank)</td></tr>
 
 </table>





821938089: Does the Nodes class have a DOM update feature like the Elements class?
If so, please provide aliases for all original array methods.
jhy: > Does the Nodes class have a DOM update feature like the Elements class? If so, please provide aliases for all original array methods.

@821938089 yup, same as Elements.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index a01e11d9..7a871390 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,6 +7,7 @@
 * Removed previously deprecated methods. [#2317](https://github.com/jhy/jsoup/pull/2317)
 
 ### Improvements
+* Enhanced the `Selector` to support direct matching against nodes such as comments and text nodes. For example, you can now find an element that follows a specific comment: `::comment:contains(prices) + p` will select `p` elements immediately after a `<!-- prices: -->` comment. Supported types include `::node`, `::leafnode`, `::comment`, `::text`, and `::data`. Node contextual selectors like `::node:contains(text)`, `:matches(regex)`, and `:blank` are also supported. Introduced `Element#selectNodes(String css)` and `Element#selectNodes(String css, Class nodeType)` for direct node selection. [#2324](https://github.com/jhy/jsoup/pull/2324)
 * Added `TagSet#onNewTag(Consumer<Tag> customizer)`: register a callback that’s invoked for each new or cloned Tag when it’s inserted into the set. Enables dynamic tweaks of tag options (for example, marking all custom tags as self-closing, or everything in a given namespace as preserving whitespace).
 * Made `TokenQueue` and `CharacterReader` autocloseable, to ensure that they will release their buffers back to the buffer pool, for later reuse.
 * Added `Selector#evaluatorOf(String css)`, as a clearer way to obtain an Evaluator from a CSS query. An alias of `QueryParser.parse(String css)`.
diff --git a/pom.xml b/pom.xml
index f129a65a..f8c86ad0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -306,6 +306,14 @@
                 <binaryCompatible>true</binaryCompatible>
                 <sourceCompatible>true</sourceCompatible>
               </overrideCompatibilityChangeParameter>
+
+              <overrideCompatibilityChangeParameter>
+                <!-- false positive on JDK21; List interface has new defaults; Elements now extends Nodes extends ArrayList (WAS Elements extends ArrayList) -->
+                <compatibilityChange>METHOD_ABSTRACT_ADDED_IN_IMPLEMENTED_INTERFACE</compatibilityChange>
+                <binaryCompatible>true</binaryCompatible>
+                <sourceCompatible>true</sourceCompatible>
+              </overrideCompatibilityChangeParameter>
+
             </overrideCompatibilityChangeParameters>
           </parameter>
         </configuration>
diff --git a/src/main/java/org/jsoup/helper/Validate.java b/src/main/java/org/jsoup/helper/Validate.java
index c9566973..d6db01be 100644
--- a/src/main/java/org/jsoup/helper/Validate.java
+++ b/src/main/java/org/jsoup/helper/Validate.java
@@ -45,9 +45,10 @@ public final class Validate {
     /**
      Verifies the input object is not null, and returns that object. Effectively this casts a nullable object to a non-
      null object. (Works around lack of Objects.requestNonNull in Android version.)
-     * @param obj nullable object to case to not-null
+     * @param obj nullable object to cast to not-null
      * @return the object, or throws an exception if it is null
      * @throws ValidationException if the object is null
+     * @deprecated prefer to use {@link #expectNotNull(Object, String, Object...)} instead
      */
     public static Object ensureNotNull(@Nullable Object obj) {
         if (obj == null)
@@ -58,11 +59,12 @@ public final class Validate {
     /**
      Verifies the input object is not null, and returns that object. Effectively this casts a nullable object to a non-
      null object. (Works around lack of Objects.requestNonNull in Android version.)
-     * @param obj nullable object to case to not-null
+     * @param obj nullable object to cast to not-null
      * @param msg the String format message to include in the validation exception when thrown
      * @param args the arguments to the msg
      * @return the object, or throws an exception if it is null
      * @throws ValidationException if the object is null
+     * @deprecated prefer to use {@link #expectNotNull(Object, String, Object...)} instead
      */
     public static Object ensureNotNull(@Nullable Object obj, String msg, Object... args) {
         if (obj == null)
@@ -70,6 +72,36 @@ public final class Validate {
         else return obj;
     }
 
+    /**
+     Verifies the input object is not null, and returns that object, maintaining its type. Effectively this casts a
+     nullable object to a non-null object.
+
+     @param obj nullable object to cast to not-null
+     @return the object, or throws an exception if it is null
+     @throws ValidationException if the object is null
+     */
+    public static <T> T expectNotNull(@Nullable T obj) {
+        if (obj == null)
+            throw new ValidationException("Object must not be null");
+        else return obj;
+    }
+
+    /**
+     Verifies the input object is not null, and returns that object, maintaining its type. Effectively this casts a
+     nullable object to a non-null object.
+
+     @param obj nullable object to cast to not-null
+     @param msg the String format message to include in the validation exception when thrown
+     @param args the arguments to the msg
+     @return the object, or throws an exception if it is null
+     @throws ValidationException if the object is null
+     */
+    public static <T> T expectNotNull(@Nullable T obj, String msg, Object... args) {
+        if (obj == null)
+            throw new ValidationException(String.format(msg, args));
+        else return obj;
+    }
+
     /**
      * Validates that the value is true
      * @param val object to test
diff --git a/src/main/java/org/jsoup/internal/Normalizer.java b/src/main/java/org/jsoup/internal/Normalizer.java
index 3d91e020..9ecc3583 100644
--- a/src/main/java/org/jsoup/internal/Normalizer.java
+++ b/src/main/java/org/jsoup/internal/Normalizer.java
@@ -13,7 +13,7 @@ public final class Normalizer {
 
     /** Drops the input string to lower case. */
     public static String lowerCase(final String input) {
-        return input != null ? input.toLowerCase(Locale.ENGLISH) : "";
+        return input != null ? input.toLowerCase(Locale.ROOT) : "";
     }
 
     /** Lower-cases and trims the input string. */
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index cd1ae0a6..f45088f1 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -13,6 +13,7 @@ import org.jsoup.select.Elements;
 import org.jsoup.select.Evaluator;
 import org.jsoup.select.NodeFilter;
 import org.jsoup.select.NodeVisitor;
+import org.jsoup.select.Nodes;
 import org.jsoup.select.Selector;
 import org.jspecify.annotations.Nullable;
 
@@ -580,15 +581,122 @@ public class Element extends Node implements Iterable<Element> {
      @since 1.15.2
      */
     public Element expectFirst(String cssQuery) {
-        return (Element) Validate.ensureNotNull(
+        return Validate.expectNotNull(
             Selector.selectFirst(cssQuery, this),
             parent() != null ?
-                "No elements matched the query '%s' on element '%s'.":
+                "No elements matched the query '%s' on element '%s'." :
                 "No elements matched the query '%s' in the document."
             , cssQuery, this.tagName()
         );
     }
 
+    /**
+     Find nodes that match the supplied {@link Evaluator}, with this element as the starting context. Matched
+     nodes may include this element, or any of its descendents.
+
+     @param evaluator an evaluator
+     @return a list of nodes that match the query (empty if none match)
+     @since 1.21.1
+     */
+    public Nodes<Node> selectNodes(Evaluator evaluator) {
+        return selectNodes(evaluator, Node.class);
+    }
+
+    /**
+     Find nodes that match the supplied {@link Selector} CSS query, with this element as the starting context. Matched
+     nodes may include this element, or any of its descendents.
+     <p>To select leaf nodes, the query should specify the node type, e.g. {@code ::text},
+     {@code ::comment}, {@code ::data}, {@code ::leafnode}.</p>
+
+     @param cssQuery a {@link Selector} CSS query
+     @return a list of nodes that match the query (empty if none match)
+     @since 1.21.1
+     */
+    public Nodes<Node> selectNodes(String cssQuery) {
+        return selectNodes(cssQuery, Node.class);
+    }
+
+    /**
+     Find nodes that match the supplied Evaluator, with this element as the starting context. Matched
+     nodes may include this element, or any of its descendents.
+
+     @param evaluator an evaluator
+     @param type the type of node to collect (e.g. {@link Element}, {@link LeafNode}, {@link TextNode} etc)
+     @param <T> the type of node to collect
+     @return a list of nodes that match the query (empty if none match)
+     @since 1.21.1
+     */
+    public <T extends Node> Nodes<T> selectNodes(Evaluator evaluator, Class<T> type) {
+        Validate.notNull(evaluator);
+        return Collector.collectNodes(evaluator, this, type);
+    }
+
+    /**
+     Find nodes that match the supplied {@link Selector} CSS query, with this element as the starting context. Matched
+     nodes may include this element, or any of its descendents.
+     <p>To select specific node types, use {@code ::text}, {@code ::comment}, {@code ::leafnode}, etc. For example, to
+     select all text nodes under {@code p} elements: </p>
+     <pre>    Nodes&lt;TextNode&gt; textNodes = doc.selectNodes("p ::text", TextNode.class);</pre>
+
+     @param cssQuery a {@link Selector} CSS query
+     @param type the type of node to collect (e.g. {@link Element}, {@link LeafNode}, {@link TextNode} etc)
+     @param <T> the type of node to collect
+     @return a list of nodes that match the query (empty if none match)
+     @since 1.21.1
+     */
+    public <T extends Node> Nodes<T> selectNodes(String cssQuery, Class<T> type) {
+        Validate.notEmpty(cssQuery);
+        return selectNodes(evaluatorOf(cssQuery), type);
+    }
+
+    /**
+     Find the first Node that matches the {@link Selector} CSS query, with this element as the starting context.
+     <p>This is effectively the same as calling {@code element.selectNodes(query).first()}, but is more efficient as
+     query
+     execution stops on the first hit.</p>
+     <p>Also known as {@code querySelector()} in the Web DOM.</p>
+
+     @param cssQuery cssQuery a {@link Selector} CSS-like query
+     @return the first matching node, or <b>{@code null}</b> if there is no match.
+     @since 1.21.1
+     @see #expectFirst(String)
+     */
+    public @Nullable <T extends Node> T selectFirstNode(String cssQuery, Class<T> type) {
+        return selectFirstNode(evaluatorOf(cssQuery), type);
+    }
+
+    /**
+     Finds the first Node that matches the supplied Evaluator, with this element as the starting context, or
+     {@code null} if none match.
+
+     @param evaluator an element evaluator
+     @return the first matching node (walking down the tree, starting from this element), or {@code null} if none
+     match.
+     @since 1.21.1
+     */
+    public @Nullable <T extends Node> T selectFirstNode(Evaluator evaluator, Class<T> type) {
+        return Collector.findFirstNode(evaluator, this, type);
+    }
+
+    /**
+     Just like {@link #selectFirstNode(String, Class)}, but if there is no match, throws an
+     {@link IllegalArgumentException}. This is useful if you want to simply abort processing on a failed match.
+
+     @param cssQuery a {@link Selector} CSS-like query
+     @return the first matching node
+     @throws IllegalArgumentException if no match is found
+     @since 1.21.1
+     */
+    public <T extends Node> T expectFirstNode(String cssQuery, Class<T> type) {
+        return Validate.expectNotNull(
+            selectFirstNode(cssQuery, type),
+            parent() != null ?
+                "No nodes matched the query '%s' on element '%s'.":
+                "No nodes matched the query '%s' in the document."
+            , cssQuery, this.tagName()
+        );
+    }
+
     /**
      * Checks if this element matches the given {@link Selector} CSS query. Also knows as {@code matches()} in the Web
      * DOM.
@@ -1046,22 +1154,7 @@ public class Element extends Node implements Iterable<Element> {
         return siblings;
     }
 
-    /**
-     * Gets the next sibling element of this element. E.g., if a {@code div} contains two {@code p}s,
-     * the {@code nextElementSibling} of the first {@code p} is the second {@code p}.
-     * <p>
-     * This is similar to {@link #nextSibling()}, but specifically finds only Elements
-     * </p>
-     * @return the next element, or null if there is no next element
-     * @see #previousElementSibling()
-     */
-    public @Nullable Element nextElementSibling() {
-        Node next = this;
-        while ((next = next.nextSibling()) != null) {
-            if (next instanceof Element) return (Element) next;
-        }
-        return null;
-    }
+
 
     /**
      * Get each of the sibling elements that come after this element.
@@ -1072,19 +1165,6 @@ public class Element extends Node implements Iterable<Element> {
         return nextElementSiblings(true);
     }
 
-    /**
-     * Gets the previous element sibling of this element.
-     * @return the previous element, or null if there is no previous element
-     * @see #nextElementSibling()
-     */
-    public @Nullable Element previousElementSibling() {
-        Node prev = this;
-        while ((prev = prev.previousSibling()) != null) {
-            if (prev instanceof Element) return (Element) prev;
-        }
-        return null;
-    }
-
     /**
      * Get each of the element siblings before this element.
      *
@@ -1505,6 +1585,14 @@ public class Element extends Node implements Iterable<Element> {
         return wholeTextOf(nodeStream());
     }
 
+    /**
+     An Element's nodeValue is its whole own text.
+     */
+    @Override
+    public String nodeValue() {
+        return wholeOwnText();
+    }
+
     private static String wholeTextOf(Stream<Node> stream) {
         return stream.map(node -> {
             if (node instanceof TextNode) return ((TextNode) node).getWholeText();
diff --git a/src/main/java/org/jsoup/nodes/Entities.java b/src/main/java/org/jsoup/nodes/Entities.java
index b0b451c0..0379299d 100644
--- a/src/main/java/org/jsoup/nodes/Entities.java
+++ b/src/main/java/org/jsoup/nodes/Entities.java
@@ -224,12 +224,12 @@ public class Entities {
                     }
                 }
             }
-            appendEscaped(codePoint, accum, options, mode, syntax, coreCharset, fallback);
+            appendEscaped(codePoint, accum, options, mode, coreCharset, fallback);
         }
     }
 
     private static void appendEscaped(int codePoint, QuietAppendable accum, int options, EscapeMode escapeMode,
-        Syntax syntax, CoreCharset coreCharset, CharsetEncoder fallback) {
+        CoreCharset coreCharset, CharsetEncoder fallback) {
         // specific character range for xml 1.0; drop (not encode) if so
         if (EscapeMode.xhtml == escapeMode && !isValidXmlChar(codePoint)) {
             return;
diff --git a/src/main/java/org/jsoup/nodes/LeafNode.java b/src/main/java/org/jsoup/nodes/LeafNode.java
index 254266b3..e05425a8 100644
--- a/src/main/java/org/jsoup/nodes/LeafNode.java
+++ b/src/main/java/org/jsoup/nodes/LeafNode.java
@@ -2,6 +2,7 @@ package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.QuietAppendable;
+import org.jspecify.annotations.Nullable;
 
 import java.util.List;
 
@@ -43,6 +44,16 @@ public abstract class LeafNode extends Node {
         return attr(nodeName());
     }
 
+    @Override @Nullable
+    public Element parent() {
+        return parentNode;
+    }
+
+    @Override
+    public String nodeValue() {
+        return coreValue();
+    }
+
     void coreValue(String value) {
         attr(nodeName(), value);
     }
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index 8a23680e..f2f7cc70 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -26,7 +26,7 @@ import java.util.stream.Stream;
 public abstract class Node implements Cloneable {
     static final List<Node> EmptyNodes = Collections.emptyList();
     static final String EmptyString = "";
-    @Nullable Node parentNode; // Nodes don't always have parents
+    @Nullable Element parentNode; // Nodes don't always have parents
     int siblingIndex;
 
     /**
@@ -51,6 +51,15 @@ public abstract class Node implements Cloneable {
         return nodeName();
     }
 
+    /**
+     Get the node's value. For a TextNode, the whole text; for a Comment, the comment data; for an Element,
+     wholeOwnText. Returns "" if there is no value.
+     @return the node's value
+     */
+    public String nodeValue() {
+        return "";
+    }
+
     /**
      Test if this node has the specified normalized name, in any namespace.
      * @param normalName a normalized element name (e.g. {@code div}).
@@ -313,16 +322,26 @@ public abstract class Node implements Cloneable {
      */
     public abstract Node empty();
 
-
     /**
-     Gets this node's parent node.
+     Gets this node's parent node. This is always an Element.
      @return parent node; or null if no parent.
      @see #hasParent()
+     @see #parentElement();
      */
     public @Nullable Node parent() {
         return parentNode;
     }
 
+    /**
+     Gets this node's parent Element.
+     @return parent element; or null if this node has no parent.
+     @see #hasParent()
+     @since 1.21.1
+     */
+    public @Nullable Element parentElement() {
+        return parentNode;
+    }
+
     /**
      Gets this node's parent node. Not overridable by extending classes, so useful if you really just need the Node type.
      @return parent node; or null if no parent.
@@ -516,7 +535,8 @@ public abstract class Node implements Cloneable {
         Validate.notNull(parentNode);
         if (this.parentNode != null)
             this.parentNode.removeChild(this);
-        this.parentNode = parentNode;
+        assert parentNode instanceof Element;
+        this.parentNode = (Element) parentNode;
     }
 
     protected void replaceChild(Node out, Node in) {
@@ -529,7 +549,8 @@ public abstract class Node implements Cloneable {
 
         final int index = out.siblingIndex;
         ensureChildNodes().set(index, in);
-        in.parentNode = this;
+        assert this instanceof Element;
+        in.parentNode = (Element) this;
         in.setSiblingIndex(index);
         out.parentNode = null;
     }
@@ -578,8 +599,9 @@ public abstract class Node implements Cloneable {
                 firstParent.empty();
                 nodes.addAll(index, Arrays.asList(children));
                 i = children.length;
+                assert this instanceof Element;
                 while (i-- > 0) {
-                    children[i].parentNode = this;
+                    children[i].parentNode = (Element) this;
                 }
                 if (!(wasEmpty && children[0].siblingIndex == 0)) // skip reindexing if we just moved
                     reindexChildren(index);
@@ -696,6 +718,64 @@ public abstract class Node implements Cloneable {
         return children.get(size - 1);
     }
 
+    /**
+     Gets the first sibling of this node. That may be this node.
+
+     @return the first sibling node
+     @since 1.21.1
+     */
+    public Node firstSibling() {
+        if (parentNode != null) {
+            //noinspection DataFlowIssue
+            return parentNode.firstChild();
+        } else
+            return this; // orphan is its own first sibling
+    }
+
+    /**
+     Gets the last sibling of this node. That may be this node.
+
+     @return the last sibling (aka the parent's last child)
+     @since 1.21.1
+     */
+    public Node lastSibling() {
+        if (parentNode != null) {
+            //noinspection DataFlowIssue (not nullable, would be this if no other sibs)
+            return parentNode.lastChild();
+        } else
+            return this;
+    }
+
+    /**
+     Gets the next sibling Element of this node. E.g., if a {@code div} contains two {@code p}s, the
+     {@code nextElementSibling} of the first {@code p} is the second {@code p}.
+     <p>This is similar to {@link #nextSibling()}, but specifically finds only Elements.</p>
+
+     @return the next element, or null if there is no next element
+     @see #previousElementSibling()
+     */
+    public @Nullable Element nextElementSibling() {
+        Node next = this;
+        while ((next = next.nextSibling()) != null) {
+            if (next instanceof Element) return (Element) next;
+        }
+        return null;
+    }
+
+    /**
+     Gets the previous Element sibling of this node.
+
+     @return the previous element, or null if there is no previous element
+     @see #nextElementSibling()
+     */
+    public @Nullable Element previousElementSibling() {
+        Node prev = this;
+        while ((prev = prev.previousSibling()) != null) {
+            if (prev instanceof Element) return (Element) prev;
+        }
+        return null;
+    }
+
     /**
      * Perform a depth-first traversal through this node and its descendants.
      * @param nodeVisitor the visitor callbacks to perform on each node
@@ -827,7 +907,7 @@ public abstract class Node implements Cloneable {
 
     /**
      * Check if this node is the same instance of another (object identity test).
-     * <p>For an node value equality check, see {@link #hasSameValue(Object)}</p>
+     * <p>For a node value equality check, see {@link #hasSameValue(Object)}</p>
      * @param o other object to compare to
      * @return true if the content of this node is the same as the other
      * @see Node#hasSameValue(Object)
@@ -915,6 +995,7 @@ public abstract class Node implements Cloneable {
      * Not a deep copy of children.
      */
     protected Node doClone(@Nullable Node parent) {
+        assert parent == null || parent instanceof Element;
         Node clone;
 
         try {
@@ -923,7 +1004,7 @@ public abstract class Node implements Cloneable {
             throw new RuntimeException(e);
         }
 
-        clone.parentNode = parent; // can be null, to create an orphan split
+        clone.parentNode = (Element) parent; // can be null, to create an orphan split
         clone.siblingIndex = parent == null ? 0 : siblingIndex;
         // if not keeping the parent, shallowClone the ownerDocument to preserve its settings
         if (parent == null && !(this instanceof Document)) {
diff --git a/src/main/java/org/jsoup/parser/StreamParser.java b/src/main/java/org/jsoup/parser/StreamParser.java
index 90fc0002..a4d8be33 100644
--- a/src/main/java/org/jsoup/parser/StreamParser.java
+++ b/src/main/java/org/jsoup/parser/StreamParser.java
@@ -221,7 +221,7 @@ public class StreamParser implements Closeable {
      @throws IOException if an I/O error occurs
      */
     public Element expectFirst(String query) throws IOException {
-        return (Element) Validate.ensureNotNull(
+        return Validate.expectNotNull(
             selectFirst(query),
             "No elements matched the query '%s' in the document."
             , query
@@ -269,7 +269,7 @@ public class StreamParser implements Closeable {
      @throws IOException if an I/O error occurs
      */
     public Element expectNext(String query) throws IOException {
-        return (Element) Validate.ensureNotNull(
+        return Validate.expectNotNull(
             selectNext(query),
             "No elements matched the query '%s' in the document."
             , query
@@ -368,7 +368,7 @@ public class StreamParser implements Closeable {
         // NodeVisitor Interface:
         @Override public void head(Node node, int depth) {
             if (node instanceof Element) {
-                Element prev = ((Element) node).previousElementSibling();
+                Element prev = node.previousElementSibling();
                 // We prefer to wait until an element has a next sibling before emitting it; otherwise, get it in tail
                 if (prev != null) emitQueue.add(prev);
             }
diff --git a/src/main/java/org/jsoup/select/Collector.java b/src/main/java/org/jsoup/select/Collector.java
index 41994015..db1f6720 100644
--- a/src/main/java/org/jsoup/select/Collector.java
+++ b/src/main/java/org/jsoup/select/Collector.java
@@ -1,11 +1,17 @@
 package org.jsoup.select;
 
+import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.LeafNode;
+import org.jsoup.nodes.Node;
+import org.jsoup.nodes.TextNode;
 import org.jspecify.annotations.Nullable;
 
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
+import static java.util.stream.Collectors.toCollection;
+
 /**
  * Collects a list of elements that match the supplied criteria.
  *
@@ -22,7 +28,11 @@ public class Collector {
      @return list of matches; empty if none
      */
     public static Elements collect(Evaluator eval, Element root) {
-        return stream(eval, root).collect(Collectors.toCollection(Elements::new));
+        Stream<Element> stream = eval.wantsNodes() ?
+            streamNodes(eval, root, Element.class) :
+            stream(eval, root);
+
+        return stream.collect(toCollection(Elements::new));
     }
 
     /**
@@ -38,6 +48,22 @@ public class Collector {
         return root.stream().filter(evaluator.asPredicate(root));
     }
 
+    /**
+     Obtain a Stream of nodes, of the specified type, by visiting the root and every descendant of root and testing it
+     against the evaluator.
+
+     @param evaluator Evaluator to test elements against
+     @param root root of tree to descend
+     @param type the type of node to collect (e.g. {@link Element}, {@link LeafNode}, {@link TextNode} etc)
+     @param <T> the type of node to collect
+     @return A {@link Stream} of matches
+     @since 1.21.1
+     */
+    public static <T extends Node> Stream<T> streamNodes(Evaluator evaluator, Element root, Class<T> type) {
+        evaluator.reset();
+        return root.nodeStream(type).filter(evaluator.asNodePredicate(root));
+    }
+
     /**
      Finds the first Element that matches the Evaluator that descends from the root, and stops the query once that first
      match is found.
@@ -48,4 +74,32 @@ public class Collector {
     public static @Nullable Element findFirst(Evaluator eval, Element root) {
         return stream(eval, root).findFirst().orElse(null);
     }
+
+    /**
+     Finds the first Node that matches the Evaluator that descends from the root, and stops the query once that first
+     match is found.
+
+     @param eval Evaluator to test elements against
+     @param root root of tree to descend
+     @param type the type of node to collect (e.g. {@link Element}, {@link LeafNode}, {@link TextNode} etc)
+     @return the first match; {@code null} if none
+     @since 1.21.1
+     */
+    public static <T extends Node> @Nullable T findFirstNode(Evaluator eval, Element root, Class<T> type) {
+        return streamNodes(eval, root, type).findFirst().orElse(null);
+    }
+
+    /**
+     Build a list of nodes that match the supplied criteria, by visiting the root and every descendant of root, and
+     testing it against the Evaluator.
+
+     @param evaluator Evaluator to test elements against
+     @param root root of tree to descend
+     @param type the type of node to collect (e.g. {@link Element}, {@link LeafNode}, {@link TextNode} etc)
+     @param <T> the type of node to collect
+     @return list of matches; empty if none
+     */
+    public static <T extends Node> Nodes<T> collectNodes(Evaluator evaluator, Element root, Class<T> type) {
+        return streamNodes(evaluator, root, type).collect(toCollection(Nodes::new));
+    }
 }
diff --git a/src/main/java/org/jsoup/select/CombiningEvaluator.java b/src/main/java/org/jsoup/select/CombiningEvaluator.java
index 17968789..0c8c7d5b 100644
--- a/src/main/java/org/jsoup/select/CombiningEvaluator.java
+++ b/src/main/java/org/jsoup/select/CombiningEvaluator.java
@@ -2,6 +2,8 @@ package org.jsoup.select;
 
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.LeafNode;
+import org.jsoup.nodes.Node;
 import org.jspecify.annotations.Nullable;
 
 import java.util.ArrayList;
@@ -18,6 +20,7 @@ public abstract class CombiningEvaluator extends Evaluator {
     final List<Evaluator> sortedEvaluators; // cost ascending order
     int num = 0;
     int cost = 0;
+    boolean wantsNodes;
 
     CombiningEvaluator() {
         super();
@@ -47,6 +50,11 @@ public abstract class CombiningEvaluator extends Evaluator {
         return cost;
     }
 
+    @Override
+    boolean wantsNodes() {
+        return wantsNodes;
+    }
+
     void updateEvaluators() {
         // used so we don't need to bash on size() for every match test
         num = evaluators.size();
@@ -59,6 +67,14 @@ public abstract class CombiningEvaluator extends Evaluator {
         sortedEvaluators.clear();
         sortedEvaluators.addAll(evaluators);
         sortedEvaluators.sort(Comparator.comparingInt(Evaluator::cost));
+
+        // any want nodes?
+        for (Evaluator evaluator : evaluators) {
+            if (evaluator.wantsNodes()) {
+                wantsNodes = true;
+                break;
+            }
+        }
     }
 
     public static final class And extends CombiningEvaluator {
@@ -71,10 +87,20 @@ public abstract class CombiningEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element element) {
+        public boolean matches(Element root, Element el) {
+            for (int i = 0; i < num; i++) {
+                Evaluator eval = sortedEvaluators.get(i);
+                if (!eval.matches(root, el))
+                    return false;
+            }
+            return true;
+        }
+
+        @Override
+        public boolean matches(Element root, LeafNode leaf) {
             for (int i = 0; i < num; i++) {
-                Evaluator s = sortedEvaluators.get(i);
-                if (!s.matches(root, element))
+                Evaluator eval = sortedEvaluators.get(i);
+                if (!eval.matches(root, leaf))
                     return false;
             }
             return true;
@@ -107,10 +133,20 @@ public abstract class CombiningEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element node) {
+        public boolean matches(Element root, Element element) {
+            for (int i = 0; i < num; i++) {
+                Evaluator eval = sortedEvaluators.get(i);
+                if (eval.matches(root, element))
+                    return true;
+            }
+            return false;
+        }
+
+        @Override
+        public boolean matches(Element root, LeafNode leaf) {
             for (int i = 0; i < num; i++) {
-                Evaluator s = sortedEvaluators.get(i);
-                if (s.matches(root, node))
+                Evaluator eval = sortedEvaluators.get(i);
+                if (eval.matches(root, leaf))
                     return true;
             }
             return false;
diff --git a/src/main/java/org/jsoup/select/Elements.java b/src/main/java/org/jsoup/select/Elements.java
index 2c8fc28e..dce0af16 100644
--- a/src/main/java/org/jsoup/select/Elements.java
+++ b/src/main/java/org/jsoup/select/Elements.java
@@ -27,7 +27,7 @@ import java.util.function.UnaryOperator;
  replace} Elements in the list will also act on the underlying {@link org.jsoup.nodes.Document DOM}.</p>
 
  @author Jonathan Hedley, jonathan@hedley.net */
-public class Elements extends ArrayList<Element> {
+public class Elements extends Nodes<Element> {
     public Elements() {
     }
 
@@ -38,11 +38,11 @@ public class Elements extends ArrayList<Element> {
     public Elements(Collection<Element> elements) {
         super(elements);
     }
-    
+
     public Elements(List<Element> elements) {
         super(elements);
     }
-    
+
     public Elements(Element... elements) {
     	super(Arrays.asList(elements));
     }
@@ -272,29 +272,6 @@ public class Elements extends ArrayList<Element> {
             .map(Element::html)
             .collect(StringUtil.joining("\n"));
     }
-    
-    /**
-     * Get the combined outer HTML of all matched elements.
-     * @return string of all element's outer HTML.
-     * @see #text()
-     * @see #html()
-     */
-    public String outerHtml() {
-        return stream()
-            .map(Element::outerHtml)
-            .collect(StringUtil.joining("\n"));
-    }
-
-    /**
-     * Get the combined outer HTML of all matched elements. Alias of {@link #outerHtml()}.
-     * @return string of all element's outer HTML.
-     * @see #text()
-     * @see #html()
-     */
-    @Override
-    public String toString() {
-        return outerHtml();
-    }
 
     /**
      * Update (rename) the tag name of each matched element. For example, to change each {@code <i>} to a {@code <em>}, do
@@ -349,30 +326,30 @@ public class Elements extends ArrayList<Element> {
         }
         return this;
     }
-    
+
     /**
-     * Insert the supplied HTML before each matched element's outer HTML.
-     * @param html HTML to insert before each element
-     * @return this, for chaining
-     * @see Element#before(String)
+     Insert the supplied HTML before each matched element's outer HTML.
+
+     @param html HTML to insert before each element
+     @return this, for chaining
+     @see Element#before(String)
      */
+    @Override
     public Elements before(String html) {
-        for (Element element : this) {
-            element.before(html);
-        }
+        super.before(html);
         return this;
     }
-    
+
     /**
-     * Insert the supplied HTML after each matched element's outer HTML.
-     * @param html HTML to insert after each element
-     * @return this, for chaining
-     * @see Element#after(String)
+     Insert the supplied HTML after each matched element's outer HTML.
+
+     @param html HTML to insert after each element
+     @return this, for chaining
+     @see Element#after(String)
      */
+    @Override
     public Elements after(String html) {
-        for (Element element : this) {
-            element.after(html);
-        }
+        super.after(html);
         return this;
     }
 
@@ -381,15 +358,13 @@ public class Elements extends ArrayList<Element> {
      {@code <p><b>This</b> is <b>Jsoup</b></p>},
      <code>doc.select("b").wrap("&lt;i&gt;&lt;/i&gt;");</code>
      becomes {@code <p><i><b>This</b></i> is <i><b>jsoup</b></i></p>}
+
      @param html HTML to wrap around each element, e.g. {@code <div class="head"></div>}. Can be arbitrarily deep.
      @return this (for chaining)
      @see Element#wrap
      */
     public Elements wrap(String html) {
-        Validate.notEmpty(html);
-        for (Element element : this) {
-            element.wrap(html);
-        }
+        super.wrap(html);
         return this;
     }
 
@@ -446,10 +421,9 @@ public class Elements extends ArrayList<Element> {
      * @see #empty()
      * @see #clear()
      */
+    @Override
     public Elements remove() {
-        for (Element element : this) {
-            element.remove();
-        }
+        super.remove();
         return this;
     }
     
@@ -487,7 +461,7 @@ public class Elements extends ArrayList<Element> {
      @since 1.19.1
      */
     public Element expectFirst(String cssQuery) {
-        return (Element) Validate.ensureNotNull(
+        return Validate.expectNotNull(
             Selector.selectFirst(cssQuery, this),
             "No elements matched the query '%s' in the elements.", cssQuery
         );
@@ -633,7 +607,7 @@ public class Elements extends ArrayList<Element> {
      @return The first matched element, or <code>null</code> if contents is empty.
      */
     public @Nullable Element first() {
-        return isEmpty() ? null : get(0);
+        return super.first();
     }
 
     /**
@@ -641,7 +615,7 @@ public class Elements extends ArrayList<Element> {
      @return The last matched element, or <code>null</code> if contents is empty.
      */
     public @Nullable Element last() {
-        return isEmpty() ? null : get(size() - 1);
+        return super.last();
     }
 
     /**
@@ -718,150 +692,41 @@ public class Elements extends ArrayList<Element> {
 
     /**
      Replace the Element at the specified index in this list, and in the DOM.
-     * @param index index of the element to replace
-     * @param element element to be stored at the specified position
-     * @return the old Element at this index
-     * @since 1.17.1
+
+     @param index index of the element to replace
+     @param element element to be stored at the specified position
+     @return the old Element at this index
+     @since 1.17.1
      */
-    @Override public Element set(int index, Element element) {
-        Validate.notNull(element);
-        Element old = super.set(index, element);
-        old.replaceWith(element);
-        return old;
+    @Override
+    public Element set(int index, Element element) {
+        return super.set(index, element);
     }
 
     /**
      Remove the Element at the specified index in this ist, and from the DOM.
+
      @param index the index of the element to be removed
      @return the old element at this index
      @see #deselect(int)
      @since 1.17.1
      */
-    @Override public Element remove(int index) {
-        Element old = super.remove(index);
-        old.remove();
-        return old;
+    @Override
+    public Element remove(int index) {
+        return super.remove(index);
     }
 
-    /**
-     Remove the specified Element from this list, and from the DOM.
-     @param o element to be removed from this list, if present
-     @return if this list contained the Element
-     @see #deselect(Object)
-     @since 1.17.1
-     */
-    @Override public boolean remove(Object o) {
-        int index = super.indexOf(o);
-        if (index == -1) {
-            return false;
-        } else {
-            remove(index);
-            return true;
-        }
-    }
 
     /**
      Remove the Element at the specified index in this list, but not from the DOM.
+
      @param index the index of the element to be removed
      @return the old element at this index
-     @see #remove(int) 
+     @see #remove(int)
      @since 1.19.2
      */
+    @Override
     public Element deselect(int index) {
-        return super.remove(index);
-    }
-
-    /**
-     Remove the specified Element from this list, but not from the DOM.
-     @param o element to be removed from this list, if present
-     @return if this list contained the Element
-     @see #remove(Object) 
-     @since 1.19.2
-     */
-    public boolean deselect(Object o) {
-        return super.remove(o);
-    }
-
-    /**
-     Removes all the elements from this list, and each of them from the DOM.
-     @since 1.17.1
-     @see #deselectAll()
-     */
-    @Override public void clear() {
-        remove();
-        super.clear();
-    }
-
-    /**
-     Like {@link #clear()}, removes all the elements from this list, but not from the DOM.
-     @see #clear()
-     @since 1.19.2
-     */
-    public void deselectAll() {
-        super.clear();
-    }
-
-    /**
-     Removes from this list, and from the DOM, each of the elements that are contained in the specified collection and
-     are in this list.
-     * @param c collection containing elements to be removed from this list
-     * @return {@code true} if elements were removed from this list
-     * @since 1.17.1
-     */
-    @Override public boolean removeAll(Collection<?> c) {
-        boolean anyRemoved = false;
-        for (Object o : c) {
-            anyRemoved |= this.remove(o);
-        }
-        return anyRemoved;
-    }
-
-    /**
-     Retain in this list, and in the DOM, only the elements that are in the specified collection and are in this list.
-     In other words, remove elements from this list and the DOM any item that is in this list but not in the specified
-     collection.
-     * @param c collection containing elements to be retained in this list
-     * @return {@code true} if elements were removed from this list
-     * @since 1.17.1
-     */
-    @Override public boolean retainAll(Collection<?> c) {
-        boolean anyRemoved = false;
-        for (Iterator<Element> it = this.iterator(); it.hasNext(); ) {
-            Element el = it.next();
-            if (!c.contains(el)) {
-                it.remove();
-                anyRemoved = true;
-            }
-        }
-        return anyRemoved;
-    }
-
-    /**
-     Remove from the list, and from the DOM, all elements in this list that mach the given filter.
-     * @param filter a predicate which returns {@code true} for elements to be removed
-     * @return {@code true} if elements were removed from this list
-     * @since 1.17.1
-     */
-    @Override public boolean removeIf(Predicate<? super Element> filter) {
-        boolean anyRemoved = false;
-        for (Iterator<Element> it = this.iterator(); it.hasNext(); ) {
-            Element el = it.next();
-            if (filter.test(el)) {
-                it.remove();
-                anyRemoved = true;
-            }
-        }
-        return anyRemoved;
-    }
-
-    /**
-     Replace each element in this list with the result of the operator, and update the DOM.
-     * @param operator the operator to apply to each element
-     * @since 1.17.1
-     */
-    @Override public void replaceAll(UnaryOperator<Element> operator) {
-        for (int i = 0; i < this.size(); i++) {
-            this.set(i, operator.apply(this.get(i)));
-        }
+        return super.deselect(index);
     }
 }
diff --git a/src/main/java/org/jsoup/select/Evaluator.java b/src/main/java/org/jsoup/select/Evaluator.java
index 0d47e85d..19fc83a3 100644
--- a/src/main/java/org/jsoup/select/Evaluator.java
+++ b/src/main/java/org/jsoup/select/Evaluator.java
@@ -5,6 +5,7 @@ import org.jsoup.nodes.Comment;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.DocumentType;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.LeafNode;
 import org.jsoup.nodes.Node;
 import org.jsoup.nodes.PseudoTextElement;
 import org.jsoup.nodes.TextNode;
@@ -22,7 +23,7 @@ import static org.jsoup.internal.StringUtil.normaliseWhitespace;
 
 
 /**
- An Evaluator tests if an element meets the selector's requirements. Obtain an evaluator for a given CSS selector
+ An Evaluator tests if an element (or a node) meets the selector's requirements. Obtain an evaluator for a given CSS selector
  with {@link Selector#evaluatorOf(String css)}. If you are executing the same selector on many elements (or documents), it
  can be more efficient to compile and reuse an Evaluator than to reparse the selector on each invocation of select().
  <p>Evaluators are thread-safe and may be used concurrently across multiple documents.</p>
@@ -41,6 +42,10 @@ public abstract class Evaluator {
         return element -> matches(root, element);
     }
 
+    Predicate<Node> asNodePredicate(Element root) {
+        return node -> matches(root, node);
+    }
+
     /**
      * Test if the element meets the evaluator's requirements.
      *
@@ -51,6 +56,23 @@ public abstract class Evaluator {
      */
     public abstract boolean matches(Element root, Element element);
 
+    final boolean matches(Element root, Node node) {
+        if (node instanceof Element) {
+            return matches(root, (Element) node);
+        } else if (node instanceof LeafNode && wantsNodes()) {
+            return matches(root, (LeafNode) node);
+        }
+        return false;
+    }
+
+    boolean matches(Element root, LeafNode leafNode) {
+        return false;
+    }
+
+    boolean wantsNodes() {
+        return false;
+    }
+
     /**
      Reset any internal state in this Evaluator before executing a new Collector evaluation.
      */
diff --git a/src/main/java/org/jsoup/select/NodeEvaluator.java b/src/main/java/org/jsoup/select/NodeEvaluator.java
new file mode 100644
index 00000000..cfaee79c
--- /dev/null
+++ b/src/main/java/org/jsoup/select/NodeEvaluator.java
@@ -0,0 +1,122 @@
+package org.jsoup.select;
+
+import org.jsoup.internal.StringUtil;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.LeafNode;
+import org.jsoup.nodes.Node;
+
+import java.util.regex.Pattern;
+
+import static org.jsoup.internal.Normalizer.lowerCase;
+import static org.jsoup.internal.StringUtil.normaliseWhitespace;
+
+abstract class NodeEvaluator extends Evaluator {
+
+    @Override
+    public boolean matches(Element root, Element element) {
+        return evaluateMatch(element);
+    }
+
+    @Override boolean matches(Element root, LeafNode leaf) {
+        return evaluateMatch(leaf);
+    }
+
+    abstract boolean evaluateMatch(Node node);
+    
+    @Override boolean wantsNodes() {
+        return true;
+    }
+
+    static class InstanceType extends NodeEvaluator {
+        final java.lang.Class<? extends Node> type;
+        final String selector;
+
+        InstanceType(java.lang.Class<? extends Node> type, String selector) {
+            super();
+            this.type = type;
+            this.selector = "::" + selector;
+        }
+
+        @Override
+        boolean evaluateMatch(Node node) {
+            return type.isInstance(node);
+        }
+
+        @Override
+        protected int cost() {
+            return 1;
+        }
+
+        @Override
+        public String toString() {
+            return selector;
+        }
+    }
+
+    static class ContainsValue extends NodeEvaluator {
+        private final String searchText;
+
+        public ContainsValue(String searchText) {
+            this.searchText = lowerCase(normaliseWhitespace(searchText));
+        }
+
+        @Override
+        boolean evaluateMatch(Node node) {
+            return lowerCase(node.nodeValue()).contains(searchText);
+        }
+
+        @Override
+        protected int cost() {
+            return 6;
+        }
+
+        @Override
+        public String toString() {
+            return String.format(":contains(%s)", searchText);
+        }
+    }
+
+    /**
+     Matches nodes with no value or only whitespace.
+     */
+    static class BlankValue extends NodeEvaluator {
+
+        @Override
+        boolean evaluateMatch(Node node) {
+            return StringUtil.isBlank(node.nodeValue());
+        }
+
+        @Override
+        protected int cost() {
+            return 4;
+        }
+
+        @Override
+        public String toString() {
+            return ":blank";
+        }
+    }
+
+    static class MatchesValue extends NodeEvaluator {
+        private final Pattern pattern;
+
+        protected MatchesValue(Pattern pattern) {
+            this.pattern = pattern;
+        }
+
+        @Override
+        boolean evaluateMatch(Node node) {
+            return pattern.matcher(node.nodeValue()).find();
+        }
+
+        @Override
+        protected int cost() {
+            return 8;
+        }
+
+        @Override
+        public String toString() {
+            return String.format(":matches(%s)", pattern);
+        }
+    }
+}
diff --git a/src/main/java/org/jsoup/select/Nodes.java b/src/main/java/org/jsoup/select/Nodes.java
new file mode 100644
index 00000000..5d14e55d
--- /dev/null
+++ b/src/main/java/org/jsoup/select/Nodes.java
@@ -0,0 +1,344 @@
+package org.jsoup.select;
+
+import org.jsoup.helper.Validate;
+import org.jsoup.internal.StringUtil;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+
+/**
+ A list of {@link Node} objects, with methods that act on every node in the list.
+ <p>Methods that {@link #set(int, T) set}, {@link #remove(int) remove}, or
+ {@link #replaceAll(UnaryOperator)  replace} nodes in the list will also act on the underlying
+ {@link org.jsoup.nodes.Document DOM}.</p>
+
+ <p>If there are other bulk methods (perhaps from Elements) that would be useful here, please <a
+ href="https://jsoup.org/discussion">provide feedback</a>.</p>
+
+ @see Element#selectNodes(String)
+ @see Element#selectNodes(String, Class)
+ @since 1.21.1 */
+public class Nodes<T extends Node> extends ArrayList<T> {
+    public Nodes() {
+    }
+
+    public Nodes(int initialCapacity) {
+        super(initialCapacity);
+    }
+
+    public Nodes(Collection<T> nodes) {
+        super(nodes);
+    }
+
+    public Nodes(List<T> nodes) {
+        super(nodes);
+    }
+
+    @SafeVarargs
+    public Nodes(T... nodes) {
+        super(Arrays.asList(nodes));
+    }
+
+    /**
+     * Creates a deep copy of these nodes.
+     * @return a deep copy
+     */
+    @Override
+    public Nodes<T> clone() {
+        Nodes<T> clone = new Nodes<>(size());
+        for (T node : this)
+            clone.add((T) node.clone());
+        return clone;
+    }
+
+    /**
+     Convenience method to get the Nodes as a plain ArrayList. This allows modification to the list of nodes
+     without modifying the source Document. I.e. whereas calling {@code nodes.remove(0)} will remove the nodes from
+     both the Nodes and the DOM, {@code nodes.asList().remove(0)} will remove the node from the list only.
+     <p>Each Node is still the same DOM connected Node.</p>
+
+     @return a new ArrayList containing the nodes in this list
+     @see #Nodes(List)
+     */
+    public ArrayList<T> asList() {
+        return new ArrayList<>(this);
+    }
+
+    /**
+     Remove each matched node from the DOM.
+     <p>The nodes will still be retained in this list, in case further processing of them is desired.</p>
+     <p>
+     E.g. HTML: {@code <div><p>Hello</p> <p>there</p> <img></div>}<br>
+     <code>doc.select("p").remove();</code><br>
+     HTML = {@code <div> <img></div>}
+     <p>
+     Note that this method should not be used to clean user-submitted HTML; rather, use {@link org.jsoup.safety.Cleaner}
+     to clean HTML.
+
+     @return this, for chaining
+     @see Element#empty()
+     @see Elements#empty()
+     @see #clear()
+     */
+    public Nodes<T> remove() {
+        for (T node : this) {
+            node.remove();
+        }
+        return this;
+    }
+
+    /**
+     Get the combined outer HTML of all matched nodes.
+
+     @return string of all node's outer HTML.
+     @see Elements#text()
+     @see Elements#html()
+     */
+    public String outerHtml() {
+        return stream()
+            .map(Node::outerHtml)
+            .collect(StringUtil.joining("\n"));
+    }
+
+    /**
+     Get the combined outer HTML of all matched nodes. Alias of {@link #outerHtml()}.
+
+     @return string of all the node's outer HTML.
+     @see Elements#text()
+     @see #outerHtml()
+     */
+    @Override
+    public String toString() {
+        return outerHtml();
+    }
+
+    /**
+     Insert the supplied HTML before each matched node's outer HTML.
+
+     @param html HTML to insert before each node
+     @return this, for chaining
+     @see Element#before(String)
+     */
+    public Nodes<T> before(String html) {
+        for (T node : this) {
+            node.before(html);
+        }
+        return this;
+    }
+
+    /**
+     Insert the supplied HTML after each matched nodes's outer HTML.
+
+     @param html HTML to insert after each node
+     @return this, for chaining
+     @see Element#after(String)
+     */
+    public Nodes<T> after(String html) {
+        for (T node : this) {
+            node.after(html);
+        }
+        return this;
+    }
+
+    /**
+     Wrap the supplied HTML around each matched node. For example, with HTML
+     {@code <p><b>This</b> is <b>Jsoup</b></p>},
+     <code>doc.select("b").wrap("&lt;i&gt;&lt;/i&gt;");</code>
+     becomes {@code <p><i><b>This</b></i> is <i><b>jsoup</b></i></p>}
+     @param html HTML to wrap around each node, e.g. {@code <div class="head"></div>}. Can be arbitrarily deep.
+     @return this (for chaining)
+     @see Element#wrap
+     */
+    public Nodes<T> wrap(String html) {
+        Validate.notEmpty(html);
+        for (T node : this) {
+            node.wrap(html);
+        }
+        return this;
+    }
+
+    // list-like methods
+    /**
+     Get the first matched element.
+     @return The first matched element, or <code>null</code> if contents is empty.
+     */
+    public @Nullable T first() {
+        return isEmpty() ? null : get(0);
+    }
+
+    /**
+     Get the last matched element.
+     @return The last matched element, or <code>null</code> if contents is empty.
+     */
+    public @Nullable T last() {
+        return isEmpty() ? null : get(size() - 1);
+    }
+
+    // ArrayList<T> methods that update the DOM:
+
+    /**
+     Replace the node at the specified index in this list, and in the DOM.
+
+     @param index index of the node to replace
+     @param node node to be stored at the specified position
+     @return the old Node at this index
+     */
+    @Override
+    public T set(int index, T node) {
+        Validate.notNull(node);
+        T old = super.set(index, node);
+        old.replaceWith(node);
+        return old;
+    }
+
+    /**
+     Remove the node at the specified index in this list, and from the DOM.
+
+     @param index the index of the node to be removed
+     @return the old node at this index
+     @see #deselect(int)
+     */
+    @Override
+    public T remove(int index) {
+        T old = super.remove(index);
+        old.remove();
+        return old;
+    }
+
+    /**
+     Remove the specified node from this list, and from the DOM.
+
+     @param o node to be removed from this list, if present
+     @return if this list contained the Node
+     @see #deselect(Object)
+     */
+    @Override
+    public boolean remove(Object o) {
+        int index = super.indexOf(o);
+        if (index == -1) {
+            return false;
+        } else {
+            remove(index);
+            return true;
+        }
+    }
+
+    /**
+     Remove the node at the specified index in this list, but not from the DOM.
+
+     @param index the index of the node to be removed
+     @return the old node at this index
+     @see #remove(int)
+     */
+    public T deselect(int index) {
+        return super.remove(index);
+    }
+
+    /**
+     Remove the specified node from this list, but not from the DOM.
+
+     @param o node to be removed from this list, if present
+     @return if this list contained the Node
+     @see #remove(Object)
+     */
+    public boolean deselect(Object o) {
+        return super.remove(o);
+    }
+
+    /**
+     Removes all the nodes from this list, and each of them from the DOM.
+
+     @see #deselectAll()
+     */
+    @Override
+    public void clear() {
+        remove();
+        super.clear();
+    }
+
+    /**
+     Like {@link #clear()}, removes all the nodes from this list, but not from the DOM.
+
+     @see #clear()
+     */
+    public void deselectAll() {
+        super.clear();
+    }
+
+    /**
+     Removes from this list, and from the DOM, each of the nodes that are contained in the specified collection and are
+     in this list.
+
+     @param c collection containing nodes to be removed from this list
+     @return {@code true} if nodes were removed from this list
+     */
+    @Override
+    public boolean removeAll(Collection<?> c) {
+        boolean anyRemoved = false;
+        for (Object o : c) {
+            anyRemoved |= this.remove(o);
+        }
+        return anyRemoved;
+    }
+
+    /**
+     Retain in this list, and in the DOM, only the nodes that are in the specified collection and are in this list. In
+     other words, remove nodes from this list and the DOM any item that is in this list but not in the specified
+     collection.
+
+     @param toRemove collection containing nodes to be retained in this list
+     @return {@code true} if nodes were removed from this list
+     @since 1.17.1
+     */
+    @Override
+    public boolean retainAll(Collection<?> toRemove) {
+        boolean anyRemoved = false;
+        for (Iterator<T> it = this.iterator(); it.hasNext(); ) {
+            T el = it.next();
+            if (!toRemove.contains(el)) {
+                it.remove();
+                anyRemoved = true;
+            }
+        }
+        return anyRemoved;
+    }
+
+    /**
+     Remove from the list, and from the DOM, all nodes in this list that mach the given predicate.
+
+     @param filter a predicate which returns {@code true} for nodes to be removed
+     @return {@code true} if nodes were removed from this list
+     */
+    @Override
+    public boolean removeIf(Predicate<? super T> filter) {
+        boolean anyRemoved = false;
+        for (Iterator<T> it = this.iterator(); it.hasNext(); ) {
+            T node = it.next();
+            if (filter.test(node)) {
+                it.remove();
+                anyRemoved = true;
+            }
+        }
+        return anyRemoved;
+    }
+
+    /**
+     Replace each node in this list with the result of the operator, and update the DOM.
+
+     @param operator the operator to apply to each node
+     */
+    @Override
+    public void replaceAll(UnaryOperator<T> operator) {
+        for (int i = 0; i < this.size(); i++) {
+            this.set(i, operator.apply(this.get(i)));
+        }
+    }
+}
diff --git a/src/main/java/org/jsoup/select/QueryParser.java b/src/main/java/org/jsoup/select/QueryParser.java
index bb1b5e95..981d90b8 100644
--- a/src/main/java/org/jsoup/select/QueryParser.java
+++ b/src/main/java/org/jsoup/select/QueryParser.java
@@ -2,6 +2,11 @@ package org.jsoup.select;
 
 import org.jsoup.internal.StringUtil;
 import org.jsoup.helper.Validate;
+import org.jsoup.nodes.Comment;
+import org.jsoup.nodes.DataNode;
+import org.jsoup.nodes.LeafNode;
+import org.jsoup.nodes.Node;
+import org.jsoup.nodes.TextNode;
 import org.jsoup.parser.TokenQueue;
 import org.jspecify.annotations.Nullable;
 
@@ -22,6 +27,7 @@ public class QueryParser implements AutoCloseable {
 
     private final TokenQueue tq;
     private final String query;
+    private boolean inNodeContext; // ::comment:contains should act on node value, vs element text
 
     /**
      * Create a new QueryParser.
@@ -129,8 +135,9 @@ public class QueryParser implements AutoCloseable {
         // zero or more subclasses (#, ., [)
         while(true) {
             Evaluator right = parseSubclass();
-            if (right != null)
+            if (right != null) {
                 left = and(left, right);
+            }
             else break; // no more simple tokens
         }
 
@@ -162,6 +169,7 @@ public class QueryParser implements AutoCloseable {
         if      (tq.matchChomp('#'))    return byId();
         else if (tq.matchChomp('.'))    return byClass();
         else if (tq.matches('['))       return byAttribute();
+        else if (tq.matchChomp("::"))   return parseNodeSelector(); // ::comment etc
         else if (tq.matchChomp(':'))    return parsePseudoSelector();
         else                            return null;
     }
@@ -240,6 +248,8 @@ public class QueryParser implements AutoCloseable {
                 return new Evaluator.IsOnlyOfType();
             case "empty":
                 return new Evaluator.IsEmpty();
+            case "blank":
+                return new NodeEvaluator.BlankValue();
             case "root":
                 return new Evaluator.IsRoot();
             case "matchText":
@@ -249,6 +259,43 @@ public class QueryParser implements AutoCloseable {
         }
     }
 
+    // ::comment etc
+    private Evaluator parseNodeSelector() {
+        final String pseudo = tq.consumeCssIdentifier();
+        inNodeContext = true;  // Enter node context
+
+        Evaluator left;
+        switch (pseudo) {
+            case "node":
+                left = new NodeEvaluator.InstanceType(Node.class, pseudo);
+                break;
+            case "leafnode":
+                left = new NodeEvaluator.InstanceType(LeafNode.class, pseudo);
+                break;
+            case "text":
+                left = new NodeEvaluator.InstanceType(TextNode.class, pseudo);
+                break;
+            case "comment":
+                left = new NodeEvaluator.InstanceType(Comment.class, pseudo);
+                break;
+            case "data":
+                left = new NodeEvaluator.InstanceType(DataNode.class, pseudo);
+                break;
+            default:
+                throw new Selector.SelectorParseException("Could not parse query '%s': unexpected token at '%s'", query,
+                    tq.remainder());
+        }
+
+        // Handle following subclasses in node context (like ::comment:contains())
+        Evaluator right;
+        while ((right = parseSubclass()) != null) {
+            left = and(left, right);
+        }
+
+        inNodeContext = false;
+        return left;
+    }
+
     private Evaluator byId() {
         String id = tq.consumeCssIdentifier();
         Validate.notEmpty(id);
@@ -391,6 +438,10 @@ public class QueryParser implements AutoCloseable {
         String query = own ? ":containsOwn" : ":contains";
         String searchText = TokenQueue.unescape(consumeParens());
         Validate.notEmpty(searchText, query + "(text) query must not be empty");
+
+        if (inNodeContext)
+            return new NodeEvaluator.ContainsValue(searchText);
+
         return own
             ? new Evaluator.ContainsOwnText(searchText)
             : new Evaluator.ContainsText(searchText);
@@ -417,10 +468,14 @@ public class QueryParser implements AutoCloseable {
         String query = own ? ":matchesOwn" : ":matches";
         String regex = consumeParens(); // don't unescape, as regex bits will be escaped
         Validate.notEmpty(regex, query + "(regex) query must not be empty");
+        Pattern pattern = Pattern.compile(regex);
+
+        if (inNodeContext)
+            return new NodeEvaluator.MatchesValue(pattern);
 
         return own
-            ? new Evaluator.MatchesOwn(Pattern.compile(regex))
-            : new Evaluator.Matches(Pattern.compile(regex));
+            ? new Evaluator.MatchesOwn(pattern)
+            : new Evaluator.Matches(pattern);
     }
 
     // :matches(regex), matchesOwn(regex)
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 4aa8d654..19b97659 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -2,6 +2,7 @@ package org.jsoup.select;
 
 import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
 import org.jsoup.parser.TokenQueue;
 import org.jspecify.annotations.Nullable;
 
@@ -21,8 +22,12 @@ import java.util.stream.Stream;
  The universal selector {@code *} is implicit when no element selector is supplied (i.e. {@code .header} and
  {@code *.header} are equivalent).
  </p>
+
+ <p>You can easily test different selectors using the <a href="https://try.jsoup.org/?utm_source=jsoup&amp;utm_medium=javadoc">Try jsoup online playground</a>.
+
  <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>
  <tr><th align="left">Pattern</th><th align="left">Matches</th><th align="left">Example</th></tr>
  <tr><td><code>*</code></td><td>any element</td><td><code>*</code></td></tr>
@@ -42,12 +47,14 @@ import java.util.stream.Stream;
  <tr><td><code>[attr~=<em>regex</em>]</code></td><td>elements with an attribute named "attr", and value matching the regular expression</td><td><code>img[src~=(?i)\\.(png|jpe?g)]</code></td></tr>
  <tr><td><code>[*]</code></td><td>elements with any attribute</td><td><code>p[*]</code> finds <code>p</code> elements that have at least one attribute; <code>p:not([*])</code> finds those with no attributes</td></tr>
  <tr><td></td><td>The above may be combined in any order</td><td><code>div.header[title]</code></td></tr>
+
  <tr><td colspan="3"><h3>Combinators</h3></td></tr>
  <tr><td><code>E F</code></td><td>an F element descended from an E element</td><td><code>div a</code>, <code>.logo h1</code></td></tr>
  <tr><td><code>E {@literal >} F</code></td><td>an F direct child of E</td><td><code>ol {@literal >} li</code></td></tr>
  <tr><td><code>E + F</code></td><td>an F element immediately preceded by sibling E</td><td><code>li + li</code>, <code>div.head + div</code></td></tr>
  <tr><td><code>E ~ F</code></td><td>an F element preceded by sibling E</td><td><code>h1 ~ p</code></td></tr>
  <tr><td><code>E, F, G</code></td><td>all matching elements E, F, or G</td><td><code>a[href], div, h3</code></td></tr>
+
  <tr><td colspan="3"><h3>Pseudo selectors</h3></td></tr>
  <tr><td><code>:lt(<em>n</em>)</code></td><td>elements whose sibling index is less than <em>n</em></td><td><code>td:lt(3)</code> finds the first 3 cells of each row</td></tr>
  <tr><td><code>:gt(<em>n</em>)</code></td><td>elements whose sibling index is greater than <em>n</em></td><td><code>td:gt(1)</code> finds cells after skipping the first two</td></tr>
@@ -65,6 +72,7 @@ import java.util.stream.Stream;
  <tr><td><code>:matchesWholeOwnText(<em>regex</em>)</code></td><td>elements whose own <b>non-normalized</b> whole text matches the specified regular expression. The text must appear in the found element, not any of its descendants.</td><td><code>td:matchesWholeOwnText(\n\\d+)</code> finds table cells directly containing digits following a neewline.</td></tr>
  <tr><td></td><td>The above may be combined in any order and with other selectors</td><td><code>.light:contains(name):eq(0)</code></td></tr>
  <tr><td><code>:matchText</code></td><td>treats text nodes as elements, and so allows you to match against and select text nodes.<p><b>Note</b> that using this selector will modify the DOM, so you may want to {@code clone} your document before using.</td><td>{@code p:matchText:firstChild} with input {@code <p>One<br />Two</p>} will return one {@link org.jsoup.nodes.PseudoTextElement} with text "{@code One}".</td></tr>
+
  <tr><td colspan="3"><h3>Structural pseudo selectors</h3></td></tr>
  <tr><td><code>:root</code></td><td>The element that is the root of the document. In HTML, this is the <code>html</code> element</td><td><code>:root</code></td></tr>
  <tr><td><code>:nth-child(<em>a</em>n+<em>b</em>)</code></td><td><p>elements that have <code><em>a</em>n+<em>b</em>-1</code> siblings <b>before</b> it in the document tree, for any positive integer or zero value of <code>n</code>, and has a parent element. For values of <code>a</code> and <code>b</code> greater than zero, this effectively divides the element's children into groups of a elements (the last group taking the remainder), and selecting the <em>b</em>th element of each group. For example, this allows the selectors to address every other row in a table, and could be used to alternate the color of paragraph text in a cycle of four. The <code>a</code> and <code>b</code> values must be integers (positive, negative, or zero). The index of the first child of an element is 1.</p>
@@ -79,13 +87,27 @@ import java.util.stream.Stream;
  <tr><td><code>:only-child</code></td><td>elements that have a parent element and whose parent element have no other element children</td><td></td></tr>
  <tr><td><code>:only-of-type</code></td><td> an element that has a parent element and whose parent element has no other element children with the same expanded element name</td><td></td></tr>
  <tr><td><code>:empty</code></td><td>elements that contain no child elements or nodes, with the exception of blank text nodes, comments, XML declarations, and doctype declarations. In other words, it matches elements that are effectively empty of meaningful content.</td><td><code>li:not(:empty)</code></td></tr>
+
+ <tr><td colspan="3"><h3>Node pseudo selectors</h3></td></tr>
+ <tr><td colspan="3">These selectors enable matching specific leaf nodes, including Comments, TextNodes. When used with {@link Element#select(String)}, these can be used with structural selectors such as <code>:has()</code> to refine which Elements are matched. To retrieve matching Nodes directly, use {@Element#selectNodes(String)}.</td></tr>
+ <tr><td>::node</td><td>Matches any node</td><td></td></tr>
+ <tr><td>::leafnode</td><td>Matches any leaf-node (this is, a Node which is not an Element)</td><td></td></tr>
+ <tr><td>::comment</td><td>Matches a Comment node</td><td></td></tr>
+ <tr><td>::text</td><td>Matches a TextNode</td><td></td></tr>
+ <tr><td>::data</td><td>Matches a DataNode</td><td></td></tr>
+ <tr><td>::node:contains(text)</td><td>Matches a node that has a (normalized, case-insensitive) value containing <i>text</i>.</td><td>::comment:contains(foo bar)</td></tr>
+ <tr><td>::node:matches(regex)</td><td>Matches a node that has a value matching the regex.</td><td>::comment:matches(\\d+)</td></tr>
+ <tr><td>::node:blank</td><td>Matches a node that has either no value, or a value of only whitespace.</td><td>::comment:not(:blank)</td></tr>
  </table>
 
  <p>A word on using regular expressions in these selectors: depending on the content of the regex, you will need to quote the pattern using <b><code>Pattern.quote("regex")</code></b> for it to parse correctly through both the selector parser and the regex parser. E.g. <code>String query = "div:matches(" + Pattern.quote(regex) + ");"</code>.</p>
  <p><b>Escaping special characters:</b> to match a tag, ID, or other selector that does not follow the regular CSS syntax, the query must be escaped with the <code>\</code> character. For example, to match by ID {@code <p id="i.d">}, use {@code document.select("#i\\.d")}.</p>
 
  @see Element#select(String css)
+ @see Element#selectFirst(String css)
  @see Element#select(Evaluator eval)
+ @see Element#selectNodes(String css)
+ @see Element#selectNodes(String css, Class nodeType)
  @see Elements#select(String css)
  @see Element#selectXpath(String xpath) */
 public class Selector {
diff --git a/src/main/java/org/jsoup/select/StructuralEvaluator.java b/src/main/java/org/jsoup/select/StructuralEvaluator.java
index 48cff78d..ed01acf8 100644
--- a/src/main/java/org/jsoup/select/StructuralEvaluator.java
+++ b/src/main/java/org/jsoup/select/StructuralEvaluator.java
@@ -4,7 +4,10 @@ import org.jsoup.internal.Functions;
 import org.jsoup.internal.SoftPool;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.LeafNode;
+import org.jsoup.nodes.Node;
 import org.jsoup.nodes.NodeIterator;
+import org.jsoup.nodes.TextNode;
 
 import java.util.ArrayList;
 import java.util.IdentityHashMap;
@@ -15,20 +18,27 @@ import java.util.Map;
  */
 abstract class StructuralEvaluator extends Evaluator {
     final Evaluator evaluator;
+    boolean wantsNodes; // if the evaluator requested nodes, not just elements
 
     public StructuralEvaluator(Evaluator evaluator) {
         this.evaluator = evaluator;
+        wantsNodes = evaluator.wantsNodes();
+    }
+
+    @Override
+    boolean wantsNodes() {
+        return wantsNodes;
     }
 
     // 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>>>
+    final ThreadLocal<IdentityHashMap<Node, IdentityHashMap<Node, Boolean>>>
         threadMemo = ThreadLocal.withInitial(IdentityHashMap::new);
 
-    boolean memoMatches(final Element root, final Element element) {
-        Map<Element, IdentityHashMap<Element, Boolean>> rootMemo = threadMemo.get();
-        Map<Element, Boolean> memo = rootMemo.computeIfAbsent(root, Functions.identityMapFunction());
-        return memo.computeIfAbsent(element, key -> evaluator.matches(root, key));
+    boolean memoMatches(final Element root, final Node node) {
+        Map<Node, IdentityHashMap<Node, Boolean>> rootMemo = threadMemo.get();
+        Map<Node, Boolean> memo = rootMemo.computeIfAbsent(root, Functions.identityMapFunction());
+        return memo.computeIfAbsent(node, key -> evaluator.matches(root, key));
     }
 
     @Override protected void reset() {
@@ -37,6 +47,18 @@ abstract class StructuralEvaluator extends Evaluator {
         super.reset();
     }
 
+    @Override
+    public boolean matches(Element root, Element element) {
+        return evaluateMatch(root, element);
+    }
+
+    @Override
+    boolean matches(Element root, LeafNode leafNode) {
+        return evaluateMatch(root, leafNode);
+    }
+
+    abstract boolean evaluateMatch(Element root, Node node);
+
     static class Root extends Evaluator {
         @Override
         public boolean matches(Element root, Element element) {
@@ -48,13 +70,13 @@ abstract class StructuralEvaluator extends Evaluator {
         }
 
         @Override public String toString() {
-            return "";
+            return ">";
         }
     }
 
     static class Has extends StructuralEvaluator {
-        static final SoftPool<NodeIterator<Element>> ElementIterPool =
-            new SoftPool<>(() -> new NodeIterator<>(new Element("html"), Element.class));
+        static final SoftPool<NodeIterator<Node>> NodeIterPool =
+            new SoftPool<>(() -> new NodeIterator<>(new TextNode(""), Node.class));
         // the element here is just a placeholder so this can be final - gets set in restart()
 
         private final boolean checkSiblings; // evaluating against siblings (or children)
@@ -73,22 +95,27 @@ abstract class StructuralEvaluator extends Evaluator {
                 }
             }
             // otherwise we only want to match children (or below), and not the input element. And we want to minimize GCs so reusing the Iterator obj
-            NodeIterator<Element> it = ElementIterPool.borrow();
+            NodeIterator<Node> it = NodeIterPool.borrow();
             it.restart(element);
             try {
                 while (it.hasNext()) {
-                    Element el = it.next();
-                    if (el == element) continue; // don't match self, only descendants
-                    if (evaluator.matches(element, el)) {
+                    Node node = it.next();
+                    if (node == element) continue; // don't match self, only descendants
+                    if (evaluator.matches(element, node)) {
                         return true;
                     }
                 }
             } finally {
-                ElementIterPool.release(it);
+                NodeIterPool.release(it);
             }
             return false;
         }
 
+        @Override
+        boolean evaluateMatch(Element root, Node node) {
+            return false; // unused; :has(::comment)) goes via implicit root combinator
+        }
+
         /* Test if the :has sub-clause wants sibling elements (vs nested elements) - will be a Combining eval */
         private static boolean evalWantsSiblings(Evaluator eval) {
             if (eval instanceof CombiningEvaluator) {
@@ -118,8 +145,8 @@ abstract class StructuralEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element element) {
-            return evaluator.matches(root, element);
+        boolean evaluateMatch(Element root, Node node) {
+            return evaluator.matches(root, node);
         }
 
         @Override protected int cost() {
@@ -138,8 +165,8 @@ abstract class StructuralEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element element) {
-            return !memoMatches(root, element);
+        boolean evaluateMatch(Element root, Node node) {
+            return !memoMatches(root, node);
         }
 
         @Override protected int cost() {
@@ -161,11 +188,11 @@ abstract class StructuralEvaluator extends Evaluator {
         }
 
         @Override
-        public boolean matches(Element root, Element element) {
-            if (root == element)
+        boolean evaluateMatch(Element root, Node node) {
+            if (root == node)
                 return false;
 
-            for (Element parent = element.parent(); parent != null; parent = parent.parent()) {
+            for (Node parent = node.parent(); parent != null; parent = parent.parent()) {
                 if (memoMatches(root, parent))
                     return true;
                 if (parent == root)
@@ -174,7 +201,8 @@ abstract class StructuralEvaluator extends Evaluator {
             return false;
         }
 
-        @Override protected int cost() {
+        @Override
+        protected int cost() {
             return 8 * evaluator.cost(); // probably lower than has(), but still significant, depending on doc and el depth.
         }
 
@@ -188,11 +216,12 @@ abstract class StructuralEvaluator extends Evaluator {
      Holds a list of evaluators for one > two > three immediate parent matches, and the final direct evaluator under
      test. To match, these are effectively ANDed together, starting from the last, matching up to the first.
      */
-    static class ImmediateParentRun extends Evaluator {
+    static class ImmediateParentRun extends StructuralEvaluator {
         final ArrayList<Evaluator> evaluators = new ArrayList<>();
         int cost = 2;
 
         public ImmediateParentRun(Evaluator evaluator) {
+            super(evaluator);
             evaluators.add(evaluator);
             cost += evaluator.cost();
         }
@@ -200,20 +229,20 @@ abstract class StructuralEvaluator extends Evaluator {
         void add(Evaluator evaluator) {
             evaluators.add(evaluator);
             cost += evaluator.cost();
+            wantsNodes |= evaluator.wantsNodes();
         }
 
-        @Override
-        public boolean matches(Element root, Element element) {
-            if (element == root)
+        @Override boolean evaluateMatch(Element root, Node node) {
+            if (node == root)
                 return false; // cannot match as the second eval (first parent test) would be above the root
 
             for (int i = evaluators.size() -1; i >= 0; --i) {
-                if (element == null)
+                if (node == null)
                     return false;
                 Evaluator eval = evaluators.get(i);
-                if (!eval.matches(root, element))
+                if (!eval.matches(root, node))
                     return false;
-                element = element.parent();
+                node = node.parent();
             }
             return true;
         }
@@ -241,12 +270,12 @@ abstract class StructuralEvaluator extends Evaluator {
             super(evaluator);
         }
 
-        @Override
-        public boolean matches(Element root, Element element) {
-            if (root == element) return false;
+        // matches any previous sibling, so can be same in Element only or wantsNodes context
+        @Override boolean evaluateMatch(Element root, Node node) {
+            if (root == node) return false;
 
-            for (Element sib = element.firstElementSibling(); sib != null; sib = sib.nextElementSibling()) {
-                if (sib == element) break;
+            for (Node sib = node.firstSibling(); sib != null; sib = sib.nextSibling()) {
+                if (sib == node) break;
                 if (memoMatches(root, sib)) return true;
             }
 
@@ -268,12 +297,10 @@ abstract class StructuralEvaluator extends Evaluator {
             super(evaluator);
         }
 
-        @Override
-        public boolean matches(Element root, Element element) {
-            if (root == element)
-                return false;
+        @Override boolean evaluateMatch(Element root, Node node) {
+            if (root == node) return false;
 
-            Element prev = element.previousElementSibling();
+            Node prev = wantsNodes ? node.previousSibling() : node.previousElementSibling();
             return prev != null && memoMatches(root, prev);
         }
 


Test patch:
diff --git a/src/test/java/org/jsoup/helper/ValidateTest.java b/src/test/java/org/jsoup/helper/ValidateTest.java
index ad49ff72..c7ffdde3 100644
--- a/src/test/java/org/jsoup/helper/ValidateTest.java
+++ b/src/test/java/org/jsoup/helper/ValidateTest.java
@@ -109,6 +109,23 @@ public class ValidateTest {
         assertTrue(threw);
     }
 
+    @Test void expectNotNull() {
+        String foo = "Foo";
+        String foo2 = Validate.expectNotNull(foo);
+        assertSame(foo, foo2);
+
+        // Test with a null object
+        String bar = null;
+        boolean threw = false;
+        try {
+            Validate.expectNotNull(bar);
+        } catch (ValidationException e) {
+            threw = true;
+            assertEquals("Object must not be null", e.getMessage());
+        }
+        assertTrue(threw);
+    }
+
     @Test
     public void testNotNullParam() {
         // Test with a non-null object
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index c71eadc5..bf70d0ae 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -3224,4 +3224,27 @@ public class ElementTest {
         assertEquals("<script>a < b</script>", script.outerHtml()); // not encoded
         assertEquals("a < b", script.data());
     }
+
+    @Test void expectFirstNode() {
+        Document doc = Jsoup.parse("<span id=1>One</span> <span id=2>Two</span>");
+        TextNode text = doc.expectFirstNode("::text", TextNode.class);
+        assertEquals("1", text.parent().id());
+
+        TextNode text2 = doc.selectFirstNode("::text", TextNode.class);
+        assertSame(text, text2);
+
+        assertNull(doc.selectFirstNode("::comment", Comment.class));
+    }
+
+    @Test void expectFirstThrows() {
+        Document doc = Jsoup.parse("<span id=1>One</span> <span id=2>Two</span>");
+        boolean threw = false;
+        try {
+            doc.expectFirstNode("::comment", Comment.class);
+        } catch (IllegalArgumentException e) {
+            threw = true;
+            assertEquals("No nodes matched the query '::comment' in the document.", e.getMessage());
+        }
+        assertTrue(threw);
+    }
 }
diff --git a/src/test/java/org/jsoup/nodes/NodeTest.java b/src/test/java/org/jsoup/nodes/NodeTest.java
index ae665774..16ecb0ec 100644
--- a/src/test/java/org/jsoup/nodes/NodeTest.java
+++ b/src/test/java/org/jsoup/nodes/NodeTest.java
@@ -463,6 +463,14 @@ public class NodeTest {
         assertNull(firstEl.lastElementChild());
     }
 
+    @Test void firstLastSibling() {
+        Document doc = Jsoup.parse("<div><span>One</span> Two <span>Three</span>");
+        Elements spans = doc.select("span");
+        TextNode text = (TextNode) spans.first().nextSibling();
+        assertSame(spans.get(0), text.firstSibling());
+        assertSame(spans.get(1), text.lastSibling());
+    }
+
     @Test void nodeName() {
         Element div = new Element("DIV");
         assertEquals("DIV", div.tagName());
diff --git a/src/test/java/org/jsoup/nodes/TextNodeTest.java b/src/test/java/org/jsoup/nodes/TextNodeTest.java
index f20ca8ed..f99cb285 100644
--- a/src/test/java/org/jsoup/nodes/TextNodeTest.java
+++ b/src/test/java/org/jsoup/nodes/TextNodeTest.java
@@ -4,6 +4,7 @@ import org.jsoup.Jsoup;
 import org.jsoup.TextUtil;
 import org.jsoup.helper.ValidationException;
 import org.jsoup.internal.StringUtil;
+import org.jsoup.select.Nodes;
 import org.junit.jupiter.api.Test;
 
 import java.util.List;
@@ -61,14 +62,14 @@ public class TextNodeTest {
         assertSame(tn.parent(), tail.parent());
     }
 
-    @Test public void testSplitAnEmbolden() {
+    @Test public void testSplitAndEmbolden() {
         Document doc = Jsoup.parse("<div>Hello there</div>");
         Element div = doc.select("div").first();
         TextNode tn = (TextNode) div.childNode(0);
         TextNode tail = tn.splitText(6);
         tail.wrap("<b></b>");
 
-        assertEquals("Hello <b>there</b>", TextUtil.stripNewlines(div.html())); // not great that we get \n<b>there there... must correct
+        assertEquals("Hello <b>there</b>", div.html());
     }
 
     @Test void testSplitTextValidation() {
@@ -225,4 +226,14 @@ public class TextNodeTest {
         assertEquals("/foo.html", clone.attr("href"));
         assertEquals("Two", clone.text());
     }
+
+    @Test void parentElement() {
+        Document doc = Jsoup.parse("<p>Text</p>");
+        TextNode text = doc.selectNodes("::text", TextNode.class).first();
+        assertNotNull(text);
+        Element p = text.parent();
+        assertNotNull(p);
+        p = ((Node) text).parentElement();
+        assertNotNull(p);
+    }
 }
diff --git a/src/test/java/org/jsoup/select/EvaluatorDebug.java b/src/test/java/org/jsoup/select/EvaluatorDebug.java
index 7174ce58..266ed1cb 100644
--- a/src/test/java/org/jsoup/select/EvaluatorDebug.java
+++ b/src/test/java/org/jsoup/select/EvaluatorDebug.java
@@ -48,8 +48,11 @@ public class EvaluatorDebug {
     }
 
     public static String sexpr(String query) {
-        Document doc = asDocument(query);
+        return sexpr(QueryParser.parse(query));
+    }
 
+    public static String sexpr(Evaluator eval) {
+        Document doc = asDocument(eval);
         SexprVisitor sv = new SexprVisitor();
         doc.childNode(0).traverse(sv); // skip outer #document
         return sv.result();
diff --git a/src/test/java/org/jsoup/select/NodesTest.java b/src/test/java/org/jsoup/select/NodesTest.java
new file mode 100644
index 00000000..411e1b26
--- /dev/null
+++ b/src/test/java/org/jsoup/select/NodesTest.java
@@ -0,0 +1,31 @@
+package org.jsoup.select;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.TextNode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class NodesTest {
+    @Test void before() {
+        Document doc = Jsoup.parse("<span>One</span> <span>Two</span> <span>Three</span>");
+        Nodes<TextNode> nodes = doc.selectNodes("::text:contains(o)", TextNode.class);
+        nodes.before("<wbr>");
+        assertEquals("<span><wbr>One</span> <span><wbr>Two</span> <span>Three</span>", doc.body().html());
+    }
+
+    @Test void after() {
+        Document doc = Jsoup.parse("<span>One</span> <span>Two</span> <span>Three</span>");
+        Nodes<TextNode> nodes = doc.selectNodes("::text:contains(o)", TextNode.class);
+        nodes.after("<wbr>");
+        assertEquals("<span>One<wbr></span> <span>Two<wbr></span> <span>Three</span>", doc.body().html());
+    }
+
+    @Test void wrap() {
+        Document doc = Jsoup.parse("<span>One</span> <span>Two</span> <span>Three</span>");
+        Nodes<TextNode> nodes = doc.selectNodes("::text:contains(o)", TextNode.class);
+        nodes.wrap("<b></b>");
+        assertEquals("<span><b>One</b></span> <span><b>Two</b></span> <span>Three</span>", doc.body().html());
+    }
+}
diff --git a/src/test/java/org/jsoup/select/QueryParserTest.java b/src/test/java/org/jsoup/select/QueryParserTest.java
index c9addbff..a4327372 100644
--- a/src/test/java/org/jsoup/select/QueryParserTest.java
+++ b/src/test/java/org/jsoup/select/QueryParserTest.java
@@ -228,4 +228,11 @@ public class QueryParserTest {
                 "Could not parse query '+ + div': unexpected token at '+ div'",
                 exception2.getMessage());
     }
+
+    @Test void hasNodeSelector() {
+        String q = "p:has(::comment:contains(some text))";
+        Evaluator e = QueryParser.parse(q);
+        assertEquals("(And (Tag 'p')(Has (And (InstanceType '::comment')(ContainsValue ':contains(some text)'))))", sexpr(e));
+        assertEquals(q, e.toString());
+    }
 }
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index 0c4064e2..bbb22cae 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -2,8 +2,11 @@ package org.jsoup.select;
 
 import org.jsoup.Jsoup;
 import org.jsoup.MultiLocaleExtension.MultiLocaleTest;
+import org.jsoup.nodes.Comment;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+import org.jsoup.nodes.TextNode;
 import org.jsoup.parser.Parser;
 import org.junit.jupiter.api.Test;
 
@@ -12,6 +15,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.stream.Collectors;
 
+import static org.jsoup.select.EvaluatorDebug.sexpr;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -488,6 +492,25 @@ public class SelectorTest {
         assertEquals("Three", els.first().text());
     }
 
+    @Test public void elelemtDescendantSkipsNodes() {
+        String h = "<div class=foo> <!-- foo --> <ol> <li>One<li><!-- bar --> Two<li>Three</ol></div>";
+        Document doc = Jsoup.parse(h);
+        Elements els = doc.select(".foo > ol, ol > li + li");
+
+        assertEquals(3, els.size());
+        assertEquals("ol", els.get(0).tagName());
+        assertEquals("Two", els.get(1).text());
+        assertEquals("Three", els.get(2).text());
+    }
+
+    @Test public void siblingsSkipNodes() {
+        String h = "<ol><li id=1><!-- foo -->One<li id=2><!-- foo -->Two<li id=3><!-- foo -->Three</ol>";
+        Document doc = Jsoup.parse(h);
+        Elements els = doc.select("#1 ~ #3");
+        assertEquals(1, els.size());
+        assertEquals("Three", els.first().text());
+    }
+
     // for http://github.com/jhy/jsoup/issues#issue/10
     @Test public void testCharactersInIdAndClass() {
         // using CSS spec for identifiers (id and class): a-z0-9, -, _. NOT . (which is OK in html spec, but not css)
@@ -1170,7 +1193,7 @@ public class SelectorTest {
         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();
+        IdentityHashMap<Node, IdentityHashMap<Node, Boolean>> map = prevEval.threadMemo.get();
         assertEquals(0, map.size()); // no memo yet
 
         Document doc1 = Jsoup.parse("<p>One<p>Two<p>Three");
@@ -1282,7 +1305,7 @@ public class SelectorTest {
         // https://github.com/jhy/jsoup/issues/2073
         Document doc = Jsoup.parse("<div id=parent><span class=child></span><span class=child></span><span class=child></span></div>");
         String q = "#parent [class*=child], .some-other-selector .nested";
-        assertEquals("(Or (And (AttributeWithValueContaining '[class*=child]')(Ancestor (Id '#parent')))(And (Class '.nested')(Ancestor (Class '.some-other-selector'))))", EvaluatorDebug.sexpr(q));
+        assertEquals("(Or (And (AttributeWithValueContaining '[class*=child]')(Ancestor (Id '#parent')))(And (Class '.nested')(Ancestor (Class '.some-other-selector'))))", sexpr(q));
         Elements els = doc.select(q);
         assertEquals(3, els.size());
     }
@@ -1478,4 +1501,191 @@ public class SelectorTest {
         Evaluator eval = Selector.evaluatorOf("div > p");
         assertEquals("div > p", eval.toString());
     }
+
+    @Test void hasComment() {
+        Document doc = Jsoup.parse("<div id=1>One</div><div id=2>Two <!-- foo --></div>");
+        Elements els = doc.select("div:has(::comment)");
+        assertSelectedIds(els, "2");
+    }
+
+    @Test void hasCommentWithText() {
+        Document doc = Jsoup.parse("<div id=1>One <!-- qux bar --></div><div id=2>Two <!-- foo qux --></div>");
+        Elements els = doc.select("div:has(::comment:contains(foo):contains(qux))");
+        assertSelectedIds(els, "2");
+    }
+
+    @Test void descendantComment() {
+        Document doc = Jsoup.parse("<div id=1><div id=2><!-- comment2 --><div id=3><!-- comment3 --></div></div></div><div id=4><div id=5><div id=6>Not</div></div></div>");
+
+        String q = "div > div:has(::comment)";
+        assertEquals("(ImmediateParentRun (Tag 'div')(And (Tag 'div')(Has (InstanceType '::comment'))))", sexpr(q));
+        Elements els1 = doc.select(q);
+        assertSelectedIds(els1, "2", "3");
+
+        String q2 = "div div:has(>::comment:contains(comment3))";
+        assertEquals("(And (Ancestor (Tag 'div'))(And (Tag 'div')(Has (ImmediateParentRun (Root '>')(And (InstanceType '::comment')(ContainsValue ':contains(comment3)'))))))", sexpr(q2));
+        Elements els2 = doc.select(q2);
+        assertSelectedIds(els2, "3");
+
+        String q3 = "div:has(>::comment) div";
+        assertEquals("(And (Tag 'div')(Ancestor (And (Tag 'div')(Has (ImmediateParentRun (Root '>')(InstanceType '::comment'))))))", sexpr(q3));
+        Elements els3 = doc.select(q3);
+        assertSelectedIds(els3, "3");
+    }
+
+    @Test void nodeWithElementAncestor() {
+        Document doc = Jsoup.parse("<div id=1><div id=2><p> <!-- comment --></p></div></div>");
+        String q = "div:has(p ::comment)";
+        assertEquals("(And (Tag 'div')(Has (And (InstanceType '::comment')(Ancestor (Tag 'p')))))", sexpr(q));
+        Elements els = doc.select(q);
+        assertSelectedIds(els, "1", "2");
+    }
+
+    @Test void precedingComment() {
+        Document doc = Jsoup.parse("<div><!-- comment --><p id=1><p id=2></div><div><p id=3><p id=4>");
+
+        String q = "::comment ~ p";
+        assertEquals("(And (Tag 'p')(PreviousSibling (InstanceType '::comment')))", sexpr(q));
+        Elements els1 = doc.select(q);
+        assertSelectedIds(els1, "1", "2");
+
+        String q2 = "::comment + p";
+        assertEquals("(And (Tag 'p')(ImmediatePreviousSibling (InstanceType '::comment')))", sexpr(q2));
+        Elements els2 = doc.select(q2);
+        assertSelectedIds(els2, "1");
+    }
+
+    @Test void datanode() {
+        Document doc = Jsoup.parse("<div id=1> <!-- foo --> </div> <div id=2> <script>foo</script> </div> <div><script>bar></script>");
+        String q = "div:has(::data:contains(foo))";
+        assertEquals("(And (Tag 'div')(Has (And (InstanceType '::data')(ContainsValue ':contains(foo)'))))", sexpr(q));
+        Elements els = doc.select(q);
+        assertSelectedIds(els, "2");
+    }
+
+    @Test void leafNode() {
+        Document doc = Jsoup.parse("<div id=1></div><div id=2> </div>");
+        String q = "div:has(::leafnode)";
+        assertEquals("(And (Tag 'div')(Has (InstanceType '::leafnode')))", sexpr(q));
+        Elements els = doc.select(q);
+        assertSelectedIds(els, "2");
+    }
+
+    @Test void leafNodeContains() {
+        Document doc = Jsoup.parse("<div id=1>foo</div><div id=2><!-- bar --></div><div id=3>Bar</div><div id=4><script id=5> Bar </script></div>");
+        String q = "div:has(::leafnode:contains(Bar))";
+        assertEquals("(And (Tag 'div')(Has (And (InstanceType '::leafnode')(ContainsValue ':contains(bar)'))))", sexpr(q));
+        Elements els = doc.select(q);
+        assertSelectedIds(els, "2", "3", "4");
+    }
+
+    @Test void nodeContains() {
+        Document doc = Jsoup.parse("<div><p>One</p></div><div>Two</div>");
+        String q = "div ::node:contains(One)";
+        Nodes<Node> nodes = doc.selectNodes(Selector.evaluatorOf(q));
+        // should have the P and the Text (because nodeValue is ownText)
+        assertEquals(2, nodes.size());
+        assertEquals("One", nodes.get(0).nodeValue());
+        assertEquals("p", nodes.get(0).nodeName());
+        assertEquals("One", nodes.get(1).nodeValue());
+        assertEquals("#text", nodes.get(1).nodeName());
+    }
+
+    @Test void selectComment() {
+        Document doc = Jsoup.parse("<div><!-- find this --></div><!-- and this --><p><!-- not that --></p>");
+        String q = "::comment:contains(this)";
+        assertEquals("(And (InstanceType '::comment')(ContainsValue ':contains(this)'))", sexpr(q));
+        Nodes<Comment> comments = doc.selectNodes(q, Comment.class);
+
+        assertEquals(2, comments.size());
+        assertEquals(" find this ", comments.get(0).getData());
+        assertEquals(" find this ", comments.get(0).nodeValue());
+        assertEquals(" and this ", comments.get(1).getData());
+
+        Nodes<Node> nodes = doc.selectNodes("::comment");
+        assertEquals(3, nodes.size());
+        assertEquals(" find this ", nodes.get(0).nodeValue());
+        assertEquals(" and this ", nodes.get(1).nodeValue());
+        assertEquals(" not that ", nodes.get(2).nodeValue());
+    }
+
+    @Test void selectTextNodes() {
+        Document doc = Jsoup.parse("<p>One</p> <p>Two</p>");
+        Nodes<TextNode> text = doc.selectNodes("p ::text", TextNode.class);
+        assertEquals(2, text.size());
+        assertEquals("One", text.get(0).getWholeText());
+        assertEquals("Two", text.get(1).getWholeText());
+    }
+
+    @Test void elementsViaNodeInterface() {
+        Document doc = Jsoup.parse("<p>One</p> <p>Two</p>");
+        Nodes<Element> ps = doc.selectNodes("p", Element.class);
+        assertEquals(2, ps.size());
+        assertEquals("One", ps.get(0).text());
+        assertEquals("Two", ps.get(1).text());
+    }
+
+    @Test void blankNodes() {
+        Document doc = Jsoup.parse("<p> </p><p><!--  --><!----></p><p>\n</p><p>One</p><p><!-- two --></p>");
+
+        Nodes<Node> nodes = doc.selectNodes("::node:blank", Node.class);
+        assertEquals(12, nodes.size());
+        assertEquals("#document", nodes.get(0).nodeName());
+        assertEquals("html", nodes.get(1).nodeName());
+        assertEquals("head", nodes.get(2).nodeName());
+        assertEquals("body", nodes.get(3).nodeName());
+        assertEquals("p", nodes.get(4).nodeName());
+        assertEquals("#text", nodes.get(5).nodeName());
+        assertEquals("p", nodes.get(6).nodeName());
+        assertEquals("#comment", nodes.get(7).nodeName());
+        assertEquals("#comment", nodes.get(8).nodeName());
+        assertEquals("p", nodes.get(9).nodeName());
+        assertEquals("#text", nodes.get(10).nodeName());
+        assertEquals("p", nodes.get(11).nodeName());
+
+        Nodes<Comment> comments = doc.selectNodes("::comment:blank", Comment.class);
+        assertEquals(2, comments.size());
+        assertEquals("  ", comments.get(0).getData());
+        assertEquals("", comments.get(1).getData());
+
+        String notBlank = "::comment:not(:blank)";
+        Evaluator notBlankEval = QueryParser.parse(notBlank);
+        assertEquals("(And (InstanceType '::comment')(Not (BlankValue ':blank')))", sexpr(notBlankEval));
+        Nodes<Comment> commentsWithData = doc.selectNodes(notBlankEval, Comment.class);
+        assertEquals(1, commentsWithData.size());
+        assertEquals(" two ", commentsWithData.get(0).getData());
+    }
+
+    @Test void blankElements() {
+        Document doc = Jsoup.parse("<p id=1>  </p><p id=2>One</p><p id=3><span>One</span></p>");
+        Elements els = doc.select("p:blank");
+        assertSelectedIds(els, "1", "3");
+    }
+
+    @Test void nonBlankText() {
+        Document doc = Jsoup.parse("<p id=1>  </p><p id=2>One</p><p id=3><span id=4>Two</span></p>");
+        Elements els = doc.select(":not(:blank)");
+        assertSelectedIds(els, "2", "4");
+
+        Nodes<TextNode> text = doc.selectNodes("::text:not(:blank)", TextNode.class);
+        assertEquals(2, text.size());
+        assertEquals("One", text.get(0).getWholeText());
+        assertEquals("Two", text.get(1).getWholeText());
+    }
+
+    @Test void nodeMatches() {
+        Document doc = Jsoup.parse("<p>1234</p> <p>123</p> <p>12</p> <p>1</p> <!--4321--> <!--432--> <!-- 43 -->");
+
+        String regex = "::leafnode:matches(\\d{3,4})";
+        Evaluator eval = Selector.evaluatorOf(regex);
+        assertEquals("(And (InstanceType '::leafnode')(MatchesValue ':matches(\\d{3,4})'))", sexpr(eval));
+
+        Nodes<Node> nodes = doc.selectNodes(eval);
+        assertEquals(4, nodes.size());
+        assertEquals("1234", nodes.get(0).nodeValue());
+        assertEquals("123", nodes.get(1).nodeValue());
+        assertEquals("4321", nodes.get(2).nodeValue());
+        assertEquals("432", nodes.get(3).nodeValue());
+    }
+
 }