Repository: jhy/jsoup
Pull request #2080 (2023-12-09 10:10:42)

Simplify node filtering operations using Streams API


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 3c1b3f18..d7559c2e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,10 +3,13 @@
 ## 1.17.2 (Pending)
 
 ### Improvements
-* Added `Element.attribute(String)` and `Attributes.attribute(String)` to more simply obtain an `Attribute` object. 
+
+* Added `Element.attribute(String)` and `Attributes.attribute(String)` to more simply obtain an `Attribute` object.
   [2069](https://github.com/jhy/jsoup/issues/2069)
-* If source tracking is on, and an Attribute's key is changed (via `Attribute.setKey(String)`), the source range is 
+* If source tracking is on, and an Attribute's key is changed (via `Attribute.setKey(String)`), the source range is
   now still tracked in `Attribute.sourceRange()`. [2070](https://github.com/jhy/jsoup/issues/2070)
+* Added support for the `[*]` element with any attribute selector. And also restored support for selecting by an empty
+  attribute name prefix (`[^]`). [2079](https://github.com/jhy/jsoup/issues/2079)
 
 ### Bug Fixes
 
@@ -21,6 +24,12 @@
   `parent [attr=va]`, causing incorrect selections. The fix includes a EvaluatorDebug class that generates a sexpr
   to represent the query, allowing simpler and more thorough query parse
   tests. [2073](https://github.com/jhy/jsoup/issues/2073)
+* When generating XML-syntax output from parsed HTML, script nodes containing (pseudo) CData sections would have an
+  extraneous CData section added, causing script execution errors. Now, the data content is emitted in a HTML/XML/XHTML
+  polyglot format, if the data is not already within a CData section. [2078](https://github.com/jhy/jsoup/issues/2078)
+* The `:has` evaluator held a non-thread-safe Iterator, and so if an Evaluator object was shared across multiple
+  concurrent threads, a NoSuchElement exception may be thrown, and the selected results may be incorrect. Now, the
+  iterator object is a thread-local. [2088](https://github.com/jhy/jsoup/issues/2088)
 
 ---
 Older changes for versions 0.1.1 (2010-Jan-31) through 1.17.1 (2023-Nov-27) may be found in
diff --git a/pom.xml b/pom.xml
index 09ee8e9b..cffd1a99 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,7 +42,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-compiler-plugin</artifactId>
-        <version>3.11.0</version>
+        <version>3.12.0</version>
         <configuration>
           <encoding>UTF-8</encoding>
           <compilerArgs>
@@ -88,19 +88,15 @@
                 <version>2.3.3_r2</version>
               </signature>
               <ignores>
-                <ignore>java.util.function.Consumer</ignore>
-                <ignore>java.util.function.Supplier</ignore>
+                <ignore>java.util.function.*</ignore>
+                <ignore>java.util.stream.*</ignore>
                 <ignore>java.lang.ThreadLocal</ignore>
                 <ignore>java.io.UncheckedIOException</ignore>
-                <ignore>java.util.function.Predicate</ignore>
-                <ignore>java.util.function.UnaryOperator</ignore>
-                <ignore>java.util.stream.Stream</ignore>
-                <ignore>java.util.stream.StreamSupport</ignore>
+                <ignore>java.util.List</ignore> <!-- List#stream() -->
+                <ignore>java.util.Objects</ignore>
+                <ignore>java.util.Optional</ignore>
                 <ignore>java.util.Spliterator</ignore>
                 <ignore>java.util.Spliterators</ignore>
-                <ignore>java.util.Optional</ignore>
-                <ignore>java.util.stream.Collector</ignore>
-                <ignore>java.util.stream.Collectors</ignore>
 
                 <ignore>java.net.HttpURLConnection</ignore><!-- .setAuthenticator(java.net.Authenticator) in Java 9; only used in multirelease 9+ version -->
               </ignores>
@@ -113,7 +109,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-javadoc-plugin</artifactId>
-        <version>3.6.2</version>
+        <version>3.6.3</version>
         <configuration>
           <doclint>none</doclint>
           <source>8</source>
@@ -198,7 +194,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-surefire-plugin</artifactId>
-        <version>3.2.2</version>
+        <version>3.2.3</version>
         <configuration>
           <!-- smaller stack to find stack overflows -->
           <argLine>-Xss256k</argLine>
@@ -206,7 +202,7 @@
       </plugin>
       <plugin>
         <artifactId>maven-failsafe-plugin</artifactId>
-        <version>3.2.2</version>
+        <version>3.2.3</version>
         <executions>
           <execution>
             <goals>
@@ -378,7 +374,7 @@
         <plugins>
           <plugin>
             <artifactId>maven-failsafe-plugin</artifactId>
-            <version>3.2.2</version>
+            <version>3.2.3</version>
             <executions>
               <execution>
                 <goals>
diff --git a/src/main/java/org/jsoup/examples/ListLinks.java b/src/main/java/org/jsoup/examples/ListLinks.java
index 935bd14f..cd6ed61f 100644
--- a/src/main/java/org/jsoup/examples/ListLinks.java
+++ b/src/main/java/org/jsoup/examples/ListLinks.java
@@ -24,7 +24,7 @@ public class ListLinks {
 
         print("\nMedia: (%d)", media.size());
         for (Element src : media) {
-            if (src.normalName().equals("img"))
+            if (src.nameIs("img"))
                 print(" * %s: <%s> %sx%s (%s)",
                         src.tagName(), src.attr("abs:src"), src.attr("width"), src.attr("height"),
                         trim(src.attr("alt"), 20));
diff --git a/src/main/java/org/jsoup/nodes/DataNode.java b/src/main/java/org/jsoup/nodes/DataNode.java
index 4a0cf434..9b5a77d8 100644
--- a/src/main/java/org/jsoup/nodes/DataNode.java
+++ b/src/main/java/org/jsoup/nodes/DataNode.java
@@ -1,7 +1,6 @@
 package org.jsoup.nodes;
 
 import java.io.IOException;
-import org.jsoup.nodes.Entities.EscapeMode;
 
 /**
  A data node, for contents of style, script tags etc, where contents should not show in text().
@@ -41,14 +40,18 @@ public class DataNode extends LeafNode {
 
     @Override
     void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        if (out.syntax() == Document.OutputSettings.Syntax.xml) {
-            // In XML mode, output data nodes as CDATA, so can parse as XML
-            accum
-                .append("<![CDATA[")
-                .append(getWholeData())
-                .append("]]>");
+        /* For XML output, escape the DataNode in a CData section. The data may contain pseudo-CData content if it was
+        parsed as HTML, so don't double up Cdata. Output in polygot HTML / XHTML / XML format. */
+        final String data = getWholeData();
+        if (out.syntax() == Document.OutputSettings.Syntax.xml && !data.contains("<![CDATA[")) {
+            if (parentNameIs("script"))
+                accum.append("//<![CDATA[\n").append(data).append("\n//]]>");
+            else if (parentNameIs("style"))
+                accum.append("/*<![CDATA[*/\n").append(data).append("\n/*]]>*/");
+            else
+                accum.append("<![CDATA[").append(data).append("]]>");
         } else {
-            // In HTML, data is not escaped in return from data nodes, so " in script, style is plain
+            // In HTML, data is not escaped in the output of data nodes, so < and & in script, style is OK
             accum.append(getWholeData());
         }
     }
diff --git a/src/main/java/org/jsoup/nodes/Document.java b/src/main/java/org/jsoup/nodes/Document.java
index fc095170..a9a12f06 100644
--- a/src/main/java/org/jsoup/nodes/Document.java
+++ b/src/main/java/org/jsoup/nodes/Document.java
@@ -17,6 +17,8 @@ import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.util.List;
 
+import static org.jsoup.parser.Parser.NamespaceHtml;
+
 /**
  A HTML Document.
 
@@ -49,7 +51,7 @@ public class Document extends Element {
      @see #Document(String namespace, String baseUri)
      */
     public Document(String baseUri) {
-        this(Parser.NamespaceHtml, baseUri);
+        this(NamespaceHtml, baseUri);
     }
 
     /**
@@ -114,7 +116,7 @@ public class Document extends Element {
     private Element htmlEl() {
         Element el = firstElementChild();
         while (el != null) {
-            if (el.normalName().equals("html"))
+            if (el.nameIs("html"))
                 return el;
             el = el.nextElementSibling();
         }
@@ -133,7 +135,7 @@ public class Document extends Element {
         final Element html = htmlEl();
         Element el = html.firstElementChild();
         while (el != null) {
-            if (el.normalName().equals("head"))
+            if (el.nameIs("head"))
                 return el;
             el = el.nextElementSibling();
         }
@@ -153,7 +155,7 @@ public class Document extends Element {
         final Element html = htmlEl();
         Element el = html.firstElementChild();
         while (el != null) {
-            if ("body".equals(el.normalName()) || "frameset".equals(el.normalName()))
+            if (el.nameIs("body") || el.nameIs("frameset"))
                 return el;
             el = el.nextElementSibling();
         }
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 62305731..0a63d73b 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -30,10 +30,12 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Consumer;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.jsoup.internal.Normalizer.normalize;
 import static org.jsoup.nodes.TextNode.lastCharIsWhitespace;
+import static org.jsoup.parser.Parser.NamespaceHtml;
 import static org.jsoup.parser.TokenQueue.escapeCssIdentifier;
 
 /**
@@ -169,10 +171,22 @@ public class Element extends Node {
      * normal name of {@code div}.
      * @return normal name
      */
+    @Override
     public String normalName() {
         return tag.normalName();
     }
 
+    /**
+     Test if this Element has the specified normalized name, and is in the specified namespace.
+     * @param normalName a normalized element name (e.g. {@code div}).
+     * @param namespace the namespace
+     * @return true if the element's normal name matches exactly, and is in the specified namespace
+     * @since 1.17.2
+     */
+    public boolean elementIs(String normalName, String namespace) {
+        return tag.normalName().equals(normalName) && tag.namespace().equals(namespace);
+    }
+
     /**
      * Change (rename) the tag of this element. For example, convert a {@code <span>} to a {@code <div>} with
      * {@code el.tagName("div");}.
@@ -306,7 +320,7 @@ public class Element extends Node {
     public Elements parents() {
         Elements parents = new Elements();
         Element parent = this.parent();
-        while (parent != null && !parent.isNode("#root")) {
+        while (parent != null && !parent.nameIs("#root")) {
             parents.add(parent);
             parent = parent.parent();
         }
@@ -398,6 +412,13 @@ public class Element extends Node {
         return NodeUtils.stream(this, Element.class);
     }
 
+    private <T> List<T> filterNodes(Class<T> clazz) {
+        return childNodes.stream()
+                .filter(clazz::isInstance)
+                .map(clazz::cast)
+                .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
+    }
+
     /**
      * Get this element's child text nodes. The list is unmodifiable but the text nodes may be manipulated.
      * <p>
@@ -415,12 +436,7 @@ public class Element extends Node {
      * </ul>
      */
     public List<TextNode> textNodes() {
-        List<TextNode> textNodes = new ArrayList<>();
-        for (Node node : childNodes) {
-            if (node instanceof TextNode)
-                textNodes.add((TextNode) node);
-        }
-        return Collections.unmodifiableList(textNodes);
+        return filterNodes(TextNode.class);
     }
 
     /**
@@ -433,12 +449,7 @@ public class Element extends Node {
      * @see #data()
      */
     public List<DataNode> dataNodes() {
-        List<DataNode> dataNodes = new ArrayList<>();
-        for (Node node : childNodes) {
-            if (node instanceof DataNode)
-                dataNodes.add((DataNode) node);
-        }
-        return Collections.unmodifiableList(dataNodes);
+        return filterNodes(DataNode.class);
     }
 
     /**
@@ -1395,7 +1406,7 @@ public class Element extends Node {
             } else if (node instanceof Element) {
                 Element element = (Element) node;
                 if (accum.length() > 0 &&
-                    (element.isBlock() || element.isNode("br")) &&
+                    (element.isBlock() || element.nameIs("br")) &&
                     !lastCharIsWhitespace(accum))
                     accum.append(' ');
             }
@@ -1429,7 +1440,7 @@ public class Element extends Node {
     private static void appendWholeText(Node node, StringBuilder accum) {
         if (node instanceof TextNode) {
             accum.append(((TextNode) node).getWholeText());
-        } else if (node.isNode("br")) {
+        } else if (node.nameIs("br")) {
             accum.append("\n");
         }
     }
@@ -1477,7 +1488,7 @@ public class Element extends Node {
             if (child instanceof TextNode) {
                 TextNode textNode = (TextNode) child;
                 appendNormalisedText(accum, textNode);
-            } else if (child.isNode("br") && !lastCharIsWhitespace(accum)) {
+            } else if (child.nameIs("br") && !lastCharIsWhitespace(accum)) {
                 accum.append(" ");
             }
         }
@@ -1717,7 +1728,7 @@ public class Element extends Node {
      * @return the value of the form element, or empty string if not set.
      */
     public String val() {
-        if (normalName().equals("textarea"))
+        if (elementIs("textarea", NamespaceHtml))
             return text();
         else
             return attr("value");
@@ -1729,7 +1740,7 @@ public class Element extends Node {
      * @return this element (for chaining)
      */
     public Element val(String value) {
-        if (normalName().equals("textarea"))
+        if (elementIs("textarea", NamespaceHtml))
             text(value);
         else
             attr("value", value);
@@ -1922,6 +1933,6 @@ public class Element extends Node {
         return (parent() == null || parent().isBlock())
             && !isEffectivelyFirst()
             && !out.outline()
-            && !isNode("br");
+            && !nameIs("br");
     }
 }
diff --git a/src/main/java/org/jsoup/nodes/FormElement.java b/src/main/java/org/jsoup/nodes/FormElement.java
index 33b75e79..fc8595cb 100644
--- a/src/main/java/org/jsoup/nodes/FormElement.java
+++ b/src/main/java/org/jsoup/nodes/FormElement.java
@@ -93,7 +93,7 @@ public class FormElement extends Element {
 
             if (type.equalsIgnoreCase("button") || type.equalsIgnoreCase("image")) continue; // browsers don't submit these
 
-            if ("select".equals(el.normalName())) {
+            if (el.nameIs("select")) {
                 Elements options = el.select("option[selected]");
                 boolean set = false;
                 for (Element option: options) {
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index c84a6a0f..667f5d84 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -51,6 +51,38 @@ public abstract class Node implements Cloneable {
         return nodeName();
     }
 
+    /**
+     Test if this node has the specified normalized name, in any namespace.
+     * @param normalName a normalized element name (e.g. {@code div}).
+     * @return true if the element's normal name matches exactly
+     * @since 1.17.2
+     */
+    public boolean nameIs(String normalName) {
+        return normalName().equals(normalName);
+    }
+
+    /**
+     Test if this node's parent has the specified normalized name.
+     * @param normalName a normalized name (e.g. {@code div}).
+     * @return true if the parent element's normal name matches exactly
+     * @since 1.17.2
+     */
+    public boolean parentNameIs(String normalName) {
+        return parentNode != null && parentNode.normalName().equals(normalName);
+    }
+
+    /**
+     Test if this node's parent is an Element with the specified normalized name and namespace.
+     * @param normalName a normalized element name (e.g. {@code div}).
+     * @param namespace the namespace
+     * @return true if the parent element's normal name matches exactly, and that element is in the specified namespace
+     * @since 1.17.2
+     */
+    public boolean parentElementIs(String normalName, String namespace) {
+        return parentNode != null && parentNode instanceof Element
+            && ((Element) parentNode).elementIs(normalName, namespace);
+    }
+
     /**
      * Check if this Node has an actual Attributes object.
      */
@@ -772,16 +804,6 @@ public abstract class Node implements Cloneable {
         return Range.of(this, true);
     }
 
-    /** Test if this node is not null and has the supplied normal name. */
-    static boolean isNode(@Nullable Node node, String normalName) {
-        return node != null && node.normalName().equals(normalName);
-    }
-
-    /** Test if this node has the supplied normal name. */
-    final boolean isNode(String normalName) {
-        return normalName().equals(normalName);
-    }
-
     /** Test if this node is the first child, or first following blank text. */
     final boolean isEffectivelyFirst() {
         if (siblingIndex == 0) return true;
diff --git a/src/main/java/org/jsoup/nodes/TextNode.java b/src/main/java/org/jsoup/nodes/TextNode.java
index 7a7f00a5..8b2f15ba 100644
--- a/src/main/java/org/jsoup/nodes/TextNode.java
+++ b/src/main/java/org/jsoup/nodes/TextNode.java
@@ -98,14 +98,14 @@ public class TextNode extends LeafNode {
             boolean isBlank = isBlank();
             boolean couldSkip = (next instanceof Element && ((Element) next).shouldIndent(out)) // next will indent
                 || (next instanceof TextNode && (((TextNode) next).isBlank())) // next is blank text, from re-parenting
-                || (prev instanceof Element && (((Element) prev).isBlock() || prev.isNode("br"))) // br is a bit special - make sure we don't get a dangling blank line, but not a block otherwise wraps in head
+                || (prev instanceof Element && (((Element) prev).isBlock() || prev.nameIs("br"))) // br is a bit special - make sure we don't get a dangling blank line, but not a block otherwise wraps in head
                 ;
             if (couldSkip && isBlank) return;
 
             if (
-                (siblingIndex == 0 && parent != null && parent.tag().formatAsBlock() && !isBlank) ||
+                (prev == null && parent != null && parent.tag().formatAsBlock() && !isBlank) ||
                 (out.outline() && siblingNodes().size() > 0 && !isBlank) ||
-                (siblingIndex > 0 && isNode(prev, "br")) // special case wrap on inline <br> - doesn't make sense as a block tag
+                (prev != null && prev.nameIs("br")) // special case wrap on inline <br> - doesn't make sense as a block tag
             )
                 indent(accum, depth, out);
         }
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
index f76fc486..6e4d39e1 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
@@ -193,7 +193,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         }
         // If the adjusted current node is a MathML annotation-xml element and the token is a start tag whose tag name is "svg"
         if (Parser.NamespaceMathml.equals(ns)
-            && el.normalName().equals("annotation-xml")
+            && el.nameIs("annotation-xml")
             && token.isStartTag()
             && "svg".equals(token.asStartTag().normalName))
             return true;
@@ -231,7 +231,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         An SVG title element
          */
         if (Parser.NamespaceMathml.equals(el.tag().namespace())
-            && el.normalName().equals("annotation-xml")) {
+            && el.nameIs("annotation-xml")) {
             String encoding = Normalizer.normalize(el.attr("encoding"));
             if (encoding.equals("text/html") || encoding.equals("application/xhtml+xml"))
                 return true;
@@ -464,7 +464,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         final int upper = bottom >= maxQueueDepth ? bottom - maxQueueDepth : 0;
         for (int pos = bottom; pos >= upper; pos--) {
             Element next = stack.get(pos);
-            if (next.normalName().equals(elName) && NamespaceHtml.equals(next.tag().namespace())) {
+            if (next.elementIs(elName, NamespaceHtml)) {
                 return next;
             }
         }
@@ -488,7 +488,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
     Element popStackToClose(String elName) {
         for (int pos = stack.size() -1; pos >= 0; pos--) {
             Element el = pop();
-            if (el.normalName().equals(elName) && NamespaceHtml.equals(el.tag().namespace())) {
+            if (el.elementIs(elName, NamespaceHtml)) {
                 return el;
             }
         }
@@ -500,7 +500,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
     Element popStackToCloseAnyNamespace(String elName) {
         for (int pos = stack.size() -1; pos >= 0; pos--) {
             Element el = pop();
-            if (el.normalName().equals(elName)) {
+            if (el.nameIs(elName)) {
                 return el;
             }
         }
@@ -534,7 +534,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         for (int pos = stack.size() -1; pos >= 0; pos--) {
             Element next = stack.get(pos);
             if (NamespaceHtml.equals(next.tag().namespace()) &&
-                (StringUtil.in(next.normalName(), nodeNames) || next.normalName().equals("html")))
+                (StringUtil.in(next.normalName(), nodeNames) || next.nameIs("html")))
                 break;
             else
                 pop();
@@ -963,7 +963,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
             Element next = formattingElements.get(pos);
             if (next == null) // scope marker
                 break;
-            else if (next.normalName().equals(nodeName))
+            else if (next.nameIs(nodeName))
                 return next;
         }
         return null;
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
index d90c0b08..f1b2d7b2 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
@@ -351,7 +351,7 @@ enum HtmlTreeBuilderState {
                     stack = tb.getStack();
                     for (int i = stack.size() - 1; i > 0; i--) {
                         el = stack.get(i);
-                        if (el.normalName().equals("li")) {
+                        if (el.nameIs("li")) {
                             tb.processEndTag("li");
                             break;
                         }
@@ -381,7 +381,7 @@ enum HtmlTreeBuilderState {
                 case "body":
                     tb.error(this);
                     stack = tb.getStack();
-                    if (stack.size() == 1 || (stack.size() > 2 && !stack.get(1).normalName().equals("body")) || tb.onStack("template")) {
+                    if (stack.size() == 1 || (stack.size() > 2 && !stack.get(1).nameIs("body")) || tb.onStack("template")) {
                         // only in fragment case
                         return false; // ignore
                     } else {
@@ -399,7 +399,7 @@ enum HtmlTreeBuilderState {
                 case "frameset":
                     tb.error(this);
                     stack = tb.getStack();
-                    if (stack.size() == 1 || (stack.size() > 2 && !stack.get(1).normalName().equals("body"))) {
+                    if (stack.size() == 1 || (stack.size() > 2 && !stack.get(1).nameIs("body"))) {
                         // only in fragment case
                         return false; // ignore
                     } else if (!tb.framesetOk()) {
@@ -856,7 +856,7 @@ enum HtmlTreeBuilderState {
 
             for (int pos = stack.size() - 1; pos >= 0; pos--) {
                 Element node = stack.get(pos);
-                if (node.normalName().equals(name)) {
+                if (node.nameIs(name)) {
                     tb.generateImpliedEndTags(name);
                     if (!tb.currentElementIs(name))
                         tb.error(this);
@@ -1484,7 +1484,7 @@ enum HtmlTreeBuilderState {
                     name = end.normalName();
                     switch (name) {
                         case "optgroup":
-                            if (tb.currentElementIs("option") && tb.aboveOnStack(tb.currentElement()) != null && tb.aboveOnStack(tb.currentElement()).normalName().equals("optgroup"))
+                            if (tb.currentElementIs("option") && tb.aboveOnStack(tb.currentElement()) != null && tb.aboveOnStack(tb.currentElement()).nameIs("optgroup"))
                                 tb.processEndTag("option");
                             if (tb.currentElementIs("optgroup"))
                                 tb.pop();
@@ -1813,10 +1813,10 @@ enum HtmlTreeBuilderState {
                         Validate.wtf("Stack unexpectedly empty");
                     int i = stack.size() - 1;
                     Element el = stack.get(i);
-                    if (!el.normalName().equals(end.normalName))
+                    if (!el.nameIs(end.normalName))
                         tb.error(this);
                     while (i != 0) {
-                        if (el.normalName().equals(end.normalName)) {
+                        if (el.nameIs(end.normalName)) {
                             tb.popStackToCloseAnyNamespace(el.normalName());
                             return true;
                         }
diff --git a/src/main/java/org/jsoup/select/Evaluator.java b/src/main/java/org/jsoup/select/Evaluator.java
index 24489691..5381bca7 100644
--- a/src/main/java/org/jsoup/select/Evaluator.java
+++ b/src/main/java/org/jsoup/select/Evaluator.java
@@ -74,7 +74,7 @@ public abstract class Evaluator {
 
         @Override
         public boolean matches(Element root, Element element) {
-            return (element.normalName().equals(tagName));
+            return (element.nameIs(tagName));
         }
 
         @Override protected int cost() {
@@ -191,7 +191,7 @@ public abstract class Evaluator {
         private final String keyPrefix;
 
         public AttributeStarting(String keyPrefix) {
-            Validate.notEmpty(keyPrefix);
+            Validate.notNull(keyPrefix); // OK to be empty - will find elements with any attributes
             this.keyPrefix = lowerCase(keyPrefix);
         }
 
diff --git a/src/main/java/org/jsoup/select/QueryParser.java b/src/main/java/org/jsoup/select/QueryParser.java
index ed003cc4..32419c91 100644
--- a/src/main/java/org/jsoup/select/QueryParser.java
+++ b/src/main/java/org/jsoup/select/QueryParser.java
@@ -292,6 +292,8 @@ public class QueryParser {
         if (cq.isEmpty()) {
             if (key.startsWith("^"))
                 eval = new Evaluator.AttributeStarting(key.substring(1));
+            else if (key.equals("*")) // any attribute
+                eval = new Evaluator.AttributeStarting("");
             else
                 eval = new Evaluator.Attribute(key);
         } else {
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 9a5f5ff2..322cfa07 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -36,6 +36,7 @@ import java.util.IdentityHashMap;
  * <tr><td><code>[attr$=valSuffix]</code></td><td>elements with an attribute named "attr", and value ending with "valSuffix"</td><td><code>img[src$=.png]</code></td></tr>
  * <tr><td><code>[attr*=valContaining]</code></td><td>elements with an attribute named "attr", and value containing "valContaining"</td><td><code>a[href*=/search/]</code></td></tr>
  * <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>
diff --git a/src/main/java/org/jsoup/select/StructuralEvaluator.java b/src/main/java/org/jsoup/select/StructuralEvaluator.java
index 1e844270..ce5051fb 100644
--- a/src/main/java/org/jsoup/select/StructuralEvaluator.java
+++ b/src/main/java/org/jsoup/select/StructuralEvaluator.java
@@ -59,7 +59,8 @@ abstract class StructuralEvaluator extends Evaluator {
     }
 
     static class Has extends StructuralEvaluator {
-        final NodeIterator<Element> it = new NodeIterator<>(new Element("html"), Element.class);
+        static final ThreadLocal<NodeIterator<Element>> ThreadElementIter =
+            ThreadLocal.withInitial(() -> new NodeIterator<>(new Element("html"), Element.class));
         // the element here is just a placeholder so this can be final - gets set in restart()
 
         public Has(Evaluator evaluator) {
@@ -68,6 +69,8 @@ abstract class StructuralEvaluator extends Evaluator {
 
         @Override public boolean matches(Element root, Element element) {
             // for :has, we only want to match children (or below), not the input element. And we want to minimize GCs
+            NodeIterator<Element> it = ThreadElementIter.get();
+
             it.restart(element);
             while (it.hasNext()) {
                 Element el = it.next();


Test patch:
diff --git a/src/test/java/org/jsoup/helper/W3CDomTest.java b/src/test/java/org/jsoup/helper/W3CDomTest.java
index fe8379ae..de0ce166 100644
--- a/src/test/java/org/jsoup/helper/W3CDomTest.java
+++ b/src/test/java/org/jsoup/helper/W3CDomTest.java
@@ -351,12 +351,17 @@ public class W3CDomTest {
         org.jsoup.nodes.Document jdoc = Jsoup.parse(html);
         jdoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
         String xml = jdoc.body().html();
-        assertTrue(xml.contains("<script><![CDATA[")); // as asserted in ElementTest
+        assertTrue(xml.contains("<script>//<![CDATA[\n1 && 2\n//]]></script>")); // as asserted in ElementTest
         Document doc = parseXml(xml, false);
         NodeList list = xpath(doc, "//script");
-        assertEquals(1, list.getLength());
-        Node script = list.item(0); // will be the cdata node
-        assertEquals("1 && 2", script.getTextContent());
+        assertEquals(2, list.getLength());
+        Node scriptComment = list.item(0); // will be the cdata node
+        assertEquals("//", scriptComment.getTextContent());
+        Node script = list.item(1);
+        assertEquals("\n" +
+            "1 && 2\n" +
+            "//", script.getTextContent());
+
     }
 
     @Test public void handlesEmptyDoctype() {
diff --git a/src/test/java/org/jsoup/nodes/DataNodeTest.java b/src/test/java/org/jsoup/nodes/DataNodeTest.java
new file mode 100644
index 00000000..a7b626de
--- /dev/null
+++ b/src/test/java/org/jsoup/nodes/DataNodeTest.java
@@ -0,0 +1,73 @@
+package org.jsoup.nodes;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class DataNodeTest {
+
+    @Test
+    public void xmlOutputScriptWithCData() throws IOException {
+        DataNode node = new DataNode("//<![CDATA[\nscript && <> data]]>");
+        node.parentNode = new Element("script");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("//<![CDATA[\nscript && <> data]]>", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputScriptWithoutCData() throws IOException {
+        DataNode node = new DataNode("script && <> data");
+        node.parentNode = new Element("script");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("//<![CDATA[\nscript && <> data\n//]]>", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputStyleWithCData() throws IOException {
+        DataNode node = new DataNode("/*<![CDATA[*/\nstyle && <> data]]>");
+        node.parentNode = new Element("style");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("/*<![CDATA[*/\nstyle && <> data]]>", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputStyleWithoutCData() throws IOException {
+        DataNode node = new DataNode("style && <> data");
+        node.parentNode = new Element("style");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("/*<![CDATA[*/\nstyle && <> data\n/*]]>*/", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputOtherWithCData() throws IOException {
+        DataNode node = new DataNode("<![CDATA[other && <> data]]>");
+        node.parentNode = new Element("other");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("<![CDATA[other && <> data]]>", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputOtherWithoutCData() throws IOException {
+        DataNode node = new DataNode("other && <> data");
+        node.parentNode = new Element("other");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("<![CDATA[other && <> data]]>", accum.toString());
+    }
+
+    @Test
+    public void xmlOutputOrphanWithoutCData() throws IOException {
+        DataNode node = new DataNode("other && <> data");
+        StringBuilder accum = new StringBuilder();
+        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        assertEquals("<![CDATA[other && <> data]]>", accum.toString());
+    }
+
+}
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index b4d37477..71a43b78 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -2755,16 +2755,56 @@ public class ElementTest {
         assertEquals("1 && 2", scriptDataNode.getWholeData());
 
         doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
-        String xml = doc.body().html();
+        Element p = doc.expectFirst("p");
+        String xml = p.html();
+        assertEquals(
+            "<script>//<![CDATA[\n" +
+                "1 && 2\n" +
+                "//]]></script>\n" +
+                "<style>/*<![CDATA[*/\n" +
+                "3 && 4\n" +
+                "/*]]>*/</style> 5 &amp;&amp; 6",
+            xml);
+
+        Document xmlDoc = Jsoup.parse(xml, Parser.xmlParser());
+        assertEquals(xml, xmlDoc.html());
+        Element scriptXmlEl = xmlDoc.expectFirst("script");
+        TextNode scriptText = (TextNode) scriptXmlEl.childNode(0);
+        assertEquals("//", scriptText.getWholeText());
+        CDataNode scriptCdata = (CDataNode) scriptXmlEl.childNode(1);
+        assertEquals("\n1 && 2\n//", scriptCdata.text());
+    }
+
+    @Test void datanodesOutputExistingCdataInXhtml() {
+        String html = "<p><script>//<![CDATA[\n1 && 2\n//]]></script><style>\n/*<![CDATA[*/3 && 4\n/*]]>*/</style> 5 &amp;&amp; 6</p>";;
+        Document doc = Jsoup.parse(html); // parsed as HTML
+        String out = TextUtil.normalizeSpaces(doc.body().html());
+        assertEquals("<p><script>//<![CDATA[1 && 2//]]></script><style>/*<![CDATA[*/3 && 4/*]]>*/</style> 5 &amp;&amp; 6</p>", out);
+        Element scriptEl = doc.expectFirst("script");
+        DataNode scriptDataNode = (DataNode) scriptEl.childNode(0);
+        assertEquals("//<![CDATA[\n" +
+            "1 && 2\n" +
+            "//]]>", scriptDataNode.getWholeData());
+
+        doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+        Element p = doc.expectFirst("p");
+        String xml = p.html();
         assertEquals(
-            "<p><script><![CDATA[1 && 2]]></script><style><![CDATA[3 && 4]]></style> 5 &amp;&amp; 6</p>",
-            TextUtil.normalizeSpaces(xml));
+            "<script>//<![CDATA[\n" +
+                "1 && 2\n" +
+                "//]]></script>\n" +
+                "<style>\n" +
+                "/*<![CDATA[*/3 && 4\n" +
+                "/*]]>*/</style> 5 &amp;&amp; 6",
+            xml);
 
         Document xmlDoc = Jsoup.parse(xml, Parser.xmlParser());
         assertEquals(xml, xmlDoc.html());
         Element scriptXmlEl = xmlDoc.expectFirst("script");
-        CDataNode scriptCdata = (CDataNode) scriptXmlEl.childNode(0);
-        assertEquals(scriptCdata.text(), scriptDataNode.getWholeData());
+        TextNode scriptText = (TextNode) scriptXmlEl.childNode(0);
+        assertEquals("//", scriptText.getWholeText());
+        CDataNode scriptCdata = (CDataNode) scriptXmlEl.childNode(1);
+        assertEquals("\n1 && 2\n//", scriptCdata.text());
     }
 
     @Test void outerHtmlAppendable() {
diff --git a/src/test/java/org/jsoup/nodes/NodeTest.java b/src/test/java/org/jsoup/nodes/NodeTest.java
index e0622be5..6e42343a 100644
--- a/src/test/java/org/jsoup/nodes/NodeTest.java
+++ b/src/test/java/org/jsoup/nodes/NodeTest.java
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test;
 
 import java.util.List;
 
+import static org.jsoup.parser.Parser.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -455,11 +456,41 @@ public class NodeTest {
         assertEquals("DIV", div.tagName());
         assertEquals("DIV", div.nodeName());
         assertEquals("div", div.normalName());
-        assertTrue(div.isNode("div"));
-        assertTrue(Node.isNode(div, "div"));
+        assertTrue(div.nameIs("div"));
 
         TextNode text = new TextNode("Some Text");
         assertEquals("#text", text.nodeName());
         assertEquals("#text", text.normalName());
     }
+
+    @Test void elementIs() {
+        String html = "<div><p>One</p>";
+        Document doc = Jsoup.parse(html);
+
+        Element p = doc.expectFirst("p");
+        TextNode text = (TextNode) p.childNode(0);
+
+        assertTrue(text.parentElementIs("p", NamespaceHtml));
+        assertFalse(text.parentElementIs("div", NamespaceHtml));
+        assertFalse(text.parentElementIs("p", NamespaceXml));
+
+        assertTrue(p.parentElementIs("div", NamespaceHtml));
+        assertTrue(p.elementIs("p", NamespaceHtml));
+        assertTrue(p.nameIs("p"));
+        assertFalse(p.nameIs("P"));
+    }
+
+    @Test void svgElementIs() {
+        String html = "<div><svg><path>1,2,3</path></svg></div>";
+        Document doc = Jsoup.parse(html);
+
+        Element svg = doc.expectFirst("svg");
+        assertTrue(svg.nameIs("svg"));
+        assertFalse(svg.elementIs("svg", NamespaceHtml));
+        assertTrue(svg.elementIs("svg", NamespaceSvg));
+
+        TextNode data = (TextNode) svg.childNode(0).childNode(0);
+        assertTrue(data.parentElementIs("path", NamespaceSvg));
+        assertTrue(data.parentNameIs("path"));
+    }
 }
diff --git a/src/test/java/org/jsoup/select/SelectorIT.java b/src/test/java/org/jsoup/select/SelectorIT.java
new file mode 100644
index 00000000..ed884b76
--- /dev/null
+++ b/src/test/java/org/jsoup/select/SelectorIT.java
@@ -0,0 +1,58 @@
+package org.jsoup.select;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class SelectorIT {
+
+    @Test
+    public void multiThreadHas() throws InterruptedException {
+        final String html = "<div id=1></div><div id=2><p>One</p><p>Two</p>";
+        final Evaluator eval = QueryParser.parse("div:has(p)");
+
+        int numThreads = 20;
+        int numThreadLoops = 5;
+
+        SelectorIT.ThreadCatcher catcher = new SelectorIT.ThreadCatcher();
+
+        Thread[] threads = new Thread[numThreads];
+        for (int threadNum = 0; threadNum < numThreads; threadNum++) {
+            Thread thread = new Thread(() -> {
+                Document doc = Jsoup.parse(html);
+                for (int loop = 0; loop < numThreadLoops; loop++) {
+                    Elements els = doc.select(eval);
+                    assertEquals(1, els.size());
+                    assertEquals("2", els.get(0).id());
+                }
+            });
+            thread.setName("Runner-" + threadNum);
+            thread.start();
+            thread.setUncaughtExceptionHandler(catcher);
+            threads[threadNum] = thread;
+        }
+
+        // now join them all
+        for (Thread thread : threads) {
+            thread.join();
+        }
+
+        assertEquals(0, catcher.exceptionCount.get());
+    }
+
+    static class ThreadCatcher implements Thread.UncaughtExceptionHandler {
+        AtomicInteger exceptionCount = new AtomicInteger();
+
+        @Override
+        public void uncaughtException(Thread t, Throwable e) {
+
+            e.printStackTrace();
+            exceptionCount.incrementAndGet();
+        }
+    }
+
+}
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index fd5740e4..493580bb 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -2,7 +2,6 @@ package org.jsoup.select;
 
 import org.jsoup.Jsoup;
 import org.jsoup.MultiLocaleExtension.MultiLocaleTest;
-import org.jsoup.TextUtil;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 import org.jsoup.parser.Parser;
@@ -1230,4 +1229,29 @@ public class SelectorTest {
         Elements els = doc.select(q);
         assertEquals(3, els.size());
     }
+
+    @Test public void emptyAttributePrefix() {
+        // https://github.com/jhy/jsoup/issues/2079
+        // Discovered feature: [^] should find elements with any attribute (any prefix)
+        String html = "<p one>One<p one two>Two<p>Three";
+        Document doc = Jsoup.parse(html);
+
+        Elements els = doc.select("[^]");
+        assertSelectedOwnText(els, "One", "Two");
+
+        Elements emptyAttr = doc.select("p:not([^])");
+        assertSelectedOwnText(emptyAttr, "Three");
+    }
+
+    @Test public void anyAttribute() {
+        // https://github.com/jhy/jsoup/issues/2079
+        String html = "<div id=1><p one>One<p one two>Two<p>Three";
+        Document doc = Jsoup.parse(html);
+
+        Elements els = doc.select("p[*]");
+        assertSelectedOwnText(els, "One", "Two");
+
+        Elements emptyAttr = doc.select("p:not([*])");
+        assertSelectedOwnText(emptyAttr, "Three");
+    }
 }
diff --git a/src/test/java/org/jsoup/select/TraversorTest.java b/src/test/java/org/jsoup/select/TraversorTest.java
index 2b1da281..3e08149c 100644
--- a/src/test/java/org/jsoup/select/TraversorTest.java
+++ b/src/test/java/org/jsoup/select/TraversorTest.java
@@ -132,7 +132,7 @@ public class TraversorTest {
             public void head(Node node, int depth) {
                 if (node instanceof Element) {
                     Element el = (Element) node;
-                    if (el.normalName().equals("i")) {
+                    if (el.nameIs("i")) {
                         Element u = new Element("u").insertChildren(0, el.childNodes());
                         el.replaceWith(u);
                     }