domq 0.5.0

Tiny, zero-dependency, forgiving HTML parser: turn messy real-world HTML into a clean DOM tree (and JSON). WASM-first, no_std-friendly.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! The tree builder: applies the recovery rules to a token stream.
//!
//! Parsing is infallible. The builder keeps an "open elements" stack and
//! recovers from the common real-world mistakes — omitted end tags, mis-nested
//! tags, stray end tags — recording each as a [`ParseError`] rather than
//! bailing out. See the crate docs for the exact recovery rules we implement.
//!
//! An end tag looks for the element it closes among the
//! [`MAX_END_TAG_SEARCH`] (256) innermost open elements, then among the
//! [`OUTER_END_TAG_SEARCH`] (8) outermost ones — where `</body>`, `</html>` or
//! a page wrapper's end tag finds its element under a pile of unclosed tags.
//! An element open only in between is out of reach: its end tag is treated as
//! stray ([`ErrorKind::StrayEndTag`]) and the element stays open until an end
//! tag that does reach it, or the end of the input. That makes every end tag
//! O(1) however deep the stack — an unbounded scan is quadratic on
//! `<div>`×N then `</x>`×N — with no per-name bookkeeping (which an attacker
//! could make collide), so the event stream's memory stays proportional to
//! the nesting depth.
//!
//! The builder does not know what it builds: it drives a [`Sink`]. The
//! [`DomSink`] turns what it hears into the [`Dom`] arena, and the streaming
//! API ([`crate::events`]) turns it into events — so both apply exactly the
//! same recovery, by construction. The builder is generic over the sink, so
//! the `Dom` path compiles to the same straight-line code it always did.
//!
//! The `Dom` also gets each node's source range (see
//! [`NodeRef::source_range`](crate::NodeRef::source_range)): leaf nodes get
//! their token's range, and an element's end is filled in when it is popped
//! off the open-element stack.

use alloc::borrow::Cow;
use alloc::vec::Vec;

use crate::entities::Problem;
use crate::error::{ErrorKind, ParseError};
use crate::node::{AttrRange, NodeId, RawKind, Span, SrcRange};
use crate::tags;
use crate::token::{AttrValue, Token};
use crate::tokenizer::Tokenizer;
use crate::tree::Dom;

/// Parse `src` into a best-effort [`Dom`]. Never panics; diagnostics land in
/// [`Dom::errors`].
pub(crate) fn parse_document(src: &str) -> Dom {
    let mut tk = Tokenizer::new(src);
    let mut builder = TreeBuilder::new(DomSink::new(src.len()), src);
    while let Some((tok, pos)) = tk.next_token() {
        let end = tk.offset();
        builder.token(tok, tk.attrs(), pos, end);
    }
    builder.finish(src.len(), &mut tk.errors);
    let mut dom = builder.sink.dom;
    dom.errors = builder.errors;
    dom.release_spare_capacity();
    dom
}

