From 3017137db5b93d8711d15e1850b525e19fb4b5b2 Mon Sep 17 00:00:00 2001 From: Chris Midgley Date: Tue, 21 Jan 2025 11:31:45 +0000 Subject: [PATCH 1/4] Add `selectFirst` and `expectFirst` to Elements --- src/main/java/org/jsoup/select/Elements.java | 26 +++++++++++++++ src/main/java/org/jsoup/select/Selector.java | 22 +++++++++++++ .../java/org/jsoup/select/ElementsTest.java | 33 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/src/main/java/org/jsoup/select/Elements.java b/src/main/java/org/jsoup/select/Elements.java index e05d1fb93d..2c356eb2e0 100644 --- a/src/main/java/org/jsoup/select/Elements.java +++ b/src/main/java/org/jsoup/select/Elements.java @@ -452,6 +452,32 @@ 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 query A {@link Selector} query + * @return the first matching element, or {@code null} if there is no match. + * @see #expectFirst(String) + */ + public @Nullable Element selectFirst(String query) { + return Selector.selectFirst(query, this); + } + + /** + * Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. + * @param query A {@link Selector} query + * @return the first matching element + * @throws IllegalArgumentException if no match is found + */ + public Element expectFirst(String query) { + return (Element) Validate.ensureNotNull( + Selector.selectFirst(query, this), + "No elements matched the query '%s'." + , query + ); + } + /** * 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..fe1d42248e 100644 --- a/src/main/java/org/jsoup/select/Selector.java +++ b/src/main/java/org/jsoup/select/Selector.java @@ -197,6 +197,28 @@ 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 query CSS selector + @param roots root elements to descend into + @return matching elements, empty if none + */ + public static @Nullable Element selectFirst(String query, Iterable roots) { + Validate.notEmpty(query); + Validate.notNull(roots); + Evaluator evaluator = QueryParser.parse(query); + + for (Element root : roots) { + var 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..35e3091275 100644 --- a/src/test/java/org/jsoup/select/ElementsTest.java +++ b/src/test/java/org/jsoup/select/ElementsTest.java @@ -599,4 +599,37 @@ public void tail(Node node, int depth) { // check dom assertEquals("

One
Two
Three
Four
", TextUtil.normalizeSpaces(doc.body().html())); } + + @Test public void selectFirst() { + Document doc = Jsoup.parse("

One

Two Jsoup

Three

"); + Element span = doc.children().selectFirst("span"); + assertNotNull(span); + assertEquals("Jsoup", span.text()); + } + + @Test public void selectFirstNullOnNoMatch() { + Document doc = Jsoup.parse("

One

Two

Three

"); + Element span = doc.children().selectFirst("span"); + assertNull(span); + } + + @Test public void expectFirst() { + Document doc = Jsoup.parse("

One

Two Jsoup

Three

