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