Repository: jhy/jsoup (java)

Issue:
`cssSelector` behavior changed since v1.16.2
Before v1.16.2, calling `cssSelector()` on the "A" paragraph in
```
<div>
  <div id="id1">
    <p>A</p>
  </div>
</div>
```
produced the result `#id1 > p`.

From v1.16.2, the result becomes `html > body > div > div > p` which is bound more to the structure of the HTML, and doesn't utilize the supposedly unique id anymore.

The [cssSelector doc](https://jsoup.org/apidocs/org/jsoup/nodes/Element.html#cssSelector()) also says
> If the element has an ID, returns #id; otherwise returns the parent (if any) CSS selector, followed by '>', followed by a unique selector for the element (tag.class.class:nth-child(n)).

which very likely leads to the understanding that the higher precedence of ID also applies to the parent CSS selector. 

Pull request description:
Restore the behavior of cssSelector before v1.16.2 for elements with an id
Fixes #2282 

- Utilize id for not only the element itself, but also for its parent elements;
- Stop the processing when encountering an element with a unique id.

In case of an extremely deeply nested element, which is the reason for the changes in v1.16.2, this can possibly greatly reduce the size of the output of `cssSelector` if one of the element's parents has an id.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index ea2fd7cc..280a92d5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,12 @@
 # jsoup Changelog
 
+## 1.19.2 (PENDING)
+
+### Changes
+
+### Improvements
+* `Element.cssSelector()` will prefer to return shorter selectors by using ancestor IDs when available and unique. E.g. `#id > div > p` instead of  `html > body > div > div > p` [#2283](https://github.com/jhy/jsoup/pull/2283).
+
 ## 1.19.1 (2025-03-04)
 
 ### Changes
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 09f576f3..c2581a36 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -938,32 +938,47 @@ public class Element extends Node implements Iterable<Element> {
     }
 
     /**
-     * Get a CSS selector that will uniquely select this element.
-     * <p>
-     * If the element has an ID, returns #id;
-     * otherwise returns the parent (if any) CSS selector, followed by {@literal '>'},
-     * followed by a unique selector for the element (tag.class.class:nth-child(n)).
-     * </p>
-     *
-     * @return the CSS Path that can be used to retrieve the element in a selector.
+     Gets an #id selector for this element, if it has a unique ID. Otherwise, returns an empty string.
+
+     @param ownerDoc the document that owns this element, if there is one
      */
-    public String cssSelector() {
-        if (id().length() > 0) {
-            // prefer to return the ID - but check that it's actually unique first!
-            String idSel = "#" + escapeCssIdentifier(id());
-            Document doc = ownerDocument();
-            if (doc != null) {
-                Elements els = doc.select(idSel);
-                if (els.size() == 1 && els.get(0) == this) // otherwise, continue to the nth-child impl
-                    return idSel;
+    private String uniqueIdSelector(@Nullable Document ownerDoc) {
+        String id = id();
+        if (!id.isEmpty()) { // check if the ID is unique and matches this
+            String idSel = "#" + escapeCssIdentifier(id);
+            if (ownerDoc != null) {
+                Elements els = ownerDoc.select(idSel);
+                if (els.size() == 1 && els.get(0) == this) return idSel;
             } else {
-                return idSel; // no ownerdoc, return the ID selector
+                return idSel;
             }
         }
+        return EmptyString;
+    }
+
+    /**
+     Get a CSS selector that will uniquely select this element.
+     <p>
+     If the element has an ID, returns #id; otherwise returns the parent (if any) CSS selector, followed by
+     {@literal '>'}, followed by a unique selector for the element (tag.class.class:nth-child(n)).
+     </p>
 
+     @return the CSS Path that can be used to retrieve the element in a selector.
+     */
+    public String cssSelector() {
+        Document ownerDoc = ownerDocument();
+        String idSel = uniqueIdSelector(ownerDoc);
+        if (!idSel.isEmpty()) return idSel;
+
+        // No unique ID, work up the parent stack and find either a unique ID to hang from, or just a GP > Parent > Child chain
         StringBuilder selector = StringUtil.borrowBuilder();
         Element el = this;
         while (el != null && !(el instanceof Document)) {
+            idSel = el.uniqueIdSelector(ownerDoc);
+            if (!idSel.isEmpty()) {
+                selector.insert(0, idSel);
+                break; // found a unique ID to use as ancestor; stop
+            }
             selector.insert(0, el.cssSelectorComponent());
             el = el.parent();
         }


Test patch:
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index b53444ae..a7b68d87 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -2592,6 +2592,28 @@ public class ElementTest {
         assertEquals("div", el.cssSelector());
     }
 
+    @Test void cssSelectorParentWithId() {
+        // https://github.com/jhy/jsoup/issues/2282
+        Document doc = Jsoup.parse("<div><div id=id1><p>A</p></div><div><p>B</p></div><div class='c1 c2'><p>C</p></div></div>");
+        Elements els = doc.select("p");
+        Element pA = els.get(0);
+        Element pB = els.get(1);
+        Element pC = els.get(2);
+        assertEquals("#id1 > p", pA.cssSelector());
+        assertEquals("html > body > div > div:nth-child(2) > p", pB.cssSelector());
+        assertEquals("html > body > div > div.c1.c2 > p", pC.cssSelector());
+    }
+
+    @Test void cssSelectorWithNonUniqueId() {
+        Document doc = Jsoup.parse("<main id=out><div><div id=in>One</div><div id=in>Two</div></div></main>");
+        Element two = doc.expectFirst("div:containsOwn(Two)");
+        String selector = two.cssSelector();
+        assertEquals("#out > div > div:nth-child(2)", selector);
+        Elements found = doc.select(selector);
+        assertEquals(1, found.size());
+        assertEquals(two, found.first());
+    }
+
     @Test void cssSelectorDoesntStackOverflow() {
         // https://github.com/jhy/jsoup/issues/2001
         Element element = new Element("element");