"); + Element span = doc.children().expectFirst("span"); + assertNotNull(span); + assertEquals("Jsoup", span.text()); + } + + @Test public 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'.", e.getMessage()); + } + assertTrue(threw); + } } From 67e0b64c67c16ec1a821b6060ed788c532e29d0a Mon Sep 17 00:00:00 2001 From: Chris Midgley Date: Tue, 21 Jan 2025 11:42:20 +0000 Subject: [PATCH 2/4] Use explicit type instead of var --- src/main/java/org/jsoup/select/Selector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java index fe1d42248e..95289944d0 100644 --- a/src/main/java/org/jsoup/select/Selector.java +++ b/src/main/java/org/jsoup/select/Selector.java @@ -210,7 +210,7 @@ static Elements filterOut(Collection elements, Collection outs Evaluator evaluator = QueryParser.parse(query); for (Element root : roots) { - var first = Collector.findFirst(evaluator, root); + Element first = Collector.findFirst(evaluator, root); if (first != null) { return first; } From 9747b57ebd55d4a8df311cf0aec8b913e4023cf4 Mon Sep 17 00:00:00 2001 From: Jonathan Hedley Date: Fri, 24 Jan 2025 12:42:26 +1100 Subject: [PATCH 3/4] Added CHANGES, tests; tweaked format --- CHANGES.md | 2 ++ src/main/java/org/jsoup/select/Elements.java | 35 ++++++++++--------- src/main/java/org/jsoup/select/Selector.java | 17 +++++---- .../java/org/jsoup/select/ElementsTest.java | 27 +++++++++++--- 4 files changed, 51 insertions(+), 30 deletions(-) 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 2c356eb2e0..c0111554c7 100644 --- a/src/main/java/org/jsoup/select/Elements.java +++ b/src/main/java/org/jsoup/select/Elements.java @@ -453,28 +453,31 @@ public Elements select(String query) { } /** - * 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 query A {@link Selector} query - * @return the first matching element, or {@code null} if there is no match. - * @see #expectFirst(String) + 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 query) { - return Selector.selectFirst(query, this); + 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 query A {@link Selector} query - * @return the first matching element - * @throws IllegalArgumentException if no match is found + 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 query) { + public Element expectFirst(String cssQuery) { return (Element) Validate.ensureNotNull( - Selector.selectFirst(query, this), - "No elements matched the query '%s'." - , query + Selector.selectFirst(cssQuery, this), + "No elements matched the query '%s' in the elements.", cssQuery ); } diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java index 95289944d0..7cbc164cbc 100644 --- a/src/main/java/org/jsoup/select/Selector.java +++ b/src/main/java/org/jsoup/select/Selector.java @@ -200,20 +200,19 @@ static Elements filterOut(Collection elements, Collection outs /** Find the first element matching the query, across multiple roots. - @param query CSS selector + @param cssQuery CSS selector @param roots root elements to descend into - @return matching elements, empty if none + @return the first matching element, or {@code null} if none + @since 1.19.1 */ - public static @Nullable Element selectFirst(String query, Iterable roots) { - Validate.notEmpty(query); + public static @Nullable Element selectFirst(String cssQuery, Iterable roots) { + Validate.notEmpty(cssQuery); Validate.notNull(roots); - Evaluator evaluator = QueryParser.parse(query); + Evaluator evaluator = QueryParser.parse(cssQuery); for (Element root : roots) { - Element first = Collector.findFirst(evaluator, root); - if (first != null) { - return first; - } + Element first = Collector.findFirst(evaluator, root); + if (first != null) return first; } return null; diff --git a/src/test/java/org/jsoup/select/ElementsTest.java b/src/test/java/org/jsoup/select/ElementsTest.java index 35e3091275..004da32299 100644 --- a/src/test/java/org/jsoup/select/ElementsTest.java +++ b/src/test/java/org/jsoup/select/ElementsTest.java @@ -600,27 +600,27 @@ public void tail(Node node, int depth) { assertEquals("
One
Two
Three
Four
", TextUtil.normalizeSpaces(doc.body().html())); } - @Test public void selectFirst() { + @Test void selectFirst() { Document doc = Jsoup.parse("

One

Two Jsoup

Three

"); Element span = doc.children().selectFirst("span"); assertNotNull(span); assertEquals("Jsoup", span.text()); } - @Test public void selectFirstNullOnNoMatch() { + @Test void selectFirstNullOnNoMatch() { Document doc = Jsoup.parse("

One

Two

Three

"); Element span = doc.children().selectFirst("span"); assertNull(span); } - @Test public void expectFirst() { + @Test void expectFirst() { Document doc = Jsoup.parse("

One

Two Jsoup

Three

"); Element span = doc.children().expectFirst("span"); assertNotNull(span); assertEquals("Jsoup", span.text()); } - @Test public void expectFirstThrowsOnNoMatch() { + @Test void expectFirstThrowsOnNoMatch() { Document doc = Jsoup.parse("

One

Two

Three

"); boolean threw = false; @@ -628,8 +628,25 @@ public void tail(Node node, int depth) { Element span = doc.children().expectFirst("span"); } catch (IllegalArgumentException e) { threw = true; - assertEquals("No elements matched the query 'span'.", e.getMessage()); + 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 + } } From 9372b76c4bf10f5e86bda5df2787503484d7456b Mon Sep 17 00:00:00 2001 From: Jonathan Hedley Date: Fri, 24 Jan 2025 12:45:34 +1100 Subject: [PATCH 4/4] Corrected test --- src/test/java/org/jsoup/select/ElementsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/jsoup/select/ElementsTest.java b/src/test/java/org/jsoup/select/ElementsTest.java index 004da32299..b0814c71f5 100644 --- a/src/test/java/org/jsoup/select/ElementsTest.java +++ b/src/test/java/org/jsoup/select/ElementsTest.java @@ -628,7 +628,7 @@ public void tail(Node node, int depth) { Element span = doc.children().expectFirst("span"); } catch (IllegalArgumentException e) { threw = true; - assertEquals("No elements matched the query 'span'. in the elements", e.getMessage()); + assertEquals("No elements matched the query 'span' in the elements.", e.getMessage()); } assertTrue(threw); }