Repository: jhy/jsoup (java)

Issue:
FR: selectFirst() and expectFirst() on org.jsoup.select.Elements
It'd be useful to be able to use `selectFirst()` and `expectFirst()` on `org.jsoup.select.Elements` instances to mirror the methods in `org.jsoup.nodes.Element`.
As far as I understand this is because `org.jsoup.select.Selector` provides both `select(String, Element)` and `select(String, Iterable<Element>)` but only `selectFirst(String, Element)`.

I could take a look at it myself, but people knowledgeable about jsoup internals would probably take much less time.

Bonus points if it's actually faster than `select(...).first()` 😉

Thanks for your work

Pull request description:
Add `selectFirst` and `expectFirst` to Elements
Closes #2256.

I copied the docstrings, and behaviour, from the equivalent methods on `Element` and the surrounding methods on `Elements`.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index f7dae1d9..f67d2ce5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -36,6 +36,8 @@
   planner. [2254](https://github.com/jhy/jsoup/issues/2254)
 * Removed the legacy parsing rules for `<isindex>` tags, which would autovivify a `form` element with labels. This is no
   longer in the spec.
+* Added `Elements.selectFirst(String cssQuery)` and `Elements.expectFirst(String cssQuery)`, to select the first
+  matching element from an `Elements` list.  [2263](https://github.com/jhy/jsoup/pull/2263/)
 
 ### Bug Fixes
 
diff --git a/pom.xml b/pom.xml
index bfc4b8e7..eb400a06 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,7 +33,7 @@
 
   <properties>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-    <jetty.version>9.4.56.v20240826</jetty.version>
+    <jetty.version>9.4.57.v20241219</jetty.version>
   </properties>
 
   <build>
@@ -275,7 +275,7 @@
         <!-- API version compat check - https://siom79.github.io/japicmp/ -->
         <groupId>com.github.siom79.japicmp</groupId>
         <artifactId>japicmp-maven-plugin</artifactId>
-        <version>0.23.0</version>
+        <version>0.23.1</version>
         <configuration>
           <!-- hard code previous version; can't detect when running stateless on build server -->
           <oldVersion>
diff --git a/src/main/java/org/jsoup/select/Elements.java b/src/main/java/org/jsoup/select/Elements.java
index e05d1fb9..c0111554 100644
--- a/src/main/java/org/jsoup/select/Elements.java
+++ b/src/main/java/org/jsoup/select/Elements.java
@@ -452,6 +452,35 @@ public class Elements extends ArrayList<Element> {
         return Selector.select(query, this);
     }
 
+    /**
+     Find the first Element that matches the {@link Selector} CSS query within this element list.
+     <p>This is effectively the same as calling {@code elements.select(query).first()}, but is more efficient as query
+     execution stops on the first hit.</p>
+
+     @param cssQuery a {@link Selector} query
+     @return the first matching element, or <b>{@code null}</b> if there is no match.
+     @see #expectFirst(String)
+     @since 1.19.1
+     */
+    public @Nullable Element selectFirst(String cssQuery) {
+        return Selector.selectFirst(cssQuery, this);
+    }
+
+    /**
+     Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}.
+
+     @param cssQuery a {@link Selector} query
+     @return the first matching element
+     @throws IllegalArgumentException if no match is found
+     @since 1.19.1
+     */
+    public Element expectFirst(String cssQuery) {
+        return (Element) Validate.ensureNotNull(
+            Selector.selectFirst(cssQuery, this),
+            "No elements matched the query '%s' in the elements.", cssQuery
+        );
+    }
+
     /**
      * Remove elements from this list that match the {@link Selector} query.
      * <p>
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 5b0041f6..7cbc164c 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -197,6 +197,27 @@ public class Selector {
         return Collector.findFirst(QueryParser.parse(cssQuery), root);
     }
 
+    /**
+     Find the first element matching the query, across multiple roots.
+
+     @param cssQuery CSS selector
+     @param roots root elements to descend into
+     @return the first matching element, or {@code null} if none
+     @since 1.19.1
+     */
+    public static @Nullable Element selectFirst(String cssQuery, Iterable<Element> roots) {
+        Validate.notEmpty(cssQuery);
+        Validate.notNull(roots);
+        Evaluator evaluator = QueryParser.parse(cssQuery);
+
+        for (Element root : roots) {
+            Element first = Collector.findFirst(evaluator, root);
+            if (first != null) return first;
+        }
+
+        return null;
+    }
+
     public static class SelectorParseException extends IllegalStateException {
         public SelectorParseException(String msg) {
             super(msg);


Test patch:
diff --git a/src/test/java/org/jsoup/select/ElementsTest.java b/src/test/java/org/jsoup/select/ElementsTest.java
index b5ea4ef3..b0814c71 100644
--- a/src/test/java/org/jsoup/select/ElementsTest.java
+++ b/src/test/java/org/jsoup/select/ElementsTest.java
@@ -599,4 +599,54 @@ public class ElementsTest {
         // check dom
         assertEquals("<div> One</div><div> Two</div><div> Three</div><div> Four</div>", TextUtil.normalizeSpaces(doc.body().html()));
     }
+
+    @Test void selectFirst() {
+        Document doc = Jsoup.parse("<p>One</p><p>Two <span>Jsoup</span></p><p><span>Three</span></p>");
+        Element span = doc.children().selectFirst("span");
+        assertNotNull(span);
+        assertEquals("Jsoup", span.text());
+    }
+
+    @Test void selectFirstNullOnNoMatch() {
+        Document doc = Jsoup.parse("<p>One</p><p>Two</p><p>Three</p>");
+        Element span = doc.children().selectFirst("span");
+        assertNull(span);
+    }
+
+    @Test void expectFirst() {
+        Document doc = Jsoup.parse("<p>One</p><p>Two <span>Jsoup</span></p><p><span>Three</span></p>");
+        Element span = doc.children().expectFirst("span");
+        assertNotNull(span);
+        assertEquals("Jsoup", span.text());
+    }
+
+    @Test void expectFirstThrowsOnNoMatch() {
+        Document doc = Jsoup.parse("<p>One</p><p>Two</p><p>Three</p>");
+
+        boolean threw = false;
+        try {
+            Element span = doc.children().expectFirst("span");
+        } catch (IllegalArgumentException e) {
+            threw = true;
+            assertEquals("No elements matched the query 'span' in the elements.", e.getMessage());
+        }
+        assertTrue(threw);
+    }
+
+    @Test void selectFirstFromPreviousSelect() {
+        Document doc = Jsoup.parse("<div><p>One</p></div><div><p><span>Two</span></p></div><div><p><span>Three</span></p></div>");
+        Elements divs = doc.select("div");
+        assertEquals(3, divs.size());
+
+        Element span = divs.selectFirst("p span");
+        assertNotNull(span);
+        assertEquals("Two", span.text());
+
+        // test roots
+        assertNotNull(span.selectFirst("span")); // reselect self
+        assertNull(span.selectFirst(">span")); // no span>span
+
+        assertNotNull(divs.selectFirst("div")); // reselect self, similar to element.select
+        assertNull(divs.selectFirst(">div")); // no div>div
+    }
 }