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