Skip to main content

domtree/
tokenizer.rs

1//! The streaming HTML tokenizer.
2//!
3//! Byte-driven and pull-based: it implements [`Iterator`] and yields borrowed
4//! [`Token`]s without allocating anything except each tag's attribute list. All
5//! cursor access goes through `get()`/iterators, so it never panics on
6//! malformed or truncated input — the whole point of the crate.
7//!
8//! The tree builder uses the crate-internal [`Tokenizer::next_token`], which
9//! leaves a start tag's attributes in a buffer that is reused for every tag
10//! ([`Tokenizer::attrs`]), so parsing allocates nothing per tag at all. The
11//! scans for the next `<`, quote, `-->` or `>` go through [`crate::scan`].
12
13use alloc::vec::Vec;
14
15use crate::error::{ErrorKind, ParseError};
16use crate::scan;
17use crate::token::{AttrValue, Token};
18
19/// A streaming HTML tokenizer. Construct with [`Tokenizer::new`] (or the
20/// top-level [`crate::tokenize`]) and iterate to get [`Token`]s.
21pub struct Tokenizer<'a> {
22    src: &'a str,
23    bytes: &'a [u8],
24    pos: usize,
25    /// When set, the next token is the raw-text content up to this element's
26    /// matching end tag (used for `<script>`/`<style>`/`<title>`/`<textarea>`).
27    raw_text_end: Option<&'a str>,
28    /// The attributes of the start tag read last (see [`Tokenizer::attrs`]).
29    attrs: Vec<(&'a str, AttrValue<'a>)>,
30    pub(crate) errors: Vec<ParseError>,
31}
32
33impl<'a> Tokenizer<'a> {
34    /// Create a tokenizer over `src`.
35    pub fn new(src: &'a str) -> Self {
36        Tokenizer {
37            src,
38            bytes: src.as_bytes(),
39            pos: 0,
40            raw_text_end: None,
41            attrs: Vec::new(),
42            errors: Vec::new(),
43        }
44    }
45
46    // --- cursor helpers (all panic-free) ---------------------------------
47
48    #[inline]
49    fn peek(&self) -> Option<u8> {
50        self.bytes.get(self.pos).copied()
51    }
52
53    #[inline]
54    fn peek_at(&self, off: usize) -> Option<u8> {
55        self.bytes.get(self.pos + off).copied()
56    }
57
58    #[inline]
59    fn slice(&self, start: usize, end: usize) -> &'a str {
60        self.src.get(start..end).unwrap_or("")
61    }
62
63    #[inline]
64    fn rest(&self) -> &'a [u8] {
65        self.bytes.get(self.pos..).unwrap_or(&[])
66    }
67
68    fn starts_with(&self, s: &[u8]) -> bool {
69        self.rest().starts_with(s)
70    }
71
72    fn starts_with_ci(&self, s: &[u8]) -> bool {
73        self.rest()
74            .get(..s.len())
75            .is_some_and(|p| p.eq_ignore_ascii_case(s))
76    }
77
78    /// Advance to the next `needle` byte, or to the end of the input.
79    #[inline]
80    fn skip_to(&mut self, needle: u8) {
81        match scan::find_byte(needle, self.rest()) {
82            Some(rel) => self.pos += rel,
83            None => self.pos = self.bytes.len(),
84        }
85    }
86
87    fn skip_whitespace(&mut self) {
88        while let Some(b) = self.peek() {
89            if b.is_ascii_whitespace() {
90                self.pos += 1;
91            } else {
92                break;
93            }
94        }
95    }
96
97    fn error(&mut self, pos: usize, kind: ErrorKind) {
98        self.errors.push(ParseError::new(pos, kind));
99    }
100
101    /// The byte offset just past the last token returned (its end).
102    #[inline]
103    pub(crate) fn offset(&self) -> usize {
104        self.pos
105    }
106
107    // --- token production ------------------------------------------------
108
109    /// The attributes of the start tag returned last by
110    /// [`Tokenizer::next_token`], in source order.
111    #[inline]
112    pub(crate) fn attrs(&self) -> &[(&'a str, AttrValue<'a>)] {
113        &self.attrs
114    }
115
116    /// Like `Iterator::next`, but also returns the byte offset the token
117    /// started at, and a [`Token::StartTag`]'s `attrs` is always empty: the
118    /// attributes are in [`Tokenizer::attrs`] until the next call.
119    pub(crate) fn next_token(&mut self) -> Option<(Token<'a>, usize)> {
120        if self.pos >= self.bytes.len() {
121            return None;
122        }
123        let start = self.pos;
124        if let Some(end_name) = self.raw_text_end.take() {
125            return Some((self.read_raw_text(end_name), start));
126        }
127        let tok = match self.peek() {
128            Some(b'<') => self.read_markup(),
129            _ => self.read_text(),
130        };
131        Some((tok, start))
132    }
133
134    fn read_text(&mut self) -> Token<'a> {
135        let start = self.pos;
136        // Scan to the next '<' in one shot, not a byte-at-a-time `peek` loop.
137        self.skip_to(b'<');
138        Token::Text(self.slice(start, self.pos))
139    }
140
141    fn read_markup(&mut self) -> Token<'a> {
142        match self.peek_at(1) {
143            Some(b'/') => self.read_end_tag(),
144            Some(b'!') => self.read_bang(),
145            Some(b'?') => self.read_bogus_comment(),
146            Some(c) if c.is_ascii_alphabetic() => self.read_start_tag(),
147            _ => {
148                // A lone '<' that doesn't begin markup is literal text.
149                let s = self.slice(self.pos, self.pos + 1);
150                self.pos += 1;
151                Token::Text(s)
152            }
153        }
154    }
155
156    fn read_tag_name(&mut self) -> &'a str {
157        let start = self.pos;
158        while let Some(b) = self.peek() {
159            if b.is_ascii_alphanumeric() || matches!(b, b'-' | b':' | b'_' | b'.') {
160                self.pos += 1;
161            } else {
162                break;
163            }
164        }
165        self.slice(start, self.pos)
166    }
167
168    fn read_start_tag(&mut self) -> Token<'a> {
169        let open = self.pos;
170        self.pos += 1; // consume '<'
171        let name = self.read_tag_name();
172        self.attrs.clear();
173        let mut self_closing = false;
174
175        loop {
176            self.skip_whitespace();
177            match self.peek() {
178                None => {
179                    self.error(open, ErrorKind::UnterminatedTag);
180                    break;
181                }
182                Some(b'>') => {
183                    self.pos += 1;
184                    break;
185                }
186                Some(b'/') => {
187                    if self.peek_at(1) == Some(b'>') {
188                        self_closing = true;
189                        self.pos += 2;
190                        break;
191                    }
192                    self.pos += 1; // stray slash
193                }
194                Some(_) => match self.read_attribute() {
195                    Some(attr) => self.attrs.push(attr),
196                    None => self.pos += 1, // couldn't form an attr; make progress
197                },
198            }
199        }
200
201        if !self_closing && is_rawish_name(name) {
202            self.raw_text_end = Some(name);
203        }
204
205        Token::StartTag {
206            name,
207            attrs: Vec::new(),
208            self_closing,
209        }
210    }
211
212    fn read_attribute(&mut self) -> Option<(&'a str, AttrValue<'a>)> {
213        let nstart = self.pos;
214        while let Some(b) = self.peek() {
215            if b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/') {
216                break;
217            }
218            self.pos += 1;
219        }
220        let name = self.slice(nstart, self.pos);
221        if name.is_empty() {
222            return None;
223        }
224        self.skip_whitespace();
225        if self.peek() == Some(b'=') {
226            self.pos += 1;
227            self.skip_whitespace();
228            Some((name, self.read_attr_value()))
229        } else {
230            Some((name, AttrValue::Empty))
231        }
232    }
233
234    fn read_attr_value(&mut self) -> AttrValue<'a> {
235        match self.peek() {
236            Some(q @ (b'"' | b'\'')) => {
237                self.pos += 1; // opening quote
238                let start = self.pos;
239                self.skip_to(q);
240                let val = self.slice(start, self.pos);
241                if self.peek() == Some(q) {
242                    self.pos += 1; // closing quote
243                }
244                AttrValue::Quoted(val)
245            }
246            _ => {
247                let start = self.pos;
248                while let Some(b) = self.peek() {
249                    if b.is_ascii_whitespace() || b == b'>' {
250                        break;
251                    }
252                    self.pos += 1;
253                }
254                AttrValue::Unquoted(self.slice(start, self.pos))
255            }
256        }
257    }
258
259    fn read_end_tag(&mut self) -> Token<'a> {
260        let open = self.pos;
261        self.pos += 2; // consume '</'
262        let name = self.read_tag_name();
263        self.skip_to(b'>');
264        if self.peek() == Some(b'>') {
265            self.pos += 1;
266        } else {
267            self.error(open, ErrorKind::UnterminatedTag);
268        }
269        Token::EndTag { name }
270    }
271
272    fn read_bang(&mut self) -> Token<'a> {
273        if self.starts_with(b"<!--") {
274            self.read_comment()
275        } else if self.starts_with(b"<![CDATA[") {
276            self.read_cdata()
277        } else if self.starts_with_ci(b"<!doctype") {
278            self.read_doctype()
279        } else {
280            self.read_bogus_comment()
281        }
282    }
283
284    fn read_comment(&mut self) -> Token<'a> {
285        let open = self.pos;
286        self.pos += 4; // consume '<!--'
287        let start = self.pos;
288        if let Some(rel) = scan::find(b"-->", self.rest()) {
289            self.pos += rel;
290            let s = self.slice(start, self.pos);
291            self.pos += 3;
292            return Token::Comment(s);
293        }
294        self.pos = self.bytes.len();
295        self.error(open, ErrorKind::UnterminatedComment);
296        Token::Comment(self.slice(start, self.pos))
297    }
298
299    fn read_cdata(&mut self) -> Token<'a> {
300        let open = self.pos;
301        self.pos += 9; // consume '<![CDATA['
302        let start = self.pos;
303        if let Some(rel) = scan::find(b"]]>", self.rest()) {
304            self.pos += rel;
305            let s = self.slice(start, self.pos);
306            self.pos += 3;
307            return Token::Cdata(s);
308        }
309        self.pos = self.bytes.len();
310        self.error(open, ErrorKind::UnterminatedCdata);
311        Token::Cdata(self.slice(start, self.pos))
312    }
313
314    fn read_doctype(&mut self) -> Token<'a> {
315        let open = self.pos;
316        self.pos += 9; // consume '<!doctype'
317        self.skip_whitespace();
318        let start = self.pos;
319        self.skip_to(b'>');
320        let s = self.slice(start, self.pos);
321        if self.peek() == Some(b'>') {
322            self.pos += 1;
323        } else {
324            self.error(open, ErrorKind::UnterminatedTag);
325        }
326        Token::Doctype(s)
327    }
328
329    /// `<! ... >` (not comment/doctype/cdata) and `<? ... >` are treated as
330    /// bogus comments, matching browser recovery.
331    fn read_bogus_comment(&mut self) -> Token<'a> {
332        let open = self.pos;
333        self.pos += 1; // consume '<'
334        if matches!(self.peek(), Some(b'!') | Some(b'?')) {
335            self.pos += 1;
336        }
337        let start = self.pos;
338        self.skip_to(b'>');
339        let s = self.slice(start, self.pos);
340        if self.peek() == Some(b'>') {
341            self.pos += 1;
342        }
343        self.error(open, ErrorKind::BogusComment);
344        Token::Comment(s)
345    }
346
347    /// Scan raw-text content up to (but not consuming) the matching end tag.
348    fn read_raw_text(&mut self, end_name: &str) -> Token<'a> {
349        let start = self.pos;
350        loop {
351            match scan::find_byte(b'<', self.rest()) {
352                None => {
353                    self.pos = self.bytes.len();
354                    break;
355                }
356                Some(rel) => {
357                    let lt = self.pos + rel;
358                    if self.is_close_tag_at(lt, end_name) {
359                        self.pos = lt;
360                        break;
361                    }
362                    self.pos = lt + 1;
363                }
364            }
365        }
366        Token::Text(self.slice(start, self.pos))
367    }
368
369    fn is_close_tag_at(&self, lt: usize, name: &str) -> bool {
370        if self.bytes.get(lt) != Some(&b'<') || self.bytes.get(lt + 1) != Some(&b'/') {
371            return false;
372        }
373        let after = lt + 2;
374        let nm = name.as_bytes();
375        match self.bytes.get(after..after + nm.len()) {
376            Some(slice) if slice.eq_ignore_ascii_case(nm) => {
377                match self.bytes.get(after + nm.len()) {
378                    None => true,
379                    Some(b) => b.is_ascii_whitespace() || matches!(b, b'>' | b'/'),
380                }
381            }
382            _ => false,
383        }
384    }
385}
386
387impl<'a> Iterator for Tokenizer<'a> {
388    type Item = Token<'a>;
389
390    fn next(&mut self) -> Option<Token<'a>> {
391        let (mut tok, _) = self.next_token()?;
392        if let Token::StartTag { attrs, .. } = &mut tok {
393            // Hand the buffer over: the next tag starts a fresh one.
394            *attrs = core::mem::take(&mut self.attrs);
395        }
396        Some(tok)
397    }
398}
399
400/// Whether an element's content is treated as raw text by the tokenizer
401/// (no nested tags). Entity-decoding of that text is decided later, per element.
402fn is_rawish_name(name: &str) -> bool {
403    ["script", "style", "title", "textarea"]
404        .iter()
405        .any(|t| name.eq_ignore_ascii_case(t))
406}