/// What the tree builder tells about the document it recovers, in document
/// order. Byte offsets are into the source; `start..end` is a token's range.
pub(crate) trait Sink<'a> {
    /// What the open-elements stack keeps for each open element.
    type Open;

    /// The lower-cased tag name of an open element.
    fn name<'s>(&'s self, open: &'s Self::Open) -> &'s str;

    /// One attribute of the start tag about to be reported by
    /// [`Sink::start`], as written: the name not yet lower-cased, the value
    /// not yet entity-decoded. Returns the first problem decoding met, at a
    /// byte offset into `value` (see [`crate::entities::decode_into`]).
    fn attr(&mut self, name: &'a str, value: &'a str) -> Option<Problem>;

    /// An element starts, inside `parent` (`None`: at the top level). Its
    /// `attrs` attributes were just passed to [`Sink::attr`].
    fn start(
        &mut self,
        parent: Option<&Self::Open>,
        name: Cow<'a, str>,
        attrs: usize,
        start: usize,
        end: usize,
    ) -> Self::Open;

    /// An element ends: at its own end tag (`start..end`), or — `implied` —
    /// where recovery closed it (`start == end`; also right after a void or
    /// self-closing start tag).
    fn end(&mut self, open: Self::Open, start: usize, end: usize, implied: bool);

    /// Character data, entity-decoded if `decode`. Returns the first problem
    /// decoding met, at a byte offset into `text`.
    fn text(
        &mut self,
        parent: Option<&Self::Open>,
        text: &'a str,
        decode: bool,
        start: usize,
        end: usize,
    ) -> Option<Problem>;

    /// A comment (or bogus comment).
    fn comment(&mut self, parent: Option<&Self::Open>, text: &'a str, start: usize, end: usize);

    /// A doctype, its text trimmed.
    fn doctype(&mut self, parent: Option<&Self::Open>, text: &'a str, start: usize, end: usize);
}

/// The recovery state machine: feed it tokens with [`TreeBuilder::token`],
/// then call [`TreeBuilder::finish`] once.
pub(crate) struct TreeBuilder<'a, S: Sink<'a>> {
    pub(crate) sink: S,
    open: OpenElements<S::Open>,
    /// Recovery diagnostics; complete and sorted after `finish`.
    pub(crate) errors: Vec<ParseError>,
    /// The source the tokens borrow from.
    src: &'a str,
}

impl<'a, S: Sink<'a>> TreeBuilder<'a, S> {
    /// A builder for the tokens of `src`.
    pub(crate) fn new(sink: S, src: &'a str) -> Self {
        TreeBuilder {
            sink,
            open: OpenElements::default(),
            errors: Vec::new(),
            src,
        }
    }

    /// The byte offset in the source of `s`, a slice of it — as every string
    /// in a token is (an attribute value, say, which the tokenizer hands over
    /// without its position). `None` if `s` is not inside the source.
    fn source_offset(&self, s: &str) -> Option<usize> {
        let offset = s.as_ptr().addr().checked_sub(self.src.as_ptr().addr())?;
        (offset.checked_add(s.len())? <= self.src.len()).then_some(offset)
    }

    /// Feed one token that spans `pos..end`. A start tag's attributes come
    /// separately (see [`Tokenizer::attrs`]).
    #[inline]
    pub(crate) fn token(
        &mut self,
        tok: Token<'a>,
        attrs: &[(&'a str, AttrValue<'a>)],
        pos: usize,
        end: usize,
    ) {
        match tok {
            Token::StartTag {
                name, self_closing, ..
            } => self.start_tag(name, attrs, self_closing, pos, end),
            Token::EndTag { name } => self.end_tag(name, pos, end),
            Token::Text(t) => self.text(t, pos, end),
            Token::Cdata(c) => {
                if !c.is_empty() {
                    self.sink.text(self.open.top(), c, false, pos, end);
                }
            }
            Token::Comment(c) => self.sink.comment(self.open.top(), c, pos, end),
            Token::Doctype(d) => self.sink.doctype(self.open.top(), d.trim(), pos, end),
        }
    }

    /// The input ended at byte `len`: everything still open runs to it. Folds
    /// in the tokenizer's `lexical` diagnostics and orders all by position.
    pub(crate) fn finish(&mut self, len: usize, lexical: &mut Vec<ParseError>) {
        if self.open.len() > 0 {
            self.error(len, ErrorKind::ImplicitlyClosed);
        }
        while self.open.len() > 0 {
            self.pop(len, len, true);
        }
        self.errors.append(lexical);
        self.errors.sort_by_key(|e| e.position);
    }

    #[inline]
    fn error(&mut self, pos: usize, kind: ErrorKind) {
        self.errors.push(ParseError::new(pos, kind));
    }

    /// Pop the current node: it ends at `start..end` (see [`Sink::end`]).
    #[inline]
    fn pop(&mut self, start: usize, end: usize, implied: bool) {
        if let Some(open) = self.open.pop(&self.sink) {
            self.sink.end(open, start, end, implied);
        }
    }

    fn start_tag(
        &mut self,
        name: &'a str,
        attrs: &[(&'a str, AttrValue<'a>)],
        self_closing: bool,
        pos: usize,
        end: usize,
    ) {
        let mut lname = lower(name);
        // `<image>` is a misspelled `<img>` outside SVG and MathML (where
        // `<image>` is a real element), as in browsers.
        if lname == "image"
            && self.open.find(&self.sink, "svg").is_none()
            && self.open.find(&self.sink, "math").is_none()
        {
            lname = Cow::Borrowed("img");
        }

        // A start tag that closes an element further down (a `<li>` past an
        // unclosed `<a>`, a `<div>` past a `<b>` in a `<p>`): close what sits
        // above that element first.
        for p in [false, true] {
            if let Some(idx) = self.open.closed_by(&self.sink, &lname, p) {
                while self.open.len() > idx + 1 {
                    self.pop(pos, pos, true);
                    self.error(pos, ErrorKind::ImplicitlyClosed);
                }
                // The element found is now the current node.
                self.pop(pos, pos, true);
                self.error(pos, ErrorKind::ImplicitlyClosed);
            }
        }

        // Implicit close: pop open elements that this start tag closes.
        // They end where this tag starts.
        while self
            .open
            .top()
            .is_some_and(|top| tags::implicitly_closes(&lname, self.sink.name(top)))
        {
            self.pop(pos, pos, true);
            self.error(pos, ErrorKind::ImplicitlyClosed);
        }

        for &(k, v) in attrs {
            let value = v.as_str();
            if let Some((at, kind)) = self.sink.attr(k, value) {
                // At the `&`; the tag's start if the value can't be located.
                let at = self.source_offset(value).map_or(pos, |offset| offset + at);
                self.error(at, kind);
            }
        }
        let void = self_closing || tags::is_void(&lname);
        let open = self
            .sink
            .start(self.open.top(), lname, attrs.len(), pos, end);
        if void {
            self.sink.end(open, end, end, true);
        } else {
            self.open.push(&self.sink, open);
        }
    }

    fn end_tag(&mut self, name: &'a str, pos: usize, end: usize) {
        let lname = lower(name);
        if lname == "br" {
            // Browsers read `</br>` as `<br>`.
            self.start_tag("br", &[], false, pos, end);
            return;
        }
        if tags::is_void(&lname) {
            return; // void elements never have end tags
        }
        match self.open.find(&self.sink, &lname) {
            Some(idx) => {
                if idx + 1 != self.open.len() {
                    // Closing elements whose end tag is optional (`</ul>` over
                    // an open `<li>`) is valid HTML; anything else is
                    // mis-nesting.
                    let kind = if self.open.only_optional_above(&self.sink, idx) {
                        ErrorKind::ImplicitlyClosed
                    } else {
                        ErrorKind::MismatchedEndTag
                    };
                    self.error(pos, kind);
                }
                // Mis-nested elements above the match end where this end tag
                // starts; the match itself ends after it.
                while self.open.len() > idx + 1 {
                    self.pop(pos, pos, true);
                }
                self.pop(pos, end, false);
            }
            None => {
                self.error(pos, ErrorKind::StrayEndTag);
                if lname == "p" {
                    // A `</p>` with no `<p>` open: browsers insert an empty
                    // one (`<p><hr></p>` has two paragraphs).
                    self.start_tag("p", &[], false, pos, pos);
                    self.pop(pos, end, false);
                }
            }
        }
    }

    fn text(&mut self, t: &'a str, pos: usize, end: usize) {
        if t.is_empty() {
            return;
        }
        let (raw, closes) = {
            let top = self.open.top().map_or("", |o| self.sink.name(o));
            (tags::is_raw_text(top), tags::text_closes(top))
        };
        // Real text means an omitted `</head>` or `</colgroup>` (neither is raw
        // text, nor is what they sit in).
        if closes && t.bytes().any(|b| !b.is_ascii_whitespace()) {
            self.pop(pos, pos, true);
            self.error(pos, ErrorKind::ImplicitlyClosed);
        }
        // A text token's string starts at the token.
        if let Some((at, kind)) = self.sink.text(self.open.top(), t, !raw, pos, end) {
            self.error(pos + at, kind);
        }
    }
}

