Skip to main content

astral_html/
tokenizer.rs

1//! HTML tokenization over UTF-8 source text.
2
3use std::borrow::Cow;
4use std::collections::HashSet;
5use std::iter::FusedIterator;
6
7use memchr::{memchr, memchr_iter, memchr2, memchr3, memmem};
8
9use crate::entities::{decode, decode_from, normalize};
10
11const MAX_RETAINED_ATTRIBUTE_CAPACITY: usize = 256;
12
13/// A tokenizer state selected by the caller or a tree builder.
14#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
15pub enum State {
16    /// Ordinary HTML content.
17    #[default]
18    Data,
19    /// Text with character references, as in `title` and `textarea`.
20    Rcdata,
21    /// Text without character references, as in `style`.
22    Rawtext,
23    /// Script text, including HTML comment escapes.
24    ScriptData,
25    /// Text through the end of the input.
26    Plaintext,
27    /// CDATA content; `]]>` resumes the data state.
28    Cdata,
29}
30
31/// An attribute. The first occurrence of each normalized name is retained.
32#[derive(Debug, Clone, Eq, PartialEq)]
33pub struct Attribute<'a> {
34    /// The ASCII-lowercase attribute name.
35    pub name: Cow<'a, str>,
36    /// The decoded value, empty when no value was provided.
37    pub value: Cow<'a, str>,
38    /// The source value without quotes, or `None` when `=` was absent.
39    pub raw_value: Option<&'a str>,
40}
41
42impl<'a> Attribute<'a> {
43    /// Return the decoded attribute value.
44    #[must_use]
45    pub fn value(&self) -> &str {
46        &self.value
47    }
48
49    /// Return the source value, or `None` when no equals sign was present.
50    #[must_use]
51    pub fn raw_value(&self) -> Option<&'a str> {
52        self.raw_value
53    }
54}
55
56/// A start or end tag.
57#[derive(Debug, Clone, Eq, PartialEq)]
58pub struct Tag<'a> {
59    /// The ASCII-lowercase tag name.
60    pub name: Cow<'a, str>,
61    /// Attributes in source order, with duplicate names removed.
62    pub attributes: Vec<Attribute<'a>>,
63    /// Whether the tag ended with `/>`.
64    pub self_closing: bool,
65}
66
67/// A document type declaration.
68#[derive(Debug, Clone, Eq, PartialEq)]
69pub struct Doctype<'a> {
70    /// The ASCII-lowercase name, if present.
71    pub name: Option<Cow<'a, str>>,
72    /// The public identifier, if present.
73    pub public_id: Option<Cow<'a, str>>,
74    /// The system identifier, if present.
75    pub system_id: Option<Cow<'a, str>>,
76    /// Whether the declaration requires quirks mode.
77    pub force_quirks: bool,
78}
79
80/// An HTML token. Adjacent text tokens may be emitted separately.
81#[derive(Debug, Clone, Eq, PartialEq)]
82// Avoid niche-based discriminant decoding in the token loop.
83#[repr(u8)]
84pub enum Token<'a> {
85    /// An opening tag.
86    StartTag(Tag<'a>),
87    /// A closing tag.
88    EndTag(Tag<'a>),
89    /// Input newlines are normalized; data and RCDATA states also decode references.
90    Text(Cow<'a, str>),
91    /// An HTML comment, including bogus comments.
92    Comment(Cow<'a, str>),
93    /// A document type declaration.
94    Doctype(Doctype<'a>),
95    /// A processing instruction as defined by the HTML Living Standard.
96    ProcessingInstruction {
97        /// The processing instruction's case-sensitive target.
98        target: Cow<'a, str>,
99        /// The instruction's data, excluding the closing `?`, if present.
100        data: Cow<'a, str>,
101    },
102}
103
104/// An HTML tokenizer over borrowed UTF-8 input.
105///
106/// Tree construction is left to the caller, which selects text modes with
107/// [`Self::set_state`].
108#[derive(Debug, Clone)]
109pub struct Tokenizer<'a> {
110    source: &'a str,
111    position: usize,
112    state: State,
113    last_start_tag: Option<Cow<'static, str>>,
114    // Only large tags need an index; retain ordinary allocations for subsequent tags.
115    attribute_names: Option<HashSet<Cow<'a, str>>>,
116}
117
118impl<'a> Tokenizer<'a> {
119    /// Tokenize a complete source in the data state.
120    #[must_use]
121    pub fn new(source: &'a str) -> Self {
122        Self::with_state(source, State::Data, None)
123    }
124
125    /// Tokenize a fragment in the given state.
126    ///
127    /// `last_start_tag` identifies the end tag that can close a text mode.
128    /// Only nonempty names consisting of ASCII letters can match.
129    #[must_use]
130    pub fn with_state(source: &'a str, state: State, last_start_tag: Option<&str>) -> Self {
131        Self {
132            source,
133            position: 0,
134            state,
135            last_start_tag: last_start_tag
136                .filter(|name| can_end_text(name))
137                .map(|name| Cow::Owned(name.to_ascii_lowercase())),
138            attribute_names: None,
139        }
140    }
141
142    /// Change the state and end-tag context without advancing the source position.
143    pub fn set_state(&mut self, state: State, last_start_tag: Option<&str>) {
144        self.state = state;
145        self.last_start_tag = last_start_tag
146            .filter(|name| can_end_text(name))
147            .map(|name| Cow::Owned(name.to_ascii_lowercase()));
148    }
149
150    /// Select a text mode using one of the reader's lowercase static tag names.
151    pub(crate) fn set_static_text_state(&mut self, state: State, name: &'static str) {
152        self.state = state;
153        self.last_start_tag = Some(Cow::Borrowed(name));
154    }
155
156    /// Return the byte offset of the next unread source character.
157    #[must_use]
158    pub fn position(&self) -> usize {
159        self.position
160    }
161
162    fn current(&self) -> Option<u8> {
163        self.source.as_bytes().get(self.position).copied()
164    }
165
166    fn whitespace(&mut self) {
167        while self.current().is_some_and(is_space) {
168            self.position += 1;
169        }
170    }
171
172    /// Match `</name` followed by whitespace, `/`, or `>`, using the last start tag.
173    fn appropriate_end(&self, at: usize) -> bool {
174        let Some(name) = self.last_start_tag.as_deref() else {
175            return false;
176        };
177        let rest = &self.source.as_bytes()[at..];
178        rest.starts_with(b"</")
179            && rest
180                .get(2..2 + name.len())
181                .is_some_and(|candidate| matches_normalized_name(name.as_bytes(), candidate))
182            && rest
183                .get(2 + name.len())
184                .is_some_and(|&byte| is_space(byte) || matches!(byte, b'/' | b'>'))
185    }
186
187    /// Parse a tag, discarding it if EOF arrives before `>`.
188    #[inline(always)]
189    fn tag(&mut self, end: bool, attribute_buffer: &mut Vec<Attribute<'a>>) -> Option<Token<'a>> {
190        self.position += if end { 2 } else { 1 };
191        let name = self.name(false);
192        let (attributes, self_closing) = if self.current() == Some(b'>') {
193            self.position += 1;
194            (Vec::new(), false)
195        } else if self.source.as_bytes()[self.position..].starts_with(b"/>") {
196            self.position += 2;
197            (Vec::new(), true)
198        } else {
199            self.attributes(attribute_buffer)?
200        };
201        let tag = Tag {
202            name,
203            attributes,
204            self_closing,
205        };
206        Some(if end {
207            Token::EndTag(tag)
208        } else {
209            Token::StartTag(tag)
210        })
211    }
212
213    /// Scan and normalize a name, borrowing unchanged spelling.
214    /// The first byte is part of the name, including `=` in attribute names.
215    #[inline(always)]
216    fn name(&mut self, attribute: bool) -> Cow<'a, str> {
217        let start = self.position;
218        let bytes = self.source.as_bytes();
219        let mut end = start + 1;
220        let mut needs_normalization = bytes[start].is_ascii_uppercase() || bytes[start] == 0;
221        while let Some(&byte) = bytes.get(end) {
222            if is_space(byte) || matches!(byte, b'/' | b'>') || (attribute && byte == b'=') {
223                break;
224            }
225            needs_normalization |= byte.is_ascii_uppercase() || byte == 0;
226            end += 1;
227        }
228        self.position = end;
229        let name = &self.source[start..end];
230        if needs_normalization {
231            normalize_name(name)
232        } else {
233            Cow::Borrowed(name)
234        }
235    }
236
237    /// Read attributes through the closing delimiter, including the self-closing flag.
238    #[inline(never)]
239    fn attributes(
240        &mut self,
241        attribute_buffer: &mut Vec<Attribute<'a>>,
242    ) -> Option<(Vec<Attribute<'a>>, bool)> {
243        let mut attributes = std::mem::take(attribute_buffer);
244        let mut self_closing = false;
245        loop {
246            self.whitespace();
247            match self.current()? {
248                b'>' => {
249                    self.position += 1;
250                    break;
251                }
252                b'/' => {
253                    self.position += 1;
254                    if self.current() == Some(b'>') {
255                        self.position += 1;
256                        self_closing = true;
257                        break;
258                    }
259                    continue;
260                }
261                _ => {}
262            }
263            let attr_name = self.name(true);
264            self.whitespace();
265            let raw_value = if self.current() == Some(b'=') {
266                self.position += 1;
267                self.whitespace();
268                let quote = self.current()?;
269                if matches!(quote, b'\'' | b'"') {
270                    self.position += 1;
271                    let start = self.position;
272                    self.position = memchr(quote, &self.source.as_bytes()[start..])
273                        .map_or(self.source.len(), |offset| start + offset);
274                    let value = &self.source[start..self.position];
275                    self.current()?;
276                    self.position += 1;
277                    Some(value)
278                } else {
279                    let start = self.position;
280                    while self
281                        .current()
282                        .is_some_and(|byte| !is_space(byte) && byte != b'>')
283                    {
284                        self.position += 1;
285                    }
286                    Some(&self.source[start..self.position])
287                }
288            } else {
289                None
290            };
291            // Scan small lists; index larger lists to avoid quadratic duplicate checks.
292            let duplicate = if attributes.len() < 16 {
293                attributes
294                    .iter()
295                    .any(|attribute| attribute.name == attr_name)
296            } else {
297                let seen = self.attribute_names.get_or_insert_with(HashSet::new);
298                if seen.is_empty() {
299                    seen.extend(attributes.iter().map(|attribute| attribute.name.clone()));
300                }
301                !seen.insert(attr_name.clone())
302            };
303            if !duplicate {
304                attributes.push(Attribute {
305                    name: attr_name,
306                    value: raw_value.map_or(Cow::Borrowed(""), |value| decode(value, true)),
307                    raw_value,
308                });
309            }
310        }
311        if let Some(seen) = &mut self.attribute_names {
312            // Clearing a populated set takes O(capacity), so do not retain oversized tables.
313            if seen.capacity() > MAX_RETAINED_ATTRIBUTE_CAPACITY {
314                self.attribute_names = None;
315            } else {
316                seen.clear();
317            }
318        }
319        Some((attributes, self_closing))
320    }
321
322    fn bogus_comment(&mut self, start: usize) -> Token<'a> {
323        while self.current().is_some_and(|byte| byte != b'>') {
324            self.position += 1;
325        }
326        let result = Token::Comment(normalize_text(&self.source[start..self.position]));
327        if self.current().is_some() {
328            self.position += 1;
329        }
330        result
331    }
332
333    #[inline]
334    fn comment(&mut self) -> Token<'a> {
335        let rest = &self.source[self.position..];
336        let end = memchr3(b'-', b'<', b'>', rest.as_bytes()).and_then(|at| {
337            if rest[at..].starts_with("-->") {
338                Some(at)
339            } else if at == 0 && (rest.starts_with('>') || rest.starts_with("->")) {
340                None
341            } else {
342                memmem::find(&rest.as_bytes()[at..], b"--")
343                    .map(|offset| at + offset)
344                    .filter(|&end| rest[end..].starts_with("-->"))
345            }
346        });
347        if let Some(end) = end
348            && memchr2(b'\r', 0, &rest.as_bytes()[..end]).is_none()
349        {
350            self.position += end + 3;
351            return Token::Comment(Cow::Borrowed(&rest[..end]));
352        }
353        self.comment_slow()
354    }
355
356    #[inline(never)]
357    fn comment_slow(&mut self) -> Token<'a> {
358        #[derive(Clone, Copy)]
359        enum CommentState {
360            Start,
361            StartDash,
362            Data,
363            Less,
364            Bang,
365            BangDash,
366            BangDashDash,
367            EndDash,
368            End,
369            EndBang,
370        }
371        let mut state = CommentState::Start;
372        let mut data = String::new();
373        while let Some(mut ch) = self.source[self.position..].chars().next() {
374            let mut width = ch.len_utf8();
375            // Normalize while scanning to avoid copying the entire comment afterward.
376            if ch == '\r' {
377                ch = '\n';
378                width += usize::from(self.source.as_bytes().get(self.position + 1) == Some(&b'\n'));
379            }
380            let mut consume = true;
381            match state {
382                CommentState::Start => match ch {
383                    '-' => state = CommentState::StartDash,
384                    '>' => {
385                        self.position += 1;
386                        break;
387                    }
388                    _ => {
389                        state = CommentState::Data;
390                        consume = false;
391                    }
392                },
393                CommentState::StartDash => match ch {
394                    '-' => state = CommentState::End,
395                    '>' => {
396                        self.position += 1;
397                        break;
398                    }
399                    _ => {
400                        data.push('-');
401                        state = CommentState::Data;
402                        consume = false;
403                    }
404                },
405                CommentState::Data => match ch {
406                    '<' => {
407                        data.push('<');
408                        state = CommentState::Less;
409                    }
410                    '-' => state = CommentState::EndDash,
411                    '\0' => data.push('\u{fffd}'),
412                    '\n' => data.push('\n'),
413                    _ => {
414                        let rest = &self.source[self.position..];
415                        let end = memchr3(b'<', b'-', b'\r', rest.as_bytes()).unwrap_or(rest.len());
416                        let mut start = 0;
417                        for nul in memchr_iter(0, &rest.as_bytes()[..end]) {
418                            data.push_str(&rest[start..nul]);
419                            data.push('\u{fffd}');
420                            start = nul + 1;
421                        }
422                        data.push_str(&rest[start..end]);
423                        self.position += end;
424                        continue;
425                    }
426                },
427                CommentState::Less => match ch {
428                    '!' => {
429                        data.push('!');
430                        state = CommentState::Bang;
431                    }
432                    '<' => data.push('<'),
433                    _ => {
434                        state = CommentState::Data;
435                        consume = false;
436                    }
437                },
438                CommentState::Bang => {
439                    if ch == '-' {
440                        state = CommentState::BangDash;
441                    } else {
442                        state = CommentState::Data;
443                        consume = false;
444                    }
445                }
446                CommentState::BangDash => {
447                    if ch == '-' {
448                        state = CommentState::BangDashDash;
449                    } else {
450                        state = CommentState::EndDash;
451                        consume = false;
452                    }
453                }
454                CommentState::BangDashDash => {
455                    state = CommentState::End;
456                    consume = false;
457                }
458                CommentState::EndDash => {
459                    if ch == '-' {
460                        state = CommentState::End;
461                    } else {
462                        data.push('-');
463                        state = CommentState::Data;
464                        consume = false;
465                    }
466                }
467                CommentState::End => match ch {
468                    '>' => {
469                        self.position += 1;
470                        break;
471                    }
472                    '!' => state = CommentState::EndBang,
473                    '-' => data.push('-'),
474                    _ => {
475                        data.push_str("--");
476                        state = CommentState::Data;
477                        consume = false;
478                    }
479                },
480                CommentState::EndBang => match ch {
481                    '-' => {
482                        data.push_str("--!");
483                        state = CommentState::EndDash;
484                    }
485                    '>' => {
486                        self.position += 1;
487                        break;
488                    }
489                    _ => {
490                        data.push_str("--!");
491                        state = CommentState::Data;
492                        consume = false;
493                    }
494                },
495            }
496            if consume {
497                self.position += width;
498            }
499        }
500        Token::Comment(Cow::Owned(data))
501    }
502
503    /// Read a quoted doctype identifier up to its quote, `>`, or EOF.
504    /// The boolean is true only when the closing quote was consumed.
505    fn identifier(&mut self) -> (Cow<'a, str>, bool) {
506        let quote = self.current().expect("identifier starts at a quote");
507        self.position += 1;
508        let start = self.position;
509        while self
510            .current()
511            .is_some_and(|byte| byte != quote && byte != b'>')
512        {
513            self.position += 1;
514        }
515        let value = normalize_text(&self.source[start..self.position]);
516        let closed = self.current() == Some(quote);
517        if closed {
518            self.position += 1;
519        }
520        (value, closed)
521    }
522
523    fn finish_doctype(&mut self, doctype: Doctype<'a>) -> Token<'a> {
524        while self.current().is_some_and(|byte| byte != b'>') {
525            self.position += 1;
526        }
527        if self.current().is_some() {
528            self.position += 1;
529        }
530        Token::Doctype(doctype)
531    }
532
533    #[inline(never)]
534    fn doctype(&mut self) -> Token<'a> {
535        let mut doctype = Doctype {
536            name: None,
537            public_id: None,
538            system_id: None,
539            force_quirks: false,
540        };
541        self.whitespace();
542        if matches!(self.current(), None | Some(b'>')) {
543            doctype.force_quirks = true;
544            return self.finish_doctype(doctype);
545        }
546        let start = self.position;
547        while self
548            .current()
549            .is_some_and(|byte| !is_space(byte) && byte != b'>')
550        {
551            self.position += 1;
552        }
553        let name = &self.source[start..self.position];
554        doctype.name = Some(
555            if name
556                .bytes()
557                .any(|byte| byte.is_ascii_uppercase() || byte == 0)
558            {
559                normalize_name(name)
560            } else {
561                Cow::Borrowed(name)
562            },
563        );
564        self.whitespace();
565        match self.current() {
566            Some(b'>') => return self.finish_doctype(doctype),
567            None => {
568                doctype.force_quirks = true;
569                return Token::Doctype(doctype);
570            }
571            _ => {}
572        }
573        let keyword = self.source.as_bytes().get(self.position..self.position + 6);
574        let public = keyword.is_some_and(|value| value.eq_ignore_ascii_case(b"PUBLIC"));
575        let system = keyword.is_some_and(|value| value.eq_ignore_ascii_case(b"SYSTEM"));
576        if !public && !system {
577            doctype.force_quirks = true;
578            return self.finish_doctype(doctype);
579        }
580        self.position += 6;
581        self.whitespace();
582        if !matches!(self.current(), Some(b'\'' | b'"')) {
583            doctype.force_quirks = true;
584            return self.finish_doctype(doctype);
585        }
586        let (identifier, closed) = self.identifier();
587        if public {
588            doctype.public_id = Some(identifier);
589        } else {
590            doctype.system_id = Some(identifier);
591        }
592        if !closed {
593            doctype.force_quirks = true;
594            return self.finish_doctype(doctype);
595        }
596        self.whitespace();
597        if public {
598            match self.current() {
599                Some(b'\'' | b'"') => {
600                    let (identifier, closed) = self.identifier();
601                    doctype.system_id = Some(identifier);
602                    if !closed {
603                        doctype.force_quirks = true;
604                        return self.finish_doctype(doctype);
605                    }
606                    self.whitespace();
607                }
608                Some(b'>') => return self.finish_doctype(doctype),
609                _ => {
610                    doctype.force_quirks = true;
611                    return self.finish_doctype(doctype);
612                }
613            }
614        }
615        if self.current().is_none() {
616            doctype.force_quirks = true;
617        }
618        self.finish_doctype(doctype)
619    }
620
621    /// Recover invalid targets as bogus comments; discard unfinished instructions at EOF.
622    #[inline(never)]
623    fn processing_instruction(&mut self) -> Option<Token<'a>> {
624        let comment_start = self.position + 1;
625        self.position += 2;
626        let first = self.current()?;
627        if !first.is_ascii_alphabetic() && first != b'_' {
628            return Some(self.bogus_comment(comment_start));
629        }
630        let start = self.position;
631        while self
632            .current()
633            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
634        {
635            self.position += 1;
636        }
637        let target = &self.source[start..self.position];
638        let next = self.current()?;
639        if !(is_space(next) || matches!(next, b'?' | b'>'))
640            || target.eq_ignore_ascii_case("xml")
641            || target.eq_ignore_ascii_case("xml-stylesheet")
642        {
643            return Some(self.bogus_comment(comment_start));
644        }
645        self.whitespace();
646        let start = self.position;
647        while self.current().is_some_and(|byte| byte != b'>') {
648            self.position += 1;
649        }
650        self.current()?;
651        let data = &self.source[start..self.position];
652        let data = normalize(data.strip_suffix('?').unwrap_or(data));
653        self.position += 1;
654        Some(Token::ProcessingInstruction {
655            target: Cow::Borrowed(target),
656            data,
657        })
658    }
659
660    /// Find an appropriate end tag outside double-escaped content, or return EOF.
661    #[inline(never)]
662    fn script_end(&self, start: usize) -> usize {
663        #[derive(Clone, Copy)]
664        enum Script {
665            Data,
666            Escaped,
667            Dash,
668            DashDash,
669            Double,
670            DoubleDash,
671            DoubleDashDash,
672        }
673        let bytes = self.source.as_bytes();
674        let mut at = start;
675        let mut state = Script::Data;
676        while at < bytes.len() {
677            let offset = match state {
678                Script::Data => memchr(b'<', &bytes[at..]),
679                Script::Escaped | Script::Double => memchr2(b'<', b'-', &bytes[at..]),
680                _ => Some(0),
681            };
682            let Some(offset) = offset else {
683                return bytes.len();
684            };
685            at += offset;
686            let byte = bytes[at];
687            match state {
688                Script::Data => {
689                    if byte == b'<' && self.appropriate_end(at) {
690                        return at;
691                    }
692                    if bytes[at..].starts_with(b"<!--") {
693                        state = Script::DashDash;
694                        at += 4;
695                        continue;
696                    }
697                }
698                Script::Escaped | Script::Dash | Script::DashDash => {
699                    if byte == b'<' {
700                        if self.appropriate_end(at) {
701                            return at;
702                        }
703                        if bytes.get(at + 1).is_some_and(u8::is_ascii_alphabetic) {
704                            let word_start = at + 1;
705                            at = word_start;
706                            while bytes.get(at).is_some_and(u8::is_ascii_alphabetic) {
707                                at += 1;
708                            }
709                            state = if bytes
710                                .get(at)
711                                .is_some_and(|&b| is_space(b) || matches!(b, b'/' | b'>'))
712                                && bytes[word_start..at].eq_ignore_ascii_case(b"script")
713                            {
714                                Script::Double
715                            } else {
716                                Script::Escaped
717                            };
718                            continue;
719                        }
720                        state = Script::Escaped;
721                    } else if byte == b'-' {
722                        state = if matches!(state, Script::Escaped) {
723                            Script::Dash
724                        } else {
725                            Script::DashDash
726                        };
727                    } else if byte == b'>' && matches!(state, Script::DashDash) {
728                        state = Script::Data;
729                    } else {
730                        state = Script::Escaped;
731                    }
732                }
733                Script::Double | Script::DoubleDash | Script::DoubleDashDash => {
734                    if byte == b'<' {
735                        if bytes.get(at + 1) == Some(&b'/') {
736                            let word_start = at + 2;
737                            at = word_start;
738                            while bytes.get(at).is_some_and(u8::is_ascii_alphabetic) {
739                                at += 1;
740                            }
741                            state = if bytes
742                                .get(at)
743                                .is_some_and(|&b| is_space(b) || matches!(b, b'/' | b'>'))
744                                && bytes[word_start..at].eq_ignore_ascii_case(b"script")
745                            {
746                                Script::Escaped
747                            } else {
748                                Script::Double
749                            };
750                            continue;
751                        }
752                        state = Script::Double;
753                    } else if byte == b'-' {
754                        state = if matches!(state, Script::Double) {
755                            Script::DoubleDash
756                        } else {
757                            Script::DoubleDashDash
758                        };
759                    } else if byte == b'>' && matches!(state, Script::DoubleDashDash) {
760                        state = Script::Data;
761                    } else {
762                        state = Script::Double;
763                    }
764                }
765            }
766            at += 1;
767        }
768        at
769    }
770
771    /// Read the next token, reusing the supplied attribute buffer when possible.
772    ///
773    /// The attribute buffer must be empty; returned tags may take ownership of it.
774    #[inline(always)]
775    pub(crate) fn next_with_attribute_buffer(
776        &mut self,
777        attribute_buffer: &mut Vec<Attribute<'a>>,
778    ) -> Option<Token<'a>> {
779        loop {
780            self.current()?;
781            let start = self.position;
782            match self.state {
783                State::Plaintext => {
784                    self.position = self.source.len();
785                    return Some(Token::Text(normalize_text(&self.source[start..])));
786                }
787                State::Cdata => {
788                    let rest = &self.source[start..];
789                    let end = rest.find("]]>").map_or(self.source.len(), |at| start + at);
790                    self.position = (end + 3).min(self.source.len());
791                    self.state = State::Data;
792                    if end > start {
793                        return Some(Token::Text(normalize(&self.source[start..end])));
794                    }
795                    continue;
796                }
797                State::Rcdata | State::Rawtext | State::ScriptData => {
798                    if self.appropriate_end(start) {
799                        self.state = State::Data;
800                        return self.tag(true, attribute_buffer);
801                    }
802                    let end = if self.state == State::ScriptData {
803                        self.script_end(start)
804                    } else {
805                        let mut end = start;
806                        loop {
807                            let Some(offset) = memchr(b'<', &self.source.as_bytes()[end..]) else {
808                                break self.source.len();
809                            };
810                            end += offset;
811                            if self.appropriate_end(end) {
812                                break end;
813                            }
814                            end += 1;
815                        }
816                    };
817                    self.position = end;
818                    let text = if self.state == State::Rcdata {
819                        replace_null(decode(&self.source[start..end], false))
820                    } else {
821                        normalize_text(&self.source[start..end])
822                    };
823                    return Some(Token::Text(text));
824                }
825                State::Data => {}
826            }
827            if self.current() != Some(b'<') {
828                let rest = &self.source.as_bytes()[start..];
829                let prefix = rest.len().min(16);
830                let first = rest[..prefix]
831                    .iter()
832                    .position(|byte| matches!(byte, b'<' | b'&' | b'\r'))
833                    .or_else(|| memchr3(b'<', b'&', b'\r', &rest[prefix..]).map(|at| prefix + at))
834                    .unwrap_or(rest.len());
835                if first == rest.len() || rest[first] == b'<' {
836                    self.position += first;
837                    return Some(Token::Text(Cow::Borrowed(
838                        &self.source[start..self.position],
839                    )));
840                }
841                self.position = memchr(b'<', &rest[first..])
842                    .map_or(self.source.len(), |offset| start + first + offset);
843                return Some(Token::Text(decode_from(
844                    &self.source[start..self.position],
845                    false,
846                    first,
847                )));
848            }
849            let rest = &self.source.as_bytes()[start..];
850            match rest.get(1).copied() {
851                Some(byte) if byte.is_ascii_alphabetic() => {
852                    return self.tag(false, attribute_buffer);
853                }
854                Some(b'/') => match rest.get(2).copied() {
855                    Some(byte) if byte.is_ascii_alphabetic() => {
856                        return self.tag(true, attribute_buffer);
857                    }
858                    Some(b'>') => {
859                        self.position += 3;
860                        continue;
861                    }
862                    None => {
863                        self.position += 2;
864                        return Some(Token::Text(Cow::Borrowed("</")));
865                    }
866                    _ => {
867                        self.position += 2;
868                        return Some(self.bogus_comment(start + 2));
869                    }
870                },
871                Some(b'!') => {
872                    self.position += 2;
873                    if rest.starts_with(b"<!--") {
874                        self.position += 2;
875                        return Some(self.comment());
876                    }
877                    if rest
878                        .get(2..9)
879                        .is_some_and(|keyword| keyword.eq_ignore_ascii_case(b"DOCTYPE"))
880                    {
881                        self.position += 7;
882                        return Some(self.doctype());
883                    }
884                    return Some(self.bogus_comment(start + 2));
885                }
886                Some(b'?') => return self.processing_instruction(),
887                _ => {
888                    self.position += 1;
889                    // Keep literal less-than signs in one text run so documents
890                    // do not retain a node for every byte of malformed markup.
891                    let bytes = self.source.as_bytes();
892                    loop {
893                        let Some(offset) = memchr(b'<', &bytes[self.position..]) else {
894                            self.position = bytes.len();
895                            break;
896                        };
897                        self.position += offset;
898                        if bytes.get(self.position + 1).is_some_and(|byte| {
899                            byte.is_ascii_alphabetic() || matches!(byte, b'/' | b'!' | b'?')
900                        }) {
901                            break;
902                        }
903                        self.position += 1;
904                    }
905                    return Some(Token::Text(decode(
906                        &self.source[start..self.position],
907                        false,
908                    )));
909                }
910            }
911        }
912    }
913}
914
915impl<'a> Iterator for Tokenizer<'a> {
916    type Item = Token<'a>;
917
918    fn next(&mut self) -> Option<Self::Item> {
919        self.next_with_attribute_buffer(&mut Vec::new())
920    }
921}
922
923impl FusedIterator for Tokenizer<'_> {}
924
925/// Text states accumulate only ASCII letters in end-tag names.
926fn can_end_text(name: &str) -> bool {
927    !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphabetic())
928}
929
930fn is_space(byte: u8) -> bool {
931    matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
932}
933
934/// Compare `candidate` with `name`, whose ASCII letters must already be lowercase.
935#[inline(always)]
936pub(crate) fn matches_normalized_name(name: &[u8], candidate: &[u8]) -> bool {
937    // The standard comparison processes long names in vector-sized chunks.
938    if name.len() >= 16 {
939        return name.eq_ignore_ascii_case(candidate);
940    }
941    name.len() == candidate.len()
942        && name
943            .iter()
944            .zip(candidate)
945            .all(|(&name, &candidate)| name == candidate.to_ascii_lowercase())
946}
947
948/// Normalize a name already known to contain uppercase ASCII or NUL.
949#[inline(never)]
950fn normalize_name(input: &str) -> Cow<'_, str> {
951    if let [byte @ b'A'..=b'Z'] = input.as_bytes() {
952        let start = usize::from(byte - b'A');
953        return Cow::Borrowed(&"abcdefghijklmnopqrstuvwxyz"[start..start + 1]);
954    }
955    let names: &'static [&str] = match input.len() {
956        2 => &["br", "hr"],
957        3 => &["col", "img", "wbr", "xmp"],
958        4 if matches!(input.as_bytes()[0], b'h' | b'H') => &["head", "href", "html"],
959        4 => &["area", "base", "body", "link", "meta", "name"],
960        5 => &["embed", "input", "param", "style", "title", "track"],
961        6 => &["iframe", "script", "source"],
962        7 => &["content", "noembed"],
963        8 => &["noframes", "textarea"],
964        9 => &["plaintext"],
965        _ => &[],
966    };
967    if let Some(&name) = names
968        .iter()
969        .find(|name| matches_normalized_name(name.as_bytes(), input.as_bytes()))
970    {
971        return Cow::Borrowed(name);
972    }
973    replace_null(Cow::Owned(input.to_ascii_lowercase()))
974}
975
976/// Normalize newlines and replace NUL together in text modes and declarations.
977#[inline]
978fn normalize_text(input: &str) -> Cow<'_, str> {
979    let Some(first) = memchr2(b'\r', 0, input.as_bytes()) else {
980        return Cow::Borrowed(input);
981    };
982    Cow::Owned(normalize_text_slow(input, first))
983}
984
985/// Copy transformed text once, reserving room for every NUL replacement.
986#[inline(never)]
987fn normalize_text_slow(input: &str, first: usize) -> String {
988    let nulls = memchr_iter(0, &input.as_bytes()[first..]).count();
989    let capacity = input.len().saturating_add(nulls.saturating_mul(2));
990    let mut output = String::with_capacity(capacity);
991    output.push_str(&input[..first]);
992    let mut rest = &input[first..];
993    while let Some(at) = memchr2(b'\r', 0, rest.as_bytes()) {
994        output.push_str(&rest[..at]);
995        let newline = rest.as_bytes()[at] == b'\r';
996        output.push(if newline { '\n' } else { '\u{fffd}' });
997        rest = &rest[at + 1..];
998        if newline && let Some(suffix) = rest.strip_prefix('\n') {
999            rest = suffix;
1000        }
1001    }
1002    output.push_str(rest);
1003    output
1004}
1005
1006/// Replace NUL, reusing owned buffers.
1007#[inline(never)]
1008fn replace_null(input: Cow<'_, str>) -> Cow<'_, str> {
1009    if input.as_bytes().contains(&0) {
1010        replace_null_slow(input)
1011    } else {
1012        input
1013    }
1014}
1015
1016/// Keep the expansion stack frame out of the unchanged-token path.
1017#[inline(never)]
1018fn replace_null_slow(input: Cow<'_, str>) -> Cow<'_, str> {
1019    let Cow::Owned(input) = input else {
1020        return Cow::Owned(input.replace('\0', "\u{fffd}"));
1021    };
1022    let first = memchr(0, input.as_bytes()).expect("input contains NUL");
1023    let nulls = memchr_iter(0, &input.as_bytes()[first..]).count();
1024    let mut bytes = input.into_bytes();
1025    let read = bytes.len();
1026    bytes.reserve_exact(2 * nulls);
1027    let mut write = read + 2 * nulls;
1028    bytes.resize(write, 0);
1029    // Expand from the end so writes cannot overwrite unread source bytes.
1030    for read in (first..read).rev() {
1031        if bytes[read] == 0 {
1032            write -= 3;
1033            bytes[write..write + 3].copy_from_slice("\u{fffd}".as_bytes());
1034        } else {
1035            write -= 1;
1036            bytes[write] = bytes[read];
1037        }
1038    }
1039    Cow::Owned(String::from_utf8(bytes).expect("NUL replacement preserves UTF-8"))
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use std::borrow::Cow;
1045
1046    use super::{normalize_text, replace_null};
1047
1048    #[test]
1049    fn combined_text_normalization_preserves_unicode_and_newlines() {
1050        assert!(matches!(normalize_text("plain 🦀 text"), Cow::Borrowed(_)));
1051        for (source, expected) in [
1052            ("\r\n\r\n", "\n\n"),
1053            ("é\r\0\n🦀\0", "é\n�\n🦀�"),
1054            ("\0\0\r", "��\n"),
1055            ("prefix\r\nsuffix\0", "prefix\nsuffix�"),
1056        ] {
1057            assert_eq!(normalize_text(source), expected);
1058        }
1059    }
1060
1061    #[test]
1062    fn ordinary_comments_borrow_punctuation() {
1063        for text in [
1064            "generated-by tool <meta> and > punctuation",
1065            "-leading and mid-word",
1066            "<!",
1067            "é 🦀 - < >",
1068        ] {
1069            let source = format!("<!--{text}-->");
1070            assert!(matches!(
1071                super::Tokenizer::new(&source).next(),
1072                Some(super::Token::Comment(Cow::Borrowed(value))) if value == text
1073            ));
1074        }
1075    }
1076
1077    #[test]
1078    fn owned_null_replacement_reuses_spare_capacity() {
1079        for source in ["\0", "\0\0\0", "é\0🦀\0", "prefix\0suffix"] {
1080            let mut input = String::with_capacity(3 * source.len());
1081            input.push_str(source);
1082            let allocation = input.as_ptr();
1083            let output = replace_null(Cow::Owned(input));
1084            assert_eq!(output, source.replace('\0', "\u{fffd}"));
1085            assert_eq!(output.as_ptr(), allocation);
1086        }
1087    }
1088}