Skip to content

Commit

Permalink
Add selectFirst and expectFirst to Elements (#2263)
Browse files Browse the repository at this point in the history
Add `selectFirst` and `expectFirst` to Elements

---------

Co-authored-by: Jonathan Hedley <[email protected]>
  • Loading branch information
midgleyc and jhy authored Jan 24, 2025
1 parent eff923d commit 8086b1e
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions src/main/java/org/jsoup/select/Elements.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<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>
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/jsoup/select/Selector.java
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,27 @@ static Elements filterOut(Collection<Element> elements, Collection<Element> 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<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);
Expand Down
50 changes: 50 additions & 0 deletions src/test/java/org/jsoup/select/ElementsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -599,4 +599,54 @@ public void tail(Node node, int depth) {
// 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
}
}

0 comments on commit 8086b1e

Please sign in to comment.