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("

One

Two

Three

"); + 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 + } }