Skip to main content

rama_http/protocols/html/tokenizer/
machine.rs

1//! The scan-based, resumable HTML tokenizer.
2//!
3//! The tokenizer owns a growable buffer and is driven by [`Tokenizer::write`]
4//! (feed a chunk) and [`Tokenizer::end`] (finalize); [`Tokenizer::tokenize`]
5//! is the one-shot convenience (`write` + `end`). Both paths share one
6//! resumable core, so a document tokenizes identically no matter how the
7//! input is split across `write` calls.
8//!
9//! It is built for verbatim re-serialization: the `raw` spans of the emitted
10//! tokens partition the input contiguously, so concatenating them reproduces
11//! the input exactly (the *identity* property). Bytes the HTML spec would
12//! drop (e.g. a stray `<`) are preserved as text — this is the substrate for
13//! a byte-faithful rewriter, not a DOM builder.
14//!
15//! Atomic constructs (tags, comments, CDATA, doctype, and raw-text/script
16//! bodies) are retained whole until their terminator arrives; ordinary text
17//! streams in chunk-sized pieces. A small context tracker (see
18//! [`super::context`]) supplies the open-element context needed to pick the
19//! right text mode and to recognize CDATA in foreign content.
20//!
21//! > Memory note: a single unterminated raw-text/script body (or
22//! > `<plaintext>`) is buffered until its end tag or end of input; ordinary
23//! > text is not.
24
25use memchr::{memchr, memchr2};
26
27use super::context::{ContextTracker, ParsingAmbiguityError, TextMode};
28use super::name::LocalNameHash;
29use super::sink::TokenSink;
30use super::token::{AttrRange, Cdata, Comment, Doctype, EndTag, Span, StartTag, Text};
31
32/// `<!` — the markup-declaration opener.
33const MARKUP_DECL_PREFIX: &[u8] = b"<!";
34/// `<!--` — the comment opener.
35const COMMENT_PREFIX: &[u8] = b"<!--";
36/// `-->` — the comment closer.
37const COMMENT_SUFFIX: &[u8] = b"-->";
38/// `<![CDATA[` — the CDATA-section opener.
39const CDATA_PREFIX: &[u8] = b"<![CDATA[";
40/// `]]>` — the CDATA-section closer.
41const CDATA_SUFFIX: &[u8] = b"]]>";
42/// The DOCTYPE keyword (matched ASCII case-insensitively).
43const DOCTYPE_KEYWORD: &[u8] = b"doctype";
44/// The `<script>` tag name (its body is scanned as script-data).
45const SCRIPT_NAME: &[u8] = b"script";
46
47/// A byte-faithful, low-allocation, resumable HTML tokenizer.
48#[derive(Debug)]
49pub struct Tokenizer {
50    /// Bytes received but not yet fully tokenized.
51    buffer: Vec<u8>,
52    /// Reusable attribute-range scratch for the current tag.
53    attributes: Vec<AttrRange>,
54    context: ContextTracker,
55    strict: bool,
56}
57
58impl Default for Tokenizer {
59    fn default() -> Self {
60        Self {
61            buffer: Vec::new(),
62            attributes: Vec::new(),
63            context: ContextTracker::new(false),
64            strict: false,
65        }
66    }
67}
68
69/// Tokenizes `input` in one pass with the default (non-strict) tokenizer,
70/// dispatching events to `sink`.
71///
72/// # Errors
73///
74/// The default tokenizer is non-strict, so this does not abort on ambiguous
75/// contexts; build a strict [`Tokenizer`] (via
76/// [`with_strict`](Tokenizer::with_strict)) to get a [`ParsingAmbiguityError`].
77pub fn tokenize<S: TokenSink>(input: &[u8], sink: &mut S) -> Result<(), ParsingAmbiguityError> {
78    Tokenizer::new().tokenize(input, sink)
79}
80
81impl Tokenizer {
82    /// Creates a new tokenizer.
83    ///
84    /// Non-strict by default (rama's convention): ambiguous text-mode contexts
85    /// are tokenized best-effort. Enable [`with_strict`](Self::with_strict) to
86    /// abort on such ambiguity instead.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    rama_utils::macros::generate_set_and_with! {
93        /// Sets strict mode. When enabled, ambiguous text-mode contexts abort
94        /// with a [`ParsingAmbiguityError`] instead of tokenizing best-effort.
95        pub fn strict(mut self, strict: bool) -> Self {
96            self.strict = strict;
97            self.context = ContextTracker::new(strict);
98            self
99        }
100    }
101
102    /// Feeds a chunk of input, emitting every token that is now complete.
103    ///
104    /// # Errors
105    ///
106    /// See [`tokenize`].
107    pub fn write<S: TokenSink>(
108        &mut self,
109        chunk: &[u8],
110        sink: &mut S,
111    ) -> Result<(), ParsingAmbiguityError> {
112        self.buffer.extend_from_slice(chunk);
113        self.run(false, sink)
114    }
115
116    /// Finalizes the stream, emitting any remaining (possibly unterminated)
117    /// tokens, then resets for reuse.
118    ///
119    /// # Errors
120    ///
121    /// See [`tokenize`].
122    pub fn end<S: TokenSink>(&mut self, sink: &mut S) -> Result<(), ParsingAmbiguityError> {
123        let result = self.run(true, sink);
124        self.reset();
125        result
126    }
127
128    /// Tokenizes a complete `input` in one shot (`write` then `end`).
129    ///
130    /// # Errors
131    ///
132    /// See [`tokenize`].
133    pub fn tokenize<S: TokenSink>(
134        &mut self,
135        input: &[u8],
136        sink: &mut S,
137    ) -> Result<(), ParsingAmbiguityError> {
138        if let Err(err) = self.write(input, sink) {
139            self.reset();
140            return Err(err);
141        }
142        self.end(sink)
143    }
144
145    fn reset(&mut self) {
146        self.buffer.clear();
147        self.context = ContextTracker::new(self.strict);
148    }
149
150    /// The resumable core. Emits all complete tokens from the buffer; if
151    /// `is_final` is false, stops at the first incomplete construct and
152    /// retains it (and the trailing text run) for the next call.
153    fn run<S: TokenSink>(
154        &mut self,
155        is_final: bool,
156        sink: &mut S,
157    ) -> Result<(), ParsingAmbiguityError> {
158        // Disjoint field borrows: the buffer is read while the attribute
159        // scratch and context are mutated.
160        let Self {
161            buffer,
162            attributes,
163            context,
164            ..
165        } = self;
166
167        let mut pos = 0;
168        // Start of the pending (not-yet-emitted) text run.
169        let mut text_start = 0;
170
171        loop {
172            let Some(rel) = memchr(b'<', buffer.get(pos..).unwrap_or(&[])) else {
173                // No more markup: the rest is text.
174                emit_text(buffer, text_start, buffer.len(), sink);
175                text_start = buffer.len();
176                break;
177            };
178            let lt = pos + rel;
179
180            match classify(buffer, lt, context.cdata_allowed()) {
181                Resolve::Text => pos = lt + 1, // a `<` that isn't markup is text
182                Resolve::NeedMore => {
183                    if is_final {
184                        pos = lt + 1; // a trailing `<` at EOF is text
185                    } else {
186                        emit_text(buffer, text_start, lt, sink);
187                        text_start = lt;
188                        break;
189                    }
190                }
191                Resolve::Construct(construct) => {
192                    if !is_final && !terminator_available(construct, buffer, lt, context) {
193                        emit_text(buffer, text_start, lt, sink);
194                        text_start = lt;
195                        break;
196                    }
197                    emit_text(buffer, text_start, lt, sink);
198                    pos = process(construct, attributes, context, buffer, lt, sink)?;
199                    text_start = pos;
200                }
201            }
202        }
203
204        buffer.drain(..text_start);
205        Ok(())
206    }
207}
208
209/// The classification of a `<` and whether more input is needed to make it.
210enum Resolve {
211    /// The `<` is literal text (e.g. `< ` or `<3`).
212    Text,
213    /// More input is required to classify (only returned in non-final runs).
214    NeedMore,
215    Construct(Construct),
216}
217
218#[derive(Debug, Clone, Copy)]
219enum Construct {
220    StartTag,
221    EndTag,
222    Comment,
223    Cdata,
224    Doctype,
225    /// A bogus comment; the payload is the opener length to skip for `data`.
226    BogusComment(usize),
227}
228
229/// A pending raw-text scan triggered by a text-mode start tag.
230#[derive(Debug, Clone, Copy)]
231struct RawScan {
232    mode: TextMode,
233    /// Span of the element name, used to find the matching end tag.
234    name: Span,
235}
236
237/// Classifies the `<` at `lt`, signalling [`Resolve::NeedMore`] when the
238/// available bytes are only a prefix of a longer opener (`<!--`, `<![CDATA[`,
239/// `<!doctype`).
240fn classify(input: &[u8], lt: usize, cdata_allowed: bool) -> Resolve {
241    match input.get(lt + 1) {
242        None => Resolve::NeedMore,
243        Some(b) if b.is_ascii_alphabetic() => Resolve::Construct(Construct::StartTag),
244        Some(b'/') => match input.get(lt + 2) {
245            None => Resolve::NeedMore,
246            Some(c) if c.is_ascii_alphabetic() => Resolve::Construct(Construct::EndTag),
247            Some(_) => Resolve::Construct(Construct::BogusComment(2)),
248        },
249        Some(b'!') => classify_markup_declaration(input, lt, cdata_allowed),
250        Some(b'?') => Resolve::Construct(Construct::BogusComment(1)),
251        Some(_) => Resolve::Text,
252    }
253}
254
255fn classify_markup_declaration(input: &[u8], lt: usize, cdata_allowed: bool) -> Resolve {
256    // Bytes after the `<!`.
257    let rest = input.get(lt + MARKUP_DECL_PREFIX.len()..).unwrap_or(&[]);
258
259    if rest.starts_with(b"--") {
260        return Resolve::Construct(Construct::Comment);
261    }
262    if is_strict_prefix(rest, b"--") {
263        return Resolve::NeedMore;
264    }
265    if cdata_allowed {
266        let cdata_inner = &CDATA_PREFIX[MARKUP_DECL_PREFIX.len()..]; // `[CDATA[`
267        if rest.starts_with(cdata_inner) {
268            return Resolve::Construct(Construct::Cdata);
269        }
270        if is_strict_prefix(rest, cdata_inner) {
271            return Resolve::NeedMore;
272        }
273    }
274    if starts_with_ci(rest, DOCTYPE_KEYWORD) {
275        return Resolve::Construct(Construct::Doctype);
276    }
277    if is_strict_prefix_ci(rest, DOCTYPE_KEYWORD) {
278        return Resolve::NeedMore;
279    }
280    Resolve::Construct(Construct::BogusComment(MARKUP_DECL_PREFIX.len()))
281}
282
283/// Whether the construct at `lt` has its terminator within the buffer (so it
284/// can be emitted without more input). For a text-mode start tag this also
285/// requires the body's end tag to be present.
286fn terminator_available(
287    construct: Construct,
288    input: &[u8],
289    lt: usize,
290    context: &ContextTracker,
291) -> bool {
292    match construct {
293        Construct::StartTag => {
294            let name_end = scan_tag_name(input, lt + 1);
295            let (close, _self_closing, complete) = scan_attributes(None, input, name_end);
296            if !complete {
297                return false;
298            }
299            let name = slice(input, lt + 1, name_end);
300            match context.peek_text_mode(LocalNameHash::of(name)) {
301                None => true,
302                Some(mode) => find_body_end(mode, input, close, name).is_some(),
303            }
304        }
305        Construct::EndTag => scan_attributes(None, input, scan_tag_name(input, lt + 2)).2,
306        Construct::Comment => find_seq(input, lt + COMMENT_PREFIX.len(), COMMENT_SUFFIX).is_some(),
307        Construct::Cdata => find_seq(input, lt + CDATA_PREFIX.len(), CDATA_SUFFIX).is_some(),
308        Construct::Doctype => {
309            let from = lt + MARKUP_DECL_PREFIX.len() + DOCTYPE_KEYWORD.len();
310            memchr(b'>', input.get(from..).unwrap_or(&[])).is_some()
311        }
312        Construct::BogusComment(open) => {
313            memchr(b'>', input.get(lt + open..).unwrap_or(&[])).is_some()
314        }
315    }
316}
317
318/// Emits the construct at `lt`, returning the position just past it.
319fn process<S: TokenSink>(
320    construct: Construct,
321    attributes: &mut Vec<AttrRange>,
322    context: &mut ContextTracker,
323    input: &[u8],
324    lt: usize,
325    sink: &mut S,
326) -> Result<usize, ParsingAmbiguityError> {
327    Ok(match construct {
328        Construct::StartTag => {
329            let (close, name_hash, name, self_closing) =
330                scan_start_tag(attributes, input, lt, sink);
331            let text_mode = context.on_start_tag(
332                name_hash,
333                slice(input, name.start, name.end),
334                attributes,
335                input,
336                self_closing,
337            )?;
338            if let Some(mode) = text_mode {
339                scan_raw_text(attributes, input, close, RawScan { mode, name }, sink)
340            } else {
341                close
342            }
343        }
344        Construct::EndTag => {
345            let (close, name_hash, name) = scan_end_tag(attributes, input, lt, sink);
346            context.on_end_tag(name_hash, slice(input, name.start, name.end));
347            close
348        }
349        Construct::Comment => scan_comment(input, lt, sink),
350        Construct::Cdata => scan_cdata(input, lt, sink),
351        Construct::Doctype => scan_doctype(input, lt, sink),
352        Construct::BogusComment(open) => scan_bogus_comment(input, lt, open, sink),
353    })
354}
355
356fn scan_start_tag<S: TokenSink>(
357    attributes: &mut Vec<AttrRange>,
358    input: &[u8],
359    lt: usize,
360    sink: &mut S,
361) -> (usize, LocalNameHash, Span, bool) {
362    let name_start = lt + 1;
363    let name_end = scan_tag_name(input, name_start);
364    let name = Span::new(name_start, name_end);
365    let name_hash = LocalNameHash::of(slice(input, name_start, name_end));
366
367    attributes.clear();
368    let (close, self_closing, _complete) = scan_attributes(Some(attributes), input, name_end);
369
370    let tag = StartTag {
371        input,
372        raw: Span::new(lt, close),
373        name,
374        name_hash,
375        attributes,
376        self_closing,
377    };
378    sink.start_tag(&tag);
379    (close, name_hash, name, self_closing)
380}
381
382fn scan_end_tag<S: TokenSink>(
383    attributes: &mut Vec<AttrRange>,
384    input: &[u8],
385    lt: usize,
386    sink: &mut S,
387) -> (usize, LocalNameHash, Span) {
388    let name_start = lt + 2;
389    let name_end = scan_tag_name(input, name_start);
390    let name = Span::new(name_start, name_end);
391    let name_hash = LocalNameHash::of(slice(input, name_start, name_end));
392
393    // End tags may carry (ignored) attributes; scan past them so that a `>`
394    // inside a quoted value does not close the tag prematurely.
395    attributes.clear();
396    let (close, _self_closing, _complete) = scan_attributes(Some(attributes), input, name_end);
397
398    let tag = EndTag {
399        input,
400        raw: Span::new(lt, close),
401        name,
402        name_hash,
403    };
404    sink.end_tag(&tag);
405    (close, name_hash, name)
406}
407
408/// Scans an element body in a raw text mode, emitting a [`Text`] token for
409/// the content and (if found) the matching end tag.
410fn scan_raw_text<S: TokenSink>(
411    attributes: &mut Vec<AttrRange>,
412    input: &[u8],
413    content_start: usize,
414    scan: RawScan,
415    sink: &mut S,
416) -> usize {
417    let name = slice(input, scan.name.start, scan.name.end);
418    if let Some(lt) = find_body_end(scan.mode, input, content_start, name) {
419        emit_text(input, content_start, lt, sink);
420        let (close, _name_hash, _name) = scan_end_tag(attributes, input, lt, sink);
421        close
422    } else {
423        emit_text(input, content_start, input.len(), sink);
424        input.len()
425    }
426}
427
428fn find_body_end(mode: TextMode, input: &[u8], content_start: usize, name: &[u8]) -> Option<usize> {
429    match mode {
430        TextMode::PlainText => None,
431        TextMode::RawText | TextMode::RcData => {
432            find_appropriate_end_tag(input, content_start, name)
433        }
434        TextMode::ScriptData => find_script_data_end(input, content_start),
435    }
436}
437
438/// Parses a tag's attribute list starting at `from` (just after the tag
439/// name), optionally recording attributes into `attributes`. Returns the
440/// position just past the closing `>` (or end of input), whether the tag was
441/// self-closing, and whether it actually closed (vs. running out of input).
442///
443/// Used for both emitting (with `Some` storage) and the streaming
444/// completeness check (`None`), so the two can never disagree on where a tag
445/// ends.
446fn scan_attributes(
447    mut attributes: Option<&mut Vec<AttrRange>>,
448    input: &[u8],
449    from: usize,
450) -> (usize, bool, bool) {
451    let mut i = from;
452    loop {
453        i = skip_space(input, i);
454        match input.get(i) {
455            None => return (input.len(), false, false),
456            Some(b'>') => return (i + 1, false, true),
457            Some(b'/') => {
458                if input.get(i + 1) == Some(&b'>') {
459                    return (i + 2, true, true);
460                }
461                i += 1; // stray solidus
462            }
463            Some(_) => i = scan_one_attribute(attributes.as_deref_mut(), input, i),
464        }
465    }
466}
467
468fn scan_one_attribute(attributes: Option<&mut Vec<AttrRange>>, input: &[u8], i: usize) -> usize {
469    let name_start = i;
470    let name_end = scan_attribute_name(input, i);
471
472    let after_ws = skip_space(input, name_end);
473    let (value, has_value, after) = if input.get(after_ws) == Some(&b'=') {
474        let value_pos = skip_space(input, after_ws + 1);
475        match input.get(value_pos) {
476            Some(&q @ (b'"' | b'\'')) => {
477                let (value, after) = scan_quoted_value(input, value_pos, q);
478                (value, true, after)
479            }
480            None => (Span::empty(value_pos), true, value_pos),
481            Some(_) => {
482                let (value, after) = scan_unquoted_value(input, value_pos);
483                (value, true, after)
484            }
485        }
486    } else {
487        (Span::empty(name_end), false, name_end)
488    };
489
490    if let Some(attributes) = attributes {
491        attributes.push(AttrRange {
492            name: Span::new(name_start, name_end),
493            value,
494            has_value,
495        });
496    }
497    after
498}
499
500fn emit_text<S: TokenSink>(input: &[u8], start: usize, end: usize, sink: &mut S) {
501    if end > start {
502        sink.text(&Text {
503            input,
504            raw: Span::new(start, end),
505        });
506    }
507}
508
509fn scan_comment<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
510    let data_start = lt + COMMENT_PREFIX.len();
511    let (data_end, close) = match find_seq(input, data_start, COMMENT_SUFFIX) {
512        Some(at) => (at, at + COMMENT_SUFFIX.len()),
513        None => (input.len(), input.len()),
514    };
515    sink.comment(&Comment {
516        input,
517        raw: Span::new(lt, close),
518        data: Span::new(data_start.min(data_end), data_end),
519    });
520    close
521}
522
523fn scan_cdata<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
524    let data_start = lt + CDATA_PREFIX.len();
525    let (data_end, close) = match find_seq(input, data_start, CDATA_SUFFIX) {
526        Some(at) => (at, at + CDATA_SUFFIX.len()),
527        None => (input.len(), input.len()),
528    };
529    sink.cdata(&Cdata {
530        input,
531        raw: Span::new(lt, close),
532        data: Span::new(data_start.min(data_end), data_end),
533    });
534    close
535}
536
537fn scan_doctype<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
538    let after_keyword = lt + MARKUP_DECL_PREFIX.len() + DOCTYPE_KEYWORD.len();
539    let (content_end, close) = match memchr(b'>', input.get(after_keyword..).unwrap_or(&[])) {
540        Some(rel) => (after_keyword + rel, after_keyword + rel + 1),
541        None => (input.len(), input.len()),
542    };
543    let name = parse_doctype_name(input, after_keyword, content_end);
544    sink.doctype(&Doctype {
545        input,
546        raw: Span::new(lt, close),
547        name,
548    });
549    close
550}
551
552fn parse_doctype_name(input: &[u8], from: usize, end: usize) -> Option<Span> {
553    let start = skip_space(input, from).min(end);
554    let mut i = start;
555    while i < end {
556        match input.get(i) {
557            Some(b) if is_html_space(*b) => break,
558            Some(_) => i += 1,
559            None => break,
560        }
561    }
562    (i > start).then(|| Span::new(start, i))
563}
564
565fn scan_bogus_comment<S: TokenSink>(
566    input: &[u8],
567    lt: usize,
568    open_len: usize,
569    sink: &mut S,
570) -> usize {
571    let data_start = lt + open_len;
572    let (data_end, close) = match memchr(b'>', input.get(data_start..).unwrap_or(&[])) {
573        Some(rel) => (data_start + rel, data_start + rel + 1),
574        None => (input.len(), input.len()),
575    };
576    sink.comment(&Comment {
577        input,
578        raw: Span::new(lt, close),
579        data: Span::new(data_start.min(data_end), data_end),
580    });
581    close
582}
583
584fn scan_tag_name(input: &[u8], from: usize) -> usize {
585    let mut i = from;
586    while let Some(&b) = input.get(i) {
587        if is_html_space(b) || b == b'/' || b == b'>' {
588            break;
589        }
590        i += 1;
591    }
592    i
593}
594
595fn scan_attribute_name(input: &[u8], from: usize) -> usize {
596    let mut i = from;
597    while let Some(&b) = input.get(i) {
598        if is_html_space(b) || b == b'=' || b == b'/' || b == b'>' {
599            break;
600        }
601        i += 1;
602    }
603    i
604}
605
606fn scan_quoted_value(input: &[u8], quote_pos: usize, quote: u8) -> (Span, usize) {
607    let start = quote_pos + 1;
608    match memchr(quote, input.get(start..).unwrap_or(&[])) {
609        Some(rel) => (Span::new(start, start + rel), start + rel + 1),
610        None => (Span::new(start, input.len()), input.len()),
611    }
612}
613
614fn scan_unquoted_value(input: &[u8], from: usize) -> (Span, usize) {
615    let mut i = from;
616    while let Some(&b) = input.get(i) {
617        if is_html_space(b) || b == b'>' {
618            break;
619        }
620        i += 1;
621    }
622    (Span::new(from, i), i)
623}
624
625fn skip_space(input: &[u8], from: usize) -> usize {
626    let mut i = from;
627    while let Some(&b) = input.get(i) {
628        if is_html_space(b) {
629            i += 1;
630        } else {
631            break;
632        }
633    }
634    i
635}
636
637/// HTML whitespace (space, tab, LF, FF, CR) as a `[bool; 256]` table so the
638/// hot-path check is a single branchless byte load.
639const HTML_SPACE: [bool; 256] = rama_utils::byte_set::set_each([false; 256], b" \t\n\x0c\r");
640
641#[inline]
642const fn is_html_space(b: u8) -> bool {
643    HTML_SPACE[b as usize]
644}
645
646fn starts_with_ci(haystack: &[u8], lower_needle: &[u8]) -> bool {
647    haystack
648        .get(..lower_needle.len())
649        .is_some_and(|head| head.eq_ignore_ascii_case(lower_needle))
650}
651
652/// Whether `bytes` is a strict (shorter) prefix of `whole`.
653fn is_strict_prefix(bytes: &[u8], whole: &[u8]) -> bool {
654    bytes.len() < whole.len() && whole.starts_with(bytes)
655}
656
657/// ASCII-case-insensitive [`is_strict_prefix`].
658fn is_strict_prefix_ci(bytes: &[u8], whole: &[u8]) -> bool {
659    bytes.len() < whole.len()
660        && whole
661            .get(..bytes.len())
662            .is_some_and(|head| head.eq_ignore_ascii_case(bytes))
663}
664
665/// Finds `needle` in `input[from..]`, returning the absolute start index.
666fn find_seq(input: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
667    let Some((&first, rest)) = needle.split_first() else {
668        return Some(from);
669    };
670    let mut i = from;
671    while let Some(rel) = memchr(first, input.get(i..).unwrap_or(&[])) {
672        let at = i + rel;
673        if input.get(at + 1..at + 1 + rest.len()) == Some(rest) {
674            return Some(at);
675        }
676        i = at + 1;
677    }
678    None
679}
680
681fn is_tag_terminator(b: u8) -> bool {
682    is_html_space(b) || b == b'/' || b == b'>'
683}
684
685/// Whether `lt` begins an "appropriate end tag" — `</name` for `name`
686/// (ASCII case-insensitive) followed by a tag terminator.
687fn is_appropriate_end_tag(input: &[u8], lt: usize, name: &[u8]) -> bool {
688    if input.get(lt + 1) != Some(&b'/') {
689        return false;
690    }
691    let start = lt + 2;
692    let end = start + name.len();
693    if !input
694        .get(start..end)
695        .is_some_and(|s| s.eq_ignore_ascii_case(name))
696    {
697        return false;
698    }
699    matches!(input.get(end), Some(&b) if is_tag_terminator(b))
700}
701
702/// Finds the first appropriate end tag for raw-text / RCDATA content,
703/// returning the position of its `<`.
704fn find_appropriate_end_tag(input: &[u8], from: usize, name: &[u8]) -> Option<usize> {
705    let mut i = from;
706    while let Some(rel) = memchr(b'<', input.get(i..).unwrap_or(&[])) {
707        let lt = i + rel;
708        if is_appropriate_end_tag(input, lt, name) {
709            return Some(lt);
710        }
711        i = lt + 1;
712    }
713    None
714}
715
716#[derive(Debug, Clone, Copy)]
717enum ScriptState {
718    Data,
719    Escaped,
720    DoubleEscaped,
721}
722
723/// Finds the `</script>` that terminates a script element per the HTML
724/// script-data escape rules (handling `<!-- … -->` and nested `<script>`),
725/// returning the position of its `<`.
726fn find_script_data_end(input: &[u8], from: usize) -> Option<usize> {
727    let mut state = ScriptState::Data;
728    let mut i = from;
729    loop {
730        match state {
731            ScriptState::Data => {
732                let lt = i + memchr(b'<', input.get(i..).unwrap_or(&[]))?;
733                if is_appropriate_end_tag(input, lt, SCRIPT_NAME) {
734                    return Some(lt);
735                }
736                if input.get(lt + 1) == Some(&b'!')
737                    && input.get(lt + 2..).is_some_and(|s| s.starts_with(b"--"))
738                {
739                    state = ScriptState::Escaped;
740                    i = lt + 4;
741                } else {
742                    i = lt + 1;
743                }
744            }
745            ScriptState::Escaped => {
746                let at = i + memchr2(b'<', b'-', input.get(i..).unwrap_or(&[]))?;
747                if input.get(at) == Some(&b'-') {
748                    i = consume_comment_close(input, at, &mut state);
749                } else if is_appropriate_end_tag(input, at, SCRIPT_NAME) {
750                    // `</script>` ends the script even inside an escaped comment.
751                    return Some(at);
752                } else if let Some(after) = double_escape_start(input, at) {
753                    state = ScriptState::DoubleEscaped;
754                    i = after;
755                } else {
756                    i = at + 1;
757                }
758            }
759            ScriptState::DoubleEscaped => {
760                let at = i + memchr2(b'<', b'-', input.get(i..).unwrap_or(&[]))?;
761                if input.get(at) == Some(&b'-') {
762                    i = consume_comment_close(input, at, &mut state);
763                } else if let Some(after) = double_escape_end(input, at) {
764                    state = ScriptState::Escaped;
765                    i = after;
766                } else {
767                    i = at + 1;
768                }
769            }
770        }
771    }
772}
773
774/// At a `-` in (double-)escaped script data: `-->` returns to script-data,
775/// otherwise the dash is consumed. Returns the next scan position.
776fn consume_comment_close(input: &[u8], at: usize, state: &mut ScriptState) -> usize {
777    if input.get(at..).is_some_and(|s| s.starts_with(b"-->")) {
778        *state = ScriptState::Data;
779        at + 3
780    } else {
781        at + 1
782    }
783}
784
785/// `<script` (no slash) + terminator in escaped script data, entering the
786/// double-escaped state. Returns the position after the name.
787fn double_escape_start(input: &[u8], lt: usize) -> Option<usize> {
788    script_word_boundary(input, lt + 1)
789}
790
791/// `</script` + terminator in double-escaped script data, returning to the
792/// escaped state. Returns the position after the name.
793fn double_escape_end(input: &[u8], lt: usize) -> Option<usize> {
794    if input.get(lt + 1) != Some(&b'/') {
795        return None;
796    }
797    script_word_boundary(input, lt + 2)
798}
799
800/// If the ASCII-alpha run at `from` equals `script` (case-insensitive) and is
801/// followed by a tag terminator, returns the run's end position.
802fn script_word_boundary(input: &[u8], from: usize) -> Option<usize> {
803    let end = scan_ascii_alpha(input, from);
804    if end == from || !slice(input, from, end).eq_ignore_ascii_case(SCRIPT_NAME) {
805        return None;
806    }
807    matches!(input.get(end), Some(&b) if is_tag_terminator(b)).then_some(end)
808}
809
810fn scan_ascii_alpha(input: &[u8], from: usize) -> usize {
811    let mut i = from;
812    while input.get(i).is_some_and(u8::is_ascii_alphabetic) {
813        i += 1;
814    }
815    i
816}
817
818fn slice(input: &[u8], start: usize, end: usize) -> &[u8] {
819    input.get(start..end).unwrap_or(&[])
820}