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