Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* Named character references without a semicolon before `-` or `_` now decode correctly in attribute values, matching HTML and browser behavior (e.g., `&copy-` becomes `©-`). [#2588](https://github.com/jhy/jsoup/issues/2588)
* Support downloads > 2GB via `Response.bodyStream()` when `maxBodySize(0)` is configured. [#2593](https://github.com/jhy/jsoup/issues/2593)
* Updated table and table-fragment parsing to place misnested content correctly per the HTML5 spec. For example, `<table><b><p>X</b>` places the paragraph before the table. [#2601](https://github.com/jhy/jsoup/issues/2601)
* `StreamParser` now preserves its progress if you use the same `Parser` to parse another document, or call `Element.append(String)` on a streamed element. Previously, these operations replaced the active parse state and prevented streaming from continuing correctly. [#2605](https://github.com/jhy/jsoup/issues/2605)
* Updated the adoption-agency algorithm to match the current HTML5 spec and to preserve formatting order when recovering misnested elements. [#2604](https://github.com/jhy/jsoup/pull/2604)

## 1.23.2 (2026-Aug-26)
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/org/jsoup/parser/StreamParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,19 @@ Selectors that depend on knowing all siblings (e.g. {@code :last-child}, {@code
@since 1.18.1 */
public class StreamParser implements Closeable {
final private Parser parser;
final private TreeBuilder treeBuilder;
final TreeBuilder treeBuilder;
final private ElementIterator it = new ElementIterator();
@Nullable private Document document;
private boolean stopped = false;
private boolean stopped = true;

/**
Construct a new StreamParser, using the supplied base Parser.
The supplied parser provides configuration and collects parse errors; streaming uses an independent tree builder.
@param parser the configured base parser
*/
public StreamParser(Parser parser) {
this.parser = parser;
treeBuilder = parser.getTreeBuilder();
treeBuilder = parser.getTreeBuilder().newInstance();
treeBuilder.nodeListener(it);
}

Expand Down Expand Up @@ -180,7 +181,6 @@ read. Structural changes (e.g. insert, remove) may be made to the Document conte
@return the (partial) Document
*/
public Document document() {
document = treeBuilder.doc;
Validate.notNull(document, "Must run parse() before calling.");
return document;
}
Expand Down
17 changes: 11 additions & 6 deletions src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.StringReader;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -130,11 +131,16 @@ public class HtmlTreeBuilderTest {
// exercise an adoption error, then verify that the parser can consume the remaining input
private static void assertAdoptionRecovery(HtmlTreeBuilder tb, Consumer<HtmlTreeBuilder> exercise, String message) throws IOException {
Parser parser = new Parser(tb).setTrackErrors(20);
try (StreamParser stream = new StreamParser(parser).parseFragment("<p>After</p>", new Element("div"), "")) {
tb.initialiseParse(new StringReader("<p>After</p>"), "", parser);
tb.initialiseParseFragment(new Element("div"));
try {
exercise.accept(tb);
assertTrue(parser.getErrors().stream().anyMatch(error -> error.getErrorMessage().equals(message)),
() -> "Expected error: " + message + "; got: " + parser.getErrors());
assertEquals("After", stream.complete().text());
tb.runParser();
assertEquals("After", tb.doc.text());
} finally {
tb.closeParse();
}
}

Expand Down Expand Up @@ -185,7 +191,7 @@ private static void assertAdoptionRecovery(HtmlTreeBuilder tb, Consumer<HtmlTree
// a table removed while still open uses the element above it on the stack
Parser parser = Parser.htmlParser();
try (StreamParser stream = new StreamParser(parser).parseFragment("", new Element("div"), "")) {
HtmlTreeBuilder tb = (HtmlTreeBuilder) parser.getTreeBuilder();
HtmlTreeBuilder tb = (HtmlTreeBuilder) stream.treeBuilder;
Element container = stream.document().child(0);
Element table = container.appendElement("table");
tb.push(table);
Expand Down Expand Up @@ -239,10 +245,9 @@ public void nonnull() {

@Test void tracksParseLifecycle() throws IOException {
Parser parser = Parser.htmlParser();
TreeBuilder treeBuilder = parser.getTreeBuilder();
assertFalse(treeBuilder.isComplete());

try (StreamParser streamParser = new StreamParser(parser).parse("<title>One</title><p id=hit>Full</p>", "")) {
TreeBuilder treeBuilder = streamParser.treeBuilder;
assertFalse(treeBuilder.isComplete());
streamParser.expectFirst("title");
Element open = streamParser.document().expectFirst("#hit");
assertTrue(treeBuilder.isOpen(open));
Expand Down
73 changes: 69 additions & 4 deletions src/test/java/org/jsoup/parser/StreamParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,72 @@ void canStreamXml() {
assertEquals("", seen3.toString());
}

@Test void streamsAfterParserReuse() throws IOException {
Parser parser = Parser.htmlParser();
try (StreamParser streamer = new StreamParser(parser).parse("<p>One</p><p>Two</p>", "")) {
Document doc = streamer.document();
assertSame(parser, doc.parser());
assertEquals("One", streamer.expectFirst("p").text());

Document ordinary = parser.parseInput("<div>Other</div>", "");
assertEquals("Other", ordinary.body().text());

assertSame(doc, streamer.document());
assertEquals("Two", streamer.selectNext("p").text());
assertEquals("<p>One</p>\n<p>Two</p>", streamer.complete().body().html());
}
}

@Test void streamsAfterHtmlAppend() throws IOException {
Parser parser = Parser.htmlParser();
try (StreamParser streamer = new StreamParser(parser).parse("<p>One</p><p>Two</p>", "")) {
Document doc = streamer.document();
assertSame(parser, doc.parser());
Element first = streamer.expectFirst("p");
assertEquals("One", first.text());

first.append("<b>Added</b>");
assertEquals("One<b>Added</b>", first.html());

assertSame(doc, streamer.document());
assertEquals("Two", streamer.selectNext("p").text());
assertEquals("<p>One<b>Added</b></p>\n<p>Two</p>", streamer.complete().body().html());
}
}

@Test void sharesParserSettingsAndErrors() throws IOException {
Parser parser = Parser.htmlParser();
try (StreamParser streamer = new StreamParser(parser)) {
parser.settings(ParseSettings.preserveCase).setTrackPosition(true).setTrackErrors(10);
Document doc = streamer.parse("<P ID=one>One</P></span>", "").complete();

assertSame(parser, doc.parser());
Element p = doc.expectFirst("P");
assertEquals("P", p.tagName());
assertTrue(p.attributes().hasKey("ID"));
assertTrue(p.sourceRange().isTracked());
assertFalse(parser.getErrors().isEmpty());
}
}

@Test void iteratorEmptyBeforeParse() {
try (StreamParser streamer = new StreamParser(Parser.htmlParser())) {
Iterator<Element> iterator = streamer.iterator();
assertFalse(iterator.hasNext());
assertThrows(NoSuchElementException.class, iterator::next);
}
}

@Test void parserReuseDoesNotStartStream() {
Parser parser = Parser.htmlParser();
try (StreamParser streamer = new StreamParser(parser)) {
parser.parseInput("<p>Other</p>", "");

assertThrows(IllegalArgumentException.class, streamer::document);
assertFalse(streamer.iterator().hasNext());
}
}

@Test void canStopAndCompleteAndReuse() throws IOException {
StreamParser parser = new StreamParser(Parser.htmlParser());
String html1 = "<p id=one>One<p id=two>Two";
Expand Down Expand Up @@ -526,12 +592,11 @@ static StreamParser basic() {
}

static boolean isClosed(StreamParser streamer) {
// a bit of a back door in!
return getReader(streamer) == null;
}

private static CharacterReader getReader(StreamParser streamer) {
return streamer.document().parser().getTreeBuilder().reader;
private static CharacterReader getReader(StreamParser streamer) {
return streamer.treeBuilder.reader;
}

@Test void doesNotReadPastParse() throws IOException {
Expand Down Expand Up @@ -694,7 +759,7 @@ void canStreamFragment() {
.parseFragment("<div>One</div><div>Two</div>", context, "")) {
Element container = parser.expectFirst("#context");
assertEquals("One Two", container.text());
assertFalse(parser.document().parser().getTreeBuilder().isOpen(container));
assertFalse(parser.treeBuilder.isOpen(container));
assertSame(parser.document().child(0), container);
assertEquals(container.childNodes(), parser.completeFragment());
}
Expand Down