/// The [`Sink`] that builds a [`Dom`].
pub(crate) struct DomSink {
    pub(crate) dom: Dom,
    /// Source ranges are two `u32`s. Every offset is at most the input's
    /// length, so one check covers them all; an input too big for them gets
    /// no ranges (and `set_end` is then a no-op, so the casts never matter).
    fits: bool,
}

impl DomSink {
    pub(crate) fn new(src_len: usize) -> Self {
        DomSink {
            dom: Dom::with_capacity(src_len),
            fits: src_len < u32::MAX as usize,
        }
    }

    #[inline]
    fn range(&self, start: usize, end: usize) -> SrcRange {
        if self.fits {
            SrcRange::new(start as u32, end as u32)
        } else {
            SrcRange::NONE
        }
    }

    #[inline]
    fn leaf(&mut self, parent: Option<&(NodeId, Span)>, raw: RawKind, start: usize, end: usize) {
        let id = self.dom.alloc(raw, self.range(start, end));
        self.dom.append(parent.map(|&(p, _)| p), id);
    }
}

impl<'a> Sink<'a> for DomSink {
    /// The element and its interned name (saves an arena lookup per check).
    type Open = (NodeId, Span);

    #[inline]
    fn name<'s>(&'s self, open: &'s (NodeId, Span)) -> &'s str {
        self.dom.span_str(open.1)
    }

