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