Skip to main content

html5_parser/
tokenizer.rs

1// WHATWG HTML5 tokenizer state machine
2// (https://html.spec.whatwg.org/multipage/parsing.html#tokenization).
3//
4// The full state machine is implemented: tag/attribute/text tokenization,
5// character references, comments, DOCTYPE, processing instructions,
6// RCDATA/RAWTEXT/PLAINTEXT/script-data (including escaped/double-escaped),
7// and CDATA sections. See plan/02-tokenizer.md.
8
9use std::collections::VecDeque;
10
11use crate::entities;
12
13/// A source position: where a node parsed from the input started.
14/// Matches `html-conform::finding::SourceLocation`'s field layout exactly
15/// so the eventual integration (Phase 05) needs no conversion.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Position {
18    /// One-based line number.
19    pub line: u32,
20    /// One-based column number.
21    pub column: u32,
22    /// Zero-based byte offset.
23    pub byte_offset: usize,
24}
25
26/// A single WHATWG "parse error" (§13.2.2) — a point where the input
27/// deviated from strict grammar but the tokenizer still recovered per its
28/// own well-defined algorithm. Never fatal: [`crate::Document`]
29/// construction always completes regardless of how many of these occur.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct ParseError {
32    pub kind: ParseErrorKind,
33    pub position: Position,
34}
35
36/// Which WHATWG "parse error" (§13.2.2) occurred. Variant names mirror
37/// the spec's own kebab-case error identifiers, translated to
38/// PascalCase. Only variants this crate actually detects and reports
39/// exist — no catch-all/string-payload variant, so matching on a
40/// specific kind stays meaningful. `#[non_exhaustive]` because more
41/// variants are expected in follow-up phases (`plan/07-parse-errors.md`:
42/// tokenizer-level errors only so far — tree-construction-level errors,
43/// e.g. stray end tags across the whole document, are follow-up work,
44/// not yet represented here) — adding one later must not be a breaking
45/// change for any caller matching on this type.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum ParseErrorKind {
49    // Markup declaration open / comments (§13.2.5.42, .45–.52)
50    CdataInHtmlContent,
51    IncorrectlyOpenedComment,
52    AbruptClosingOfEmptyComment,
53    NestedComment,
54    IncorrectlyClosedComment,
55    EofInComment,
56    // Tag open / attributes (§13.2.5.6–.41)
57    InvalidFirstCharacterOfTagName,
58    EofBeforeTagName,
59    MissingEndTagName,
60    EofInTag,
61    EndTagWithAttributes,
62    EndTagWithTrailingSolidus,
63    DuplicateAttribute,
64    UnexpectedNullCharacter,
65    UnexpectedCharacterInAttributeName,
66    MissingAttributeValue,
67    UnexpectedCharacterInUnquotedAttributeValue,
68    MissingWhitespaceBetweenAttributes,
69    UnexpectedSolidusInTag,
70    UnexpectedEqualsSignBeforeAttributeName,
71    // Character references (§13.2.5.77–.84)
72    UnknownNamedCharacterReference,
73    AbsenceOfDigitsInNumericCharacterReference,
74    MissingSemicolonAfterCharacterReference,
75    NullCharacterReference,
76    CharacterReferenceOutsideUnicodeRange,
77    SurrogateCharacterReference,
78    NoncharacterCharacterReference,
79    ControlCharacterReference,
80    // DOCTYPE (§13.2.5.53–.68)
81    MissingWhitespaceBeforeDoctypeName,
82    MissingDoctypeName,
83    InvalidCharacterSequenceAfterDoctypeName,
84    MissingWhitespaceAfterDoctypePublicKeyword,
85    MissingWhitespaceAfterDoctypeSystemKeyword,
86    MissingDoctypePublicIdentifier,
87    MissingDoctypeSystemIdentifier,
88    MissingQuoteBeforeDoctypePublicIdentifier,
89    MissingQuoteBeforeDoctypeSystemIdentifier,
90    AbruptDoctypePublicIdentifier,
91    AbruptDoctypeSystemIdentifier,
92    MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers,
93    UnexpectedCharacterAfterDoctypeSystemIdentifier,
94    EofInDoctype,
95    // Processing instructions (§13.2.5.73–.76)
96    EofInProcessingInstruction,
97    InvalidFirstCharacterOfProcessingInstructionTarget,
98    InvalidProcessingInstructionTarget,
99    DisallowedProcessingInstructionTarget,
100    // Text content (§13.2.5.x script-data-like/CDATA states)
101    EofInScriptHtmlCommentLikeText,
102    EofInCdata,
103    // Input stream preprocessing (§13.2.3.5) — a one-time scan over the
104    // whole input, not a tokenizer-state transition. `surrogate-in-
105    // input-stream` has no variant here: it can never fire against a
106    // Rust `&str` input, since an unpaired surrogate is not a valid
107    // `char` in the first place (Rust's type system already rejects it
108    // at the `&str` boundary) — there is nothing this crate could ever
109    // detect, so no dead variant for it.
110    NoncharacterInInputStream,
111    ControlCharacterInInputStream,
112}
113
114impl std::fmt::Display for ParseErrorKind {
115    /// A short, human-readable description — not a transcription of any
116    /// particular consumer's exact wording (e.g. not vnu's), just a
117    /// plain-English description of the WHATWG condition this variant
118    /// names. Consumers that need their own phrasing should match on
119    /// [`ParseErrorKind`] directly instead of parsing this string.
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        let text = match self {
122            Self::CdataInHtmlContent => "a CDATA section outside foreign content",
123            Self::IncorrectlyOpenedComment => "a comment that doesn't start with \"<!--\"",
124            Self::AbruptClosingOfEmptyComment => "an empty comment closed abruptly with \">\"",
125            Self::NestedComment => "a nested \"<!--\" inside a comment",
126            Self::IncorrectlyClosedComment => "a comment closed with \"--!>\" instead of \"-->\"",
127            Self::EofInComment => "end of file inside a comment",
128            Self::InvalidFirstCharacterOfTagName => "an invalid first character of a tag name",
129            Self::EofBeforeTagName => "end of file before a tag name",
130            Self::MissingEndTagName => "an end tag with no name (\"</>\")",
131            Self::EofInTag => "end of file inside a tag",
132            Self::EndTagWithAttributes => "an end tag with attributes",
133            Self::EndTagWithTrailingSolidus => "an end tag with a trailing \"/\"",
134            Self::DuplicateAttribute => "a duplicate attribute on a tag",
135            Self::UnexpectedNullCharacter => "an unexpected U+0000 NULL character",
136            Self::UnexpectedCharacterInAttributeName => {
137                "an unexpected character in an attribute name"
138            }
139            Self::MissingAttributeValue => "a missing attribute value after \"=\"",
140            Self::UnexpectedCharacterInUnquotedAttributeValue => {
141                "an unexpected character in an unquoted attribute value"
142            }
143            Self::MissingWhitespaceBetweenAttributes => "missing whitespace between attributes",
144            Self::UnexpectedSolidusInTag => "an unexpected \"/\" inside a tag",
145            Self::UnexpectedEqualsSignBeforeAttributeName => {
146                "an unexpected \"=\" before an attribute name"
147            }
148            Self::UnknownNamedCharacterReference => "an unknown named character reference",
149            Self::AbsenceOfDigitsInNumericCharacterReference => {
150                "a numeric character reference with no digits"
151            }
152            Self::MissingSemicolonAfterCharacterReference => {
153                "a character reference not terminated by \";\""
154            }
155            Self::NullCharacterReference => "a character reference resolving to U+0000 NULL",
156            Self::CharacterReferenceOutsideUnicodeRange => {
157                "a character reference outside the Unicode range"
158            }
159            Self::SurrogateCharacterReference => "a character reference resolving to a surrogate",
160            Self::NoncharacterCharacterReference => {
161                "a character reference resolving to a noncharacter"
162            }
163            Self::ControlCharacterReference => {
164                "a character reference resolving to a control character"
165            }
166            Self::MissingWhitespaceBeforeDoctypeName => {
167                "missing whitespace before the DOCTYPE name"
168            }
169            Self::MissingDoctypeName => "a DOCTYPE with no name",
170            Self::InvalidCharacterSequenceAfterDoctypeName => {
171                "an invalid character sequence after the DOCTYPE name"
172            }
173            Self::MissingWhitespaceAfterDoctypePublicKeyword => {
174                "missing whitespace after the DOCTYPE \"PUBLIC\" keyword"
175            }
176            Self::MissingWhitespaceAfterDoctypeSystemKeyword => {
177                "missing whitespace after the DOCTYPE \"SYSTEM\" keyword"
178            }
179            Self::MissingDoctypePublicIdentifier => "a missing DOCTYPE public identifier",
180            Self::MissingDoctypeSystemIdentifier => "a missing DOCTYPE system identifier",
181            Self::MissingQuoteBeforeDoctypePublicIdentifier => {
182                "a missing quote before the DOCTYPE public identifier"
183            }
184            Self::MissingQuoteBeforeDoctypeSystemIdentifier => {
185                "a missing quote before the DOCTYPE system identifier"
186            }
187            Self::AbruptDoctypePublicIdentifier => {
188                "a DOCTYPE public identifier closed abruptly with \">\""
189            }
190            Self::AbruptDoctypeSystemIdentifier => {
191                "a DOCTYPE system identifier closed abruptly with \">\""
192            }
193            Self::MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers => {
194                "missing whitespace between the DOCTYPE public and system identifiers"
195            }
196            Self::UnexpectedCharacterAfterDoctypeSystemIdentifier => {
197                "an unexpected character after the DOCTYPE system identifier"
198            }
199            Self::EofInDoctype => "end of file inside a DOCTYPE",
200            Self::EofInProcessingInstruction => "end of file inside a processing instruction",
201            Self::InvalidFirstCharacterOfProcessingInstructionTarget => {
202                "an invalid first character of a processing instruction target"
203            }
204            Self::InvalidProcessingInstructionTarget => "an invalid processing instruction target",
205            Self::DisallowedProcessingInstructionTarget => {
206                "a disallowed processing instruction target (\"xml\" or \"xml-stylesheet\")"
207            }
208            Self::EofInScriptHtmlCommentLikeText => {
209                "end of file inside a script element's HTML-comment-like text"
210            }
211            Self::EofInCdata => "end of file inside a CDATA section",
212            Self::NoncharacterInInputStream => "a Unicode noncharacter in the input stream",
213            Self::ControlCharacterInInputStream => "a control character in the input stream",
214        };
215        f.write_str(text)
216    }
217}
218
219/// A single `name=value` attribute on a start or end tag token.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub(crate) struct Attribute {
222    pub(crate) name: String,
223    pub(crate) value: String,
224}
225
226/// A start or end tag token (§13.2.5: both share the same field set — the
227/// tokenizer does not distinguish their meaning further, that is
228/// tree-construction's job).
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub(crate) struct TagToken {
231    pub(crate) name: String,
232    pub(crate) self_closing: bool,
233    pub(crate) attributes: Vec<Attribute>,
234}
235
236/// A DOCTYPE token.
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub(crate) struct DoctypeToken {
239    pub(crate) name: Option<String>,
240    pub(crate) public_identifier: Option<String>,
241    pub(crate) system_identifier: Option<String>,
242    pub(crate) force_quirks: bool,
243}
244
245/// A processing instruction token (§13.2.5.72–.76 — target and data,
246/// distinct from a comment). `html-conform`'s `normalize()` drops
247/// processing-instruction nodes downstream (see its doc comment), but the
248/// tokenizer models the token faithfully regardless — that's a
249/// tree-construction/adapter-layer decision, not this layer's.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub(crate) struct ProcessingInstructionToken {
252    pub(crate) target: String,
253    pub(crate) data: String,
254}
255
256/// The kinds of token the tokenizer emits (§13.2.5).
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub(crate) enum TokenKind {
259    Doctype(DoctypeToken),
260    StartTag(TagToken),
261    EndTag(TagToken),
262    Comment(String),
263    ProcessingInstruction(ProcessingInstructionToken),
264    /// A single character. Runs of adjacent character tokens are merged
265    /// into text nodes by tree-construction/the adapter layer, not here —
266    /// analogous to `html-conform::infoset::merge_text_and_comment_runs`.
267    Character(char),
268    Eof,
269}
270
271/// A token together with its start position in the source.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub(crate) struct Token {
274    pub(crate) kind: TokenKind,
275    pub(crate) position: Position,
276}
277
278/// The tokenizer's current state (§13.2.5). Only the states needed for
279/// tag/attribute/text tokenization are implemented so far — see the module
280/// header and plan/02-tokenizer.md for what is still missing.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282enum State {
283    Data,
284    TagOpen,
285    EndTagOpen,
286    TagName,
287    BeforeAttributeName,
288    AttributeName,
289    AfterAttributeName,
290    BeforeAttributeValue,
291    AttributeValueDoubleQuoted,
292    AttributeValueSingleQuoted,
293    AttributeValueUnquoted,
294    AfterAttributeValueQuoted,
295    SelfClosingStartTag,
296    CharacterReference,
297    NamedCharacterReference,
298    AmbiguousAmpersand,
299    NumericCharacterReference,
300    HexadecimalCharacterReferenceStart,
301    HexadecimalCharacterReference,
302    DecimalCharacterReference,
303    NumericCharacterReferenceEnd,
304    MarkupDeclarationOpen,
305    BogusComment,
306    CommentStart,
307    CommentStartDash,
308    Comment,
309    CommentLessThanSign,
310    CommentLessThanSignBang,
311    CommentLessThanSignBangDash,
312    CommentLessThanSignBangDashDash,
313    CommentEndDash,
314    CommentEnd,
315    CommentEndBang,
316    Doctype,
317    BeforeDoctypeName,
318    DoctypeName,
319    AfterDoctypeName,
320    AfterDoctypePublicKeyword,
321    BeforeDoctypePublicIdentifier,
322    DoctypePublicIdentifierDoubleQuoted,
323    DoctypePublicIdentifierSingleQuoted,
324    AfterDoctypePublicIdentifier,
325    BetweenDoctypePublicAndSystemIdentifiers,
326    AfterDoctypeSystemKeyword,
327    BeforeDoctypeSystemIdentifier,
328    DoctypeSystemIdentifierDoubleQuoted,
329    DoctypeSystemIdentifierSingleQuoted,
330    AfterDoctypeSystemIdentifier,
331    BogusDoctype,
332    ProcessingInstructionOpen,
333    ProcessingInstructionTarget,
334    AfterProcessingInstructionTarget,
335    ProcessingInstructionData,
336    ProcessingInstructionQuestionable,
337    RcData,
338    RcDataLessThanSign,
339    RcDataEndTagOpen,
340    RcDataEndTagName,
341    RawText,
342    RawTextLessThanSign,
343    RawTextEndTagOpen,
344    RawTextEndTagName,
345    PlainText,
346    ScriptData,
347    ScriptDataLessThanSign,
348    ScriptDataEndTagOpen,
349    ScriptDataEndTagName,
350    ScriptDataEscapeStart,
351    ScriptDataEscapeStartDash,
352    ScriptDataEscaped,
353    ScriptDataEscapedDash,
354    ScriptDataEscapedDashDash,
355    ScriptDataEscapedLessThanSign,
356    ScriptDataEscapedEndTagOpen,
357    ScriptDataEscapedEndTagName,
358    ScriptDataDoubleEscapeStart,
359    ScriptDataDoubleEscaped,
360    ScriptDataDoubleEscapedDash,
361    ScriptDataDoubleEscapedDashDash,
362    ScriptDataDoubleEscapedLessThanSign,
363    ScriptDataDoubleEscapeEnd,
364    CdataSection,
365    CdataSectionBracket,
366    CdataSectionEnd,
367}
368
369/// The tokenizer states reachable only via explicit external signaling
370/// from tree-construction (§13.2.6), never decided by the tokenizer
371/// itself: entering RCDATA/RAWTEXT/script-data/PLAINTEXT depends on which
372/// HTML element was just inserted (`<title>`/`<textarea>` → `RcData`,
373/// `<style>`/`<xmp>`/`<iframe>`/`<noembed>`/`<noframes>` → `RawText`,
374/// `<script>` → `ScriptData`, `<plaintext>` → `PlainText`), knowledge the
375/// tokenizer deliberately does not have — see plan/02-tokenizer.md's
376/// Normative Grundlage.
377///
378/// There is deliberately no matching "switch back" method: leaving these
379/// states again is entirely tokenizer-internal, driven by the
380/// "appropriate end tag token" mechanism (§13.2.5, based on the last
381/// start tag *this tokenizer* emitted, tracked in `last_start_tag_name`)
382/// — tree-construction has no say in it. `PlainText` never leaves at all
383/// (a one-way trip, per spec there is no returning state). `ScriptData`'s
384/// internal escaped/double-escaped sub-states are entirely
385/// self-contained too — the `<!--`/`<script`/`</script`-in-script-data
386/// dance (§13.2.5.18–.31) never needs external input either.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub(crate) enum ExternalState {
389    RcData,
390    RawText,
391    ScriptData,
392    PlainText,
393}
394
395/// Which of a DOCTYPE token's two quoted identifiers is currently being
396/// consumed — shared by the (double-quoted)/(single-quoted) public/system
397/// identifier states, analogous to how `step_attribute_value_quoted`
398/// shares one function across the two attribute-value quoting states.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum DoctypeIdentifierKind {
401    Public,
402    System,
403}
404
405/// Where attribute-value characters currently being consumed should go:
406/// either into a real attribute on the current tag token, or nowhere.
407/// §13.2.5's duplicate-attribute rule: a duplicate attribute's value is
408/// still parsed (to keep the tokenizer in sync with the input), but
409/// discarded rather than kept.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411enum AttributeValueTarget {
412    Index(usize),
413    Discarded,
414}
415
416/// The WHATWG HTML5 tokenizer (§13.2.5). Consumes a whole input string up
417/// front — character-reference/foreign-content switching does not need a
418/// byte-level streaming reader for this crate's Step-1 scope — and yields
419/// [`Token`]s one at a time via [`Iterator`].
420pub(crate) struct Tokenizer {
421    chars: Vec<char>,
422    positions: Vec<Position>,
423    index: usize,
424    /// Set when a state reconsumes the just-processed character: the next
425    /// `consume()` call returns this instead of advancing.
426    saved: Option<(Option<char>, Position)>,
427    state: State,
428    current_tag: Option<TagToken>,
429    current_tag_is_end: bool,
430    /// Position of the `<` that started the tag token currently being
431    /// built (or, in a malformed-markup error path, the `<` being
432    /// re-emitted as a plain character token).
433    current_tag_start: Position,
434    /// Position of the `/` that started the current end-tag-open attempt —
435    /// only needed to place the synthesized `/` character token correctly
436    /// in the rare `eof-before-tag-name` case (input ending in `</`).
437    slash_position: Position,
438    current_attribute_name: String,
439    attribute_value_target: AttributeValueTarget,
440    /// The state to switch back to once the current character reference
441    /// (§13.2.5.77–.84) has been resolved.
442    return_state: State,
443    /// Position of the `&` that started the character reference attempt
444    /// currently in progress.
445    character_reference_start: Position,
446    /// Index into `chars` of that same `&`.
447    character_reference_start_index: usize,
448    /// Accumulator for `&#...;`/`&#x...;` numeric character references.
449    character_reference_code: u32,
450    /// The comment token currently being built (bogus-comment or real
451    /// comment states). Its start position reuses `current_tag_start`:
452    /// only one of {tag, comment} is ever in progress at a time, both
453    /// always start at the same `<` the Data state saw.
454    current_comment_data: String,
455    /// The DOCTYPE token currently being built. Its start position also
456    /// reuses `current_tag_start`, same reasoning as `current_comment_data`.
457    current_doctype: Option<DoctypeToken>,
458    /// Accumulates a processing instruction's target name (§13.2.5.73)
459    /// before the PI token itself exists yet.
460    pi_temporary_buffer: String,
461    /// The processing instruction token currently being built, once its
462    /// target is known. Start position also reuses `current_tag_start`.
463    current_processing_instruction: Option<ProcessingInstructionToken>,
464    /// The tag name of the last *start* tag this tokenizer emitted —
465    /// drives the "appropriate end tag token" check (§13.2.5) that
466    /// RCDATA/RAWTEXT/script-data end-tag-name states use to decide
467    /// whether a `</...>`-looking sequence is a real end tag or just more
468    /// text. `None` until the first start tag is emitted (per spec: "If
469    /// no start tag has been emitted from this tokenizer, then no end tag
470    /// token is appropriate").
471    last_start_tag_name: Option<String>,
472    /// Scratch buffer for RCDATA/RAWTEXT/script-data's end-tag-name
473    /// states: accumulates the possible end tag's name so it can be
474    /// flushed back out as literal text if it turns out not to be an
475    /// appropriate end tag token.
476    text_end_tag_buffer: String,
477    /// Set by the tree-builder (Phase 03) whenever the "adjusted current
478    /// node" changes — whether it is currently in a non-HTML namespace.
479    /// Consulted only by `run_markup_declaration_open`'s `[CDATA[` branch
480    /// (§13.2.5.42), which needs this as a synchronous fact at the moment
481    /// of the match, not as a persisted mode switch like `switch_to()`.
482    /// Defaults to `false` (never foreign), correct for any document with
483    /// no foreign content.
484    in_foreign_content: bool,
485    /// Positions of the `]` characters currently withheld while
486    /// CDATA-section-bracket/-end (§13.2.5.70/.71) figure out whether
487    /// they're part of the `]]>` terminator or just content — never more
488    /// than 2 entries, oldest first. Needed because those states don't
489    /// emit a token for a `]` the moment they consume it (unlike almost
490    /// everything else in this tokenizer): whether/when a withheld `]`
491    /// becomes a real character token depends on what follows it.
492    cdata_pending_brackets: Vec<Position>,
493    pending: VecDeque<Token>,
494    eof_returned: bool,
495    /// Accumulated [`ParseError`]s, in the order encountered. Drained
496    /// once by [`Tokenizer::take_errors`] after tokenization finishes
497    /// (see `lib.rs::parse`) — never read mid-stream, so plain
498    /// accumulation (not a queue like `pending`) is enough.
499    errors: Vec<ParseError>,
500}
501
502impl Tokenizer {
503    pub(crate) fn new(input: &str) -> Self {
504        // §13.2.3.5 "Preprocessing the input stream": normalize CRLF and
505        // lone CR to a single LF before tokenization. `byte_offset` tracks
506        // the *original* input's byte offsets (not the normalized
507        // stream's), so positions stay meaningful against the source text
508        // callers actually have.
509        let mut chars = Vec::new();
510        let mut positions = Vec::new();
511        let mut line = 1u32;
512        let mut column = 1u32;
513        // §13.2.3.5's input-stream-wide checks: a one-time scan, not a
514        // tokenizer-state transition, so collected separately here and
515        // merged into `errors` below rather than going through `self.error()`
516        // (no `self` exists yet at this point in construction). No
517        // `surrogate-in-input-stream` check: unreachable for a Rust `&str`
518        // input, see `ParseErrorKind`'s doc comment.
519        let mut input_stream_errors = Vec::new();
520        let mut iter = input.char_indices().peekable();
521        while let Some((byte_offset, c)) = iter.next() {
522            let (emitted, skip_next) = if c == '\r' {
523                ('\n', matches!(iter.peek(), Some((_, '\n'))))
524            } else {
525                (c, false)
526            };
527            let position = Position {
528                line,
529                column,
530                byte_offset,
531            };
532            let code = u32::from(emitted);
533            if is_noncharacter(code) {
534                input_stream_errors.push(ParseError {
535                    kind: ParseErrorKind::NoncharacterInInputStream,
536                    position,
537                });
538            } else if is_control(code) && code != 0x00 && !is_ascii_whitespace(code) {
539                input_stream_errors.push(ParseError {
540                    kind: ParseErrorKind::ControlCharacterInInputStream,
541                    position,
542                });
543            }
544            positions.push(position);
545            chars.push(emitted);
546            if emitted == '\n' {
547                line += 1;
548                column = 1;
549            } else {
550                column += 1;
551            }
552            if skip_next {
553                iter.next();
554            }
555        }
556        positions.push(Position {
557            line,
558            column,
559            byte_offset: input.len(),
560        });
561
562        let origin = Position {
563            line: 1,
564            column: 1,
565            byte_offset: 0,
566        };
567        Tokenizer {
568            chars,
569            positions,
570            index: 0,
571            saved: None,
572            state: State::Data,
573            current_tag: None,
574            current_tag_is_end: false,
575            current_tag_start: origin,
576            slash_position: origin,
577            current_attribute_name: String::new(),
578            attribute_value_target: AttributeValueTarget::Discarded,
579            return_state: State::Data,
580            character_reference_start: origin,
581            character_reference_start_index: 0,
582            character_reference_code: 0,
583            current_comment_data: String::new(),
584            current_doctype: None,
585            pi_temporary_buffer: String::new(),
586            current_processing_instruction: None,
587            last_start_tag_name: None,
588            text_end_tag_buffer: String::new(),
589            in_foreign_content: false,
590            cdata_pending_brackets: Vec::new(),
591            pending: VecDeque::new(),
592            eof_returned: false,
593            errors: input_stream_errors,
594        }
595    }
596
597    /// Records a [`ParseError`] at `position`. Called at every point in
598    /// the state machine below marked with a `// <kebab-case-name> parse
599    /// error.` comment (identified during Phase 02's spec research,
600    /// implemented in Phase 07 — see `plan/07-parse-errors.md`).
601    fn error(&mut self, kind: ParseErrorKind, position: Position) {
602        self.errors.push(ParseError { kind, position });
603    }
604
605    /// Drains and returns every [`ParseError`] recorded so far. Called
606    /// once by `lib.rs::parse` after tokenization finishes.
607    pub(crate) fn take_errors(&mut self) -> Vec<ParseError> {
608        std::mem::take(&mut self.errors)
609    }
610
611    /// Called by the tree-builder (Phase 03) right after it inserts an
612    /// element that switches the tokenizer's content model. See
613    /// [`ExternalState`] for which element maps to which state and why
614    /// there is no matching "switch back" method.
615    pub(crate) fn switch_to(&mut self, state: ExternalState) {
616        self.state = match state {
617            ExternalState::RcData => State::RcData,
618            ExternalState::RawText => State::RawText,
619            ExternalState::ScriptData => State::ScriptData,
620            ExternalState::PlainText => State::PlainText,
621        };
622    }
623
624    /// Called by the tree-builder (Phase 03) whenever the "adjusted
625    /// current node" changes (i.e. whenever the stack of open elements is
626    /// pushed or popped) — see the `in_foreign_content` field doc.
627    pub(crate) fn set_in_foreign_content(&mut self, in_foreign_content: bool) {
628        self.in_foreign_content = in_foreign_content;
629    }
630
631    fn consume(&mut self) -> (Option<char>, Position) {
632        if let Some(saved) = self.saved.take() {
633            return saved;
634        }
635        let position = self.positions[self.index];
636        let ch = self.chars.get(self.index).copied();
637        if ch.is_some() {
638            self.index += 1;
639        }
640        (ch, position)
641    }
642
643    fn is_whitespace(c: char) -> bool {
644        matches!(c, '\t' | '\n' | '\x0C' | ' ')
645    }
646
647    /// Starts a new tag token. `current_tag_start` must already hold the
648    /// position of the `<` that introduced it (set in the Data state).
649    fn start_tag_token(&mut self, is_end: bool) {
650        self.current_tag = Some(TagToken {
651            name: String::new(),
652            self_closing: false,
653            attributes: Vec::new(),
654        });
655        self.current_tag_is_end = is_end;
656    }
657
658    fn emit_tag(&mut self, out: &mut Vec<Token>) {
659        let tag = self
660            .current_tag
661            .take()
662            .expect("emit_tag called with no tag token in progress");
663        let kind = if self.current_tag_is_end {
664            // end-tag-with-attributes / end-tag-with-trailing-solidus
665            // parse errors — detected at emission time (a property of
666            // the finished token), not a single input-character
667            // transition like every other error in this file.
668            if !tag.attributes.is_empty() {
669                self.error(ParseErrorKind::EndTagWithAttributes, self.current_tag_start);
670            }
671            if tag.self_closing {
672                self.error(
673                    ParseErrorKind::EndTagWithTrailingSolidus,
674                    self.current_tag_start,
675                );
676            }
677            TokenKind::EndTag(tag)
678        } else {
679            // "appropriate end tag token" (§13.2.5) is defined against the
680            // last *start* tag emitted, so only start tags update it.
681            self.last_start_tag_name = Some(tag.name.clone());
682            TokenKind::StartTag(tag)
683        };
684        out.push(Token {
685            kind,
686            position: self.current_tag_start,
687        });
688    }
689
690    /// Switches to the Data state and emits the current tag token — the
691    /// common "`>` closes the tag" pattern shared by tag-name/attribute
692    /// states.
693    fn close_tag(&mut self, out: &mut Vec<Token>) -> bool {
694        self.state = State::Data;
695        self.emit_tag(out);
696        false
697    }
698
699    fn emit_comment(&mut self, out: &mut Vec<Token>) {
700        let data = std::mem::take(&mut self.current_comment_data);
701        out.push(Token {
702            kind: TokenKind::Comment(data),
703            position: self.current_tag_start,
704        });
705    }
706
707    fn current_doctype_mut(&mut self) -> &mut DoctypeToken {
708        self.current_doctype
709            .as_mut()
710            .expect("doctype state reached with no doctype token in progress")
711    }
712
713    fn doctype_identifier_mut(&mut self, kind: DoctypeIdentifierKind) -> &mut String {
714        let doctype = self.current_doctype_mut();
715        let field = match kind {
716            DoctypeIdentifierKind::Public => &mut doctype.public_identifier,
717            DoctypeIdentifierKind::System => &mut doctype.system_identifier,
718        };
719        field
720            .as_mut()
721            .expect("doctype identifier appended to before being set to Some")
722    }
723
724    /// Sets the current DOCTYPE token's public identifier to the empty
725    /// string (not missing) and switches to `quote_state` — the common
726    /// "public identifier starts here" pattern shared by
727    /// `AfterDoctypePublicKeyword`/`BeforeDoctypePublicIdentifier`'s `"`/`'`
728    /// branches.
729    fn start_doctype_public_identifier(&mut self, quote_state: State) -> bool {
730        self.current_doctype_mut().public_identifier = Some(String::new());
731        self.state = quote_state;
732        false
733    }
734
735    /// Same as [`start_doctype_public_identifier`](Self::start_doctype_public_identifier),
736    /// for the system identifier — shared by
737    /// `AfterDoctypePublicIdentifier`/`BetweenDoctypePublicAndSystemIdentifiers`/
738    /// `AfterDoctypeSystemKeyword`/`BeforeDoctypeSystemIdentifier`'s `"`/`'`
739    /// branches.
740    fn start_doctype_system_identifier(&mut self, quote_state: State) -> bool {
741        self.current_doctype_mut().system_identifier = Some(String::new());
742        self.state = quote_state;
743        false
744    }
745
746    fn emit_doctype(&mut self, out: &mut Vec<Token>) {
747        let doctype = self
748            .current_doctype
749            .take()
750            .expect("emit_doctype called with no doctype token in progress");
751        out.push(Token {
752            kind: TokenKind::Doctype(doctype),
753            position: self.current_tag_start,
754        });
755    }
756
757    /// Switches to the Data state and emits the current DOCTYPE token
758    /// as-is (`force_quirks` untouched).
759    fn close_doctype(&mut self, out: &mut Vec<Token>) -> bool {
760        self.state = State::Data;
761        self.emit_doctype(out);
762        false
763    }
764
765    /// Sets `force_quirks`, then behaves like `close_doctype` — the
766    /// common "premature `>`" pattern across most DOCTYPE sub-states.
767    fn close_doctype_with_quirks(&mut self, out: &mut Vec<Token>) -> bool {
768        self.current_doctype_mut().force_quirks = true;
769        self.close_doctype(out)
770    }
771
772    /// The common `eof-in-doctype` handling shared by every DOCTYPE
773    /// sub-state *except* the DOCTYPE state itself (13.2.5.53), which is
774    /// reached before any token exists yet and so creates one first
775    /// instead of assuming one is already in progress (that site reports
776    /// the same error itself, see its own call site).
777    fn eof_in_doctype(&mut self, out: &mut Vec<Token>, position: Position) -> bool {
778        self.error(ParseErrorKind::EofInDoctype, position);
779        self.current_doctype_mut().force_quirks = true;
780        self.emit_doctype(out);
781        push_eof(out, position);
782        false
783    }
784
785    /// The common "unexpected character here" pattern across most DOCTYPE
786    /// sub-states: sets `force_quirks` and reconsumes in the bogus DOCTYPE
787    /// state.
788    fn bogus_doctype_with_quirks(&mut self) -> bool {
789        self.current_doctype_mut().force_quirks = true;
790        self.state = State::BogusDoctype;
791        true
792    }
793
794    fn emit_processing_instruction(&mut self, out: &mut Vec<Token>) {
795        let pi = self.current_processing_instruction.take().expect(
796            "emit_processing_instruction called with no processing instruction token in progress",
797        );
798        out.push(Token {
799            kind: TokenKind::ProcessingInstruction(pi),
800            position: self.current_tag_start,
801        });
802    }
803
804    /// §13.2.5's "convert the temporary buffer to a comment": a
805    /// processing instruction with an invalid/disallowed target is
806    /// instead treated as a bogus comment whose data is "?" followed by
807    /// whatever was accumulated in `pi_temporary_buffer` so far.
808    fn convert_pi_temporary_buffer_to_comment(&mut self) {
809        let target = std::mem::take(&mut self.pi_temporary_buffer);
810        self.current_comment_data = format!("?{target}");
811        self.state = State::BogusComment;
812    }
813
814    /// True if the upcoming input, starting at `self.index`, matches
815    /// `literal` character-for-character (or ASCII-case-insensitively, if
816    /// requested) — without consuming anything.
817    fn peek_matches(&self, literal: &str, case_insensitive: bool) -> bool {
818        self.peek_matches_at(self.index, literal, case_insensitive)
819    }
820
821    /// Like `peek_matches`, but starting at an arbitrary index rather than
822    /// `self.index` — needed where the lookahead window starts at an
823    /// already-consumed character (e.g. after DOCTYPE state's "the six
824    /// characters starting from the current input character").
825    fn peek_matches_at(&self, start: usize, literal: &str, case_insensitive: bool) -> bool {
826        literal.chars().enumerate().all(|(offset, expected)| {
827            self.chars.get(start + offset).is_some_and(|&actual| {
828                if case_insensitive {
829                    actual.eq_ignore_ascii_case(&expected)
830                } else {
831                    actual == expected
832                }
833            })
834        })
835    }
836
837    /// §13.2.5.42 "Markup declaration open state": branches on
838    /// multi-character lookahead rather than a single consumed character,
839    /// so — like the named character reference state — it is implemented
840    /// as its own routine outside the one-character-per-`step()`-call
841    /// model, dispatched from `run_until_token`. Never itself emits a
842    /// token, only ever changes `self.state` (and consumes 0+ characters).
843    fn run_markup_declaration_open(&mut self) {
844        if self.peek_matches("--", false) {
845            self.index += 2;
846            self.current_comment_data.clear();
847            self.state = State::CommentStart;
848            return;
849        }
850        if self.peek_matches("DOCTYPE", true) {
851            self.index += 7;
852            self.state = State::Doctype;
853            return;
854        }
855        if self.peek_matches("[CDATA[", false) {
856            self.index += 7;
857            if self.in_foreign_content {
858                self.state = State::CdataSection;
859            } else {
860                // cdata-in-html-content parse error.
861                self.error(
862                    ParseErrorKind::CdataInHtmlContent,
863                    self.positions[self.index],
864                );
865                self.current_comment_data = "[CDATA[".to_owned();
866                self.state = State::BogusComment;
867            }
868            return;
869        }
870        // incorrectly-opened-comment parse error; don't consume anything.
871        self.error(
872            ParseErrorKind::IncorrectlyOpenedComment,
873            self.positions[self.index],
874        );
875        self.current_comment_data.clear();
876        self.state = State::BogusComment;
877    }
878
879    /// §13.2.5's duplicate-attribute rule, applied exactly once, when
880    /// leaving the attribute name state: commits the just-built attribute
881    /// name onto the current tag token (or discards it, if it duplicates
882    /// an already-present name), and points `attribute_value_target` at
883    /// where any following value characters should go.
884    fn commit_attribute_name(&mut self, position: Position) {
885        let name = std::mem::take(&mut self.current_attribute_name);
886        let tag = self
887            .current_tag
888            .as_mut()
889            .expect("commit_attribute_name called with no tag token in progress");
890        if tag
891            .attributes
892            .iter()
893            .any(|attribute| attribute.name == name)
894        {
895            // duplicate-attribute parse error: this attribute (and its
896            // value, once parsed) is discarded — the earlier one wins.
897            self.error(ParseErrorKind::DuplicateAttribute, position);
898            self.attribute_value_target = AttributeValueTarget::Discarded;
899        } else {
900            tag.attributes.push(Attribute {
901                name,
902                value: String::new(),
903            });
904            self.attribute_value_target = AttributeValueTarget::Index(tag.attributes.len() - 1);
905        }
906    }
907
908    fn push_attribute_value_char(&mut self, c: char) {
909        if let AttributeValueTarget::Index(i) = self.attribute_value_target {
910            self.current_tag
911                .as_mut()
912                .expect("push_attribute_value_char called with no tag token in progress")
913                .attributes[i]
914                .value
915                .push(c);
916        }
917    }
918
919    fn run_until_token(&mut self) {
920        loop {
921            // §13.2.5.78 "Named character reference state" is a
922            // maximal-munch lookup against the whole named-character-
923            // references table, not a single-character transition — it
924            // gets its own non-consuming-loop handling rather than being
925            // forced through the one-character-at-a-time `step()` model.
926            if self.state == State::NamedCharacterReference {
927                self.run_named_character_reference();
928                if !self.pending.is_empty() {
929                    return;
930                }
931                continue;
932            }
933            // §13.2.5.42 "Markup declaration open state" branches on
934            // multi-character lookahead ("if the next few characters
935            // are...") rather than consuming one character at a time, and
936            // never itself emits a token — same non-consuming-loop
937            // reasoning as the named character reference state above.
938            if self.state == State::MarkupDeclarationOpen {
939                self.run_markup_declaration_open();
940                continue;
941            }
942            let (ch, position) = self.consume();
943            let mut out = Vec::new();
944            let reconsume = self.step(ch, position, &mut out);
945            if reconsume {
946                self.saved = Some((ch, position));
947            }
948            if !out.is_empty() {
949                self.pending.extend(out);
950                return;
951            }
952        }
953    }
954
955    /// Processes one input character (or EOF, as `None`) under the current
956    /// state. Returns `true` if `ch` must be reprocessed under the
957    /// (possibly just-changed) state — "reconsume" in spec terms.
958    fn step(&mut self, ch: Option<char>, position: Position, out: &mut Vec<Token>) -> bool {
959        match self.state {
960            State::Data => match ch {
961                Some('&') => {
962                    self.begin_character_reference(State::Data, position);
963                    false
964                }
965                Some('<') => {
966                    self.current_tag_start = position;
967                    self.state = State::TagOpen;
968                    false
969                }
970                Some('\0') => {
971                    // unexpected-null-character parse error, but — unlike
972                    // RCDATA/RAWTEXT/script-data — the Data state does
973                    // *not* replace it with U+FFFD, per spec.
974                    self.error(ParseErrorKind::UnexpectedNullCharacter, position);
975                    push_character(out, '\0', position);
976                    false
977                }
978                Some(c) => {
979                    push_character(out, c, position);
980                    false
981                }
982                None => {
983                    push_eof(out, position);
984                    false
985                }
986            },
987            State::TagOpen => match ch {
988                Some('!') => {
989                    self.state = State::MarkupDeclarationOpen;
990                    false
991                }
992                Some('/') => {
993                    self.slash_position = position;
994                    self.state = State::EndTagOpen;
995                    false
996                }
997                Some(c) if c.is_ascii_alphabetic() => {
998                    self.start_tag_token(false);
999                    self.state = State::TagName;
1000                    true
1001                }
1002                Some('?') => {
1003                    self.pi_temporary_buffer.clear();
1004                    self.state = State::ProcessingInstructionOpen;
1005                    false
1006                }
1007                Some(_) => {
1008                    // invalid-first-character-of-tag-name parse error.
1009                    self.error(ParseErrorKind::InvalidFirstCharacterOfTagName, position);
1010                    push_character(out, '<', self.current_tag_start);
1011                    self.state = State::Data;
1012                    true
1013                }
1014                None => {
1015                    // eof-before-tag-name parse error.
1016                    self.error(ParseErrorKind::EofBeforeTagName, position);
1017                    push_character(out, '<', self.current_tag_start);
1018                    push_eof(out, position);
1019                    false
1020                }
1021            },
1022            State::EndTagOpen => match ch {
1023                Some(c) if c.is_ascii_alphabetic() => {
1024                    self.start_tag_token(true);
1025                    self.state = State::TagName;
1026                    true
1027                }
1028                Some('>') => {
1029                    // missing-end-tag-name parse error.
1030                    self.error(ParseErrorKind::MissingEndTagName, position);
1031                    self.state = State::Data;
1032                    false
1033                }
1034                Some(_) => {
1035                    // invalid-first-character-of-tag-name parse error.
1036                    self.error(ParseErrorKind::InvalidFirstCharacterOfTagName, position);
1037                    self.current_comment_data.clear();
1038                    self.state = State::BogusComment;
1039                    true
1040                }
1041                None => {
1042                    // eof-before-tag-name parse error.
1043                    self.error(ParseErrorKind::EofBeforeTagName, position);
1044                    push_character(out, '<', self.current_tag_start);
1045                    push_character(out, '/', self.slash_position);
1046                    push_eof(out, position);
1047                    false
1048                }
1049            },
1050            State::TagName => match ch {
1051                Some(c) if Self::is_whitespace(c) => {
1052                    self.state = State::BeforeAttributeName;
1053                    false
1054                }
1055                Some('/') => {
1056                    self.state = State::SelfClosingStartTag;
1057                    false
1058                }
1059                Some('>') => self.close_tag(out),
1060                Some(c) if c.is_ascii_uppercase() => {
1061                    self.current_tag_mut().name.push(c.to_ascii_lowercase());
1062                    false
1063                }
1064                Some('\0') => {
1065                    self.current_tag_mut().name.push('\u{FFFD}');
1066                    false
1067                }
1068                Some(c) => {
1069                    self.current_tag_mut().name.push(c);
1070                    false
1071                }
1072                None => {
1073                    // eof-in-tag parse error: no tag token is emitted.
1074                    self.error(ParseErrorKind::EofInTag, position);
1075                    push_eof(out, position);
1076                    false
1077                }
1078            },
1079            State::BeforeAttributeName => match ch {
1080                Some(c) if Self::is_whitespace(c) => false,
1081                Some('/') | Some('>') | None => {
1082                    self.state = State::AfterAttributeName;
1083                    true
1084                }
1085                Some('=') => {
1086                    // unexpected-equals-sign-before-attribute-name parse
1087                    // error, but still starts an attribute literally
1088                    // named "=".
1089                    self.error(
1090                        ParseErrorKind::UnexpectedEqualsSignBeforeAttributeName,
1091                        position,
1092                    );
1093                    self.current_attribute_name.clear();
1094                    self.current_attribute_name.push('=');
1095                    self.state = State::AttributeName;
1096                    false
1097                }
1098                Some(_) => {
1099                    self.current_attribute_name.clear();
1100                    self.state = State::AttributeName;
1101                    true
1102                }
1103            },
1104            State::AttributeName => match ch {
1105                Some(c) if Self::is_whitespace(c) || c == '/' || c == '>' => {
1106                    self.commit_attribute_name(position);
1107                    self.state = State::AfterAttributeName;
1108                    true
1109                }
1110                None => {
1111                    self.commit_attribute_name(position);
1112                    self.state = State::AfterAttributeName;
1113                    true
1114                }
1115                Some('=') => {
1116                    self.commit_attribute_name(position);
1117                    self.state = State::BeforeAttributeValue;
1118                    false
1119                }
1120                Some(c) if c.is_ascii_uppercase() => {
1121                    self.current_attribute_name.push(c.to_ascii_lowercase());
1122                    false
1123                }
1124                Some('\0') => {
1125                    self.current_attribute_name.push('\u{FFFD}');
1126                    false
1127                }
1128                Some(c @ ('"' | '\'' | '<')) => {
1129                    // unexpected-character-in-attribute-name parse error,
1130                    // but still appended as-is, per spec.
1131                    self.error(ParseErrorKind::UnexpectedCharacterInAttributeName, position);
1132                    self.current_attribute_name.push(c);
1133                    false
1134                }
1135                Some(c) => {
1136                    self.current_attribute_name.push(c);
1137                    false
1138                }
1139            },
1140            State::AfterAttributeName => match ch {
1141                Some(c) if Self::is_whitespace(c) => false,
1142                Some('/') => {
1143                    self.state = State::SelfClosingStartTag;
1144                    false
1145                }
1146                Some('=') => {
1147                    self.state = State::BeforeAttributeValue;
1148                    false
1149                }
1150                Some('>') => self.close_tag(out),
1151                None => {
1152                    // eof-in-tag parse error.
1153                    self.error(ParseErrorKind::EofInTag, position);
1154                    push_eof(out, position);
1155                    false
1156                }
1157                Some(_) => {
1158                    self.current_attribute_name.clear();
1159                    self.state = State::AttributeName;
1160                    true
1161                }
1162            },
1163            State::BeforeAttributeValue => match ch {
1164                Some(c) if Self::is_whitespace(c) => false,
1165                Some('"') => {
1166                    self.state = State::AttributeValueDoubleQuoted;
1167                    false
1168                }
1169                Some('\'') => {
1170                    self.state = State::AttributeValueSingleQuoted;
1171                    false
1172                }
1173                Some('>') => {
1174                    // missing-attribute-value parse error.
1175                    self.error(ParseErrorKind::MissingAttributeValue, position);
1176                    self.close_tag(out)
1177                }
1178                _ => {
1179                    self.state = State::AttributeValueUnquoted;
1180                    true
1181                }
1182            },
1183            State::AttributeValueDoubleQuoted => {
1184                self.step_attribute_value_quoted(ch, position, out, '"')
1185            }
1186            State::AttributeValueSingleQuoted => {
1187                self.step_attribute_value_quoted(ch, position, out, '\'')
1188            }
1189            State::AttributeValueUnquoted => match ch {
1190                Some(c) if Self::is_whitespace(c) => {
1191                    self.state = State::BeforeAttributeName;
1192                    false
1193                }
1194                Some('&') => {
1195                    self.begin_character_reference(State::AttributeValueUnquoted, position);
1196                    false
1197                }
1198                Some('>') => self.close_tag(out),
1199                Some('\0') => {
1200                    self.push_attribute_value_char('\u{FFFD}');
1201                    false
1202                }
1203                Some(c @ ('"' | '\'' | '<' | '=' | '`')) => {
1204                    // unexpected-character-in-unquoted-attribute-value
1205                    // parse error, but still appended as-is, per spec.
1206                    self.error(
1207                        ParseErrorKind::UnexpectedCharacterInUnquotedAttributeValue,
1208                        position,
1209                    );
1210                    self.push_attribute_value_char(c);
1211                    false
1212                }
1213                Some(c) => {
1214                    self.push_attribute_value_char(c);
1215                    false
1216                }
1217                None => {
1218                    // eof-in-tag parse error.
1219                    self.error(ParseErrorKind::EofInTag, position);
1220                    push_eof(out, position);
1221                    false
1222                }
1223            },
1224            State::AfterAttributeValueQuoted => match ch {
1225                Some(c) if Self::is_whitespace(c) => {
1226                    self.state = State::BeforeAttributeName;
1227                    false
1228                }
1229                Some('/') => {
1230                    self.state = State::SelfClosingStartTag;
1231                    false
1232                }
1233                Some('>') => self.close_tag(out),
1234                None => {
1235                    // eof-in-tag parse error.
1236                    self.error(ParseErrorKind::EofInTag, position);
1237                    push_eof(out, position);
1238                    false
1239                }
1240                Some(_) => {
1241                    // missing-whitespace-between-attributes parse error.
1242                    self.error(ParseErrorKind::MissingWhitespaceBetweenAttributes, position);
1243                    self.state = State::BeforeAttributeName;
1244                    true
1245                }
1246            },
1247            State::SelfClosingStartTag => match ch {
1248                Some('>') => {
1249                    self.current_tag_mut().self_closing = true;
1250                    self.close_tag(out)
1251                }
1252                None => {
1253                    // eof-in-tag parse error.
1254                    self.error(ParseErrorKind::EofInTag, position);
1255                    push_eof(out, position);
1256                    false
1257                }
1258                Some(_) => {
1259                    // unexpected-solidus-in-tag parse error.
1260                    self.error(ParseErrorKind::UnexpectedSolidusInTag, position);
1261                    self.state = State::BeforeAttributeName;
1262                    true
1263                }
1264            },
1265            State::CharacterReference => match ch {
1266                Some(c) if c.is_ascii_alphanumeric() => {
1267                    self.state = State::NamedCharacterReference;
1268                    true
1269                }
1270                Some('#') => {
1271                    self.state = State::NumericCharacterReference;
1272                    false
1273                }
1274                _ => {
1275                    // Buffer is always exactly "&" here (1 char): `ch` may
1276                    // be EOF, which — unlike a real character — never
1277                    // advances `self.index`, so the end offset must be
1278                    // computed from `character_reference_start_index`
1279                    // directly rather than from `self.index`.
1280                    let end = self.character_reference_start_index + 1;
1281                    self.flush_literal_character_reference_attempt(end, out);
1282                    self.state = self.return_state;
1283                    true
1284                }
1285            },
1286            // State::NamedCharacterReference is handled entirely outside
1287            // `step()` — see `run_until_token`/`run_named_character_reference`.
1288            State::NamedCharacterReference => {
1289                unreachable!("NamedCharacterReference is dispatched before step() is called")
1290            }
1291            State::AmbiguousAmpersand => match ch {
1292                Some(c) if c.is_ascii_alphanumeric() => {
1293                    self.flush_char_as_character_reference(c, position, out);
1294                    false
1295                }
1296                Some(';') => {
1297                    // unknown-named-character-reference parse error.
1298                    self.error(ParseErrorKind::UnknownNamedCharacterReference, position);
1299                    self.state = self.return_state;
1300                    true
1301                }
1302                _ => {
1303                    self.state = self.return_state;
1304                    true
1305                }
1306            },
1307            State::NumericCharacterReference => {
1308                self.character_reference_code = 0;
1309                match ch {
1310                    Some('x') | Some('X') => {
1311                        self.state = State::HexadecimalCharacterReferenceStart;
1312                        false
1313                    }
1314                    Some(c) if c.is_ascii_digit() => {
1315                        self.state = State::DecimalCharacterReference;
1316                        true
1317                    }
1318                    _ => {
1319                        // absence-of-digits-in-numeric-character-reference
1320                        // parse error. Buffer is always exactly "&#" (2
1321                        // chars) here — see the CharacterReference state's
1322                        // fallback above for why this is a fixed offset
1323                        // rather than derived from `self.index`.
1324                        self.error(
1325                            ParseErrorKind::AbsenceOfDigitsInNumericCharacterReference,
1326                            position,
1327                        );
1328                        let end = self.character_reference_start_index + 2;
1329                        self.flush_literal_character_reference_attempt(end, out);
1330                        self.state = self.return_state;
1331                        true
1332                    }
1333                }
1334            }
1335            State::HexadecimalCharacterReferenceStart => match ch {
1336                Some(c) if c.is_ascii_hexdigit() => {
1337                    self.state = State::HexadecimalCharacterReference;
1338                    true
1339                }
1340                _ => {
1341                    // absence-of-digits-in-numeric-character-reference
1342                    // parse error. Buffer is always exactly "&#x"/"&#X" (3
1343                    // chars) here — same fixed-offset reasoning as above.
1344                    self.error(
1345                        ParseErrorKind::AbsenceOfDigitsInNumericCharacterReference,
1346                        position,
1347                    );
1348                    let end = self.character_reference_start_index + 3;
1349                    self.flush_literal_character_reference_attempt(end, out);
1350                    self.state = self.return_state;
1351                    true
1352                }
1353            },
1354            State::HexadecimalCharacterReference => match ch {
1355                Some(c) if c.is_ascii_digit() => {
1356                    self.character_reference_code = self
1357                        .character_reference_code
1358                        .saturating_mul(16)
1359                        .saturating_add(u32::from(c) - u32::from('0'));
1360                    false
1361                }
1362                Some(c) if ('A'..='F').contains(&c) => {
1363                    self.character_reference_code = self
1364                        .character_reference_code
1365                        .saturating_mul(16)
1366                        .saturating_add(u32::from(c) - 0x37);
1367                    false
1368                }
1369                Some(c) if ('a'..='f').contains(&c) => {
1370                    self.character_reference_code = self
1371                        .character_reference_code
1372                        .saturating_mul(16)
1373                        .saturating_add(u32::from(c) - 0x57);
1374                    false
1375                }
1376                Some(';') => {
1377                    self.state = State::NumericCharacterReferenceEnd;
1378                    false
1379                }
1380                _ => {
1381                    // missing-semicolon-after-character-reference parse
1382                    // error.
1383                    self.error(
1384                        ParseErrorKind::MissingSemicolonAfterCharacterReference,
1385                        position,
1386                    );
1387                    self.state = State::NumericCharacterReferenceEnd;
1388                    true
1389                }
1390            },
1391            State::DecimalCharacterReference => match ch {
1392                Some(c) if c.is_ascii_digit() => {
1393                    self.character_reference_code = self
1394                        .character_reference_code
1395                        .saturating_mul(10)
1396                        .saturating_add(u32::from(c) - u32::from('0'));
1397                    false
1398                }
1399                Some(';') => {
1400                    self.state = State::NumericCharacterReferenceEnd;
1401                    false
1402                }
1403                _ => {
1404                    // missing-semicolon-after-character-reference parse
1405                    // error.
1406                    self.error(
1407                        ParseErrorKind::MissingSemicolonAfterCharacterReference,
1408                        position,
1409                    );
1410                    self.state = State::NumericCharacterReferenceEnd;
1411                    true
1412                }
1413            },
1414            State::NumericCharacterReferenceEnd => {
1415                // §13.2.5.84 does not consume an input character at all —
1416                // whatever `ch` is must be handed on, unconsumed, to
1417                // `return_state`.
1418                let resolved =
1419                    self.resolve_numeric_character_reference_code(self.character_reference_start);
1420                self.flush_char_as_character_reference(
1421                    resolved,
1422                    self.character_reference_start,
1423                    out,
1424                );
1425                self.state = self.return_state;
1426                true
1427            }
1428            // State::MarkupDeclarationOpen is handled entirely outside
1429            // `step()` — see `run_until_token`/`run_markup_declaration_open`.
1430            State::MarkupDeclarationOpen => {
1431                unreachable!("MarkupDeclarationOpen is dispatched before step() is called")
1432            }
1433            State::BogusComment => match ch {
1434                Some('>') => {
1435                    self.state = State::Data;
1436                    self.emit_comment(out);
1437                    false
1438                }
1439                None => {
1440                    self.emit_comment(out);
1441                    push_eof(out, position);
1442                    false
1443                }
1444                Some('\0') => {
1445                    self.current_comment_data.push('\u{FFFD}');
1446                    false
1447                }
1448                Some(c) => {
1449                    self.current_comment_data.push(c);
1450                    false
1451                }
1452            },
1453            State::CommentStart => match ch {
1454                Some('-') => {
1455                    self.state = State::CommentStartDash;
1456                    false
1457                }
1458                Some('>') => {
1459                    // abrupt-closing-of-empty-comment parse error.
1460                    self.error(ParseErrorKind::AbruptClosingOfEmptyComment, position);
1461                    self.state = State::Data;
1462                    self.emit_comment(out);
1463                    false
1464                }
1465                _ => {
1466                    self.state = State::Comment;
1467                    true
1468                }
1469            },
1470            State::CommentStartDash => match ch {
1471                Some('-') => {
1472                    self.state = State::CommentEnd;
1473                    false
1474                }
1475                Some('>') => {
1476                    // abrupt-closing-of-empty-comment parse error.
1477                    self.error(ParseErrorKind::AbruptClosingOfEmptyComment, position);
1478                    self.state = State::Data;
1479                    self.emit_comment(out);
1480                    false
1481                }
1482                None => {
1483                    // eof-in-comment parse error.
1484                    self.error(ParseErrorKind::EofInComment, position);
1485                    self.emit_comment(out);
1486                    push_eof(out, position);
1487                    false
1488                }
1489                Some(_) => {
1490                    self.current_comment_data.push('-');
1491                    self.state = State::Comment;
1492                    true
1493                }
1494            },
1495            State::Comment => match ch {
1496                Some('<') => {
1497                    self.current_comment_data.push('<');
1498                    self.state = State::CommentLessThanSign;
1499                    false
1500                }
1501                Some('-') => {
1502                    self.state = State::CommentEndDash;
1503                    false
1504                }
1505                Some('\0') => {
1506                    self.current_comment_data.push('\u{FFFD}');
1507                    false
1508                }
1509                None => {
1510                    // eof-in-comment parse error.
1511                    self.error(ParseErrorKind::EofInComment, position);
1512                    self.emit_comment(out);
1513                    push_eof(out, position);
1514                    false
1515                }
1516                Some(c) => {
1517                    self.current_comment_data.push(c);
1518                    false
1519                }
1520            },
1521            State::CommentLessThanSign => match ch {
1522                Some('!') => {
1523                    self.current_comment_data.push('!');
1524                    self.state = State::CommentLessThanSignBang;
1525                    false
1526                }
1527                Some('<') => {
1528                    self.current_comment_data.push('<');
1529                    false
1530                }
1531                _ => {
1532                    self.state = State::Comment;
1533                    true
1534                }
1535            },
1536            State::CommentLessThanSignBang => match ch {
1537                Some('-') => {
1538                    self.state = State::CommentLessThanSignBangDash;
1539                    false
1540                }
1541                _ => {
1542                    self.state = State::Comment;
1543                    true
1544                }
1545            },
1546            State::CommentLessThanSignBangDash => match ch {
1547                Some('-') => {
1548                    self.state = State::CommentLessThanSignBangDashDash;
1549                    false
1550                }
1551                _ => {
1552                    self.state = State::CommentEndDash;
1553                    true
1554                }
1555            },
1556            State::CommentLessThanSignBangDashDash => {
1557                // Both the '>'/EOF branch and the "anything else"
1558                // (nested-comment parse error) branch reconsume in the
1559                // same state; they differ only in whether a parse error
1560                // is flagged.
1561                if !matches!(ch, Some('>') | None) {
1562                    self.error(ParseErrorKind::NestedComment, position);
1563                }
1564                self.state = State::CommentEnd;
1565                true
1566            }
1567            State::CommentEndDash => match ch {
1568                Some('-') => {
1569                    self.state = State::CommentEnd;
1570                    false
1571                }
1572                None => {
1573                    // eof-in-comment parse error.
1574                    self.error(ParseErrorKind::EofInComment, position);
1575                    self.emit_comment(out);
1576                    push_eof(out, position);
1577                    false
1578                }
1579                Some(_) => {
1580                    self.current_comment_data.push('-');
1581                    self.state = State::Comment;
1582                    true
1583                }
1584            },
1585            State::CommentEnd => match ch {
1586                Some('>') => {
1587                    self.state = State::Data;
1588                    self.emit_comment(out);
1589                    false
1590                }
1591                Some('!') => {
1592                    self.state = State::CommentEndBang;
1593                    false
1594                }
1595                Some('-') => {
1596                    self.current_comment_data.push('-');
1597                    false
1598                }
1599                None => {
1600                    // eof-in-comment parse error.
1601                    self.error(ParseErrorKind::EofInComment, position);
1602                    self.emit_comment(out);
1603                    push_eof(out, position);
1604                    false
1605                }
1606                Some(_) => {
1607                    self.current_comment_data.push_str("--");
1608                    self.state = State::Comment;
1609                    true
1610                }
1611            },
1612            State::CommentEndBang => match ch {
1613                Some('-') => {
1614                    self.current_comment_data.push_str("--!");
1615                    self.state = State::CommentEndDash;
1616                    false
1617                }
1618                Some('>') => {
1619                    // incorrectly-closed-comment parse error.
1620                    self.error(ParseErrorKind::IncorrectlyClosedComment, position);
1621                    self.state = State::Data;
1622                    self.emit_comment(out);
1623                    false
1624                }
1625                None => {
1626                    // eof-in-comment parse error.
1627                    self.error(ParseErrorKind::EofInComment, position);
1628                    self.emit_comment(out);
1629                    push_eof(out, position);
1630                    false
1631                }
1632                Some(_) => {
1633                    self.current_comment_data.push_str("--!");
1634                    self.state = State::Comment;
1635                    true
1636                }
1637            },
1638            State::Doctype => match ch {
1639                Some(c) if Self::is_whitespace(c) => {
1640                    self.state = State::BeforeDoctypeName;
1641                    false
1642                }
1643                Some('>') => {
1644                    self.state = State::BeforeDoctypeName;
1645                    true
1646                }
1647                None => {
1648                    // eof-in-doctype parse error. No DOCTYPE token exists
1649                    // yet at this point — unlike every other DOCTYPE
1650                    // sub-state's eof-in-doctype handling, one must be
1651                    // created first.
1652                    self.error(ParseErrorKind::EofInDoctype, position);
1653                    self.current_doctype = Some(DoctypeToken {
1654                        force_quirks: true,
1655                        ..Default::default()
1656                    });
1657                    self.emit_doctype(out);
1658                    push_eof(out, position);
1659                    false
1660                }
1661                Some(_) => {
1662                    // missing-whitespace-before-doctype-name parse error.
1663                    self.error(ParseErrorKind::MissingWhitespaceBeforeDoctypeName, position);
1664                    self.state = State::BeforeDoctypeName;
1665                    true
1666                }
1667            },
1668            State::BeforeDoctypeName => match ch {
1669                Some(c) if Self::is_whitespace(c) => false,
1670                Some(c) if c.is_ascii_uppercase() => {
1671                    self.current_doctype = Some(DoctypeToken {
1672                        name: Some(c.to_ascii_lowercase().to_string()),
1673                        ..Default::default()
1674                    });
1675                    self.state = State::DoctypeName;
1676                    false
1677                }
1678                Some('\0') => {
1679                    self.current_doctype = Some(DoctypeToken {
1680                        name: Some("\u{FFFD}".to_owned()),
1681                        ..Default::default()
1682                    });
1683                    self.state = State::DoctypeName;
1684                    false
1685                }
1686                Some('>') => {
1687                    // missing-doctype-name parse error.
1688                    self.error(ParseErrorKind::MissingDoctypeName, position);
1689                    self.current_doctype = Some(DoctypeToken {
1690                        force_quirks: true,
1691                        ..Default::default()
1692                    });
1693                    self.close_doctype(out)
1694                }
1695                None => {
1696                    // eof-in-doctype parse error.
1697                    self.error(ParseErrorKind::EofInDoctype, position);
1698                    self.current_doctype = Some(DoctypeToken {
1699                        force_quirks: true,
1700                        ..Default::default()
1701                    });
1702                    self.emit_doctype(out);
1703                    push_eof(out, position);
1704                    false
1705                }
1706                Some(c) => {
1707                    self.current_doctype = Some(DoctypeToken {
1708                        name: Some(c.to_string()),
1709                        ..Default::default()
1710                    });
1711                    self.state = State::DoctypeName;
1712                    false
1713                }
1714            },
1715            State::DoctypeName => match ch {
1716                Some(c) if Self::is_whitespace(c) => {
1717                    self.state = State::AfterDoctypeName;
1718                    false
1719                }
1720                Some('>') => self.close_doctype(out),
1721                Some(c) if c.is_ascii_uppercase() => {
1722                    self.current_doctype_mut()
1723                        .name
1724                        .as_mut()
1725                        .expect("doctype name should already be Some in DoctypeName state")
1726                        .push(c.to_ascii_lowercase());
1727                    false
1728                }
1729                Some('\0') => {
1730                    self.current_doctype_mut()
1731                        .name
1732                        .as_mut()
1733                        .expect("doctype name should already be Some in DoctypeName state")
1734                        .push('\u{FFFD}');
1735                    false
1736                }
1737                None => self.eof_in_doctype(out, position),
1738                Some(c) => {
1739                    self.current_doctype_mut()
1740                        .name
1741                        .as_mut()
1742                        .expect("doctype name should already be Some in DoctypeName state")
1743                        .push(c);
1744                    false
1745                }
1746            },
1747            State::AfterDoctypeName => match ch {
1748                Some(c) if Self::is_whitespace(c) => false,
1749                Some('>') => self.close_doctype(out),
1750                None => self.eof_in_doctype(out, position),
1751                Some(_) => {
1752                    let start = self.index - 1;
1753                    if self.peek_matches_at(start, "PUBLIC", true) {
1754                        self.index = start + 6;
1755                        self.state = State::AfterDoctypePublicKeyword;
1756                        false
1757                    } else if self.peek_matches_at(start, "SYSTEM", true) {
1758                        self.index = start + 6;
1759                        self.state = State::AfterDoctypeSystemKeyword;
1760                        false
1761                    } else {
1762                        // invalid-character-sequence-after-doctype-name
1763                        // parse error.
1764                        self.error(
1765                            ParseErrorKind::InvalidCharacterSequenceAfterDoctypeName,
1766                            position,
1767                        );
1768                        self.bogus_doctype_with_quirks()
1769                    }
1770                }
1771            },
1772            State::AfterDoctypePublicKeyword => match ch {
1773                Some(c) if Self::is_whitespace(c) => {
1774                    self.state = State::BeforeDoctypePublicIdentifier;
1775                    false
1776                }
1777                Some(c @ ('"' | '\'')) => {
1778                    // missing-whitespace-after-doctype-public-keyword
1779                    // parse error.
1780                    self.error(
1781                        ParseErrorKind::MissingWhitespaceAfterDoctypePublicKeyword,
1782                        position,
1783                    );
1784                    let quoted_state = if c == '"' {
1785                        State::DoctypePublicIdentifierDoubleQuoted
1786                    } else {
1787                        State::DoctypePublicIdentifierSingleQuoted
1788                    };
1789                    self.start_doctype_public_identifier(quoted_state)
1790                }
1791                Some('>') => {
1792                    // missing-doctype-public-identifier parse error.
1793                    self.error(ParseErrorKind::MissingDoctypePublicIdentifier, position);
1794                    self.close_doctype_with_quirks(out)
1795                }
1796                None => self.eof_in_doctype(out, position),
1797                Some(_) => {
1798                    // missing-quote-before-doctype-public-identifier
1799                    // parse error.
1800                    self.error(
1801                        ParseErrorKind::MissingQuoteBeforeDoctypePublicIdentifier,
1802                        position,
1803                    );
1804                    self.bogus_doctype_with_quirks()
1805                }
1806            },
1807            State::BeforeDoctypePublicIdentifier => match ch {
1808                Some(c) if Self::is_whitespace(c) => false,
1809                Some('"') => {
1810                    self.start_doctype_public_identifier(State::DoctypePublicIdentifierDoubleQuoted)
1811                }
1812                Some('\'') => {
1813                    self.start_doctype_public_identifier(State::DoctypePublicIdentifierSingleQuoted)
1814                }
1815                Some('>') => {
1816                    // missing-doctype-public-identifier parse error.
1817                    self.error(ParseErrorKind::MissingDoctypePublicIdentifier, position);
1818                    self.close_doctype_with_quirks(out)
1819                }
1820                None => self.eof_in_doctype(out, position),
1821                Some(_) => {
1822                    // missing-quote-before-doctype-public-identifier
1823                    // parse error.
1824                    self.error(
1825                        ParseErrorKind::MissingQuoteBeforeDoctypePublicIdentifier,
1826                        position,
1827                    );
1828                    self.bogus_doctype_with_quirks()
1829                }
1830            },
1831            State::DoctypePublicIdentifierDoubleQuoted => self.step_doctype_identifier_quoted(
1832                ch,
1833                position,
1834                out,
1835                '"',
1836                DoctypeIdentifierKind::Public,
1837            ),
1838            State::DoctypePublicIdentifierSingleQuoted => self.step_doctype_identifier_quoted(
1839                ch,
1840                position,
1841                out,
1842                '\'',
1843                DoctypeIdentifierKind::Public,
1844            ),
1845            State::AfterDoctypePublicIdentifier => match ch {
1846                Some(c) if Self::is_whitespace(c) => {
1847                    self.state = State::BetweenDoctypePublicAndSystemIdentifiers;
1848                    false
1849                }
1850                Some('>') => self.close_doctype(out),
1851                Some(c @ ('"' | '\'')) => {
1852                    // missing-whitespace-between-doctype-public-and-
1853                    // system-identifiers parse error.
1854                    self.error(
1855                        ParseErrorKind::MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers,
1856                        position,
1857                    );
1858                    let quoted_state = if c == '"' {
1859                        State::DoctypeSystemIdentifierDoubleQuoted
1860                    } else {
1861                        State::DoctypeSystemIdentifierSingleQuoted
1862                    };
1863                    self.start_doctype_system_identifier(quoted_state)
1864                }
1865                None => self.eof_in_doctype(out, position),
1866                Some(_) => self.bogus_doctype_with_quirks(),
1867            },
1868            State::BetweenDoctypePublicAndSystemIdentifiers => match ch {
1869                Some(c) if Self::is_whitespace(c) => false,
1870                Some('>') => self.close_doctype(out),
1871                Some('"') => {
1872                    self.start_doctype_system_identifier(State::DoctypeSystemIdentifierDoubleQuoted)
1873                }
1874                Some('\'') => {
1875                    self.start_doctype_system_identifier(State::DoctypeSystemIdentifierSingleQuoted)
1876                }
1877                None => self.eof_in_doctype(out, position),
1878                Some(_) => self.bogus_doctype_with_quirks(),
1879            },
1880            State::AfterDoctypeSystemKeyword => match ch {
1881                Some(c) if Self::is_whitespace(c) => {
1882                    self.state = State::BeforeDoctypeSystemIdentifier;
1883                    false
1884                }
1885                Some(c @ ('"' | '\'')) => {
1886                    // missing-whitespace-after-doctype-system-keyword
1887                    // parse error.
1888                    self.error(
1889                        ParseErrorKind::MissingWhitespaceAfterDoctypeSystemKeyword,
1890                        position,
1891                    );
1892                    let quoted_state = if c == '"' {
1893                        State::DoctypeSystemIdentifierDoubleQuoted
1894                    } else {
1895                        State::DoctypeSystemIdentifierSingleQuoted
1896                    };
1897                    self.start_doctype_system_identifier(quoted_state)
1898                }
1899                Some('>') => {
1900                    // missing-doctype-system-identifier parse error.
1901                    self.error(ParseErrorKind::MissingDoctypeSystemIdentifier, position);
1902                    self.close_doctype_with_quirks(out)
1903                }
1904                None => self.eof_in_doctype(out, position),
1905                Some(_) => {
1906                    // missing-quote-before-doctype-system-identifier
1907                    // parse error.
1908                    self.error(
1909                        ParseErrorKind::MissingQuoteBeforeDoctypeSystemIdentifier,
1910                        position,
1911                    );
1912                    self.bogus_doctype_with_quirks()
1913                }
1914            },
1915            State::BeforeDoctypeSystemIdentifier => match ch {
1916                Some(c) if Self::is_whitespace(c) => false,
1917                Some('"') => {
1918                    self.start_doctype_system_identifier(State::DoctypeSystemIdentifierDoubleQuoted)
1919                }
1920                Some('\'') => {
1921                    self.start_doctype_system_identifier(State::DoctypeSystemIdentifierSingleQuoted)
1922                }
1923                Some('>') => {
1924                    // missing-doctype-system-identifier parse error.
1925                    self.error(ParseErrorKind::MissingDoctypeSystemIdentifier, position);
1926                    self.close_doctype_with_quirks(out)
1927                }
1928                None => self.eof_in_doctype(out, position),
1929                Some(_) => {
1930                    // missing-quote-before-doctype-system-identifier
1931                    // parse error.
1932                    self.error(
1933                        ParseErrorKind::MissingQuoteBeforeDoctypeSystemIdentifier,
1934                        position,
1935                    );
1936                    self.bogus_doctype_with_quirks()
1937                }
1938            },
1939            State::DoctypeSystemIdentifierDoubleQuoted => self.step_doctype_identifier_quoted(
1940                ch,
1941                position,
1942                out,
1943                '"',
1944                DoctypeIdentifierKind::System,
1945            ),
1946            State::DoctypeSystemIdentifierSingleQuoted => self.step_doctype_identifier_quoted(
1947                ch,
1948                position,
1949                out,
1950                '\'',
1951                DoctypeIdentifierKind::System,
1952            ),
1953            State::AfterDoctypeSystemIdentifier => match ch {
1954                Some(c) if Self::is_whitespace(c) => false,
1955                Some('>') => self.close_doctype(out),
1956                None => self.eof_in_doctype(out, position),
1957                Some(_) => {
1958                    // unexpected-character-after-doctype-system-identifier
1959                    // parse error — deliberately does *not* set
1960                    // force_quirks, per spec's explicit note.
1961                    self.error(
1962                        ParseErrorKind::UnexpectedCharacterAfterDoctypeSystemIdentifier,
1963                        position,
1964                    );
1965                    self.state = State::BogusDoctype;
1966                    true
1967                }
1968            },
1969            State::BogusDoctype => match ch {
1970                Some('>') => self.close_doctype(out),
1971                Some('\0') => false,
1972                None => {
1973                    self.emit_doctype(out);
1974                    push_eof(out, position);
1975                    false
1976                }
1977                Some(_) => false,
1978            },
1979            State::ProcessingInstructionOpen => match ch {
1980                Some(c) if c.is_ascii_alphabetic() || c == '_' => {
1981                    self.state = State::ProcessingInstructionTarget;
1982                    true
1983                }
1984                None => {
1985                    // eof-in-processing-instruction parse error. Unlike
1986                    // DOCTYPE/comment states' EOF handling, the spec here
1987                    // says only "emit an end-of-file token" — no
1988                    // in-progress token exists yet to emit anyway.
1989                    self.error(ParseErrorKind::EofInProcessingInstruction, position);
1990                    push_eof(out, position);
1991                    false
1992                }
1993                Some(_) => {
1994                    // invalid-first-character-of-processing-instruction-
1995                    // target parse error. Buffer is still empty here —
1996                    // nothing has been accumulated yet.
1997                    self.error(
1998                        ParseErrorKind::InvalidFirstCharacterOfProcessingInstructionTarget,
1999                        position,
2000                    );
2001                    self.convert_pi_temporary_buffer_to_comment();
2002                    true
2003                }
2004            },
2005            State::ProcessingInstructionTarget => match ch {
2006                Some(c) if Self::is_whitespace(c) || c == '?' || c == '>' => {
2007                    let target = std::mem::take(&mut self.pi_temporary_buffer);
2008                    if target.eq_ignore_ascii_case("xml")
2009                        || target.eq_ignore_ascii_case("xml-stylesheet")
2010                    {
2011                        // disallowed-processing-instruction-target parse
2012                        // error.
2013                        self.error(
2014                            ParseErrorKind::DisallowedProcessingInstructionTarget,
2015                            position,
2016                        );
2017                        self.current_comment_data = format!("?{target}");
2018                        self.state = State::BogusComment;
2019                    } else {
2020                        self.current_processing_instruction = Some(ProcessingInstructionToken {
2021                            target,
2022                            data: String::new(),
2023                        });
2024                        self.state = State::AfterProcessingInstructionTarget;
2025                    }
2026                    true
2027                }
2028                Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_' => {
2029                    self.pi_temporary_buffer.push(c);
2030                    false
2031                }
2032                None => {
2033                    // eof-in-processing-instruction parse error.
2034                    self.error(ParseErrorKind::EofInProcessingInstruction, position);
2035                    push_eof(out, position);
2036                    false
2037                }
2038                Some(_) => {
2039                    // invalid-processing-instruction-target parse error.
2040                    self.error(ParseErrorKind::InvalidProcessingInstructionTarget, position);
2041                    self.convert_pi_temporary_buffer_to_comment();
2042                    true
2043                }
2044            },
2045            State::AfterProcessingInstructionTarget => match ch {
2046                Some(c) if Self::is_whitespace(c) => false,
2047                _ => {
2048                    self.state = State::ProcessingInstructionData;
2049                    true
2050                }
2051            },
2052            State::ProcessingInstructionData => match ch {
2053                Some('?') => {
2054                    self.state = State::ProcessingInstructionQuestionable;
2055                    false
2056                }
2057                Some('>') => {
2058                    self.state = State::Data;
2059                    self.emit_processing_instruction(out);
2060                    false
2061                }
2062                None => {
2063                    // eof-in-processing-instruction parse error: the
2064                    // in-progress PI token is discarded, not emitted —
2065                    // spec says only "emit an end-of-file token" here.
2066                    self.error(ParseErrorKind::EofInProcessingInstruction, position);
2067                    self.current_processing_instruction = None;
2068                    push_eof(out, position);
2069                    false
2070                }
2071                Some(c) => {
2072                    self.current_processing_instruction
2073                        .as_mut()
2074                        .expect("processing instruction token should already exist in data state")
2075                        .data
2076                        .push(c);
2077                    false
2078                }
2079            },
2080            State::ProcessingInstructionQuestionable => match ch {
2081                Some('>') => {
2082                    self.state = State::Data;
2083                    self.emit_processing_instruction(out);
2084                    false
2085                }
2086                None => {
2087                    self.current_processing_instruction = None;
2088                    push_eof(out, position);
2089                    false
2090                }
2091                Some(_) => {
2092                    self.current_processing_instruction
2093                        .as_mut()
2094                        .expect(
2095                            "processing instruction token should already exist in questionable state",
2096                        )
2097                        .data
2098                        .push('?');
2099                    self.state = State::ProcessingInstructionData;
2100                    true
2101                }
2102            },
2103            State::RcData => match ch {
2104                Some('&') => {
2105                    self.begin_character_reference(State::RcData, position);
2106                    false
2107                }
2108                Some('<') => {
2109                    self.current_tag_start = position;
2110                    self.state = State::RcDataLessThanSign;
2111                    false
2112                }
2113                Some('\0') => {
2114                    push_character(out, '\u{FFFD}', position);
2115                    false
2116                }
2117                Some(c) => {
2118                    push_character(out, c, position);
2119                    false
2120                }
2121                None => {
2122                    push_eof(out, position);
2123                    false
2124                }
2125            },
2126            State::RcDataLessThanSign => self.step_text_less_than_sign(
2127                ch,
2128                position,
2129                out,
2130                State::RcDataEndTagOpen,
2131                State::RcData,
2132            ),
2133            State::RcDataEndTagOpen => self.step_text_end_tag_open(
2134                ch,
2135                position,
2136                out,
2137                State::RcDataEndTagName,
2138                State::RcData,
2139            ),
2140            State::RcDataEndTagName => {
2141                self.step_text_end_tag_name(ch, position, out, State::RcData)
2142            }
2143            State::RawText => match ch {
2144                Some('<') => {
2145                    self.current_tag_start = position;
2146                    self.state = State::RawTextLessThanSign;
2147                    false
2148                }
2149                Some('\0') => {
2150                    push_character(out, '\u{FFFD}', position);
2151                    false
2152                }
2153                Some(c) => {
2154                    push_character(out, c, position);
2155                    false
2156                }
2157                None => {
2158                    push_eof(out, position);
2159                    false
2160                }
2161            },
2162            State::RawTextLessThanSign => self.step_text_less_than_sign(
2163                ch,
2164                position,
2165                out,
2166                State::RawTextEndTagOpen,
2167                State::RawText,
2168            ),
2169            State::RawTextEndTagOpen => self.step_text_end_tag_open(
2170                ch,
2171                position,
2172                out,
2173                State::RawTextEndTagName,
2174                State::RawText,
2175            ),
2176            State::RawTextEndTagName => {
2177                self.step_text_end_tag_name(ch, position, out, State::RawText)
2178            }
2179            State::PlainText => match ch {
2180                Some('\0') => {
2181                    push_character(out, '\u{FFFD}', position);
2182                    false
2183                }
2184                Some(c) => {
2185                    push_character(out, c, position);
2186                    false
2187                }
2188                None => {
2189                    push_eof(out, position);
2190                    false
2191                }
2192            },
2193            State::ScriptData => match ch {
2194                Some('<') => {
2195                    self.current_tag_start = position;
2196                    self.state = State::ScriptDataLessThanSign;
2197                    false
2198                }
2199                Some('\0') => {
2200                    push_character(out, '\u{FFFD}', position);
2201                    false
2202                }
2203                Some(c) => {
2204                    push_character(out, c, position);
2205                    false
2206                }
2207                None => {
2208                    push_eof(out, position);
2209                    false
2210                }
2211            },
2212            State::ScriptDataLessThanSign => match ch {
2213                Some('/') => {
2214                    self.text_end_tag_buffer.clear();
2215                    self.slash_position = position;
2216                    self.state = State::ScriptDataEndTagOpen;
2217                    false
2218                }
2219                Some('!') => {
2220                    self.state = State::ScriptDataEscapeStart;
2221                    push_character(out, '<', self.current_tag_start);
2222                    push_character(out, '!', position);
2223                    false
2224                }
2225                _ => {
2226                    push_character(out, '<', self.current_tag_start);
2227                    self.state = State::ScriptData;
2228                    true
2229                }
2230            },
2231            State::ScriptDataEndTagOpen => self.step_text_end_tag_open(
2232                ch,
2233                position,
2234                out,
2235                State::ScriptDataEndTagName,
2236                State::ScriptData,
2237            ),
2238            State::ScriptDataEndTagName => {
2239                self.step_text_end_tag_name(ch, position, out, State::ScriptData)
2240            }
2241            State::ScriptDataEscapeStart => match ch {
2242                Some('-') => {
2243                    self.state = State::ScriptDataEscapeStartDash;
2244                    push_character(out, '-', position);
2245                    false
2246                }
2247                _ => {
2248                    self.state = State::ScriptData;
2249                    true
2250                }
2251            },
2252            State::ScriptDataEscapeStartDash => match ch {
2253                Some('-') => {
2254                    self.state = State::ScriptDataEscapedDashDash;
2255                    push_character(out, '-', position);
2256                    false
2257                }
2258                _ => {
2259                    self.state = State::ScriptData;
2260                    true
2261                }
2262            },
2263            State::ScriptDataEscaped => match ch {
2264                Some('-') => {
2265                    self.state = State::ScriptDataEscapedDash;
2266                    push_character(out, '-', position);
2267                    false
2268                }
2269                Some('<') => {
2270                    self.current_tag_start = position;
2271                    self.state = State::ScriptDataEscapedLessThanSign;
2272                    false
2273                }
2274                Some('\0') => {
2275                    push_character(out, '\u{FFFD}', position);
2276                    false
2277                }
2278                Some(c) => {
2279                    push_character(out, c, position);
2280                    false
2281                }
2282                None => {
2283                    // eof-in-script-html-comment-like-text parse error.
2284                    self.error(ParseErrorKind::EofInScriptHtmlCommentLikeText, position);
2285                    push_eof(out, position);
2286                    false
2287                }
2288            },
2289            State::ScriptDataEscapedDash => match ch {
2290                Some('-') => {
2291                    self.state = State::ScriptDataEscapedDashDash;
2292                    push_character(out, '-', position);
2293                    false
2294                }
2295                Some('<') => {
2296                    self.current_tag_start = position;
2297                    self.state = State::ScriptDataEscapedLessThanSign;
2298                    false
2299                }
2300                Some('\0') => {
2301                    self.state = State::ScriptDataEscaped;
2302                    push_character(out, '\u{FFFD}', position);
2303                    false
2304                }
2305                None => {
2306                    push_eof(out, position);
2307                    false
2308                }
2309                Some(c) => {
2310                    self.state = State::ScriptDataEscaped;
2311                    push_character(out, c, position);
2312                    false
2313                }
2314            },
2315            State::ScriptDataEscapedDashDash => match ch {
2316                Some('-') => {
2317                    push_character(out, '-', position);
2318                    false
2319                }
2320                Some('<') => {
2321                    self.current_tag_start = position;
2322                    self.state = State::ScriptDataEscapedLessThanSign;
2323                    false
2324                }
2325                Some('>') => {
2326                    self.state = State::ScriptData;
2327                    push_character(out, '>', position);
2328                    false
2329                }
2330                Some('\0') => {
2331                    self.state = State::ScriptDataEscaped;
2332                    push_character(out, '\u{FFFD}', position);
2333                    false
2334                }
2335                None => {
2336                    push_eof(out, position);
2337                    false
2338                }
2339                Some(c) => {
2340                    self.state = State::ScriptDataEscaped;
2341                    push_character(out, c, position);
2342                    false
2343                }
2344            },
2345            State::ScriptDataEscapedLessThanSign => match ch {
2346                Some('/') => {
2347                    self.text_end_tag_buffer.clear();
2348                    self.slash_position = position;
2349                    self.state = State::ScriptDataEscapedEndTagOpen;
2350                    false
2351                }
2352                Some(c) if c.is_ascii_alphabetic() => {
2353                    self.text_end_tag_buffer.clear();
2354                    push_character(out, '<', self.current_tag_start);
2355                    self.state = State::ScriptDataDoubleEscapeStart;
2356                    true
2357                }
2358                _ => {
2359                    push_character(out, '<', self.current_tag_start);
2360                    self.state = State::ScriptDataEscaped;
2361                    true
2362                }
2363            },
2364            State::ScriptDataEscapedEndTagOpen => self.step_text_end_tag_open(
2365                ch,
2366                position,
2367                out,
2368                State::ScriptDataEscapedEndTagName,
2369                State::ScriptDataEscaped,
2370            ),
2371            State::ScriptDataEscapedEndTagName => {
2372                self.step_text_end_tag_name(ch, position, out, State::ScriptDataEscaped)
2373            }
2374            State::ScriptDataDoubleEscapeStart => match ch {
2375                Some(c) if Self::is_whitespace(c) || c == '/' || c == '>' => {
2376                    self.state = if self.text_end_tag_buffer == "script" {
2377                        State::ScriptDataDoubleEscaped
2378                    } else {
2379                        State::ScriptDataEscaped
2380                    };
2381                    push_character(out, c, position);
2382                    false
2383                }
2384                Some(c) if c.is_ascii_uppercase() => {
2385                    self.text_end_tag_buffer.push(c.to_ascii_lowercase());
2386                    push_character(out, c, position);
2387                    false
2388                }
2389                Some(c) if c.is_ascii_lowercase() => {
2390                    self.text_end_tag_buffer.push(c);
2391                    push_character(out, c, position);
2392                    false
2393                }
2394                _ => {
2395                    self.state = State::ScriptDataEscaped;
2396                    true
2397                }
2398            },
2399            State::ScriptDataDoubleEscaped => match ch {
2400                Some('-') => {
2401                    self.state = State::ScriptDataDoubleEscapedDash;
2402                    push_character(out, '-', position);
2403                    false
2404                }
2405                Some('<') => {
2406                    self.state = State::ScriptDataDoubleEscapedLessThanSign;
2407                    push_character(out, '<', position);
2408                    false
2409                }
2410                Some('\0') => {
2411                    push_character(out, '\u{FFFD}', position);
2412                    false
2413                }
2414                Some(c) => {
2415                    push_character(out, c, position);
2416                    false
2417                }
2418                None => {
2419                    push_eof(out, position);
2420                    false
2421                }
2422            },
2423            State::ScriptDataDoubleEscapedDash => match ch {
2424                Some('-') => {
2425                    self.state = State::ScriptDataDoubleEscapedDashDash;
2426                    push_character(out, '-', position);
2427                    false
2428                }
2429                Some('<') => {
2430                    self.state = State::ScriptDataDoubleEscapedLessThanSign;
2431                    push_character(out, '<', position);
2432                    false
2433                }
2434                Some('\0') => {
2435                    self.state = State::ScriptDataDoubleEscaped;
2436                    push_character(out, '\u{FFFD}', position);
2437                    false
2438                }
2439                None => {
2440                    push_eof(out, position);
2441                    false
2442                }
2443                Some(c) => {
2444                    self.state = State::ScriptDataDoubleEscaped;
2445                    push_character(out, c, position);
2446                    false
2447                }
2448            },
2449            State::ScriptDataDoubleEscapedDashDash => match ch {
2450                Some('-') => {
2451                    push_character(out, '-', position);
2452                    false
2453                }
2454                Some('<') => {
2455                    self.state = State::ScriptDataDoubleEscapedLessThanSign;
2456                    push_character(out, '<', position);
2457                    false
2458                }
2459                Some('>') => {
2460                    self.state = State::ScriptData;
2461                    push_character(out, '>', position);
2462                    false
2463                }
2464                Some('\0') => {
2465                    self.state = State::ScriptDataDoubleEscaped;
2466                    push_character(out, '\u{FFFD}', position);
2467                    false
2468                }
2469                None => {
2470                    push_eof(out, position);
2471                    false
2472                }
2473                Some(c) => {
2474                    self.state = State::ScriptDataDoubleEscaped;
2475                    push_character(out, c, position);
2476                    false
2477                }
2478            },
2479            State::ScriptDataDoubleEscapedLessThanSign => match ch {
2480                Some('/') => {
2481                    self.text_end_tag_buffer.clear();
2482                    self.state = State::ScriptDataDoubleEscapeEnd;
2483                    push_character(out, '/', position);
2484                    false
2485                }
2486                _ => {
2487                    self.state = State::ScriptDataDoubleEscaped;
2488                    true
2489                }
2490            },
2491            State::ScriptDataDoubleEscapeEnd => match ch {
2492                Some(c) if Self::is_whitespace(c) || c == '/' || c == '>' => {
2493                    self.state = if self.text_end_tag_buffer == "script" {
2494                        State::ScriptDataEscaped
2495                    } else {
2496                        State::ScriptDataDoubleEscaped
2497                    };
2498                    push_character(out, c, position);
2499                    false
2500                }
2501                Some(c) if c.is_ascii_uppercase() => {
2502                    self.text_end_tag_buffer.push(c.to_ascii_lowercase());
2503                    push_character(out, c, position);
2504                    false
2505                }
2506                Some(c) if c.is_ascii_lowercase() => {
2507                    self.text_end_tag_buffer.push(c);
2508                    push_character(out, c, position);
2509                    false
2510                }
2511                _ => {
2512                    self.state = State::ScriptDataDoubleEscaped;
2513                    true
2514                }
2515            },
2516            State::CdataSection => match ch {
2517                Some(']') => {
2518                    self.cdata_pending_brackets.push(position);
2519                    self.state = State::CdataSectionBracket;
2520                    false
2521                }
2522                None => {
2523                    // eof-in-cdata parse error.
2524                    self.error(ParseErrorKind::EofInCdata, position);
2525                    push_eof(out, position);
2526                    false
2527                }
2528                Some(c) => {
2529                    // U+0000 NULL is deliberately *not* replaced with
2530                    // U+FFFD here — the spec explicitly says NUL handling
2531                    // inside CDATA sections happens in tree-construction's
2532                    // "in foreign content" rules, not in the tokenizer.
2533                    push_character(out, c, position);
2534                    false
2535                }
2536            },
2537            State::CdataSectionBracket => match ch {
2538                Some(']') => {
2539                    self.cdata_pending_brackets.push(position);
2540                    self.state = State::CdataSectionEnd;
2541                    false
2542                }
2543                _ => {
2544                    // Exactly one `]` was withheld to get here (from
2545                    // CdataSection's own `]` branch) — flush it with its
2546                    // own remembered position, not this (different)
2547                    // character's.
2548                    let bracket_position = self
2549                        .cdata_pending_brackets
2550                        .pop()
2551                        .expect("CdataSectionBracket reached with no withheld ']'");
2552                    push_character(out, ']', bracket_position);
2553                    self.state = State::CdataSection;
2554                    true
2555                }
2556            },
2557            State::CdataSectionEnd => match ch {
2558                Some(']') => {
2559                    // A third (or later) consecutive `]`: the oldest
2560                    // withheld one can no longer be part of the `]]>`
2561                    // terminator (that needs exactly the *last* two `]`s
2562                    // before a `>`), so it's confirmed content — flush it
2563                    // with its own position, and slide the window: this
2564                    // new `]` joins the pending set in its place.
2565                    let oldest = self.cdata_pending_brackets.remove(0);
2566                    self.cdata_pending_brackets.push(position);
2567                    push_character(out, ']', oldest);
2568                    false
2569                }
2570                Some('>') => {
2571                    // The two withheld `]`s are consumed as part of the
2572                    // `]]>` terminator itself — never emitted as content.
2573                    self.cdata_pending_brackets.clear();
2574                    self.state = State::Data;
2575                    false
2576                }
2577                _ => {
2578                    // Exactly two `]`s are withheld here — flush both with
2579                    // their own remembered positions, in order.
2580                    for bracket_position in self.cdata_pending_brackets.drain(..) {
2581                        push_character(out, ']', bracket_position);
2582                    }
2583                    self.state = State::CdataSection;
2584                    true
2585                }
2586            },
2587        }
2588    }
2589
2590    fn step_attribute_value_quoted(
2591        &mut self,
2592        ch: Option<char>,
2593        position: Position,
2594        out: &mut Vec<Token>,
2595        quote: char,
2596    ) -> bool {
2597        match ch {
2598            Some(c) if c == quote => {
2599                self.state = State::AfterAttributeValueQuoted;
2600                false
2601            }
2602            Some('&') => {
2603                let quoted_state = self.state;
2604                self.begin_character_reference(quoted_state, position);
2605                false
2606            }
2607            Some('\0') => {
2608                self.push_attribute_value_char('\u{FFFD}');
2609                false
2610            }
2611            Some(c) => {
2612                self.push_attribute_value_char(c);
2613                false
2614            }
2615            None => {
2616                // eof-in-tag parse error.
2617                self.error(ParseErrorKind::EofInTag, position);
2618                push_eof(out, position);
2619                false
2620            }
2621        }
2622    }
2623
2624    fn current_tag_mut(&mut self) -> &mut TagToken {
2625        self.current_tag
2626            .as_mut()
2627            .expect("tag-name/self-closing state reached with no tag token in progress")
2628    }
2629
2630    /// §13.2.5's "appropriate end tag token": the current (end) tag token
2631    /// in progress matches the last *start* tag this tokenizer emitted.
2632    fn is_appropriate_end_tag(&self) -> bool {
2633        let Some(tag) = &self.current_tag else {
2634            return false;
2635        };
2636        self.last_start_tag_name.as_deref() == Some(tag.name.as_str())
2637    }
2638
2639    /// Shared by RCDATA/RAWTEXT's "less-than sign" states (§13.2.5.9/.12):
2640    /// either start tracking a possible end tag, or bail straight back to
2641    /// literal text. `text_state` must already have set `current_tag_start`
2642    /// to the `<`'s own position before switching into this state.
2643    fn step_text_less_than_sign(
2644        &mut self,
2645        ch: Option<char>,
2646        position: Position,
2647        out: &mut Vec<Token>,
2648        end_tag_open_state: State,
2649        text_state: State,
2650    ) -> bool {
2651        match ch {
2652            Some('/') => {
2653                self.text_end_tag_buffer.clear();
2654                self.slash_position = position;
2655                self.state = end_tag_open_state;
2656                false
2657            }
2658            _ => {
2659                push_character(out, '<', self.current_tag_start);
2660                self.state = text_state;
2661                true
2662            }
2663        }
2664    }
2665
2666    /// Shared by RCDATA/RAWTEXT's "end tag open" states (§13.2.5.10/.13).
2667    fn step_text_end_tag_open(
2668        &mut self,
2669        ch: Option<char>,
2670        _position: Position,
2671        out: &mut Vec<Token>,
2672        end_tag_name_state: State,
2673        text_state: State,
2674    ) -> bool {
2675        match ch {
2676            Some(c) if c.is_ascii_alphabetic() => {
2677                self.start_tag_token(true);
2678                self.state = end_tag_name_state;
2679                true
2680            }
2681            _ => {
2682                push_character(out, '<', self.current_tag_start);
2683                push_character(out, '/', self.slash_position);
2684                self.state = text_state;
2685                true
2686            }
2687        }
2688    }
2689
2690    /// Shared by RCDATA/RAWTEXT's "end tag name" states (§13.2.5.11/.14):
2691    /// keeps building a possible end tag as long as it could still turn
2692    /// out appropriate, and bails via `abandon_text_end_tag` the moment it
2693    /// can't (wrong name, or a name character that can't appear in a tag
2694    /// name at all).
2695    fn step_text_end_tag_name(
2696        &mut self,
2697        ch: Option<char>,
2698        _position: Position,
2699        out: &mut Vec<Token>,
2700        text_state: State,
2701    ) -> bool {
2702        match ch {
2703            Some(c) if Self::is_whitespace(c) => {
2704                if self.is_appropriate_end_tag() {
2705                    self.state = State::BeforeAttributeName;
2706                    false
2707                } else {
2708                    self.abandon_text_end_tag(out, text_state)
2709                }
2710            }
2711            Some('/') => {
2712                if self.is_appropriate_end_tag() {
2713                    self.state = State::SelfClosingStartTag;
2714                    false
2715                } else {
2716                    self.abandon_text_end_tag(out, text_state)
2717                }
2718            }
2719            Some('>') => {
2720                if self.is_appropriate_end_tag() {
2721                    self.close_tag(out)
2722                } else {
2723                    self.abandon_text_end_tag(out, text_state)
2724                }
2725            }
2726            Some(c) if c.is_ascii_uppercase() => {
2727                self.current_tag_mut().name.push(c.to_ascii_lowercase());
2728                self.text_end_tag_buffer.push(c);
2729                false
2730            }
2731            Some(c) if c.is_ascii_lowercase() => {
2732                self.current_tag_mut().name.push(c);
2733                self.text_end_tag_buffer.push(c);
2734                false
2735            }
2736            _ => self.abandon_text_end_tag(out, text_state),
2737        }
2738    }
2739
2740    /// The "anything else" fallback shared by both RCDATA/RAWTEXT
2741    /// end-tag-name states, and by the non-appropriate case of their
2742    /// whitespace/`/`/`>` branches: the `</name` seen so far wasn't (or
2743    /// can't become) a real end tag, so it's flushed back out as literal
2744    /// `<`, `/`, and each buffered name character, and `ch` is reconsumed
2745    /// under `text_state`. The buffer only ever contains ASCII letters
2746    /// (the only characters these states append), so — like
2747    /// `flush_literal_character_reference_attempt` — positions are safe
2748    /// to compute by simple increment; here from `slash_position` (the
2749    /// buffer's first character always immediately follows the `/`).
2750    fn abandon_text_end_tag(&mut self, out: &mut Vec<Token>, text_state: State) -> bool {
2751        push_character(out, '<', self.current_tag_start);
2752        push_character(out, '/', self.slash_position);
2753        let mut position = Position {
2754            line: self.slash_position.line,
2755            column: self.slash_position.column + 1,
2756            byte_offset: self.slash_position.byte_offset + 1,
2757        };
2758        let buffer = std::mem::take(&mut self.text_end_tag_buffer);
2759        for c in buffer.chars() {
2760            push_character(out, c, position);
2761            position.column += 1;
2762            position.byte_offset += 1;
2763        }
2764        self.current_tag = None;
2765        self.state = text_state;
2766        true
2767    }
2768
2769    /// Shared by the four DOCTYPE public/system × double/single-quoted
2770    /// identifier states (§13.2.5.59/.60/.65/.66) — they differ only in
2771    /// which quote character closes them and which identifier field they
2772    /// append to.
2773    fn step_doctype_identifier_quoted(
2774        &mut self,
2775        ch: Option<char>,
2776        position: Position,
2777        out: &mut Vec<Token>,
2778        quote: char,
2779        kind: DoctypeIdentifierKind,
2780    ) -> bool {
2781        match ch {
2782            Some(c) if c == quote => {
2783                self.state = match kind {
2784                    DoctypeIdentifierKind::Public => State::AfterDoctypePublicIdentifier,
2785                    DoctypeIdentifierKind::System => State::AfterDoctypeSystemIdentifier,
2786                };
2787                false
2788            }
2789            Some('\0') => {
2790                self.doctype_identifier_mut(kind).push('\u{FFFD}');
2791                false
2792            }
2793            Some('>') => {
2794                // abrupt-doctype-public-identifier /
2795                // abrupt-doctype-system-identifier parse error.
2796                let error_kind = match kind {
2797                    DoctypeIdentifierKind::Public => ParseErrorKind::AbruptDoctypePublicIdentifier,
2798                    DoctypeIdentifierKind::System => ParseErrorKind::AbruptDoctypeSystemIdentifier,
2799                };
2800                self.error(error_kind, position);
2801                self.close_doctype_with_quirks(out)
2802            }
2803            None => self.eof_in_doctype(out, position),
2804            Some(c) => {
2805                self.doctype_identifier_mut(kind).push(c);
2806                false
2807            }
2808        }
2809    }
2810
2811    /// §13.2.5's `&`-transitions ("Set the return state to X. Switch to
2812    /// the character reference state."), shared by the Data state and the
2813    /// three attribute value states. `position` is the position of the
2814    /// `&` itself; `self.index` at call time already points one past it.
2815    fn begin_character_reference(&mut self, return_state: State, position: Position) {
2816        self.return_state = return_state;
2817        self.character_reference_start = position;
2818        self.character_reference_start_index = self.index - 1;
2819        self.state = State::CharacterReference;
2820    }
2821
2822    /// §13.2.5's "consumed as part of an attribute" condition: true iff
2823    /// `return_state` is one of the three attribute value states.
2824    fn character_reference_in_attribute(&self) -> bool {
2825        matches!(
2826            self.return_state,
2827            State::AttributeValueDoubleQuoted
2828                | State::AttributeValueSingleQuoted
2829                | State::AttributeValueUnquoted
2830        )
2831    }
2832
2833    /// §13.2.5's "flush code points consumed as a character reference":
2834    /// appends `c` to the current attribute's value if the reference was
2835    /// consumed as part of an attribute, or emits it as a character token
2836    /// otherwise.
2837    fn flush_char_as_character_reference(
2838        &mut self,
2839        c: char,
2840        position: Position,
2841        out: &mut Vec<Token>,
2842    ) {
2843        if self.character_reference_in_attribute() {
2844            self.push_attribute_value_char(c);
2845        } else {
2846            push_character(out, c, position);
2847        }
2848    }
2849
2850    /// Flushes the literal source text from the `&` that started the
2851    /// current character reference attempt up to (but not including)
2852    /// `end_index` — used for every "no match"/"absence of digits"
2853    /// fallback path, where the attempted reference turns out not to be
2854    /// one and is emitted as plain text instead of being resolved. Every
2855    /// character in these buffers (`&`, `#`, `x`/`X`, the named-reference
2856    /// attempt's own letters/digits) is literal single-byte ASCII source
2857    /// text, so positions are safe to compute by simple increment from
2858    /// `character_reference_start`.
2859    fn flush_literal_character_reference_attempt(
2860        &mut self,
2861        end_index: usize,
2862        out: &mut Vec<Token>,
2863    ) {
2864        let chars: Vec<char> = self.chars[self.character_reference_start_index..end_index].to_vec();
2865        let mut position = self.character_reference_start;
2866        for c in chars {
2867            self.flush_char_as_character_reference(c, position, out);
2868            position.column += 1;
2869            position.byte_offset += 1;
2870        }
2871    }
2872
2873    /// Looks up `name` (without the leading `&`) in the generated named
2874    /// character references table.
2875    fn lookup_named_character_reference(name: &str) -> Option<&'static str> {
2876        entities::NAMED_CHARACTER_REFERENCES
2877            .binary_search_by(|&(candidate, _)| candidate.cmp(name))
2878            .ok()
2879            .map(|i| entities::NAMED_CHARACTER_REFERENCES[i].1)
2880    }
2881
2882    /// True if some table entry has `prefix` as a strict prefix — i.e. it
2883    /// is still worth trying to consume another character while looking
2884    /// for the longest match. The table is sorted, so matching entries
2885    /// form a contiguous run.
2886    fn named_character_reference_has_longer_match(prefix: &str) -> bool {
2887        let start =
2888            entities::NAMED_CHARACTER_REFERENCES.partition_point(|&(name, _)| name < prefix);
2889        entities::NAMED_CHARACTER_REFERENCES[start..]
2890            .iter()
2891            .take_while(|&&(name, _)| name.starts_with(prefix))
2892            .any(|&(name, _)| name.len() > prefix.len())
2893    }
2894
2895    /// §13.2.5.78 "Named character reference state": a maximal-munch
2896    /// lookup against the whole table, not a single-character state
2897    /// transition — implemented as its own non-consuming-loop routine
2898    /// (dispatched from `run_until_token`) rather than forced through the
2899    /// one-character-per-`step()`-call model the rest of the tokenizer
2900    /// uses. Looks ahead via a local cursor without committing `self.index`
2901    /// until the match length is known, so a longer, ultimately-failed
2902    /// attempt can cheaply "give back" the extra characters it peeked at.
2903    fn run_named_character_reference(&mut self) {
2904        let (first_char, _) = self.consume();
2905        let first_char = first_char.expect(
2906            "named character reference state entered without an alphanumeric first character",
2907        );
2908
2909        let mut candidate = String::new();
2910        candidate.push(first_char);
2911        let mut cursor = self.index;
2912        let mut best: Option<usize> = None;
2913        if Self::lookup_named_character_reference(&candidate).is_some() {
2914            best = Some(candidate.len());
2915        }
2916        loop {
2917            if !Self::named_character_reference_has_longer_match(&candidate) {
2918                break;
2919            }
2920            let Some(&c) = self.chars.get(cursor) else {
2921                break;
2922            };
2923            candidate.push(c);
2924            cursor += 1;
2925            if Self::lookup_named_character_reference(&candidate).is_some() {
2926                best = Some(candidate.len());
2927            }
2928        }
2929
2930        let mut out = Vec::new();
2931        match best {
2932            Some(matched_len) => {
2933                self.index = self.character_reference_start_index + 1 + matched_len;
2934                let matched_name = candidate[..matched_len].to_owned();
2935                let next_char = self.chars.get(self.index).copied();
2936                let last_matched_is_semicolon = matched_name.ends_with(';');
2937                let next_is_equals_or_alphanumeric = matches!(next_char, Some('='))
2938                    || matches!(next_char, Some(c) if c.is_ascii_alphanumeric());
2939                let historical = self.character_reference_in_attribute()
2940                    && !last_matched_is_semicolon
2941                    && next_is_equals_or_alphanumeric;
2942                if historical {
2943                    self.flush_literal_character_reference_attempt(self.index, &mut out);
2944                } else {
2945                    // if !last_matched_is_semicolon: missing-semicolon-
2946                    // after-character-reference parse error (still
2947                    // resolved either way, per spec).
2948                    if !last_matched_is_semicolon {
2949                        self.error(
2950                            ParseErrorKind::MissingSemicolonAfterCharacterReference,
2951                            self.character_reference_start,
2952                        );
2953                    }
2954                    let replacement = Self::lookup_named_character_reference(&matched_name)
2955                        .expect("matched_name was already verified to be a table entry");
2956                    let start = self.character_reference_start;
2957                    for c in replacement.chars() {
2958                        self.flush_char_as_character_reference(c, start, &mut out);
2959                    }
2960                }
2961                self.state = self.return_state;
2962            }
2963            None => {
2964                // unknown named character reference attempt: the whole
2965                // greedily-examined (but never matching) run is flushed
2966                // literally, then the ambiguous ampersand state takes over
2967                // from wherever it left off.
2968                self.index = cursor;
2969                self.flush_literal_character_reference_attempt(self.index, &mut out);
2970                self.state = State::AmbiguousAmpersand;
2971            }
2972        }
2973        self.pending.extend(out);
2974    }
2975
2976    /// §13.2.5.84 "Numeric character reference end state": resolves
2977    /// `character_reference_code` to the character it actually represents,
2978    /// applying the null/out-of-range/surrogate/noncharacter/control-
2979    /// character corrections the spec mandates — each with its own named
2980    /// parse error, reported at `position` (the reference's start,
2981    /// matching where the resolved character itself gets flushed).
2982    fn resolve_numeric_character_reference_code(&mut self, position: Position) -> char {
2983        const NUL: u32 = 0x00;
2984        const MAX_UNICODE: u32 = 0x10FFFF;
2985        let code = self.character_reference_code;
2986        let resolved = if code == NUL {
2987            self.error(ParseErrorKind::NullCharacterReference, position);
2988            0xFFFD
2989        } else if code > MAX_UNICODE {
2990            self.error(
2991                ParseErrorKind::CharacterReferenceOutsideUnicodeRange,
2992                position,
2993            );
2994            0xFFFD
2995        } else if is_surrogate(code) {
2996            self.error(ParseErrorKind::SurrogateCharacterReference, position);
2997            0xFFFD
2998        } else if is_noncharacter(code) {
2999            self.error(ParseErrorKind::NoncharacterCharacterReference, position);
3000            code
3001        } else if code == 0x0D || (is_control(code) && !is_ascii_whitespace(code)) {
3002            self.error(ParseErrorKind::ControlCharacterReference, position);
3003            windows_1252_override(code).unwrap_or(code)
3004        } else {
3005            code
3006        };
3007        char::from_u32(resolved).unwrap_or('\u{FFFD}')
3008    }
3009}
3010
3011/// Pushes an end-of-file token. Free function, not a method: every
3012/// `step()` arm already holds `out: &mut Vec<Token>` and `position:
3013/// Position` locally, and this exact push is by far the most repeated
3014/// shape in the whole state machine (every state's EOF branch does
3015/// exactly this).
3016fn push_eof(out: &mut Vec<Token>, position: Position) {
3017    out.push(Token {
3018        kind: TokenKind::Eof,
3019        position,
3020    });
3021}
3022
3023/// Pushes a character token. Free function for the same reason as
3024/// [`push_eof`] — the single most repeated shape after it.
3025fn push_character(out: &mut Vec<Token>, c: char, position: Position) {
3026    out.push(Token {
3027        kind: TokenKind::Character(c),
3028        position,
3029    });
3030}
3031
3032fn is_surrogate(code: u32) -> bool {
3033    (0xD800..=0xDFFF).contains(&code)
3034}
3035
3036/// Per the Infra Standard: code points U+FDD0–U+FDEF, or any code point
3037/// whose low 16 bits are 0xFFFE or 0xFFFF (U+FFFE, U+FFFF, U+1FFFE, ...,
3038/// U+10FFFE, U+10FFFF).
3039fn is_noncharacter(code: u32) -> bool {
3040    (0xFDD0..=0xFDEF).contains(&code) || matches!(code & 0xFFFF, 0xFFFE | 0xFFFF)
3041}
3042
3043/// Per the Infra Standard: a C0 control (U+0000–U+001F) or a code point in
3044/// U+007F–U+009F.
3045fn is_control(code: u32) -> bool {
3046    (0x00..=0x1F).contains(&code) || (0x7F..=0x9F).contains(&code)
3047}
3048
3049/// Per the Infra Standard: tab, LF, FF, CR, or space.
3050fn is_ascii_whitespace(code: u32) -> bool {
3051    matches!(code, 0x09 | 0x0A | 0x0C | 0x0D | 0x20)
3052}
3053
3054/// §13.2.5.84's control-character-reference override table, transcribed
3055/// verbatim from <https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state>
3056/// (the Windows-1252-derived remapping for C1 control code points). Rows
3057/// not listed here (0x81, 0x8D, 0x8F, 0x90, 0x9D) have no override — the
3058/// code point is left unchanged, per spec.
3059fn windows_1252_override(code: u32) -> Option<u32> {
3060    const TABLE: &[(u32, u32)] = &[
3061        (0x80, 0x20AC),
3062        (0x82, 0x201A),
3063        (0x83, 0x0192),
3064        (0x84, 0x201E),
3065        (0x85, 0x2026),
3066        (0x86, 0x2020),
3067        (0x87, 0x2021),
3068        (0x88, 0x02C6),
3069        (0x89, 0x2030),
3070        (0x8A, 0x0160),
3071        (0x8B, 0x2039),
3072        (0x8C, 0x0152),
3073        (0x8E, 0x017D),
3074        (0x91, 0x2018),
3075        (0x92, 0x2019),
3076        (0x93, 0x201C),
3077        (0x94, 0x201D),
3078        (0x95, 0x2022),
3079        (0x96, 0x2013),
3080        (0x97, 0x2014),
3081        (0x98, 0x02DC),
3082        (0x99, 0x2122),
3083        (0x9A, 0x0161),
3084        (0x9B, 0x203A),
3085        (0x9C, 0x0153),
3086        (0x9E, 0x017E),
3087        (0x9F, 0x0178),
3088    ];
3089    TABLE
3090        .iter()
3091        .find(|&&(from, _)| from == code)
3092        .map(|&(_, to)| to)
3093}
3094
3095impl Iterator for Tokenizer {
3096    type Item = Token;
3097
3098    fn next(&mut self) -> Option<Token> {
3099        if self.pending.is_empty() {
3100            if self.eof_returned {
3101                return None;
3102            }
3103            self.run_until_token();
3104        }
3105        let token = self.pending.pop_front()?;
3106        if matches!(token.kind, TokenKind::Eof) {
3107            self.eof_returned = true;
3108        }
3109        Some(token)
3110    }
3111}
3112
3113#[cfg(test)]
3114mod tests {
3115    // Phase 02 tokenizer tests: the subset of plan/02-tokenizer.md's test
3116    // matrix covered by the states implemented so far (tags, attributes,
3117    // plain text). Character references, comments, DOCTYPE, CDATA, and
3118    // RCDATA/RAWTEXT/script-data/PLAINTEXT switching are not covered yet.
3119    use super::*;
3120
3121    fn tokenize(input: &str) -> Vec<Token> {
3122        Tokenizer::new(input).collect()
3123    }
3124
3125    fn kinds(tokens: &[Token]) -> Vec<&TokenKind> {
3126        tokens.iter().map(|token| &token.kind).collect()
3127    }
3128
3129    fn pos(line: u32, column: u32, byte_offset: usize) -> Position {
3130        Position {
3131            line,
3132            column,
3133            byte_offset,
3134        }
3135    }
3136
3137    /// Runs `input` to completion and returns every [`ParseErrorKind`]
3138    /// recorded, in encounter order. Phase 07 (`plan/07-parse-errors.md`)
3139    /// helper — mirrors [`tokenize`] above but for errors instead of
3140    /// tokens.
3141    fn errors_for(input: &str) -> Vec<ParseErrorKind> {
3142        let mut tokenizer = Tokenizer::new(input);
3143        for _ in tokenizer.by_ref() {}
3144        tokenizer
3145            .take_errors()
3146            .into_iter()
3147            .map(|error| error.kind)
3148            .collect()
3149    }
3150
3151    #[test]
3152    fn plain_text_emits_one_character_token_per_char_then_eof() {
3153        let tokens = tokenize("hi");
3154        assert_eq!(
3155            tokens,
3156            vec![
3157                Token {
3158                    kind: TokenKind::Character('h'),
3159                    position: pos(1, 1, 0),
3160                },
3161                Token {
3162                    kind: TokenKind::Character('i'),
3163                    position: pos(1, 2, 1),
3164                },
3165                Token {
3166                    kind: TokenKind::Eof,
3167                    position: pos(1, 3, 2),
3168                },
3169            ]
3170        );
3171    }
3172
3173    #[test]
3174    fn simple_start_tag_with_no_attributes() {
3175        let tokens = tokenize("<p>");
3176        assert_eq!(
3177            tokens,
3178            vec![
3179                Token {
3180                    kind: TokenKind::StartTag(TagToken {
3181                        name: "p".to_owned(),
3182                        self_closing: false,
3183                        attributes: vec![],
3184                    }),
3185                    position: pos(1, 1, 0),
3186                },
3187                Token {
3188                    kind: TokenKind::Eof,
3189                    position: pos(1, 4, 3),
3190                },
3191            ]
3192        );
3193    }
3194
3195    #[test]
3196    fn end_tag() {
3197        let tokens = tokenize("</p>");
3198        match &tokens[0].kind {
3199            TokenKind::EndTag(tag) => assert_eq!(tag.name, "p"),
3200            other => panic!("expected end tag, got {other:?}"),
3201        }
3202    }
3203
3204    #[test]
3205    fn self_closing_tag() {
3206        let tokens = tokenize("<br/>");
3207        match &tokens[0].kind {
3208            TokenKind::StartTag(tag) => {
3209                assert_eq!(tag.name, "br");
3210                assert!(tag.self_closing);
3211            }
3212            other => panic!("expected start tag, got {other:?}"),
3213        }
3214    }
3215
3216    #[test]
3217    fn attribute_value_quoting_forms() {
3218        for html in ["<a href=\"x\">", "<a href='x'>", "<a href=x>"] {
3219            let tokens = tokenize(html);
3220            match &tokens[0].kind {
3221                TokenKind::StartTag(tag) => assert_eq!(
3222                    tag.attributes,
3223                    vec![Attribute {
3224                        name: "href".to_owned(),
3225                        value: "x".to_owned(),
3226                    }],
3227                    "input: {html:?}",
3228                ),
3229                other => panic!("expected start tag for {html:?}, got {other:?}"),
3230            }
3231        }
3232    }
3233
3234    #[test]
3235    fn multiple_attributes_separated_by_whitespace() {
3236        let tokens = tokenize(r#"<a href="x" target="y">"#);
3237        match &tokens[0].kind {
3238            TokenKind::StartTag(tag) => assert_eq!(
3239                tag.attributes,
3240                vec![
3241                    Attribute {
3242                        name: "href".to_owned(),
3243                        value: "x".to_owned(),
3244                    },
3245                    Attribute {
3246                        name: "target".to_owned(),
3247                        value: "y".to_owned(),
3248                    },
3249                ]
3250            ),
3251            other => panic!("expected start tag, got {other:?}"),
3252        }
3253    }
3254
3255    #[test]
3256    fn duplicate_attribute_name_keeps_first_occurrence_only() {
3257        let tokens = tokenize(r#"<a href="x" href="y">"#);
3258        match &tokens[0].kind {
3259            TokenKind::StartTag(tag) => assert_eq!(
3260                tag.attributes,
3261                vec![Attribute {
3262                    name: "href".to_owned(),
3263                    value: "x".to_owned(),
3264                }]
3265            ),
3266            other => panic!("expected start tag, got {other:?}"),
3267        }
3268    }
3269
3270    #[test]
3271    fn tag_and_attribute_names_are_ascii_lowercased() {
3272        let tokens = tokenize(r#"<DIV CLASS="a">"#);
3273        match &tokens[0].kind {
3274            TokenKind::StartTag(tag) => {
3275                assert_eq!(tag.name, "div");
3276                assert_eq!(tag.attributes[0].name, "class");
3277            }
3278            other => panic!("expected start tag, got {other:?}"),
3279        }
3280    }
3281
3282    #[test]
3283    fn boolean_attribute_with_no_value() {
3284        let tokens = tokenize("<input disabled>");
3285        match &tokens[0].kind {
3286            TokenKind::StartTag(tag) => assert_eq!(
3287                tag.attributes,
3288                vec![Attribute {
3289                    name: "disabled".to_owned(),
3290                    value: String::new(),
3291                }]
3292            ),
3293            other => panic!("expected start tag, got {other:?}"),
3294        }
3295    }
3296
3297    #[test]
3298    fn null_character_in_tag_name_is_replaced_with_u_fffd() {
3299        let tokens = tokenize("<a\u{0}b>");
3300        match &tokens[0].kind {
3301            TokenKind::StartTag(tag) => assert_eq!(tag.name, "a\u{FFFD}b"),
3302            other => panic!("expected start tag, got {other:?}"),
3303        }
3304    }
3305
3306    #[test]
3307    fn null_character_in_data_state_is_emitted_literally_not_replaced() {
3308        let tokens = tokenize("\u{0}");
3309        assert_eq!(tokens[0].kind, TokenKind::Character('\u{0}'));
3310    }
3311
3312    #[test]
3313    fn eof_inside_a_tag_emits_only_eof_no_tag_token() {
3314        let tokens = tokenize("<div");
3315        assert_eq!(
3316            tokens,
3317            vec![Token {
3318                kind: TokenKind::Eof,
3319                position: pos(1, 5, 4),
3320            }]
3321        );
3322    }
3323
3324    #[test]
3325    fn eof_right_after_solidus_emits_synthesized_lt_and_solidus_then_eof() {
3326        let tokens = tokenize("</");
3327        assert_eq!(
3328            tokens,
3329            vec![
3330                Token {
3331                    kind: TokenKind::Character('<'),
3332                    position: pos(1, 1, 0),
3333                },
3334                Token {
3335                    kind: TokenKind::Character('/'),
3336                    position: pos(1, 2, 1),
3337                },
3338                Token {
3339                    kind: TokenKind::Eof,
3340                    position: pos(1, 3, 2),
3341                },
3342            ]
3343        );
3344    }
3345
3346    #[test]
3347    fn position_tracking_across_a_newline() {
3348        let tokens = tokenize("a\nb");
3349        assert_eq!(tokens[0].position, pos(1, 1, 0));
3350        assert_eq!(tokens[1].position, pos(1, 2, 1)); // the '\n' character token itself
3351        assert_eq!(tokens[2].position, pos(2, 1, 2));
3352    }
3353
3354    #[test]
3355    fn crlf_and_lone_cr_normalize_to_a_single_lf_character_token() {
3356        let crlf = tokenize("a\r\nb");
3357        assert_eq!(
3358            kinds(&crlf),
3359            vec![
3360                &TokenKind::Character('a'),
3361                &TokenKind::Character('\n'),
3362                &TokenKind::Character('b'),
3363                &TokenKind::Eof,
3364            ]
3365        );
3366        let lone_cr = tokenize("a\rb");
3367        assert_eq!(
3368            kinds(&lone_cr),
3369            vec![
3370                &TokenKind::Character('a'),
3371                &TokenKind::Character('\n'),
3372                &TokenKind::Character('b'),
3373                &TokenKind::Eof,
3374            ]
3375        );
3376    }
3377
3378    #[test]
3379    fn named_character_reference_with_semicolon() {
3380        assert_eq!(
3381            kinds(&tokenize("&amp;")),
3382            vec![&TokenKind::Character('&'), &TokenKind::Eof]
3383        );
3384    }
3385
3386    #[test]
3387    fn named_character_reference_multi_codepoint() {
3388        // NotEqualTilde; -> U+2242 U+0338, a two-codepoint replacement.
3389        assert_eq!(
3390            kinds(&tokenize("&NotEqualTilde;")),
3391            vec![
3392                &TokenKind::Character('\u{2242}'),
3393                &TokenKind::Character('\u{338}'),
3394                &TokenKind::Eof,
3395            ]
3396        );
3397    }
3398
3399    #[test]
3400    fn legacy_named_character_reference_without_semicolon_still_resolves_outside_attributes() {
3401        // "amp" (no ';') is one of the 106 legacy entity names; outside an
3402        // attribute, it still resolves (with a missing-semicolon parse
3403        // error we don't track), unlike an unknown name.
3404        assert_eq!(
3405            kinds(&tokenize("&amp b")),
3406            vec![
3407                &TokenKind::Character('&'),
3408                &TokenKind::Character(' '),
3409                &TokenKind::Character('b'),
3410                &TokenKind::Eof,
3411            ]
3412        );
3413    }
3414
3415    #[test]
3416    fn unknown_named_character_reference_falls_back_to_ambiguous_ampersand() {
3417        // No entity name starts with a digit, so this can never even
3418        // start a viable match: the whole "&1" is flushed literally, then
3419        // ';' is reconsumed under the Data state as its own token.
3420        assert_eq!(
3421            kinds(&tokenize("&1;")),
3422            vec![
3423                &TokenKind::Character('&'),
3424                &TokenKind::Character('1'),
3425                &TokenKind::Character(';'),
3426                &TokenKind::Eof,
3427            ]
3428        );
3429    }
3430
3431    #[test]
3432    fn named_character_reference_historical_fallback_in_unquoted_attribute_value() {
3433        // "not" is a legacy (no-';') entity; here it's immediately
3434        // followed by 'i' (ASCII alphanumeric) inside an unquoted
3435        // attribute value, so — for historical reasons — it is *not*
3436        // resolved: the literal source text is kept instead.
3437        let tokens = tokenize("<a href=&notit=1>");
3438        match &tokens[0].kind {
3439            TokenKind::StartTag(tag) => assert_eq!(
3440                tag.attributes,
3441                vec![Attribute {
3442                    name: "href".to_owned(),
3443                    value: "&notit=1".to_owned(),
3444                }]
3445            ),
3446            other => panic!("expected start tag, got {other:?}"),
3447        }
3448    }
3449
3450    #[test]
3451    fn named_character_reference_historical_fallback_applies_in_double_quoted_attributes_too() {
3452        // The "historical reasons" fallback ("consumed as part of an
3453        // attribute") applies to *any* attribute value state, not just
3454        // unquoted — this is what keeps query strings like
3455        // `href="?a=1&copy=2"` from corrupting into "?a=1©=2" inside
3456        // quoted attributes too.
3457        let tokens = tokenize(r#"<a href="&notit">"#);
3458        match &tokens[0].kind {
3459            TokenKind::StartTag(tag) => assert_eq!(
3460                tag.attributes,
3461                vec![Attribute {
3462                    name: "href".to_owned(),
3463                    value: "&notit".to_owned(),
3464                }]
3465            ),
3466            other => panic!("expected start tag, got {other:?}"),
3467        }
3468    }
3469
3470    #[test]
3471    fn named_character_reference_ending_in_semicolon_resolves_normally_even_in_an_attribute() {
3472        // The historical fallback only ever applies when the match does
3473        // *not* end in ';' — a `;`-terminated match always resolves,
3474        // attribute or not.
3475        let tokens = tokenize(r#"<a href="&copy;">"#);
3476        match &tokens[0].kind {
3477            TokenKind::StartTag(tag) => assert_eq!(
3478                tag.attributes,
3479                vec![Attribute {
3480                    name: "href".to_owned(),
3481                    value: "\u{A9}".to_owned(),
3482                }]
3483            ),
3484            other => panic!("expected start tag, got {other:?}"),
3485        }
3486    }
3487
3488    #[test]
3489    fn decimal_and_hexadecimal_character_references() {
3490        assert_eq!(
3491            kinds(&tokenize("&#65;")),
3492            vec![&TokenKind::Character('A'), &TokenKind::Eof]
3493        );
3494        assert_eq!(
3495            kinds(&tokenize("&#x41;")),
3496            vec![&TokenKind::Character('A'), &TokenKind::Eof]
3497        );
3498        assert_eq!(
3499            kinds(&tokenize("&#X41;")),
3500            vec![&TokenKind::Character('A'), &TokenKind::Eof]
3501        );
3502    }
3503
3504    #[test]
3505    fn numeric_character_reference_missing_semicolon_still_resolves() {
3506        assert_eq!(
3507            kinds(&tokenize("&#65x")),
3508            vec![
3509                &TokenKind::Character('A'),
3510                &TokenKind::Character('x'),
3511                &TokenKind::Eof,
3512            ]
3513        );
3514    }
3515
3516    #[test]
3517    fn numeric_character_reference_null_is_replaced_with_u_fffd() {
3518        assert_eq!(
3519            kinds(&tokenize("&#0;")),
3520            vec![&TokenKind::Character('\u{FFFD}'), &TokenKind::Eof]
3521        );
3522    }
3523
3524    #[test]
3525    fn numeric_character_reference_outside_unicode_range_is_replaced_with_u_fffd() {
3526        assert_eq!(
3527            kinds(&tokenize("&#x110000;")),
3528            vec![&TokenKind::Character('\u{FFFD}'), &TokenKind::Eof]
3529        );
3530    }
3531
3532    #[test]
3533    fn numeric_character_reference_surrogate_is_replaced_with_u_fffd() {
3534        assert_eq!(
3535            kinds(&tokenize("&#xD800;")),
3536            vec![&TokenKind::Character('\u{FFFD}'), &TokenKind::Eof]
3537        );
3538    }
3539
3540    #[test]
3541    fn numeric_character_reference_windows_1252_control_override() {
3542        // 0x80 is remapped to U+20AC EURO SIGN, per the spec's
3543        // control-character-reference override table.
3544        assert_eq!(
3545            kinds(&tokenize("&#128;")),
3546            vec![&TokenKind::Character('\u{20AC}'), &TokenKind::Eof]
3547        );
3548    }
3549
3550    #[test]
3551    fn numeric_character_reference_unmapped_c1_control_is_left_unchanged() {
3552        // 0x81 is a control character but has no row in the override
3553        // table, so it passes through unchanged (still a parse error,
3554        // which we don't track).
3555        assert_eq!(
3556            kinds(&tokenize("&#129;")),
3557            vec![&TokenKind::Character('\u{81}'), &TokenKind::Eof]
3558        );
3559    }
3560
3561    #[test]
3562    fn absence_of_digits_in_numeric_character_reference_falls_back_to_literal_text() {
3563        assert_eq!(
3564            kinds(&tokenize("&#;")),
3565            vec![
3566                &TokenKind::Character('&'),
3567                &TokenKind::Character('#'),
3568                &TokenKind::Character(';'),
3569                &TokenKind::Eof,
3570            ]
3571        );
3572        assert_eq!(
3573            kinds(&tokenize("&#x;")),
3574            vec![
3575                &TokenKind::Character('&'),
3576                &TokenKind::Character('#'),
3577                &TokenKind::Character('x'),
3578                &TokenKind::Character(';'),
3579                &TokenKind::Eof,
3580            ]
3581        );
3582    }
3583
3584    #[test]
3585    fn lone_ampersand_at_eof_is_emitted_literally() {
3586        assert_eq!(
3587            kinds(&tokenize("&")),
3588            vec![&TokenKind::Character('&'), &TokenKind::Eof]
3589        );
3590    }
3591
3592    #[test]
3593    fn simple_comment() {
3594        assert_eq!(
3595            kinds(&tokenize("<!-- hi -->")),
3596            vec![&TokenKind::Comment(" hi ".to_owned()), &TokenKind::Eof]
3597        );
3598    }
3599
3600    #[test]
3601    fn abrupt_closing_of_empty_comment_still_emits_an_empty_comment_token() {
3602        assert_eq!(
3603            kinds(&tokenize("<!-->")),
3604            vec![&TokenKind::Comment(String::new()), &TokenKind::Eof]
3605        );
3606    }
3607
3608    #[test]
3609    fn comment_containing_a_lone_hyphen() {
3610        assert_eq!(
3611            kinds(&tokenize("<!-- a - b -->")),
3612            vec![&TokenKind::Comment(" a - b ".to_owned()), &TokenKind::Eof]
3613        );
3614    }
3615
3616    #[test]
3617    fn eof_inside_a_comment_still_emits_the_comment_token() {
3618        assert_eq!(
3619            kinds(&tokenize("<!-- unterminated")),
3620            vec![
3621                &TokenKind::Comment(" unterminated".to_owned()),
3622                &TokenKind::Eof,
3623            ]
3624        );
3625    }
3626
3627    #[test]
3628    fn nested_comment_open_sequence_is_absorbed_as_data_not_a_real_nested_comment() {
3629        // HTML comments don't nest; "<!--" appearing again inside a
3630        // comment is just more comment data, ending at the first real
3631        // "-->" — per the comment-less-than-sign-bang-dash-dash state's
3632        // "reconsume in comment end state" behavior.
3633        assert_eq!(
3634            kinds(&tokenize("<!--<!-->")),
3635            vec![&TokenKind::Comment("<!".to_owned()), &TokenKind::Eof]
3636        );
3637    }
3638
3639    #[test]
3640    fn incorrectly_opened_comment_falls_back_to_bogus_comment() {
3641        // "<!" not followed by "--", "DOCTYPE", or "[CDATA[" is a bogus
3642        // comment whose data is everything up to the next '>'.
3643        assert_eq!(
3644            kinds(&tokenize("<!weird>")),
3645            vec![&TokenKind::Comment("weird".to_owned()), &TokenKind::Eof]
3646        );
3647    }
3648
3649    #[test]
3650    fn end_tag_with_invalid_first_character_falls_back_to_bogus_comment() {
3651        // "</1>": '1' can't start a tag name, so this becomes a bogus
3652        // comment whose data is "1" (the reconsumed invalid character).
3653        assert_eq!(
3654            kinds(&tokenize("</1>")),
3655            vec![&TokenKind::Comment("1".to_owned()), &TokenKind::Eof]
3656        );
3657    }
3658
3659    #[test]
3660    fn cdata_outside_foreign_content_becomes_a_bogus_comment() {
3661        // No tree-construction "adjusted current node" exists at this
3662        // tokenizer-only layer, so "<![CDATA[...]]>" can never take the
3663        // real CDATA-section branch — it's always a bogus comment whose
3664        // data starts with the literal "[CDATA[".
3665        assert_eq!(
3666            kinds(&tokenize("<![CDATA[x]]>")),
3667            vec![
3668                &TokenKind::Comment("[CDATA[x]]".to_owned()),
3669                &TokenKind::Eof,
3670            ]
3671        );
3672    }
3673
3674    fn expect_doctype(tokens: &[Token]) -> &DoctypeToken {
3675        match &tokens[0].kind {
3676            TokenKind::Doctype(doctype) => doctype,
3677            other => panic!("expected DOCTYPE token, got {other:?}"),
3678        }
3679    }
3680
3681    #[test]
3682    fn simple_doctype() {
3683        let tokens = tokenize("<!DOCTYPE html>");
3684        assert_eq!(
3685            expect_doctype(&tokens),
3686            &DoctypeToken {
3687                name: Some("html".to_owned()),
3688                ..Default::default()
3689            }
3690        );
3691        assert_eq!(kinds(&tokens)[1], &TokenKind::Eof);
3692    }
3693
3694    #[test]
3695    fn doctype_keyword_and_name_are_ascii_case_insensitive_lowercased() {
3696        let tokens = tokenize("<!doctype HTML>");
3697        assert_eq!(
3698            expect_doctype(&tokens),
3699            &DoctypeToken {
3700                name: Some("html".to_owned()),
3701                ..Default::default()
3702            }
3703        );
3704    }
3705
3706    #[test]
3707    fn doctype_with_no_name_sets_force_quirks() {
3708        let tokens = tokenize("<!DOCTYPE>");
3709        assert_eq!(
3710            expect_doctype(&tokens),
3711            &DoctypeToken {
3712                force_quirks: true,
3713                ..Default::default()
3714            }
3715        );
3716    }
3717
3718    #[test]
3719    fn doctype_with_public_and_system_identifiers() {
3720        let tokens = tokenize(
3721            r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#,
3722        );
3723        assert_eq!(
3724            expect_doctype(&tokens),
3725            &DoctypeToken {
3726                name: Some("html".to_owned()),
3727                public_identifier: Some("-//W3C//DTD HTML 4.01//EN".to_owned()),
3728                system_identifier: Some("http://www.w3.org/TR/html4/strict.dtd".to_owned()),
3729                force_quirks: false,
3730            }
3731        );
3732    }
3733
3734    #[test]
3735    fn eof_inside_doctype_name_sets_force_quirks_but_keeps_the_partial_name() {
3736        let tokens = tokenize("<!DOCTYPE html");
3737        assert_eq!(
3738            tokens,
3739            vec![
3740                Token {
3741                    kind: TokenKind::Doctype(DoctypeToken {
3742                        name: Some("html".to_owned()),
3743                        force_quirks: true,
3744                        ..Default::default()
3745                    }),
3746                    position: pos(1, 1, 0),
3747                },
3748                Token {
3749                    kind: TokenKind::Eof,
3750                    position: pos(1, 15, 14),
3751                },
3752            ]
3753        );
3754    }
3755
3756    #[test]
3757    fn unrecognized_text_after_doctype_name_falls_back_to_bogus_doctype() {
3758        // Neither "PUBLIC" nor "SYSTEM" — force_quirks is set and
3759        // everything up to '>' is ignored (not appended anywhere).
3760        let tokens = tokenize("<!DOCTYPE html GARBAGE>");
3761        assert_eq!(
3762            expect_doctype(&tokens),
3763            &DoctypeToken {
3764                name: Some("html".to_owned()),
3765                force_quirks: true,
3766                ..Default::default()
3767            }
3768        );
3769    }
3770
3771    #[test]
3772    fn simple_processing_instruction() {
3773        let tokens = tokenize("<?foo bar?>");
3774        assert_eq!(
3775            kinds(&tokens),
3776            vec![
3777                &TokenKind::ProcessingInstruction(ProcessingInstructionToken {
3778                    target: "foo".to_owned(),
3779                    data: "bar".to_owned(),
3780                }),
3781                &TokenKind::Eof,
3782            ]
3783        );
3784    }
3785
3786    #[test]
3787    fn xml_target_is_disallowed_and_becomes_a_bogus_comment() {
3788        let tokens = tokenize(r#"<?xml version="1.0"?>"#);
3789        assert_eq!(
3790            kinds(&tokens),
3791            vec![
3792                &TokenKind::Comment("?xml version=\"1.0\"?".to_owned()),
3793                &TokenKind::Eof,
3794            ]
3795        );
3796    }
3797
3798    #[test]
3799    fn xml_stylesheet_target_is_disallowed_and_becomes_a_bogus_comment() {
3800        let tokens = tokenize("<?xml-stylesheet foo?>");
3801        assert_eq!(
3802            kinds(&tokens),
3803            vec![
3804                &TokenKind::Comment("?xml-stylesheet foo?".to_owned()),
3805                &TokenKind::Eof,
3806            ]
3807        );
3808    }
3809
3810    #[test]
3811    fn eof_right_after_question_mark_emits_only_eof_no_token_at_all() {
3812        // Not even a bogus comment: the temporary buffer is still empty at
3813        // this point, and processing-instruction-open-state's EOF branch
3814        // only ever emits an end-of-file token.
3815        assert_eq!(kinds(&tokenize("<?")), vec![&TokenKind::Eof]);
3816    }
3817
3818    #[test]
3819    fn eof_inside_processing_instruction_data_discards_the_pi_token() {
3820        // Unlike DOCTYPE/comment EOF handling, the spec's processing-
3821        // instruction states never emit their in-progress token on EOF.
3822        assert_eq!(kinds(&tokenize("<?foo bar")), vec![&TokenKind::Eof]);
3823    }
3824
3825    #[test]
3826    fn lone_question_marks_inside_processing_instruction_data_are_kept_literally() {
3827        let tokens = tokenize("<?foo a?b?>");
3828        assert_eq!(
3829            kinds(&tokens),
3830            vec![
3831                &TokenKind::ProcessingInstruction(ProcessingInstructionToken {
3832                    target: "foo".to_owned(),
3833                    data: "a?b".to_owned(),
3834                }),
3835                &TokenKind::Eof,
3836            ]
3837        );
3838    }
3839
3840    /// Simulates the tree-builder: tokenizes normally, but the instant a
3841    /// start tag comes out, immediately calls `switch_to(state)` — same
3842    /// sequencing Phase 03 will actually use ("insert the element, then
3843    /// switch the tokenizer").
3844    fn tokenize_switching_after_start_tag(input: &str, state: ExternalState) -> Vec<Token> {
3845        let mut tokenizer = Tokenizer::new(input);
3846        let mut tokens = Vec::new();
3847        while let Some(token) = tokenizer.next() {
3848            let is_start = matches!(&token.kind, TokenKind::StartTag(_));
3849            tokens.push(token);
3850            if is_start {
3851                tokenizer.switch_to(state);
3852            }
3853        }
3854        tokens
3855    }
3856
3857    fn characters_only(tokens: &[Token]) -> String {
3858        tokens
3859            .iter()
3860            .filter_map(|token| match &token.kind {
3861                TokenKind::Character(c) => Some(*c),
3862                _ => None,
3863            })
3864            .collect()
3865    }
3866
3867    #[test]
3868    fn rcdata_resolves_character_references_and_ends_on_matching_end_tag() {
3869        let tokens =
3870            tokenize_switching_after_start_tag("<title>AT&amp;T</title>", ExternalState::RcData);
3871        assert_eq!(
3872            kinds(&tokens),
3873            vec![
3874                &TokenKind::StartTag(TagToken {
3875                    name: "title".to_owned(),
3876                    self_closing: false,
3877                    attributes: vec![],
3878                }),
3879                &TokenKind::Character('A'),
3880                &TokenKind::Character('T'),
3881                &TokenKind::Character('&'),
3882                &TokenKind::Character('T'),
3883                &TokenKind::EndTag(TagToken {
3884                    name: "title".to_owned(),
3885                    self_closing: false,
3886                    attributes: vec![],
3887                }),
3888                &TokenKind::Eof,
3889            ]
3890        );
3891    }
3892
3893    #[test]
3894    fn rcdata_end_tag_with_wrong_name_is_kept_as_literal_text() {
3895        // "</b>" doesn't match the open "title" element, so it's not an
3896        // appropriate end tag token — RCDATA keeps consuming as text.
3897        let tokens =
3898            tokenize_switching_after_start_tag("<title>a</b>c</title>", ExternalState::RcData);
3899        assert_eq!(
3900            characters_only(&tokens[1..tokens.len() - 2]),
3901            "a</b>c".to_owned()
3902        );
3903        match &tokens.last().unwrap().kind {
3904            TokenKind::Eof => {}
3905            other => panic!("expected trailing Eof, got {other:?}"),
3906        }
3907        match &tokens[tokens.len() - 2].kind {
3908            TokenKind::EndTag(tag) => assert_eq!(tag.name, "title"),
3909            other => panic!("expected closing end tag, got {other:?}"),
3910        }
3911    }
3912
3913    #[test]
3914    fn rawtext_does_not_resolve_character_references() {
3915        let tokens =
3916            tokenize_switching_after_start_tag("<style>&amp;</style>", ExternalState::RawText);
3917        assert_eq!(characters_only(&tokens), "&amp;".to_owned());
3918    }
3919
3920    #[test]
3921    fn plaintext_never_recognizes_any_end_tag_ever_again() {
3922        // PLAINTEXT has no '<' branch at all in the spec — everything,
3923        // including what looks like a closing tag, is literal text.
3924        let tokens = tokenize_switching_after_start_tag(
3925            "<plaintext>a</plaintext>b",
3926            ExternalState::PlainText,
3927        );
3928        assert_eq!(characters_only(&tokens), "a</plaintext>b".to_owned());
3929        match &tokens.last().unwrap().kind {
3930            TokenKind::Eof => {}
3931            other => panic!("expected trailing Eof, got {other:?}"),
3932        }
3933    }
3934
3935    #[test]
3936    fn appropriate_end_tag_requires_a_start_tag_to_have_been_emitted_first() {
3937        // Switching straight into RcData without ever having tokenized a
3938        // start tag: no name to compare against, so nothing can ever be
3939        // an appropriate end tag token, per spec.
3940        let mut tokenizer = Tokenizer::new("</title>");
3941        tokenizer.switch_to(ExternalState::RcData);
3942        let tokens: Vec<_> = tokenizer.collect();
3943        assert_eq!(characters_only(&tokens), "</title>".to_owned());
3944        assert_eq!(tokens.last().unwrap().kind, TokenKind::Eof);
3945    }
3946
3947    #[test]
3948    fn script_data_plain_content_no_html_comment_like_wrapper() {
3949        let tokens = tokenize_switching_after_start_tag(
3950            "<script>var x = 1;</script>",
3951            ExternalState::ScriptData,
3952        );
3953        assert_eq!(characters_only(&tokens), "var x = 1;".to_owned());
3954        match &tokens[tokens.len() - 2].kind {
3955            TokenKind::EndTag(tag) => assert_eq!(tag.name, "script"),
3956            other => panic!("expected closing end tag, got {other:?}"),
3957        }
3958    }
3959
3960    #[test]
3961    fn script_data_does_not_resolve_character_references_either() {
3962        // Same non-processing as RAWTEXT — script data has no '&' branch.
3963        let tokens =
3964            tokenize_switching_after_start_tag("<script>&amp;</script>", ExternalState::ScriptData);
3965        assert_eq!(characters_only(&tokens), "&amp;".to_owned());
3966    }
3967
3968    #[test]
3969    fn script_data_html_comment_like_wrapper_round_trips_literally() {
3970        // The whole point of the escaped-state dance: it changes what the
3971        // tokenizer *tracks* internally, never what characters come out.
3972        let source = "<!--alert(1);-->";
3973        let tokens = tokenize_switching_after_start_tag(
3974            &format!("<script>{source}</script>"),
3975            ExternalState::ScriptData,
3976        );
3977        assert_eq!(characters_only(&tokens), source.to_owned());
3978        match &tokens[tokens.len() - 2].kind {
3979            TokenKind::EndTag(tag) => assert_eq!(tag.name, "script"),
3980            other => panic!("expected closing end tag, got {other:?}"),
3981        }
3982    }
3983
3984    #[test]
3985    fn nested_script_tags_inside_html_comment_like_wrapper_do_not_end_the_element_early() {
3986        // The classic script-data torture case (double escaping):
3987        // "<script>" appearing inside the "<!--...-->"-wrapped content
3988        // switches into "double escaped" mode so that the matching
3989        // "</script>" *inside the comment* doesn't close the real
3990        // element — only the final, real "</script>" does. Character
3991        // output is still the literal source either way.
3992        let source = "<!--<script>x</script>-->";
3993        let tokens = tokenize_switching_after_start_tag(
3994            &format!("<script>{source}</script>"),
3995            ExternalState::ScriptData,
3996        );
3997        assert_eq!(characters_only(&tokens), source.to_owned());
3998        let end_tags: Vec<_> = tokens
3999            .iter()
4000            .filter(|token| matches!(&token.kind, TokenKind::EndTag(_)))
4001            .collect();
4002        assert_eq!(
4003            end_tags.len(),
4004            1,
4005            "only the real closing tag should be an end tag token, not the one nested inside the comment-like wrapper"
4006        );
4007    }
4008
4009    #[test]
4010    fn script_data_end_tag_with_wrong_name_is_kept_as_literal_text() {
4011        let tokens = tokenize_switching_after_start_tag(
4012            "<script>a</scriptx>b</script>",
4013            ExternalState::ScriptData,
4014        );
4015        assert_eq!(characters_only(&tokens), "a</scriptx>b".to_owned());
4016        let end_tags: Vec<_> = tokens
4017            .iter()
4018            .filter_map(|token| match &token.kind {
4019                TokenKind::EndTag(tag) => Some(tag.name.as_str()),
4020                _ => None,
4021            })
4022            .collect();
4023        assert_eq!(end_tags, vec!["script"]);
4024    }
4025
4026    fn tokenize_in_foreign_content(input: &str) -> Vec<Token> {
4027        let mut tokenizer = Tokenizer::new(input);
4028        tokenizer.set_in_foreign_content(true);
4029        tokenizer.collect()
4030    }
4031
4032    #[test]
4033    fn cdata_section_outside_foreign_content_still_becomes_a_bogus_comment() {
4034        // Regression check: the new `in_foreign_content` field defaults to
4035        // `false`, so behavior for plain HTML (no Phase 03 tree-builder
4036        // ever calling `set_in_foreign_content`) is unchanged from before
4037        // this feature existed.
4038        assert_eq!(
4039            kinds(&tokenize("<![CDATA[x]]>")),
4040            vec![
4041                &TokenKind::Comment("[CDATA[x]]".to_owned()),
4042                &TokenKind::Eof,
4043            ]
4044        );
4045    }
4046
4047    #[test]
4048    fn cdata_section_in_foreign_content_yields_character_tokens() {
4049        assert_eq!(
4050            kinds(&tokenize_in_foreign_content("<![CDATA[hi]]>")),
4051            vec![
4052                &TokenKind::Character('h'),
4053                &TokenKind::Character('i'),
4054                &TokenKind::Eof,
4055            ]
4056        );
4057    }
4058
4059    #[test]
4060    fn cdata_section_null_character_is_kept_literal_not_replaced() {
4061        // Unlike RCDATA/RAWTEXT/script-data, CDATA-section-state does not
4062        // replace NUL — the spec says that's handled later, in
4063        // tree-construction's "in foreign content" rules.
4064        assert_eq!(
4065            kinds(&tokenize_in_foreign_content("<![CDATA[\u{0}]]>")),
4066            vec![&TokenKind::Character('\u{0}'), &TokenKind::Eof]
4067        );
4068    }
4069
4070    #[test]
4071    fn cdata_section_single_bracket_not_followed_by_another_is_literal_content() {
4072        let tokens = tokenize_in_foreign_content("<![CDATA[a]b]]>");
4073        assert_eq!(
4074            kinds(&tokens),
4075            vec![
4076                &TokenKind::Character('a'),
4077                &TokenKind::Character(']'),
4078                &TokenKind::Character('b'),
4079                &TokenKind::Eof,
4080            ]
4081        );
4082    }
4083
4084    #[test]
4085    fn cdata_section_three_consecutive_brackets_then_close_keeps_only_the_first_as_content() {
4086        // "]]]>": only the *last* two `]`s immediately before `>` can be
4087        // part of the `]]>` terminator, so the first `]` is confirmed
4088        // content — with its own (earlier) position, not the position of
4089        // whatever character triggered the flush.
4090        let tokens = tokenize_in_foreign_content("<![CDATA[]]]>");
4091        assert_eq!(
4092            tokens,
4093            vec![
4094                Token {
4095                    kind: TokenKind::Character(']'),
4096                    position: pos(1, 10, 9),
4097                },
4098                Token {
4099                    kind: TokenKind::Eof,
4100                    position: pos(1, 14, 13),
4101                },
4102            ]
4103        );
4104    }
4105
4106    #[test]
4107    fn cdata_section_four_consecutive_brackets_then_close_keeps_first_two_as_content() {
4108        let tokens = tokenize_in_foreign_content("<![CDATA[]]]]>");
4109        assert_eq!(
4110            kinds(&tokens),
4111            vec![
4112                &TokenKind::Character(']'),
4113                &TokenKind::Character(']'),
4114                &TokenKind::Eof,
4115            ]
4116        );
4117    }
4118
4119    #[test]
4120    fn eof_inside_cdata_section_emits_eof_after_any_withheld_brackets_flush() {
4121        let tokens = tokenize_in_foreign_content("<![CDATA[a]");
4122        assert_eq!(
4123            kinds(&tokens),
4124            vec![
4125                &TokenKind::Character('a'),
4126                &TokenKind::Character(']'),
4127                &TokenKind::Eof,
4128            ]
4129        );
4130    }
4131
4132    // Phase 07 parse-error tests (`plan/07-parse-errors.md`): one minimal
4133    // triggering input per implemented `ParseErrorKind` — not per call
4134    // site (several kinds fire from more than one state; one
4135    // representative site is enough to confirm the kind itself is wired
4136    // correctly). `EofInCdata`/`EofInScriptHtmlCommentLikeText` need
4137    // their own setup (foreign content / external state switching) and
4138    // get their own tests below the table.
4139    #[test]
4140    fn tokenizer_level_parse_errors_fire_with_the_right_kind() {
4141        let cases: &[(&str, ParseErrorKind)] = &[
4142            ("<![CDATA[x]]>", ParseErrorKind::CdataInHtmlContent),
4143            ("<!x>", ParseErrorKind::IncorrectlyOpenedComment),
4144            ("<!-->", ParseErrorKind::AbruptClosingOfEmptyComment),
4145            ("<!--<!--x-->", ParseErrorKind::NestedComment),
4146            ("<!--x--!>", ParseErrorKind::IncorrectlyClosedComment),
4147            ("<!--", ParseErrorKind::EofInComment),
4148            ("<1>", ParseErrorKind::InvalidFirstCharacterOfTagName),
4149            ("<", ParseErrorKind::EofBeforeTagName),
4150            ("</>", ParseErrorKind::MissingEndTagName),
4151            ("<p x=", ParseErrorKind::EofInTag),
4152            (r#"<p id="a" id="b">"#, ParseErrorKind::DuplicateAttribute),
4153            ("\0", ParseErrorKind::UnexpectedNullCharacter),
4154            (
4155                r#"<p ">"#,
4156                ParseErrorKind::UnexpectedCharacterInAttributeName,
4157            ),
4158            ("<p x=>", ParseErrorKind::MissingAttributeValue),
4159            (
4160                r#"<p x=a"b>"#,
4161                ParseErrorKind::UnexpectedCharacterInUnquotedAttributeValue,
4162            ),
4163            (
4164                r#"<p x="a"y="b">"#,
4165                ParseErrorKind::MissingWhitespaceBetweenAttributes,
4166            ),
4167            ("<p/ x>", ParseErrorKind::UnexpectedSolidusInTag),
4168            (
4169                "<p =>",
4170                ParseErrorKind::UnexpectedEqualsSignBeforeAttributeName,
4171            ),
4172            ("&zzz;", ParseErrorKind::UnknownNamedCharacterReference),
4173            (
4174                "&#;",
4175                ParseErrorKind::AbsenceOfDigitsInNumericCharacterReference,
4176            ),
4177            (
4178                "&#65 ",
4179                ParseErrorKind::MissingSemicolonAfterCharacterReference,
4180            ),
4181            ("&#0;", ParseErrorKind::NullCharacterReference),
4182            (
4183                "&#x110000;",
4184                ParseErrorKind::CharacterReferenceOutsideUnicodeRange,
4185            ),
4186            ("&#xD800;", ParseErrorKind::SurrogateCharacterReference),
4187            ("&#xFFFE;", ParseErrorKind::NoncharacterCharacterReference),
4188            ("&#x01;", ParseErrorKind::ControlCharacterReference),
4189            (
4190                "<!DOCTYPEhtml>",
4191                ParseErrorKind::MissingWhitespaceBeforeDoctypeName,
4192            ),
4193            ("<!DOCTYPE >", ParseErrorKind::MissingDoctypeName),
4194            (
4195                "<!DOCTYPE html foo>",
4196                ParseErrorKind::InvalidCharacterSequenceAfterDoctypeName,
4197            ),
4198            (
4199                r#"<!DOCTYPE html PUBLIC "a""b">"#,
4200                ParseErrorKind::MissingWhitespaceBetweenDoctypePublicAndSystemIdentifiers,
4201            ),
4202            (
4203                r#"<!DOCTYPE html SYSTEM "a"b>"#,
4204                ParseErrorKind::UnexpectedCharacterAfterDoctypeSystemIdentifier,
4205            ),
4206            ("<!DOCTYPE", ParseErrorKind::EofInDoctype),
4207            ("<?", ParseErrorKind::EofInProcessingInstruction),
4208            (
4209                "<? >",
4210                ParseErrorKind::InvalidFirstCharacterOfProcessingInstructionTarget,
4211            ),
4212            ("<?a$>", ParseErrorKind::InvalidProcessingInstructionTarget),
4213            (
4214                "<?xml?>",
4215                ParseErrorKind::DisallowedProcessingInstructionTarget,
4216            ),
4217            (
4218                r#"<!DOCTYPE html PUBLIC "ab>"#,
4219                ParseErrorKind::AbruptDoctypePublicIdentifier,
4220            ),
4221            (
4222                r#"<!DOCTYPE html SYSTEM "ab>"#,
4223                ParseErrorKind::AbruptDoctypeSystemIdentifier,
4224            ),
4225            (
4226                "<!DOCTYPE html PUBLIC>",
4227                ParseErrorKind::MissingDoctypePublicIdentifier,
4228            ),
4229            (
4230                "<!DOCTYPE html SYSTEM>",
4231                ParseErrorKind::MissingDoctypeSystemIdentifier,
4232            ),
4233            (
4234                "<!DOCTYPE html PUBLIC x>",
4235                ParseErrorKind::MissingQuoteBeforeDoctypePublicIdentifier,
4236            ),
4237            (
4238                "<!DOCTYPE html SYSTEM x>",
4239                ParseErrorKind::MissingQuoteBeforeDoctypeSystemIdentifier,
4240            ),
4241            (
4242                r#"<!DOCTYPE html PUBLIC"x">"#,
4243                ParseErrorKind::MissingWhitespaceAfterDoctypePublicKeyword,
4244            ),
4245            (
4246                r#"<!DOCTYPE html SYSTEM"x">"#,
4247                ParseErrorKind::MissingWhitespaceAfterDoctypeSystemKeyword,
4248            ),
4249            ("<p></p a=1>", ParseErrorKind::EndTagWithAttributes),
4250            ("<p></p/>", ParseErrorKind::EndTagWithTrailingSolidus),
4251            ("\u{FFFE}", ParseErrorKind::NoncharacterInInputStream),
4252            ("\u{1}", ParseErrorKind::ControlCharacterInInputStream),
4253        ];
4254        for (input, expected_kind) in cases {
4255            let errors = errors_for(input);
4256            assert!(
4257                errors.contains(expected_kind),
4258                "input {input:?}: expected {expected_kind:?} among {errors:?}"
4259            );
4260        }
4261    }
4262
4263    #[test]
4264    fn eof_in_cdata_fires_in_foreign_content() {
4265        let mut tokenizer = Tokenizer::new("<![CDATA[abc");
4266        tokenizer.set_in_foreign_content(true);
4267        for _ in tokenizer.by_ref() {}
4268        let kinds: Vec<_> = tokenizer
4269            .take_errors()
4270            .into_iter()
4271            .map(|error| error.kind)
4272            .collect();
4273        assert!(kinds.contains(&ParseErrorKind::EofInCdata));
4274    }
4275
4276    #[test]
4277    fn every_parse_error_kind_has_a_non_empty_display() {
4278        // Not exhaustive over every variant (that's what the `match` in
4279        // the `Display` impl itself already guarantees at compile time —
4280        // a missing arm is a compile error, not a silent gap) — just
4281        // confirms the mechanism produces real, non-empty text for a
4282        // sample of kinds actually reachable in this test module.
4283        for kind in [
4284            ParseErrorKind::DuplicateAttribute,
4285            ParseErrorKind::EofInDoctype,
4286            ParseErrorKind::NoncharacterInInputStream,
4287            ParseErrorKind::ControlCharacterInInputStream,
4288        ] {
4289            assert!(!kind.to_string().is_empty());
4290        }
4291    }
4292
4293    #[test]
4294    fn eof_in_script_html_comment_like_text_fires_mid_wrapper() {
4295        let mut tokenizer = Tokenizer::new("<script><!--x");
4296        while let Some(token) = tokenizer.next() {
4297            if matches!(&token.kind, TokenKind::StartTag(_)) {
4298                tokenizer.switch_to(ExternalState::ScriptData);
4299            }
4300        }
4301        let kinds: Vec<_> = tokenizer
4302            .take_errors()
4303            .into_iter()
4304            .map(|error| error.kind)
4305            .collect();
4306        assert!(kinds.contains(&ParseErrorKind::EofInScriptHtmlCommentLikeText));
4307    }
4308}