    #[inline]
    fn attr(&mut self, name: &'a str, value: &'a str) -> Option<Problem> {
        let key = self.dom.intern_lower(name);
        let (value, unknown) = self.dom.intern_decoded(value, true);
        self.dom.attrs.push((key, value));
        unknown
    }

    #[inline]
    fn start(
        &mut self,
        parent: Option<&(NodeId, Span)>,
        name: Cow<'a, str>,
        attrs: usize,
        start: usize,
        end: usize,
    ) -> (NodeId, Span) {
        let name = self.dom.intern(&name);
        // The attributes are the last `attrs` pairs of the arena.
        let first = self.dom.attrs.len().saturating_sub(attrs);
        let attrs = AttrRange {
            start: first as u32,
            len: (self.dom.attrs.len() - first) as u32,
        };
        // Provisional range (just the start tag); `end` moves it when the
        // element is closed.
        let id = self
            .dom
            .alloc(RawKind::Element { name, attrs }, self.range(start, end));
        self.dom.append(parent.map(|&(p, _)| p), id);
        (id, name)
    }

    #[inline]
    fn end(&mut self, (id, _): (NodeId, Span), _start: usize, end: usize, _implied: bool) {
        if let Some(n) = self.dom.node_mut(id) {
            n.src.set_end(end as u32);
        }
    }

    #[inline]
    fn text(
        &mut self,
        parent: Option<&(NodeId, Span)>,
        text: &'a str,
        decode: bool,
        start: usize,
        end: usize,
    ) -> Option<Problem> {
        let (span, unknown) = if decode {
            self.dom.intern_decoded(text, false)
        } else {
            (self.dom.intern(text), None)
        };
        self.leaf(parent, RawKind::Text(span), start, end);
        unknown
    }

    fn comment(
        &mut self,
        parent: Option<&(NodeId, Span)>,
        text: &'a str,
        start: usize,
        end: usize,
    ) {
        let span = self.dom.intern(text);
        self.leaf(parent, RawKind::Comment(span), start, end);
    }

    fn doctype(
        &mut self,
        parent: Option<&(NodeId, Span)>,
        text: &'a str,
        start: usize,
        end: usize,
    ) {
        let span = self.dom.intern(text);
        self.leaf(parent, RawKind::Doctype(span), start, end);
    }
}

/// How many of the innermost open elements an end tag searches for the one it
/// closes (see the module docs).
const MAX_END_TAG_SEARCH: usize = 256;

/// How many of the outermost open elements an end tag searches next (see the
/// module docs).
const OUTER_END_TAG_SEARCH: usize = 8;

/// The open-elements stack.
///
/// Finding the element an end tag closes scans down from the top. A match pops
/// everything it scanned past, so those scans are paid for by the pushes; an
/// end tag with *no* open match pops nothing, so on a deep stack
/// (`<div>`×N then `</x>`×N) unbounded scans would be quadratic. The scan is
/// therefore bounded: [`MAX_END_TAG_SEARCH`] elements from the top, then the
/// bottom [`OUTER_END_TAG_SEARCH`] — O(1) per end tag, whatever the names.
///
/// The same bound applies to a start tag that closes an element below the
/// current node ([`OpenElements::closed_by`]); and that scan only runs while
/// an element it could close is open, counted in `open_p` and `open_items`.
struct OpenElements<O> {
    /// Open elements, innermost last; their names come from the sink.
    stack: Vec<O>,
    /// How many `<p>` are open.
    open_p: usize,
    /// How many list items, cells, rows, table sections and captions are
    /// open (what [`tags::search`] closes).
    open_items: usize,
}

