1use super::util::{MAX_ENTITY_NAME_LEN, decode_entity};
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct Attribute {
8 pub name: String,
9 pub value: String,
10}
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum Token {
15 Doctype {
16 name: Option<String>,
17 public_id: Option<String>,
18 system_id: Option<String>,
19 force_quirks: bool,
20 },
21 StartTag {
22 name: String,
23 attributes: Vec<Attribute>,
24 self_closing: bool,
25 },
26 EndTag {
27 name: String,
28 },
29 Comment(String),
30 Text(String),
31}
32
33#[derive(Debug, PartialEq, Clone, Copy)]
35pub enum TokenizerState {
36 Data,
37 ScriptData,
38 StyleData,
39 EscapeDecoding,
40 AttributeEscapeDecoding,
41 TagOpen,
42 EndTagOpen,
43 TagName,
44 BeforeAttributeName,
45 AttributeName,
46 AfterAttributeName,
47 BeforeAttributeValue,
48 AttributeValueDoubleQuoted,
49 AttributeValueSingleQuoted,
50 AttributeValueUnquoted,
51 SelfClosingStartTag,
52 CommentStartDash,
53 Comment,
54 CommentEndDash,
55 CommentEnd,
56 BogusComment,
57 Doctype,
58 DoctypeName,
59 BeforeDoctypePublicId,
60 DoctypePublicIdWithSingleQuote,
61 DoctypePublicIdWithDoubleQuote,
62 AfterDoctypePublicId,
63 DoctypeSystemId,
64 BogusDoctype,
65}
66
67impl TokenizerState {
68 fn is_doctype(&self) -> bool {
70 matches!(
71 self,
72 TokenizerState::Doctype
73 | TokenizerState::DoctypeName
74 | TokenizerState::BeforeDoctypePublicId
75 | TokenizerState::DoctypePublicIdWithSingleQuote
76 | TokenizerState::DoctypePublicIdWithDoubleQuote
77 | TokenizerState::AfterDoctypePublicId
78 | TokenizerState::DoctypeSystemId
79 | TokenizerState::BogusDoctype
80 )
81 }
82
83 fn is_comment(&self) -> bool {
85 matches!(
86 self,
87 TokenizerState::Comment
88 | TokenizerState::CommentStartDash
89 | TokenizerState::CommentEndDash
90 | TokenizerState::CommentEnd
91 | TokenizerState::BogusComment
92 )
93 }
94}
95
96pub struct Tokenizer<'a> {
98 input: &'a str,
99 pos: usize,
100 token: Option<Token>,
101 state: TokenizerState,
102 current_token: Option<Token>,
103 current_attribute: Option<Attribute>,
104 buffer: String,
105 escape_return_state: TokenizerState,
106}
107
108impl<'a> Tokenizer<'a> {
109 pub fn new(input: &'a str) -> Self {
111 Self {
112 input,
113 pos: 0,
114 token: None,
115 state: TokenizerState::Data,
116 current_token: None,
117 current_attribute: None,
118 buffer: String::new(),
119 escape_return_state: TokenizerState::Data,
120 }
121 }
122
123 fn next_char(&mut self) -> Option<char> {
125 if self.pos >= self.input.len() {
126 None
127 } else {
128 let c = self.input[self.pos..].chars().next().unwrap();
129 self.pos += c.len_utf8();
130 Some(c)
131 }
132 }
133
134 fn commit_token(&mut self) {
136 self.token = self.current_token.take();
137 self.buffer.clear();
138 }
139
140 fn push_current_attribute(&mut self) {
142 if let (Some(attr), Some(Token::StartTag { attributes, .. })) =
143 (self.current_attribute.take(), &mut self.current_token)
144 {
145 attributes.push(attr);
146 }
147 }
148
149 fn handle_special_tag_state_transition(&mut self, token: &Token) {
150 if let Token::StartTag { name, .. } = token {
151 match name.to_lowercase().as_str() {
153 "script" => self.state = TokenizerState::ScriptData,
154 "style" => self.state = TokenizerState::StyleData,
155 _ => self.state = TokenizerState::Data,
156 }
157 } else {
158 }
160 }
161
162 #[inline(always)]
164 fn debug_emit(&self, _token: &Token) {
165 #[cfg(debug_assertions)]
166 match _token {
167 Token::StartTag { name, .. } => {
168 log::debug!(target:"HtmlTokenizer::EmitToken::TagStart", "Emitting token: {name}, Pos: {}", self.pos)
169 }
170 Token::EndTag { name } => {
171 log::debug!(target:"HtmlTokenizer::EmitToken::TagEnd", "Emitting token: {name}, Pos: {}", self.pos)
172 }
173 Token::Comment(comment) => {
174 log::debug!(target:"HtmlTokenizer::EmitToken::Comment", "Emitting token: {}, Pos: {}", comment, self.pos)
175 }
176 Token::Text(text) => {
177 log::debug!(target:"HtmlTokenizer::EmitToken::Text", "Emitting token: `{text}`, Pos: {}", self.pos)
178 }
179 _ => {}
180 }
181 }
182
183 pub fn next_token(&mut self) -> Option<Token> {
185 while let Some(c) = self.next_char() {
186 log::debug!(target:"HtmlTokenizer::Char", "State: {:?}, Char: '{}'", self.state, c);
187
188 match self.state {
189 TokenizerState::Data | TokenizerState::StyleData | TokenizerState::ScriptData => {
190 self.state_data(c)
191 }
192 TokenizerState::EscapeDecoding => self.state_escape_decoding(c),
193 TokenizerState::AttributeEscapeDecoding => self.state_attribute_escape_decoding(c),
194 _ if self.state.is_doctype() => self.state_doctype(c),
195 TokenizerState::TagOpen => self.state_tag_open(c),
196 TokenizerState::TagName => self.state_tag_name(c),
197 TokenizerState::BeforeAttributeName => self.state_before_attribute_name(c),
198 TokenizerState::AttributeName => self.state_attribute_name(c),
199 TokenizerState::BeforeAttributeValue => self.state_before_attribute_value(c),
200 TokenizerState::AttributeValueDoubleQuoted
201 | TokenizerState::AttributeValueSingleQuoted => {
202 self.state_attribute_value_quoted(c)
203 }
204 TokenizerState::AfterAttributeName => self.state_after_attribute_name(c),
205 TokenizerState::AttributeValueUnquoted => self.state_attribute_value_unquoted(c),
206 TokenizerState::SelfClosingStartTag => self.state_self_closing_start_tag(c),
207 TokenizerState::EndTagOpen => self.state_end_tag_open(c),
208 _ if self.state.is_comment() => self.state_comment(c),
209 _ => {
210 log::error!(target:"HtmlTokenizer::State", "Unimplemented state: {:?}, returning to Data state", self.state);
211 self.state = TokenizerState::Data;
212 }
213 }
214
215 if let Some(token) = self.token.take() {
216 self.debug_emit(&token);
217 self.handle_special_tag_state_transition(&token);
218 return Some(token);
219 }
220 }
221
222 if self.current_token.is_some() {
224 self.commit_token();
225 return self.token.take();
226 }
227
228 if self.state.is_comment() {
230 self.state = TokenizerState::BogusComment;
231 self.commit_token();
232 return self.token.take();
233 }
234
235 None
236 }
237
238 fn state_data(&mut self, c: char) {
240 match c {
241 '<' => {
242 let raw_text = matches!(
246 self.state,
247 TokenizerState::ScriptData | TokenizerState::StyleData
248 );
249 if raw_text && !self.input[self.pos..].starts_with('/') {
250 self.buffer.push('<');
251 match &mut self.current_token {
252 Some(Token::Text(text)) => text.push('<'),
253 _ => self.current_token = Some(Token::Text("<".to_string())),
254 }
255 } else {
256 self.commit_token();
257 self.state = TokenizerState::TagOpen;
258 }
259 }
260 '&' if self.state == TokenizerState::Data => {
262 self.buffer.clear();
267 self.buffer.push('&');
268 self.state = TokenizerState::EscapeDecoding;
269 }
270 _ => {
271 self.buffer.push(c);
272 match &mut self.current_token {
273 Some(Token::Text(text)) => text.push(c),
274 _ => self.current_token = Some(Token::Text(c.to_string())),
275 }
276 }
277 }
278 }
279
280 fn state_escape_decoding(&mut self, c: char) {
281 if c == ';' {
282 let mut iter = self.buffer.rsplitn(2, '&');
283 let entity = iter.next().unwrap_or("");
284
285 let decoded = decode_entity(entity).unwrap_or_else(|| format!("&{};", entity));
286
287 match &mut self.current_token {
288 Some(Token::Text(text)) => text.push_str(&decoded),
289 _ => self.current_token = Some(Token::Text(decoded)),
290 }
291
292 self.buffer.clear();
293 self.state = TokenizerState::Data;
294 } else if self.buffer.len() > MAX_ENTITY_NAME_LEN || self.input[self.pos..].starts_with('<')
295 {
296 let mut iter = self.buffer.rsplitn(2, '&');
297 let entity = iter.next().unwrap_or("");
298 let decoded = format!("&{}", entity);
299
300 match &mut self.current_token {
301 Some(Token::Text(text)) => text.push_str(&decoded),
302 _ => self.current_token = Some(Token::Text(decoded)),
303 }
304
305 self.buffer.clear();
306 self.state = TokenizerState::Data;
307 } else {
308 self.buffer.push(c);
309 }
310 }
311
312 fn state_tag_open(&mut self, c: char) {
313 match c {
314 '/' => self.state = TokenizerState::EndTagOpen,
315 '!' => {
316 if self.input[self.pos..].starts_with('-') {
317 self.pos += 1;
318 self.state = TokenizerState::CommentStartDash;
319 } else if self.input[self.pos..].to_lowercase().starts_with("doctype") {
320 self.pos += 7;
321 self.state = TokenizerState::Doctype;
322 self.current_token = Some(Token::Doctype {
323 name: None,
324 public_id: None,
325 system_id: None,
326 force_quirks: false,
327 });
328 } else {
329 self.state = TokenizerState::BogusComment;
330 }
331 }
332 c if c.is_ascii_alphabetic() => {
333 self.state = TokenizerState::TagName;
334 self.buffer.push(c);
335 self.current_token = Some(Token::StartTag {
336 name: c.to_string(),
337 attributes: Vec::new(),
338 self_closing: false,
339 });
340 }
341 _ => {
342 self.buffer.push('<');
343 self.buffer.push(c);
344 match &mut self.current_token {
345 Some(Token::Text(text)) => {
346 text.push('<');
347 text.push(c);
348 }
349 _ => self.current_token = Some(Token::Text(format!("<{c}"))),
350 }
351 self.state = TokenizerState::Data;
352 }
353 }
354 }
355
356 fn state_tag_name(&mut self, c: char) {
357 match c {
358 c if c.is_whitespace() => self.state = TokenizerState::BeforeAttributeName,
359 '/' => self.state = TokenizerState::SelfClosingStartTag,
360 '>' => {
361 self.commit_token();
362 self.state = TokenizerState::Data;
363 }
364 c if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':' => {
365 self.buffer.push(c);
366 match &mut self.current_token {
367 Some(Token::StartTag { name, .. }) => name.push(c),
368 Some(Token::EndTag { name }) => name.push(c),
369 _ => {}
370 }
371 }
372 _ => {
373 self.commit_token();
374 self.state = TokenizerState::Data;
375 }
376 }
377 }
378
379 fn state_before_attribute_name(&mut self, c: char) {
380 match c {
381 c if c.is_whitespace() => {}
382 '/' => self.state = TokenizerState::SelfClosingStartTag,
383 '>' => {
384 self.commit_token();
385 self.state = TokenizerState::Data;
386 }
387 c if c.is_ascii_alphanumeric() => {
388 self.state = TokenizerState::AttributeName;
389 self.buffer.push(c);
390 self.current_attribute = Some(Attribute {
391 name: c.to_string(),
392 value: String::new(),
393 });
394 }
395 _ => {}
396 }
397 }
398
399 fn state_attribute_name(&mut self, c: char) {
400 match c {
401 c if c.is_whitespace() => self.state = TokenizerState::AfterAttributeName,
402 '=' => self.state = TokenizerState::BeforeAttributeValue,
403 '/' => {
404 self.push_current_attribute();
405 self.state = TokenizerState::SelfClosingStartTag;
406 }
407 '>' => {
408 self.push_current_attribute();
409 self.commit_token();
410 self.state = TokenizerState::Data;
411 }
412 c if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':' => {
413 self.buffer.push(c);
414 if let Some(attr) = &mut self.current_attribute {
415 attr.name.push(c);
416 }
417 }
418 _ => {}
419 }
420 }
421
422 fn state_before_attribute_value(&mut self, c: char) {
423 match c {
424 c if c.is_whitespace() => {}
425 '"' => self.state = TokenizerState::AttributeValueDoubleQuoted,
426 '\'' => self.state = TokenizerState::AttributeValueSingleQuoted,
427 '>' => {
428 self.push_current_attribute();
429 self.commit_token();
430 self.state = TokenizerState::Data;
431 }
432 _ => {
433 self.state = TokenizerState::AttributeValueUnquoted;
434 self.state_attribute_value_unquoted(c);
435 }
436 }
437 }
438
439 fn state_attribute_value_quoted(&mut self, c: char) {
440 match (&self.state, c) {
441 (&TokenizerState::AttributeValueDoubleQuoted, '"')
442 | (&TokenizerState::AttributeValueSingleQuoted, '\'') => {
443 self.push_current_attribute();
444 self.state = TokenizerState::AfterAttributeName;
445 }
446 (_, '&') => self.start_attribute_escape(),
447 _ => {
448 if let Some(attr) = &mut self.current_attribute {
449 attr.value.push(c);
450 }
451 }
452 }
453 }
454
455 fn state_after_attribute_name(&mut self, c: char) {
456 match c {
457 c if c.is_whitespace() => {}
458 '/' => {
459 self.push_current_attribute();
460 self.state = TokenizerState::SelfClosingStartTag;
461 }
462 '=' => self.state = TokenizerState::BeforeAttributeValue,
463 '>' => {
464 self.push_current_attribute();
465 self.commit_token();
466 self.state = TokenizerState::Data;
467 }
468 c if c.is_ascii_alphanumeric() => {
469 self.push_current_attribute();
470 self.state = TokenizerState::AttributeName;
471 self.buffer.push(c);
472 self.current_attribute = Some(Attribute {
473 name: c.to_string(),
474 value: String::new(),
475 });
476 }
477 _ => {}
478 }
479 }
480
481 fn state_attribute_value_unquoted(&mut self, c: char) {
482 match c {
483 c if c.is_whitespace() => {
484 self.push_current_attribute();
485 self.state = TokenizerState::BeforeAttributeName;
486 }
487 '>' => {
488 self.push_current_attribute();
489 self.commit_token();
490 self.state = TokenizerState::Data;
491 }
492 '&' => self.start_attribute_escape(),
493 _ => {
494 if let Some(attr) = &mut self.current_attribute {
495 attr.value.push(c);
496 }
497 }
498 }
499 }
500
501 fn start_attribute_escape(&mut self) {
506 self.buffer.clear();
507 self.buffer.push('&');
508 self.escape_return_state = self.state;
509 self.state = TokenizerState::AttributeEscapeDecoding;
510 }
511
512 fn state_attribute_escape_decoding(&mut self, c: char) {
513 if c == ';' {
514 let mut iter = self.buffer.rsplitn(2, '&');
515 let entity = iter.next().unwrap_or("");
516 let decoded = decode_entity(entity).unwrap_or_else(|| format!("&{};", entity));
517 if let Some(attr) = &mut self.current_attribute {
518 attr.value.push_str(&decoded);
519 }
520 self.buffer.clear();
521 self.state = std::mem::replace(&mut self.escape_return_state, TokenizerState::Data);
522 } else if !(c.is_ascii_alphanumeric() || matches!(c, '#' | 'x' | 'X'))
523 || self.buffer.len() > MAX_ENTITY_NAME_LEN
524 || self.input[self.pos..].starts_with('<')
525 {
526 let mut iter = self.buffer.rsplitn(2, '&');
531 let entity = iter.next().unwrap_or("");
532 if let Some(attr) = &mut self.current_attribute {
533 attr.value.push_str(&format!("&{}", entity));
534 }
535 self.buffer.clear();
536 self.pos -= c.len_utf8();
537 self.state = std::mem::replace(&mut self.escape_return_state, TokenizerState::Data);
538 } else {
539 self.buffer.push(c);
540 }
541 }
542
543 fn state_self_closing_start_tag(&mut self, c: char) {
544 match c {
545 '>' => {
546 if let Some(Token::StartTag { self_closing, .. }) = &mut self.current_token {
547 *self_closing = true;
548 }
549 self.commit_token();
550 self.state = TokenizerState::Data;
551 }
552 _ => self.state = TokenizerState::Data,
553 }
554 }
555
556 fn state_end_tag_open(&mut self, c: char) {
557 match c {
558 c if c.is_ascii_alphabetic() => {
559 self.state = TokenizerState::TagName;
560 self.buffer.push(c);
561 self.current_token = Some(Token::EndTag {
562 name: c.to_string(),
563 });
564 }
565 _ => self.state = TokenizerState::Data,
566 }
567 }
568
569 fn state_comment(&mut self, c: char) {
570 match self.state {
571 TokenizerState::CommentStartDash => {
572 if c == '-' {
573 self.state = TokenizerState::Comment;
574 self.current_token = Some(Token::Comment(String::new()));
575 } else {
576 self.state = TokenizerState::BogusComment;
577 }
578 }
579 TokenizerState::Comment => {
580 if c == '-' {
581 self.state = TokenizerState::CommentEndDash;
582 } else if let Some(Token::Comment(comment)) = &mut self.current_token {
583 comment.push(c);
584 }
585 }
586 TokenizerState::CommentEndDash => {
587 if c == '-' {
588 self.state = TokenizerState::CommentEnd;
589 } else {
590 self.state = TokenizerState::Comment;
591 if let Some(Token::Comment(comment)) = &mut self.current_token {
592 comment.push('-');
593 comment.push(c);
594 }
595 }
596 }
597 TokenizerState::CommentEnd => {
598 if c == '>' {
599 self.commit_token();
600 self.state = TokenizerState::Data;
601 } else {
602 self.state = TokenizerState::Comment;
603 if let Some(Token::Comment(comment)) = &mut self.current_token {
604 comment.push_str("--");
605 comment.push(c);
606 }
607 }
608 }
609 _ => {}
610 }
611 }
612
613 fn peek_starts_with_ascii_ci(&self, keyword: &str) -> bool {
616 self.input[self.pos..]
617 .get(..keyword.len())
618 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
619 }
620
621 fn state_doctype(&mut self, c: char) {
622 match c {
623 c if c.is_whitespace() => match self.state {
624 TokenizerState::Doctype => self.state = TokenizerState::DoctypeName,
625 TokenizerState::DoctypeName
626 if (self.peek_starts_with_ascii_ci("public")
627 || self.peek_starts_with_ascii_ci("system")) =>
628 {
629 self.pos += 6;
630 self.state = TokenizerState::BeforeDoctypePublicId;
631 }
632 TokenizerState::AfterDoctypePublicId => {
633 self.state = TokenizerState::DoctypeSystemId;
634 }
635 _ => {}
636 },
637 '>' => {
638 if let Some(Token::Doctype { force_quirks, .. }) = &mut self.current_token
639 && self.state == TokenizerState::BogusDoctype
640 {
641 *force_quirks = true;
642 }
643 self.commit_token();
644 self.state = TokenizerState::Data;
645 }
646 _ => {
647 self.buffer.push(c);
648 match self.state {
649 TokenizerState::Doctype => self.state = TokenizerState::BogusDoctype,
650 TokenizerState::DoctypeName => {
651 if let Some(Token::Doctype { name, .. }) = &mut self.current_token {
652 if name.is_none() {
653 *name = Some(c.to_string());
654 } else if let Some(n) = name {
655 n.push(c);
656 }
657 }
658 }
659 TokenizerState::BeforeDoctypePublicId => {
660 match c {
661 '"' => self.state = TokenizerState::DoctypePublicIdWithDoubleQuote,
662 '\'' => self.state = TokenizerState::DoctypePublicIdWithSingleQuote,
663 _ if c.is_whitespace() => {}
664 _ => self.state = TokenizerState::BogusDoctype,
665 }
666 if let Some(Token::Doctype { public_id, .. }) = &mut self.current_token {
667 *public_id = Some(c.to_string());
668 }
669 }
670 TokenizerState::DoctypePublicIdWithSingleQuote
671 | TokenizerState::DoctypePublicIdWithDoubleQuote => {
672 if let Some(Token::Doctype { public_id, .. }) = &mut self.current_token
673 && let Some(pid) = public_id
674 {
675 pid.push(c);
676 }
677 if (self.state == TokenizerState::DoctypePublicIdWithSingleQuote
678 && c == '\'')
679 || (self.state == TokenizerState::DoctypePublicIdWithDoubleQuote
680 && c == '"')
681 {
682 self.state = TokenizerState::AfterDoctypePublicId;
683 }
684 }
685 TokenizerState::DoctypeSystemId => {
686 if let Some(Token::Doctype { system_id, .. }) = &mut self.current_token {
687 if system_id.is_none() {
688 *system_id = Some(c.to_string());
689 } else if let Some(sid) = system_id {
690 sid.push(c);
691 }
692 }
693 }
694 _ => {}
695 }
696 }
697 }
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn collect_tokens(input: &str) -> Vec<Token> {
706 let mut tokenizer = Tokenizer::new(input);
707 let mut tokens = Vec::new();
708 while let Some(token) = tokenizer.next_token() {
709 tokens.push(token);
710 }
711 tokens
712 }
713
714 #[test]
715 fn test_text_node() {
716 let input = "Hello, world!";
717 let tokens = collect_tokens(input);
718 assert_eq!(tokens, vec![Token::Text("Hello, world!".to_string())]);
719 }
720
721 #[test]
722 fn test_script_data_less_than_is_literal() {
723 let input = r#"<script>test('di<v');</script>"#;
726 let tokens = collect_tokens(input);
727 assert_eq!(
728 tokens,
729 vec![
730 Token::StartTag {
731 name: "script".to_string(),
732 attributes: vec![],
733 self_closing: false
734 },
735 Token::Text("test('di<v');".to_string()),
736 Token::EndTag {
737 name: "script".to_string()
738 }
739 ]
740 );
741 }
742
743 #[test]
744 fn test_simple_tag() {
745 let input = "<div></div>";
746 let tokens = collect_tokens(input);
747 assert_eq!(
748 tokens,
749 vec![
750 Token::StartTag {
751 name: "div".to_string(),
752 attributes: vec![],
753 self_closing: false
754 },
755 Token::EndTag {
756 name: "div".to_string()
757 }
758 ]
759 );
760 }
761
762 #[test]
763 fn test_tag_with_attributes() {
764 let input = r#"<a href="https://example.com" target='_blank'>Link</a>"#;
765 let tokens = collect_tokens(input);
766 assert_eq!(
767 tokens,
768 vec![
769 Token::StartTag {
770 name: "a".to_string(),
771 attributes: vec![
772 Attribute {
773 name: "href".to_string(),
774 value: "https://example.com".to_string()
775 },
776 Attribute {
777 name: "target".to_string(),
778 value: "_blank".to_string()
779 },
780 ],
781 self_closing: false
782 },
783 Token::Text("Link".to_string()),
784 Token::EndTag {
785 name: "a".to_string()
786 }
787 ]
788 );
789 }
790
791 #[test]
792 fn test_boolean_attributes() {
793 let input = r#"<script async defer src="script.js"></script>"#;
794 let tokens = collect_tokens(input);
795 assert_eq!(
796 tokens,
797 vec![
798 Token::StartTag {
799 name: "script".to_string(),
800 attributes: vec![
801 Attribute {
802 name: "async".to_string(),
803 value: String::new(),
804 },
805 Attribute {
806 name: "defer".to_string(),
807 value: String::new(),
808 },
809 Attribute {
810 name: "src".to_string(),
811 value: "script.js".to_string(),
812 },
813 ],
814 self_closing: false,
815 },
816 Token::EndTag {
817 name: "script".to_string(),
818 },
819 ]
820 );
821 }
822
823 #[test]
824 fn test_self_closing_tag() {
825 let input = "<img src='image.png'/>";
826 let tokens = collect_tokens(input);
827 assert_eq!(
828 tokens,
829 vec![Token::StartTag {
830 name: "img".to_string(),
831 attributes: vec![Attribute {
832 name: "src".to_string(),
833 value: "image.png".to_string()
834 }],
835 self_closing: true
836 }]
837 );
838 }
839
840 #[test]
841 fn test_comment() {
842 let input = "<!-- This is a comment -->";
843 let tokens = collect_tokens(input);
844 assert_eq!(
845 tokens,
846 vec![Token::Comment(" This is a comment ".to_string())]
847 );
848 }
849
850 #[test]
851 fn test_doctype() {
852 let input = "<!DOCTYPE html>";
853 let tokens = collect_tokens(input);
854 assert_eq!(
855 tokens,
856 vec![Token::Doctype {
857 name: Some("html".to_string()),
858 public_id: None,
859 system_id: None,
860 force_quirks: false
861 }]
862 );
863 }
864
865 #[test]
866 fn doctype_public_keyword_is_detected_case_insensitively() {
867 let tokens = collect_tokens(r#"<!DOCTYPE html PuBlIc "x">"#);
869 match &tokens[..] {
870 [
871 Token::Doctype {
872 name, public_id, ..
873 },
874 ] => {
875 assert_eq!(name.as_deref(), Some("html"));
876 assert!(public_id.as_deref().is_some_and(|id| id.contains('x')));
877 }
878 other => panic!("unexpected tokens: {other:?}"),
879 }
880 }
881
882 #[test]
883 fn peek_starts_with_ascii_ci_avoids_boundary_panics() {
884 let mut tokenizer = Tokenizer::new("html sYsTeM");
885 tokenizer.pos = 5;
886 assert!(tokenizer.peek_starts_with_ascii_ci("system"));
887 assert!(!tokenizer.peek_starts_with_ascii_ci("public"));
888
889 let tokenizer = Tokenizer::new("é");
891 assert!(!tokenizer.peek_starts_with_ascii_ci("public"));
892 }
893
894 #[test]
895 fn test_escape_entity() {
896 let input = "Hello & goodbye";
897 let tokens = collect_tokens(input);
898 assert_eq!(tokens, vec![Token::Text("Hello & goodbye".to_string())]);
899 }
900
901 #[test]
902 fn attribute_values_decode_character_references() {
903 let input = r#"<img src="https://x.example/a.png?w=1&h=2&s="q"" alt='it's 'x''>"#;
904 let tokens = collect_tokens(input);
905 assert_eq!(
906 tokens,
907 vec![Token::StartTag {
908 name: "img".to_string(),
909 attributes: vec![
910 Attribute {
911 name: "src".to_string(),
912 value: "https://x.example/a.png?w=1&h=2&s=\"q\"".to_string(),
913 },
914 Attribute {
915 name: "alt".to_string(),
916 value: "it's 'x'".to_string(),
917 },
918 ],
919 self_closing: false,
920 }]
921 );
922 }
923
924 #[test]
925 fn attribute_entities_close_normally_without_a_semicolon() {
926 let input = r#"<a href="?a=1&">link</a>"#;
929 let tokens = collect_tokens(input);
930 assert_eq!(
931 tokens[0],
932 Token::StartTag {
933 name: "a".to_string(),
934 attributes: vec![Attribute {
935 name: "href".to_string(),
936 value: "?a=1&".to_string(),
937 }],
938 self_closing: false,
939 }
940 );
941 assert_eq!(tokens[1], Token::Text("link".to_string()));
942 }
943
944 #[test]
945 fn attribute_entity_at_start_of_unquoted_value() {
946 let input = r#"<a href=&x>link</a>"#;
947 let tokens = collect_tokens(input);
948 assert_eq!(
949 tokens[0],
950 Token::StartTag {
951 name: "a".to_string(),
952 attributes: vec![Attribute {
953 name: "href".to_string(),
954 value: "&x".to_string(),
955 }],
956 self_closing: false,
957 }
958 );
959 }
960
961 #[test]
962 fn bare_ampersand_in_attribute_is_kept_literal() {
963 let input = r#"<img src="https://x.example/a.png?a=1&b=2">"#;
966 let tokens = collect_tokens(input);
967 assert_eq!(
968 tokens[0],
969 Token::StartTag {
970 name: "img".to_string(),
971 attributes: vec![Attribute {
972 name: "src".to_string(),
973 value: "https://x.example/a.png?a=1&b=2".to_string(),
974 }],
975 self_closing: false,
976 }
977 );
978 }
979
980 #[test]
985 fn entity_after_long_text_still_decodes() {
986 for n in [26, 27, 30, 50, 100, 400] {
987 let input = format!("{} "Q" and '9' end", "x".repeat(n));
988 let tokens = collect_tokens(&input);
989 assert_eq!(
990 tokens,
991 vec![Token::Text(format!("{} \"Q\" and '9' end", "x".repeat(n)))],
992 "failed with {n} leading chars"
993 );
994 }
995 }
996
997 #[test]
998 fn test_nested_tags() {
999 let input = "<div><span>Text</span></div>";
1000 let tokens = collect_tokens(input);
1001 assert_eq!(
1002 tokens,
1003 vec![
1004 Token::StartTag {
1005 name: "div".to_string(),
1006 attributes: vec![],
1007 self_closing: false
1008 },
1009 Token::StartTag {
1010 name: "span".to_string(),
1011 attributes: vec![],
1012 self_closing: false
1013 },
1014 Token::Text("Text".to_string()),
1015 Token::EndTag {
1016 name: "span".to_string()
1017 },
1018 Token::EndTag {
1019 name: "div".to_string()
1020 },
1021 ]
1022 );
1023 }
1024}