001package org.jsoup.parser;
002
003import org.jsoup.Connection;
004import org.jsoup.helper.Validate;
005import org.jsoup.nodes.Document;
006import org.jsoup.nodes.Element;
007import org.jsoup.nodes.Node;
008import org.jsoup.select.Evaluator;
009import org.jsoup.select.NodeVisitor;
010import org.jsoup.select.Selector;
011import org.jspecify.annotations.Nullable;
012
013import java.io.Closeable;
014import java.io.IOException;
015import java.io.Reader;
016import java.io.StringReader;
017import java.io.UncheckedIOException;
018import java.util.HashSet;
019import java.util.Iterator;
020import java.util.LinkedList;
021import java.util.List;
022import java.util.NoSuchElementException;
023import java.util.Queue;
024import java.util.Spliterator;
025import java.util.Spliterators;
026import java.util.stream.Stream;
027import java.util.stream.StreamSupport;
028
029/**
030 A StreamParser provides a progressive parse of its input. As each Element is completed, it is emitted via a Stream or
031 Iterator interface. Elements returned will be complete with all their children, and an (empty) next sibling, if
032 applicable.
033 <p>To conserve memory, you can {@link Node#remove() remove()} Elements (or their children) from the DOM during the
034 parse. This provides a mechanism to parse an input document that would otherwise be too large to fit into memory, yet
035 still providing a DOM interface to the document and its elements.</p>
036 <p>
037 Additionally, the parser provides a {@link #selectFirst(String query)} / {@link #selectNext(String query)}, which will
038 run the parser until a hit is found, at which point the parse is suspended. It can be resumed via another
039 {@code select()} call, or via the {@link #stream()} or {@link #iterator()} methods.
040 </p>
041 <p>Once the input has been fully read, the input Reader will be closed. Or, if the whole document does not need to be
042 read, call {@link #stop()} and {@link #close()}.</p>
043 <p>The {@link #document()} method will return the Document being parsed into, which will be only partially complete
044 until the input is fully consumed.</p>
045 <p>A StreamParser can be reused via a new {@link #parse(Reader, String)}, but is not thread-safe for concurrent inputs.
046 New parsers should be used in each thread.</p>
047 <p>If created via {@link Connection.Response#streamParser()}, or another Reader that is I/O backed, the iterator and
048 stream consumers will throw an {@link java.io.UncheckedIOException} if the underlying Reader errors during read.</p>
049 <p>For examples, see the jsoup
050 <a href="https://jsoup.org/cookbook/input/streamparser-dom-sax">StreamParser cookbook.</a></p>
051 <p>
052 Selectors that depend on knowing all siblings (e.g. {@code :last-child}, {@code :last-of-type}, {@code :nth-last-child},
053 {@code :only-child} and their negations) cannot be correctly evaluated while streaming, because the parser does not know
054 if a later sibling will appear. For those cases, run {@link #complete()} first to finish the parse (which is effectively
055 the same as using {@code Jsoup.parse(...)} unless you have already removed nodes during streaming).
056 </p>
057 @since 1.18.1 */
058public class StreamParser implements Closeable {
059    final private Parser parser;
060    final private TreeBuilder treeBuilder;
061    final private ElementIterator it = new ElementIterator();
062    @Nullable private Document document;
063    private boolean stopped = false;
064
065    /**
066     Construct a new StreamParser, using the supplied base Parser.
067     @param parser the configured base parser
068     */
069    public StreamParser(Parser parser) {
070        this.parser = parser;
071        treeBuilder = parser.getTreeBuilder();
072        treeBuilder.nodeListener(it);
073    }
074
075    /**
076     Provide the input for a Document parse. The input is not read until a consuming operation is called.
077     @param input the input to be read.
078     @param baseUri the URL of this input, for absolute link resolution
079     @return this parser, for chaining
080     */
081    public StreamParser parse(Reader input, String baseUri) {
082        close(); // probably a no-op, but ensures any previous reader is closed
083        it.reset();
084        treeBuilder.initialiseParse(input, baseUri, parser); // reader is not read, so no chance of IO error
085        document = treeBuilder.doc;
086        return this;
087    }
088
089    /**
090     Provide the input for a Document parse. The input is not read until a consuming operation is called.
091     @param input the input to be read
092     @param baseUri the URL of this input, for absolute link resolution
093     @return this parser
094     */
095    public StreamParser parse(String input, String baseUri) {
096        return parse(new StringReader(input), baseUri);
097    }
098
099    /**
100     Provide the input for a fragment parse. The input is not read until a consuming operation is called.
101     @param input the input to be read
102     @param context the optional fragment context element
103     @param baseUri the URL of this input, for absolute link resolution
104     @return this parser
105     @see #completeFragment()
106     */
107    public StreamParser parseFragment(Reader input, @Nullable Element context, String baseUri) {
108        parse(input, baseUri);
109        treeBuilder.initialiseParseFragment(context);
110        return this;
111    }
112
113    /**
114     Provide the input for a fragment parse. The input is not read until a consuming operation is called.
115     @param input the input to be read
116     @param context the optional fragment context element
117     @param baseUri the URL of this input, for absolute link resolution
118     @return this parser
119     @see #completeFragment()
120     */
121    public StreamParser parseFragment(String input, @Nullable Element context, String baseUri) {
122        return parseFragment(new StringReader(input), context, baseUri);
123    }
124
125    /**
126     Creates a {@link Stream} of {@link Element}s, with the input being parsed as each element is consumed. Each
127     Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that
128     (empty) sibling will exist at {@link Element#nextElementSibling()}). The stream will be emitted in document order as
129     each element is closed. That means that child elements will be returned prior to their parents.
130     <p>The stream will start from the current position of the backing iterator and the parse.</p>
131     <p>When consuming the stream, if the Reader that the Parser is reading throws an I/O exception (for example a
132     SocketTimeoutException), that will be emitted as an {@link UncheckedIOException}</p>
133     @return a stream of Element objects
134     @throws UncheckedIOException if the underlying Reader excepts during a read (in stream consuming methods)
135     */
136    public Stream<Element> stream() {
137        return StreamSupport.stream(
138            Spliterators.spliteratorUnknownSize(
139                it, Spliterator.DISTINCT | Spliterator.NONNULL | Spliterator.ORDERED),
140            false);
141    }
142
143    /**
144     Returns an {@link Iterator} of {@link Element}s, with the input being parsed as each element is consumed. Each
145     Element returned will be complete (that is, all of its children will be included, and if it has a next sibling, that
146     (empty) sibling will exist at {@link Element#nextElementSibling()}). The elements will be emitted in document order as
147     each element is closed. That means that child elements will be returned prior to their parents.
148     <p>The iterator will start from the current position of the parse.</p>
149     <p>The iterator is backed by this StreamParser, and the resources it holds.</p>
150     @return a stream of Element objects
151     */
152    public Iterator<Element> iterator() {
153        //noinspection ReturnOfInnerClass
154        return it;
155    }
156
157    /**
158     Flags that the parse should be stopped; the backing iterator will not return any more Elements.
159     @return this parser
160     */
161    public StreamParser stop() {
162        stopped = true;
163        return this;
164    }
165
166    /**
167     Closes the input and releases resources including the underlying parser and reader.
168     <p>The parser will also be closed when the input is fully read.</p>
169     <p>The parser can be reused with another call to {@link #parse(Reader, String)}.</p>
170    */
171    @Override public void close() {
172        it.deferOpenElements();
173        stopped = true;
174        treeBuilder.closeParse(); // closes the reader, frees resources
175    }
176
177    /**
178     Get the current {@link Document} as it is being parsed. It will be only partially complete until the input is fully
179     read. Structural changes (e.g. insert, remove) may be made to the Document contents.
180     @return the (partial) Document
181     */
182    public Document document() {
183        document = treeBuilder.doc;
184        Validate.notNull(document, "Must run parse() before calling.");
185        return document;
186    }
187
188    /**
189     Runs the parser until the input is fully read, and returns the completed Document.
190     @return the completed Document
191     @throws IOException if an I/O error occurs
192     */
193    public Document complete() throws IOException {
194        Document doc = document();
195        treeBuilder.runParser();
196        return doc;
197    }
198
199    /**
200     When initialized as a fragment parse, runs the parser until the input is fully read, and returns the completed
201     fragment child nodes.
202     @return the completed child nodes
203     @throws IOException if an I/O error occurs
204     @see #parseFragment(Reader, Element, String)
205     */
206    public List<Node> completeFragment() throws IOException {
207        treeBuilder.runParser();
208        return treeBuilder.completeParseFragment();
209    }
210
211    /**
212     Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the
213     input will be parsed until the first match is ready for stream emission, or the input is completely read.
214     @param query the {@link org.jsoup.select.Selector} query.
215     @return the first matching {@link Element}, or {@code null} if there's no match
216     @throws IOException if an I/O error occurs
217     @see #selectFirst(Evaluator)
218     */
219    public @Nullable Element selectFirst(String query) throws IOException {
220        return selectFirst(Selector.evaluatorOf(query));
221    }
222
223    /**
224     Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This
225     is useful if you want to simply abort processing on a failed match.
226     @param query the {@link org.jsoup.select.Selector} query.
227     @return the first matching element
228     @throws IllegalArgumentException if no match is found
229     @throws IOException if an I/O error occurs
230     */
231    public Element expectFirst(String query) throws IOException {
232        return Validate.expectNotNull(
233            selectFirst(query),
234            "No elements matched the query '%s' in the document."
235            , query
236        );
237    }
238
239    /**
240     Finds the first Element that matches the provided query. If the parsed Document does not already have a match, the
241     input will be parsed until the first match is ready for stream emission, or the input is completely read.
242     <p>By providing a compiled evaluator vs. a CSS selector, this method may be more efficient when executing the same
243     query against multiple documents.</p>
244     @param eval the {@link org.jsoup.select.Selector} evaluator.
245     @return the first matching {@link Element}, or {@code null} if there's no match
246     @throws IOException if an I/O error occurs
247     @see Selector#evaluatorOf(String css)
248     */
249    public @Nullable Element selectFirst(Evaluator eval) throws IOException {
250        final Document doc = document();
251
252        // a ready match can be returned without advancing the iterator
253        Element first = doc.selectFirst(eval);
254        if (first != null && it.isPending(first)) {
255            if (!it.awaitReady(first)) return null;
256            first = doc.selectFirst(eval);
257        }
258        if (first != null && !it.isPending(first)) return first;
259
260        // use the iterator for linear progress
261        Element emitted = selectNext(eval);
262        if (emitted == null) {
263            if (!treeBuilder.isComplete()) return null;
264            return doc.selectFirst(eval);
265        }
266
267        // reconcile the iterator's child-first emission order with document order
268        while ((first = doc.selectFirst(eval)) != null && it.isPending(first)) {
269            if (!it.awaitReady(first)) return null;
270        }
271        return first != null ? first : emitted;
272    }
273
274    /**
275     Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or
276     the input is completely read.
277     @param query the {@link org.jsoup.select.Selector} query.
278     @return the next matching {@link Element}, or {@code null} if there's no match
279     @throws IOException if an I/O error occurs
280     @see #selectNext(Evaluator)
281     */
282    public @Nullable Element selectNext(String query) throws IOException {
283        return selectNext(Selector.evaluatorOf(query));
284    }
285
286    /**
287     Just like {@link #selectFirst(String)}, but if there is no match, throws an {@link IllegalArgumentException}. This
288     is useful if you want to simply abort processing on a failed match.
289     @param query the {@link org.jsoup.select.Selector} query.
290     @return the first matching element
291     @throws IllegalArgumentException if no match is found
292     @throws IOException if an I/O error occurs
293     */
294    public Element expectNext(String query) throws IOException {
295        return Validate.expectNotNull(
296            selectNext(query),
297            "No elements matched the query '%s' in the document."
298            , query
299        );
300    }
301
302    /**
303     Finds the next Element that matches the provided query. The input will be parsed until the next match is found, or
304     the input is completely read.
305     <p>By providing a compiled evaluator vs a CSS selector, this method may be more efficient when executing the same
306     query against multiple documents.</p>
307     @param eval the {@link org.jsoup.select.Selector} evaluator.
308     @return the next matching {@link Element}, or {@code null} if there's no match
309     @throws IOException if an I/O error occurs
310     @see Selector#evaluatorOf(String css)
311     */
312    public @Nullable Element selectNext(Evaluator eval) throws IOException {
313        try {
314            final Document doc = document(); // validates the parse was initialized, keeps stack trace out of stream
315            return stream()
316                .filter(eval.asPredicate(doc))
317                .findFirst()
318                .orElse(null);
319        } catch (UncheckedIOException e) {
320            // Reader threw an IO exception emitted via Iterator's next()
321            throw e.getCause();
322        }
323    }
324
325    final class ElementIterator implements Iterator<Element>, NodeVisitor {
326        final private Queue<Element> emitQueue = new LinkedList<>(); // ready elements waiting for iterator delivery, in emission order
327        final private HashSet<Element> deferredEls = new HashSet<>(); // off-stack elements not yet ready for emission
328        private @Nullable Element current;  // most recently emitted
329        private @Nullable Element next;     // element waiting to be picked up
330        private @Nullable Element tail;     // The last tailed element (</html>), on hold for final pop
331
332        void reset() {
333            emitQueue.clear();
334            deferredEls.clear();
335            current = next = tail = null;
336            stopped = false;
337        }
338
339        /** Defers open Elements before an early close releases the tree-builder stack. */
340        void deferOpenElements() {
341            treeBuilder.copyOpenElementsTo(deferredEls);
342        }
343
344        /** Tests whether an Element has reached its iterator emission point. */
345        boolean isReady(Element element) {
346            return current == element || next == element || emitQueue.contains(element);
347        }
348
349        /** Tests whether an Element still needs parser advancement before selection. */
350        boolean isPending(Element element) {
351            // the document is pending until EOF; other pending elements are open or explicitly deferred
352            return !treeBuilder.isComplete() &&
353                (element == treeBuilder.doc || treeBuilder.isOpen(element) || deferredEls.contains(element));
354        }
355
356        /** Advances parsing until an Element is ready; returns false if parsing was stopped. */
357        boolean awaitReady(Element element) throws IOException {
358            try {
359                while (isPending(element) && !stopped) {
360                    if (!hasNext()) break;
361                    if (isPending(element)) next(); // hasNext may have buffered the target itself
362                }
363                return !isPending(element);
364            } catch (UncheckedIOException e) {
365                // preserve the checked I/O contract when iterator advancement reads input
366                throw e.getCause();
367            }
368        }
369
370        /** Marks an Element ready and queues it for iterator emission. */
371        void queueForEmission(Element element) {
372            deferredEls.remove(element);
373            emitQueue.add(element);
374        }
375
376        // Iterator Interface:
377        /**
378         {@inheritDoc}
379         @throws UncheckedIOException if the underlying Reader errors during a read
380         */
381        @Override public boolean hasNext() {
382            maybeFindNext();
383            return next != null;
384        }
385
386        /**
387         {@inheritDoc}
388         @throws UncheckedIOException if the underlying Reader errors during a read
389         */
390        @Override public Element next() {
391            maybeFindNext();
392            if (next == null) throw new NoSuchElementException();
393            current = next;
394            next = null;
395            return current;
396        }
397
398        private void maybeFindNext() {
399            if (stopped || next != null) return;
400
401            // drain the current queue before stepping to get more
402            if (!emitQueue.isEmpty()) {
403                next = emitQueue.remove();
404                return;
405            }
406
407            // step the parser, which will hit the node listeners to add to the queue:
408            while (treeBuilder.stepParser()) {
409                if (!emitQueue.isEmpty()) {
410                    next = emitQueue.remove();
411                    return;
412                }
413            }
414            close();
415
416            // send the final element out:
417            if (tail != null) {
418                deferredEls.remove(tail);
419                next = tail;
420                tail = null;
421            }
422        }
423
424        @Override public void remove() {
425            if (current == null) throw new NoSuchElementException();
426            current.remove();
427        }
428
429        // NodeVisitor Interface:
430        @Override public void head(Node node, int depth) {
431            if (node instanceof Element) {
432                Element prev = node.previousElementSibling();
433                // We prefer to wait until an element has a next sibling before emitting it; otherwise, get it in tail
434                if (prev != null) {
435                    queueForEmission(prev);
436                    if (tail == prev) tail = null; // queueing marks it ready
437                }
438            }
439        }
440
441        @Override public void tail(Node node, int depth) {
442            if (node instanceof Element) {
443                tail = (Element) node; // kept for final hit
444                if (!isReady(tail)) deferredEls.add(tail);
445                Element lastChild = tail.lastElementChild(); // won't get a nextsib, so emit that:
446                if (lastChild != null) queueForEmission(lastChild);
447                if (tail == treeBuilder.doc) deferredEls.clear(); // completed document makes stale recovery state irrelevant
448            }
449        }
450    }
451}