impl<O> Default for OpenElements<O> {
    fn default() -> Self {
        OpenElements {
            stack: Vec::new(),
            open_p: 0,
            open_items: 0,
        }
    }
}

impl<O> OpenElements<O> {
    #[inline]
    fn top(&self) -> Option<&O> {
        self.stack.last()
    }

    #[inline]
    fn len(&self) -> usize {
        self.stack.len()
    }

    #[inline]
    fn push<'a, S: Sink<'a, Open = O>>(&mut self, sink: &S, open: O) {
        self.count(sink.name(&open), true);
        self.stack.push(open);
    }

    #[inline]
    fn pop<'a, S: Sink<'a, Open = O>>(&mut self, sink: &S) -> Option<O> {
        let open = self.stack.pop()?;
        self.count(sink.name(&open), false);
        Some(open)
    }

    #[inline]
    fn count(&mut self, name: &str, pushed: bool) {
        let counter = match name {
            "p" => &mut self.open_p,
            "li" | "dd" | "dt" | "td" | "th" | "tr" | "thead" | "tbody" | "tfoot" | "caption" => {
                &mut self.open_items
            }
            _ => return,
        };
        *counter = if pushed {
            counter.saturating_add(1)
        } else {
            counter.saturating_sub(1)
        };
    }

    /// Whether every element above stack index `idx` has an optional end tag.
    /// Only asked about a match, whose end tag pops all of them: the scan is
    /// paid for by their pushes.
    fn only_optional_above<'a, S: Sink<'a, Open = O>>(&self, sink: &S, idx: usize) -> bool {
        self.stack
            .get(idx + 1..)
            .unwrap_or(&[])
            .iter()
            .all(|open| tags::end_tag_optional(sink.name(open)))
    }

    /// The stack index of the open element that the start tag `opening`
    /// closes: by [`tags::p_search`] with `p`, else by [`tags::search`];
    /// looking at the [`MAX_END_TAG_SEARCH`] innermost elements.
    fn closed_by<'a, S: Sink<'a, Open = O>>(
        &self,
        sink: &S,
        opening: &str,
        p: bool,
    ) -> Option<usize> {
        let (search, open): (tags::SearchFn, usize) = if p {
            (tags::p_search, self.open_p)
        } else {
            (tags::search, self.open_items)
        };
        if open == 0 {
            return None;
        }
        let inner = self.stack.len().saturating_sub(MAX_END_TAG_SEARCH);
        for i in (inner..self.stack.len()).rev() {
            match search(opening, sink.name(self.stack.get(i)?))? {
                tags::Search::Closes => return Some(i),
                tags::Search::Stops => return None,
                tags::Search::Continue => {}
            }
        }
        None
    }

    /// The stack index of the open element named `name` that an end tag
    /// closes: the innermost among the [`MAX_END_TAG_SEARCH`] innermost, else
    /// the innermost among the [`OUTER_END_TAG_SEARCH`] outermost. `None`
    /// makes the end tag stray.
    fn find<'a, S: Sink<'a, Open = O>>(&self, sink: &S, name: &str) -> Option<usize> {
        let named = |open: &O| sink.name(open) == name;
        let inner = self.stack.len().saturating_sub(MAX_END_TAG_SEARCH);
        if let Some(i) = self
            .stack
            .get(inner..)
            .and_then(|s| s.iter().rposition(named))
        {
            return Some(inner + i);
        }
        let outer = inner.min(OUTER_END_TAG_SEARCH);
        self.stack.get(..outer)?.iter().rposition(named)
    }
}

/// Lower-case `name`, but borrow it unchanged when it's already lower-case
/// (the common case) — avoiding an allocation per tag/attribute.
pub(crate) fn lower(name: &str) -> Cow<'_, str> {
    if name.bytes().any(|b| b.is_ascii_uppercase()) {
        Cow::Owned(name.to_ascii_lowercase())
    } else {
        Cow::Borrowed(name)
    }
}