Repository: jhy/jsoup
Pull request #2305 (2025-04-18 16:27:28)

Use proper escaping in `TokenQueue.escapeCssIdentifier()`
Fixes #1984

Side note: This method seems useful to library users when programmatically building CSS selectors. I think it should be part of the stable API surface and not live inside of `TokenQueue`.


Patch:
diff --git a/src/main/java/org/jsoup/helper/ChangeNotifyingArrayList.java b/src/main/java/org/jsoup/helper/ChangeNotifyingArrayList.java
index 67ac78d3..c6142b96 100644
--- a/src/main/java/org/jsoup/helper/ChangeNotifyingArrayList.java
+++ b/src/main/java/org/jsoup/helper/ChangeNotifyingArrayList.java
@@ -5,8 +5,9 @@ import java.util.Collection;
 
 /**
  * Implementation of ArrayList that watches out for changes to the contents.
+ @deprecated this class is no longer used and will be removed in jsoup 1.21.1.
  */
-public abstract class ChangeNotifyingArrayList<E> extends ArrayList<E> {
+@Deprecated public abstract class ChangeNotifyingArrayList<E> extends ArrayList<E> {
     public ChangeNotifyingArrayList(int initialCapacity) {
         super(initialCapacity);
     }
diff --git a/src/main/java/org/jsoup/helper/HttpConnection.java b/src/main/java/org/jsoup/helper/HttpConnection.java
index f31bff0d..2009666d 100644
--- a/src/main/java/org/jsoup/helper/HttpConnection.java
+++ b/src/main/java/org/jsoup/helper/HttpConnection.java
@@ -33,6 +33,7 @@ import java.nio.Buffer;
 import java.nio.ByteBuffer;
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -1133,14 +1134,18 @@ public class HttpConnection implements Connection {
         }
 
         /**
-         Servers may encode response headers in UTF-8 instead of RFC defined 8859. This method attempts to detect that
-         and re-decode the string as UTF-8.
+         Servers may encode response headers in UTF-8 instead of RFC defined 8859. The JVM decodes the headers (before we see them) as 8859, which can lead to mojibake data.
+         <p>This method attempts to detect that and re-decode the string as UTF-8.</p>
+         <p>However on Android, the headers will be decoded as UTF8, so we can detect and pass those directly.</p>
          * @param val a header value string that may have been incorrectly decoded as 8859.
          * @return a potentially re-decoded string.
          */
         @Nullable
-        private static String fixHeaderEncoding(@Nullable String val) {
+        static String fixHeaderEncoding(@Nullable String val) {
             if (val == null) return val;
+            // If we can't encode the string as 8859, then it couldn't have been decoded as 8859
+            if (!StandardCharsets.ISO_8859_1.newEncoder().canEncode(val))
+                return val;
             byte[] bytes = val.getBytes(ISO_8859_1);
             if (looksLikeUtf8(bytes))
                 return new String(bytes, UTF_8);
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index a624678d..650ffff9 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -1,6 +1,5 @@
 package org.jsoup.nodes;
 
-import org.jsoup.helper.ChangeNotifyingArrayList;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.Normalizer;
 import org.jsoup.internal.StringUtil;
@@ -37,7 +36,6 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static org.jsoup.internal.Normalizer.normalize;
-import static org.jsoup.nodes.Document.OutputSettings.Syntax.html;
 import static org.jsoup.nodes.Document.OutputSettings.Syntax.xml;
 import static org.jsoup.nodes.TextNode.lastCharIsWhitespace;
 import static org.jsoup.parser.Parser.NamespaceHtml;
@@ -50,11 +48,11 @@ import static org.jsoup.parser.TokenQueue.escapeCssIdentifier;
 */
 public class Element extends Node implements Iterable<Element> {
     private static final List<Element> EmptyChildren = Collections.emptyList();
+    private static final NodeList EmptyNodeList = new NodeList(0);
     private static final Pattern ClassSplit = Pattern.compile("\\s+");
     private static final String BaseUriKey = Attributes.internalKey("baseUri");
     Tag tag;
-    private @Nullable WeakReference<List<Element>> shadowChildrenRef; // points to child elements shadowed from node children
-    List<Node> childNodes;
+    NodeList childNodes;
     @Nullable Attributes attributes; // field is nullable but all methods for attributes are non-null
 
     /**
@@ -86,7 +84,7 @@ public class Element extends Node implements Iterable<Element> {
      */
     public Element(Tag tag, @Nullable String baseUri, @Nullable Attributes attributes) {
         Validate.notNull(tag);
-        childNodes = EmptyNodes;
+        childNodes = EmptyNodeList;
         this.attributes = attributes;
         this.tag = tag;
         if (baseUri != null)
@@ -108,12 +106,12 @@ public class Element extends Node implements Iterable<Element> {
      Internal test to check if a nodelist object has been created.
      */
     protected boolean hasChildNodes() {
-        return childNodes != EmptyNodes;
+        return childNodes != EmptyNodeList;
     }
 
     @Override protected List<Node> ensureChildNodes() {
-        if (childNodes == EmptyNodes) {
-            childNodes = new NodeList(this, 4);
+        if (childNodes == EmptyNodeList) {
+            childNodes = new NodeList(4);
         }
         return childNodes;
     }
@@ -393,31 +391,40 @@ public class Element extends Node implements Iterable<Element> {
      * @return a list of child elements
      */
     List<Element> childElementsList() {
-        if (childNodeSize() == 0)
-            return EmptyChildren; // short circuit creating empty
-
-        List<Element> children;
-        if (shadowChildrenRef == null || (children = shadowChildrenRef.get()) == null) {
-            final int size = childNodes.size();
-            children = new ArrayList<>(size);
-            //noinspection ForLoopReplaceableByForEach (beacause it allocates an Iterator which is wasteful here)
-            for (int i = 0; i < size; i++) {
-                final Node node = childNodes.get(i);
-                if (node instanceof Element)
-                    children.add((Element) node);
-            }
-            shadowChildrenRef = new WeakReference<>(children);
+        if (childNodeSize() == 0) return EmptyChildren; // short circuit creating empty
+        List<Element> children = cachedChildren();
+        if (children == null) {
+            children = filterNodes(Element.class);
+            stashChildren(children);
         }
         return children;
     }
 
-    /**
-     * Clears the cached shadow child elements.
-     */
-    @Override
-    void nodelistChanged() {
-        super.nodelistChanged();
-        shadowChildrenRef = null;
+    private static final String childElsKey = "jsoup.childEls";
+    private static final String childElsMod = "jsoup.childElsMod";
+
+    /** returns the cached child els, if they exist, and the modcount of our childnodes matches the stashed modcount */
+    private @Nullable List<Element> cachedChildren() {
+        Map<String, Object> userData = attributes().userData();
+        //noinspection unchecked
+        WeakReference<List<Element>> ref = (WeakReference<List<Element>>) userData.get(childElsKey);
+        if (ref != null) {
+            List<Element> els = ref.get();
+            if (els != null) {
+                Integer modCount = (Integer) userData.get(childElsMod);
+                if (modCount != null && modCount == childNodes.modCount())
+                    return els;
+            }
+        }
+        return null;
+    }
+
+    /** caches the child els into the Attribute user data. */
+    private void stashChildren(List<Element> els) {
+        Map<String, Object> userData = attributes().userData();
+        WeakReference<List<Element>> ref = new WeakReference<>(els);
+        userData.put(childElsKey, ref);
+        userData.put(childElsMod, childNodes.modCount());
     }
 
     /**
@@ -1898,7 +1905,7 @@ public class Element extends Node implements Iterable<Element> {
     protected Element doClone(@Nullable Node parent) {
         Element clone = (Element) super.doClone(parent);
         clone.attributes = attributes != null ? attributes.clone() : null;
-        clone.childNodes = new NodeList(clone, childNodes.size());
+        clone.childNodes = new NodeList(childNodes.size());
         clone.childNodes.addAll(childNodes); // the children then get iterated and cloned in Node.clone
 
         return clone;
@@ -1961,16 +1968,13 @@ public class Element extends Node implements Iterable<Element> {
         return  (Element) super.filter(nodeFilter);
     }
 
-    private static final class NodeList extends ChangeNotifyingArrayList<Node> {
-        private final Element owner;
-
-        NodeList(Element owner, int initialCapacity) {
-            super(initialCapacity);
-            this.owner = owner;
+    static final class NodeList extends ArrayList<Node> {
+        public NodeList(int size) {
+            super(size);
         }
 
-        @Override public void onContentsChanged() {
-            owner.nodelistChanged();
+        int modCount() {
+            return this.modCount;
         }
     }
 }
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index d6801c91..406295ac 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -501,10 +501,6 @@ public abstract class Node implements Cloneable {
         return el;
     }
 
-    void nodelistChanged() {
-        // Element overrides this to clear its shadow children elements
-    }
-
     /**
      * Replace this node in the DOM with the supplied node.
      * @param in the node that will replace the existing node.
diff --git a/src/main/java/org/jsoup/parser/TokenQueue.java b/src/main/java/org/jsoup/parser/TokenQueue.java
index 453eb4fc..e5553caf 100644
--- a/src/main/java/org/jsoup/parser/TokenQueue.java
+++ b/src/main/java/org/jsoup/parser/TokenQueue.java
@@ -273,23 +273,74 @@ public class TokenQueue {
         return StringUtil.releaseBuilder(out);
     }
 
-    /*
-    Given a CSS identifier (such as a tag, ID, or class), escape any CSS special characters that would otherwise not be
-    valid in a selector.
+    /**
+     Given a CSS identifier (such as a tag, ID, or class), escape any CSS special characters that would otherwise not be
+     valid in a selector.
+
+     @see <a href="https://www.w3.org/TR/cssom-1/#serialize-an-identifier">CSS Object Model, serialize an identifier</a>
      */
     public static String escapeCssIdentifier(String in) {
+        if (in.isEmpty()) return in;
+
         StringBuilder out = StringUtil.borrowBuilder();
         TokenQueue q = new TokenQueue(in);
+
+        char firstChar = q.current();
+        if (firstChar == Hyphen_Minus) {
+            q.advance();
+            if (q.isEmpty()) {
+                // If the character is the first character and is a "-" (U+002D), and there is no second character, then
+                // the escaped character.
+                appendEscaped(out, Hyphen_Minus);
+            } else {
+                out.append(Hyphen_Minus);
+
+                char secondChar = q.current();
+                if (CharacterReader.isDigit(secondChar)) {
+                    // If the character is the second character and is in the range [0-9] (U+0030 to U+0039) and the
+                    // first character is a "-" (U+002D), then the character escaped as code point.
+                    appendEscapedCodepoint(out, q.consume());
+                }
+            }
+        } else if (CharacterReader.isDigit(firstChar)) {
+            // If the character is the first character and is in the range [0-9] (U+0030 to U+0039), then the character
+            // escaped as code point.
+            appendEscapedCodepoint(out, q.consume());
+        }
+
         while (!q.isEmpty()) {
-            if (q.matchesCssIdentifier(CssIdentifierChars)) {
-                out.append(q.consume());
+            // Note: It's fine to iterate on chars because non-ASCII characters are never escaped. So surrogate pairs
+            // are kept intact.
+            char c = q.consume();
+            if (c == Unicode_Null) {
+                // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD).
+                out.append(Replacement);
+            } else if (c <= '\u001F' || c == '\u007F') {
+                // If the character is in the range [\1-\1f] (U+0001 to U+001F) or is U+007F, then the character
+                // escaped as code point.
+                appendEscapedCodepoint(out, c);
+            } else if (isIdent(c)) {
+                // If the character is not handled by one of the above rules and is greater than or equal to U+0080,
+                // is "-" (U+002D) or "_" (U+005F), or is in one of the ranges [0-9] (U+0030 to U+0039),
+                // [A-Z] (U+0041 to U+005A), or [a-z] (U+0061 to U+007A), then the character itself.
+                out.append(c);
             } else {
-                out.append(Esc).append(q.consume());
+                // Otherwise, the escaped character.
+                appendEscaped(out, c);
             }
         }
+
         return StringUtil.releaseBuilder(out);
     }
 
+    private static void appendEscaped(StringBuilder out, char c) {
+        out.append(Esc).append(c);
+    }
+
+    private static void appendEscapedCodepoint(StringBuilder out, char c) {
+        out.append(Esc).append(Integer.toHexString(c)).append(' ');
+    }
+
     /**
      * Pulls the next run of whitespace characters of the queue.
      * @return Whether consuming whitespace or not
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 7cbc164c..824d0586 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.parser.TokenQueue;
 import org.jspecify.annotations.Nullable;
 
 import java.util.Collection;
@@ -218,6 +219,33 @@ public class Selector {
         return null;
     }
 
+    /**
+     Given a CSS identifier (such as a tag, ID, or class), escape any CSS special characters that would otherwise not be
+     valid in a selector.
+
+     @see <a href="https://www.w3.org/TR/cssom-1/#serialize-an-identifier">CSS Object Model, serialize an identifier</a>
+     @since 1.20.1
+     */
+    public static String escapeCssIdentifier(String in) {
+        return TokenQueue.escapeCssIdentifier(in);
+    }
+
+    /**
+     Consume a CSS identifier (ID or class) off the queue.
+     <p>Note: For backwards compatibility this method supports improperly formatted CSS identifiers, e.g. {@code 1} instead
+     of {@code \31}.</p>
+
+     @return The unescaped identifier.
+     @throws IllegalArgumentException if an invalid escape sequence was found.
+     @see <a href="https://www.w3.org/TR/css-syntax-3/#consume-name">CSS Syntax Module Level 3, Consume an ident sequence</a>
+     @see <a href="https://www.w3.org/TR/css-syntax-3/#typedef-ident-token">CSS Syntax Module Level 3, ident-token</a>
+     @since 1.20.1
+     */
+    public static String unescapeCssIdentifier(String in) {
+        TokenQueue tq = new TokenQueue(in);
+        return tq.consumeCssIdentifier();
+    }
+
     public static class SelectorParseException extends IllegalStateException {
         public SelectorParseException(String msg) {
             super(msg);


Test patch:
diff --git a/src/test/java/org/jsoup/helper/HttpConnectionTest.java b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
index 7162e7f0..dc4c890e 100644
--- a/src/test/java/org/jsoup/helper/HttpConnectionTest.java
+++ b/src/test/java/org/jsoup/helper/HttpConnectionTest.java
@@ -5,12 +5,15 @@ import org.jsoup.Jsoup;
 import org.jsoup.MultiLocaleExtension.MultiLocaleTest;
 import org.jsoup.integration.ParseTest;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.net.Authenticator;
 import java.net.MalformedURLException;
 import java.net.PasswordAuthentication;
 import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -19,6 +22,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
+import static org.jsoup.helper.HttpConnection.Response.fixHeaderEncoding;
 import static org.junit.jupiter.api.Assertions.*;
 
 public class HttpConnectionTest {
@@ -387,4 +391,21 @@ public class HttpConnectionTest {
         assertEquals("foo", creds.getUserName());
         assertEquals("bar", new String(creds.getPassword()));
     }
+
+    /* Tests for fixHeaderEncoding. We are handling two cases when a server sends a header in UTF8. The JVM will decode
+    that in 8859 (per the RFC) and we'll get mojibake, and so we try to fix it. On Android, will be decoded correctly,
+    so should not be modified. */
+    static String mojibake(String input) {
+        // simulate mojibake by encoding in UTF-8 and decoding in ISO-8859-1
+        return new String(input.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"search.php?moji=我的", "latin=café", "🍕", "ascii"})
+    void fixesHeaderEncodingIfRequired(String input) {
+        // if the input was mojibaked, we fix it; otherwise is passed
+        // https://github.com/jhy/jsoup/issues/2011
+        assertEquals(input, fixHeaderEncoding(input));
+        assertEquals(input, fixHeaderEncoding(mojibake(input)));
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
index bdd360bc..22649ed4 100644
--- a/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
@@ -33,7 +33,7 @@ public class ProxyServlet extends AsyncProxyServlet {
         proxyHandler.addFilterWithMapping(new FilterHolder(authFilter), "/*", FilterMapping.ALL); // auth for HTTP proxy
         ServletHolder proxyServletHolder = new ServletHolder(ProxyServlet.class); // Holder wraps as it requires maxThreads initialization
         proxyServletHolder.setAsyncSupported(true);
-        proxyServletHolder.setInitParameter("maxThreads", "8");
+        proxyServletHolder.setInitParameter("maxThreads", "200");
         proxyHandler.addServletWithMapping(proxyServletHolder, "/*");
         connectHandler.setHandler(proxyHandler);
 
diff --git a/src/test/java/org/jsoup/nodes/DocumentTest.java b/src/test/java/org/jsoup/nodes/DocumentTest.java
index d740805a..c24640ff 100644
--- a/src/test/java/org/jsoup/nodes/DocumentTest.java
+++ b/src/test/java/org/jsoup/nodes/DocumentTest.java
@@ -127,7 +127,6 @@ public class DocumentTest {
         Document doc = Jsoup.parse("<title>Hello</title> <p>One\n<p>Two\n");
         String expect = "<html>\n <head>\n  <title>Hello</title>\n </head>\n <body>\n  <p>One</p>\n  <p>Two</p>\n </body>\n</html>";
         String html = doc.html();
-        System.out.println(html);
         assertEquals(expect, html);
     }
 
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index f3467974..738e8a36 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -2697,6 +2697,26 @@ public class ElementTest {
         assertSelectedOwnText(selected, "One");
     }
 
+    @Test void cssSelectorCombined() {
+        // https://github.com/jhy/jsoup/issues/1984
+        Document doc = Jsoup.parse("<img class='e\u0301'><p class=👨‍👨‍👧‍👧></p><a class='\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC67'></a>");
+        Element img = doc.expectFirst("img");
+        Element p = doc.expectFirst("p");
+        Element a = doc.expectFirst("a");
+
+        String imgQ = img.cssSelector();
+        String pQ = p.cssSelector();
+        String aQ = a.cssSelector();
+
+        assertEquals("html > body > img.é", imgQ); // previously was img.e\́; chrome gives literal body > img.e\\u0301
+        assertEquals("html > body > p.👨‍👨‍👧‍👧", pQ); // chrome gives body > p.👨‍👨‍👧‍👧
+        assertEquals("html > body > a.👨‍👨‍👧‍👧", aQ); // body > a.👨‍👨‍👧‍👧
+
+        assertSame(img, doc.expectFirst(imgQ));
+        assertSame(p, doc.expectFirst(pQ));
+        assertSame(a, doc.expectFirst(aQ));
+    }
+
     @Test void orphanSiblings() {
         Element el = new Element("div");
         assertEquals(0, el.siblingElements().size());
diff --git a/src/test/java/org/jsoup/parser/TokenQueueTest.java b/src/test/java/org/jsoup/parser/TokenQueueTest.java
index dc4c9287..1c2efcad 100644
--- a/src/test/java/org/jsoup/parser/TokenQueueTest.java
+++ b/src/test/java/org/jsoup/parser/TokenQueueTest.java
@@ -54,8 +54,99 @@ public class TokenQueueTest {
         assertEquals("\\&", TokenQueue.unescape("\\\\\\&"));
     }
 
-    @Test public void escapeCssIdentifier() {
-        assertEquals("one\\#two\\.three\\/four\\\\five", TokenQueue.escapeCssIdentifier("one#two.three/four\\five"));
+    @ParameterizedTest
+    @MethodSource("escapeCssIdentifier_WebPlatformTestParameters")
+    @MethodSource("escapeCssIdentifier_additionalParameters")
+    public void escapeCssIdentifier(String expected, String input) {
+        assertEquals(expected, TokenQueue.escapeCssIdentifier(input));
+    }
+
+    // https://github.com/web-platform-tests/wpt/blob/328fa1c67bf5dfa6f24571d4c41dd10224b6d247/css/cssom/escape.html
+    private static Stream<Arguments> escapeCssIdentifier_WebPlatformTestParameters() {
+        return Stream.of(
+            Arguments.of("", ""),
+
+            // Null bytes
+            Arguments.of("\uFFFD", "\0"),
+            Arguments.of("a\uFFFD", "a\0"),
+            Arguments.of("\uFFFDb", "\0b"),
+            Arguments.of("a\uFFFDb", "a\0b"),
+
+            // Replacement character
+            Arguments.of("\uFFFD", "\uFFFD"),
+            Arguments.of("a\uFFFD", "a\uFFFD"),
+            Arguments.of("\uFFFDb", "\uFFFDb"),
+            Arguments.of("a\uFFFDb", "a\uFFFDb"),
+
+            // Number prefix
+            Arguments.of("\\30 a", "0a"),
+            Arguments.of("\\31 a", "1a"),
+            Arguments.of("\\32 a", "2a"),
+            Arguments.of("\\33 a", "3a"),
+            Arguments.of("\\34 a", "4a"),
+            Arguments.of("\\35 a", "5a"),
+            Arguments.of("\\36 a", "6a"),
+            Arguments.of("\\37 a", "7a"),
+            Arguments.of("\\38 a", "8a"),
+            Arguments.of("\\39 a", "9a"),
+
+            // Letter number prefix
+            Arguments.of("a0b", "a0b"),
+            Arguments.of("a1b", "a1b"),
+            Arguments.of("a2b", "a2b"),
+            Arguments.of("a3b", "a3b"),
+            Arguments.of("a4b", "a4b"),
+            Arguments.of("a5b", "a5b"),
+            Arguments.of("a6b", "a6b"),
+            Arguments.of("a7b", "a7b"),
+            Arguments.of("a8b", "a8b"),
+            Arguments.of("a9b", "a9b"),
+
+            // Dash number prefix
+            Arguments.of("-\\30 a", "-0a"),
+            Arguments.of("-\\31 a", "-1a"),
+            Arguments.of("-\\32 a", "-2a"),
+            Arguments.of("-\\33 a", "-3a"),
+            Arguments.of("-\\34 a", "-4a"),
+            Arguments.of("-\\35 a", "-5a"),
+            Arguments.of("-\\36 a", "-6a"),
+            Arguments.of("-\\37 a", "-7a"),
+            Arguments.of("-\\38 a", "-8a"),
+            Arguments.of("-\\39 a", "-9a"),
+
+            // Double dash prefix
+            Arguments.of("--a", "--a"),
+
+            // Various tests
+            Arguments.of("\\1 \\2 \\1e \\1f ", "\u0001\u0002\u001E\u001F"),
+            Arguments.of("\u0080\u002D\u005F\u00A9", "\u0080\u002D\u005F\u00A9"),
+            Arguments.of("\\7f \u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008A\u008B\u008C\u008D\u008E\u008F\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009A\u009B\u009C\u009D\u009E\u009F", "\u007F\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008A\u008B\u008C\u008D\u008E\u008F\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009A\u009B\u009C\u009D\u009E\u009F"),
+            Arguments.of("\u00A0\u00A1\u00A2", "\u00A0\u00A1\u00A2"),
+            Arguments.of("a0123456789b", "a0123456789b"),
+            Arguments.of("abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnopqrstuvwxyz"),
+            Arguments.of("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
+
+            Arguments.of("hello\\\\world", "hello\\world"), // Backslashes get backslash-escaped
+            Arguments.of("hello\u1234world", "hello\u1234world"), // Code points greater than U+0080 are preserved
+            Arguments.of("\\-", "-"), // CSS.escape: Single dash escaped
+
+            Arguments.of("\\ \\!xy", "\u0020\u0021\u0078\u0079"),
+
+            // astral symbol (U+1D306 TETRAGRAM FOR CENTRE)
+            Arguments.of("\uD834\uDF06", "\uD834\uDF06"),
+
+            // lone surrogates
+            Arguments.of("\uDF06", "\uDF06"),
+            Arguments.of("\uD834", "\uD834")
+        );
+    }
+
+    private static Stream<Arguments> escapeCssIdentifier_additionalParameters() {
+        return Stream.of(
+            Arguments.of("one\\#two\\.three\\/four\\\\five", "one#two.three/four\\five"),
+            Arguments.of("-a", "-a"),
+            Arguments.of("--", "--")
+        );
     }
 
     @Test public void chompToIgnoreCase() {
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index 040b10cf..049f95dd 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -1461,4 +1461,17 @@ public class SelectorTest {
         Element img = doc.expectFirst(q);
         assertEquals("img", img.tagName());
     }
+
+    @Test void escapeCssIdentifier() {
+        // thorough tests are in TokenQueue
+        assertEquals("-\\30 a", Selector.escapeCssIdentifier("-0a"));
+        assertEquals("a0b", Selector.escapeCssIdentifier("a0b"));
+    }
+
+    @Test void unescapeCssIdentifier() {
+        // thorough tests are in TokenQueue
+        assertEquals("-0a", Selector.unescapeCssIdentifier("-\\30 a"));
+        assertEquals("a0b", Selector.unescapeCssIdentifier("a0b"));
+    }
+
 }