diff --git a/CHANGES.md b/CHANGES.md
index f7dae1d97e..f67d2ce5f5 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 `` 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/src/main/java/org/jsoup/select/Elements.java b/src/main/java/org/jsoup/select/Elements.java
index e05d1fb93d..c0111554c7 100644
--- a/src/main/java/org/jsoup/select/Elements.java
+++ b/src/main/java/org/jsoup/select/Elements.java
@@ -452,6 +452,35 @@ public Elements select(String query) {
return Selector.select(query, this);
}
+ /**
+ Find the first Element that matches the {@link Selector} CSS query within this element list.
+
This is effectively the same as calling {@code elements.select(query).first()}, but is more efficient as query
+ execution stops on the first hit.
+
+ @param cssQuery a {@link Selector} query
+ @return the first matching element, or {@code null} 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.
*
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 5b0041f669..7cbc164cbc 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -197,6 +197,27 @@ static Elements filterOut(Collection elements, Collection outs
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 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);
diff --git a/src/test/java/org/jsoup/select/ElementsTest.java b/src/test/java/org/jsoup/select/ElementsTest.java
index b5ea4ef358..b0814c71f5 100644
--- a/src/test/java/org/jsoup/select/ElementsTest.java
+++ b/src/test/java/org/jsoup/select/ElementsTest.java
@@ -599,4 +599,54 @@ public void tail(Node node, int depth) {
// check dom
assertEquals(" One
Two
Three
Four
", TextUtil.normalizeSpaces(doc.body().html()));
}
+
+ @Test void selectFirst() {
+ Document doc = Jsoup.parse("One
Two Jsoup
Three
");
+ Element span = doc.children().selectFirst("span");
+ assertNotNull(span);
+ assertEquals("Jsoup", span.text());
+ }
+
+ @Test void selectFirstNullOnNoMatch() {
+ Document doc = Jsoup.parse("One
Two
Three
");
+ Element span = doc.children().selectFirst("span");
+ assertNull(span);
+ }
+
+ @Test void expectFirst() {
+ Document doc = Jsoup.parse("One
Two Jsoup
Three
");
+ Element span = doc.children().expectFirst("span");
+ assertNotNull(span);
+ assertEquals("Jsoup", span.text());
+ }
+
+ @Test void expectFirstThrowsOnNoMatch() {
+ Document doc = Jsoup.parse("One
Two
Three
");
+
+ 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("");
+ 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
+ }
}