diff --git a/CHANGES.md b/CHANGES.md index 0b7c25e084..fe616b8025 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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., `©-` 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, `

X` 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) diff --git a/src/main/java/org/jsoup/parser/StreamParser.java b/src/main/java/org/jsoup/parser/StreamParser.java index 607c1ad16f..3e52bebd1c 100644 --- a/src/main/java/org/jsoup/parser/StreamParser.java +++ b/src/main/java/org/jsoup/parser/StreamParser.java @@ -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); } @@ -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; } diff --git a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java index c61eddca06..6e0f19ed89 100644 --- a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java +++ b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java @@ -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; @@ -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 exercise, String message) throws IOException { Parser parser = new Parser(tb).setTrackErrors(20); - try (StreamParser stream = new StreamParser(parser).parseFragment("

After

", new Element("div"), "")) { + tb.initialiseParse(new StringReader("

After

"), "", 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(); } } @@ -185,7 +191,7 @@ private static void assertAdoptionRecovery(HtmlTreeBuilder tb, ConsumerOne

Full

", "")) { + TreeBuilder treeBuilder = streamParser.treeBuilder; + assertFalse(treeBuilder.isComplete()); streamParser.expectFirst("title"); Element open = streamParser.document().expectFirst("#hit"); assertTrue(treeBuilder.isOpen(open)); diff --git a/src/test/java/org/jsoup/parser/StreamParserTest.java b/src/test/java/org/jsoup/parser/StreamParserTest.java index 2f24613de7..a6f288a1bc 100644 --- a/src/test/java/org/jsoup/parser/StreamParserTest.java +++ b/src/test/java/org/jsoup/parser/StreamParserTest.java @@ -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("

One

Two

", "")) { + Document doc = streamer.document(); + assertSame(parser, doc.parser()); + assertEquals("One", streamer.expectFirst("p").text()); + + Document ordinary = parser.parseInput("
Other
", ""); + assertEquals("Other", ordinary.body().text()); + + assertSame(doc, streamer.document()); + assertEquals("Two", streamer.selectNext("p").text()); + assertEquals("

One

\n

Two

", streamer.complete().body().html()); + } + } + + @Test void streamsAfterHtmlAppend() throws IOException { + Parser parser = Parser.htmlParser(); + try (StreamParser streamer = new StreamParser(parser).parse("

One

Two

", "")) { + Document doc = streamer.document(); + assertSame(parser, doc.parser()); + Element first = streamer.expectFirst("p"); + assertEquals("One", first.text()); + + first.append("Added"); + assertEquals("OneAdded", first.html()); + + assertSame(doc, streamer.document()); + assertEquals("Two", streamer.selectNext("p").text()); + assertEquals("

OneAdded

\n

Two

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

One

", "").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 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("

Other

", ""); + + assertThrows(IllegalArgumentException.class, streamer::document); + assertFalse(streamer.iterator().hasNext()); + } + } + @Test void canStopAndCompleteAndReuse() throws IOException { StreamParser parser = new StreamParser(Parser.htmlParser()); String html1 = "

One

Two"; @@ -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 { @@ -694,7 +759,7 @@ void canStreamFragment() { .parseFragment("

One
Two
", 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()); }