1use std::borrow::Cow;
4use std::collections::HashSet;
5use std::iter::FusedIterator;
6
7use memchr::{memchr, memchr_iter, memchr2, memchr3, memmem};
8
9use crate::entities::{decode, decode_from, normalize};
10
11#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
13pub enum State {
14 #[default]
16 Data,
17 Rcdata,
19 Rawtext,
21 ScriptData,
23 Plaintext,
25 Cdata,
27}
28
29#[derive(Debug, Clone, Eq, PartialEq)]
31pub struct Attribute<'a> {
32 pub name: Cow<'a, str>,
34 pub value: Cow<'a, str>,
36 pub raw_value: Option<&'a str>,
38}
39
40impl<'a> Attribute<'a> {
41 #[must_use]
43 pub fn value(&self) -> &str {
44 &self.value
45 }
46
47 #[must_use]
49 pub fn raw_value(&self) -> Option<&'a str> {
50 self.raw_value
51 }
52}
53
54#[derive(Debug, Clone, Eq, PartialEq)]
56pub struct Tag<'a> {
57 pub name: Cow<'a, str>,
59 pub attributes: Vec<Attribute<'a>>,
61 pub self_closing: bool,
63}
64
65#[derive(Debug, Clone, Eq, PartialEq)]
67pub struct Doctype<'a> {
68 pub name: Option<Cow<'a, str>>,
70 pub public_id: Option<Cow<'a, str>>,
72 pub system_id: Option<Cow<'a, str>>,
74 pub force_quirks: bool,
76}
77
78#[derive(Debug, Clone, Eq, PartialEq)]
80#[repr(u8)]
82pub enum Token<'a> {
83 StartTag(Tag<'a>),
85 EndTag(Tag<'a>),
87 Text(Cow<'a, str>),
89 Comment(Cow<'a, str>),
91 Doctype(Doctype<'a>),
93 ProcessingInstruction {
95 target: Cow<'a, str>,
97 data: Cow<'a, str>,
99 },
100}
101
102#[derive(Debug, Clone)]
107pub struct Tokenizer<'a> {
108 source: &'a str,
109 position: usize,
110 state: State,
111 last_start_tag: Option<Cow<'static, str>>,
112 attribute_names: Option<HashSet<Cow<'a, str>>>,
114}
115
116impl<'a> Tokenizer<'a> {
117 #[must_use]
119 pub fn new(source: &'a str) -> Self {
120 Self::with_state(source, State::Data, None)
121 }
122
123 #[must_use]
128 pub fn with_state(source: &'a str, state: State, last_start_tag: Option<&str>) -> Self {
129 Self {
130 source,
131 position: 0,
132 state,
133 last_start_tag: last_start_tag
134 .filter(|name| can_end_text(name))
135 .map(|name| Cow::Owned(name.to_ascii_lowercase())),
136 attribute_names: None,
137 }
138 }
139
140 pub fn set_state(&mut self, state: State, last_start_tag: Option<&str>) {
142 self.state = state;
143 self.last_start_tag = last_start_tag
144 .filter(|name| can_end_text(name))
145 .map(|name| Cow::Owned(name.to_ascii_lowercase()));
146 }
147
148 pub(crate) fn set_static_text_state(&mut self, state: State, name: &'static str) {
150 self.state = state;
151 self.last_start_tag = Some(Cow::Borrowed(name));
152 }
153
154 #[must_use]
156 pub fn position(&self) -> usize {
157 self.position
158 }
159
160 fn current(&self) -> Option<u8> {
161 self.source.as_bytes().get(self.position).copied()
162 }
163
164 fn whitespace(&mut self) {
165 while self.current().is_some_and(is_space) {
166 self.position += 1;
167 }
168 }
169
170 fn appropriate_end(&self, at: usize) -> bool {
172 let Some(name) = self.last_start_tag.as_deref() else {
173 return false;
174 };
175 let rest = &self.source.as_bytes()[at..];
176 rest.starts_with(b"</")
177 && rest
178 .get(2..2 + name.len())
179 .is_some_and(|candidate| matches_normalized_name(name.as_bytes(), candidate))
180 && rest
181 .get(2 + name.len())
182 .is_some_and(|&byte| is_space(byte) || matches!(byte, b'/' | b'>'))
183 }
184
185 #[inline(always)]
187 fn tag(&mut self, end: bool, attribute_buffer: &mut Vec<Attribute<'a>>) -> Option<Token<'a>> {
188 self.position += if end { 2 } else { 1 };
189 let name = self.name(false);
190 let (attributes, self_closing) = if self.current() == Some(b'>') {
191 self.position += 1;
192 (Vec::new(), false)
193 } else if self.source.as_bytes()[self.position..].starts_with(b"/>") {
194 self.position += 2;
195 (Vec::new(), true)
196 } else {
197 self.attributes(attribute_buffer)?
198 };
199 let tag = Tag {
200 name,
201 attributes,
202 self_closing,
203 };
204 Some(if end {
205 Token::EndTag(tag)
206 } else {
207 Token::StartTag(tag)
208 })
209 }
210
211 #[inline(always)]
214 fn name(&mut self, attribute: bool) -> Cow<'a, str> {
215 let start = self.position;
216 let bytes = self.source.as_bytes();
217 let mut end = start + 1;
218 let mut needs_normalization = bytes[start].is_ascii_uppercase() || bytes[start] == 0;
219 while let Some(&byte) = bytes.get(end) {
220 if is_space(byte) || matches!(byte, b'/' | b'>') || (attribute && byte == b'=') {
221 break;
222 }
223 needs_normalization |= byte.is_ascii_uppercase() || byte == 0;
224 end += 1;
225 }
226 self.position = end;
227 let name = &self.source[start..end];
228 if needs_normalization {
229 normalize_name(name)
230 } else {
231 Cow::Borrowed(name)
232 }
233 }
234
235 #[inline(never)]
237 fn attributes(
238 &mut self,
239 attribute_buffer: &mut Vec<Attribute<'a>>,
240 ) -> Option<(Vec<Attribute<'a>>, bool)> {
241 let mut attributes = std::mem::take(attribute_buffer);
242 let mut self_closing = false;
243 loop {
244 self.whitespace();
245 match self.current()? {
246 b'>' => {
247 self.position += 1;
248 break;
249 }
250 b'/' => {
251 self.position += 1;
252 if self.current() == Some(b'>') {
253 self.position += 1;
254 self_closing = true;
255 break;
256 }
257 continue;
258 }
259 _ => {}
260 }
261 let attr_name = self.name(true);
262 self.whitespace();
263 let raw_value = if self.current() == Some(b'=') {
264 self.position += 1;
265 self.whitespace();
266 let quote = self.current()?;
267 if matches!(quote, b'\'' | b'"') {
268 self.position += 1;
269 let start = self.position;
270 self.position = memchr(quote, &self.source.as_bytes()[start..])
271 .map_or(self.source.len(), |offset| start + offset);
272 let value = &self.source[start..self.position];
273 self.current()?;
274 self.position += 1;
275 Some(value)
276 } else {
277 let start = self.position;
278 while self
279 .current()
280 .is_some_and(|byte| !is_space(byte) && byte != b'>')
281 {
282 self.position += 1;
283 }
284 Some(&self.source[start..self.position])
285 }
286 } else {
287 None
288 };
289 let duplicate = if attributes.len() < 16 {
291 attributes
292 .iter()
293 .any(|attribute| attribute.name == attr_name)
294 } else {
295 let seen = self.attribute_names.get_or_insert_with(HashSet::new);
296 if seen.is_empty() {
297 seen.extend(attributes.iter().map(|attribute| attribute.name.clone()));
298 }
299 !seen.insert(attr_name.clone())
300 };
301 if !duplicate {
302 attributes.push(Attribute {
303 name: attr_name,
304 value: raw_value.map_or(Cow::Borrowed(""), |value| decode(value, true)),
305 raw_value,
306 });
307 }
308 }
309 if let Some(seen) = &mut self.attribute_names {
310 seen.clear();
311 }
312 Some((attributes, self_closing))
313 }
314
315 fn bogus_comment(&mut self, start: usize) -> Token<'a> {
316 while self.current().is_some_and(|byte| byte != b'>') {
317 self.position += 1;
318 }
319 let result = Token::Comment(normalize_text(&self.source[start..self.position]));
320 if self.current().is_some() {
321 self.position += 1;
322 }
323 result
324 }
325
326 #[inline]
327 fn comment(&mut self) -> Token<'a> {
328 let rest = &self.source[self.position..];
329 let end = memchr3(b'-', b'<', b'>', rest.as_bytes()).and_then(|at| {
330 if rest[at..].starts_with("-->") {
331 Some(at)
332 } else if at == 0 && (rest.starts_with('>') || rest.starts_with("->")) {
333 None
334 } else {
335 memmem::find(&rest.as_bytes()[at..], b"--")
336 .map(|offset| at + offset)
337 .filter(|&end| rest[end..].starts_with("-->"))
338 }
339 });
340 if let Some(end) = end
341 && memchr2(b'\r', 0, &rest.as_bytes()[..end]).is_none()
342 {
343 self.position += end + 3;
344 return Token::Comment(Cow::Borrowed(&rest[..end]));
345 }
346 self.comment_slow()
347 }
348
349 #[inline(never)]
350 fn comment_slow(&mut self) -> Token<'a> {
351 #[derive(Clone, Copy)]
352 enum CommentState {
353 Start,
354 StartDash,
355 Data,
356 Less,
357 Bang,
358 BangDash,
359 BangDashDash,
360 EndDash,
361 End,
362 EndBang,
363 }
364 let mut state = CommentState::Start;
365 let mut data = String::new();
366 while let Some(mut ch) = self.source[self.position..].chars().next() {
367 let mut width = ch.len_utf8();
368 if ch == '\r' {
370 ch = '\n';
371 width += usize::from(self.source.as_bytes().get(self.position + 1) == Some(&b'\n'));
372 }
373 let mut consume = true;
374 match state {
375 CommentState::Start => match ch {
376 '-' => state = CommentState::StartDash,
377 '>' => {
378 self.position += 1;
379 break;
380 }
381 _ => {
382 state = CommentState::Data;
383 consume = false;
384 }
385 },
386 CommentState::StartDash => match ch {
387 '-' => state = CommentState::End,
388 '>' => {
389 self.position += 1;
390 break;
391 }
392 _ => {
393 data.push('-');
394 state = CommentState::Data;
395 consume = false;
396 }
397 },
398 CommentState::Data => match ch {
399 '<' => {
400 data.push('<');
401 state = CommentState::Less;
402 }
403 '-' => state = CommentState::EndDash,
404 '\0' => data.push('\u{fffd}'),
405 '\n' => data.push('\n'),
406 _ => {
407 let rest = &self.source[self.position..];
408 let end = memchr3(b'<', b'-', b'\r', rest.as_bytes()).unwrap_or(rest.len());
409 let mut start = 0;
410 for nul in memchr_iter(0, &rest.as_bytes()[..end]) {
411 data.push_str(&rest[start..nul]);
412 data.push('\u{fffd}');
413 start = nul + 1;
414 }
415 data.push_str(&rest[start..end]);
416 self.position += end;
417 continue;
418 }
419 },
420 CommentState::Less => match ch {
421 '!' => {
422 data.push('!');
423 state = CommentState::Bang;
424 }
425 '<' => data.push('<'),
426 _ => {
427 state = CommentState::Data;
428 consume = false;
429 }
430 },
431 CommentState::Bang => {
432 if ch == '-' {
433 state = CommentState::BangDash;
434 } else {
435 state = CommentState::Data;
436 consume = false;
437 }
438 }
439 CommentState::BangDash => {
440 if ch == '-' {
441 state = CommentState::BangDashDash;
442 } else {
443 state = CommentState::EndDash;
444 consume = false;
445 }
446 }
447 CommentState::BangDashDash => {
448 state = CommentState::End;
449 consume = false;
450 }
451 CommentState::EndDash => {
452 if ch == '-' {
453 state = CommentState::End;
454 } else {
455 data.push('-');
456 state = CommentState::Data;
457 consume = false;
458 }
459 }
460 CommentState::End => match ch {
461 '>' => {
462 self.position += 1;
463 break;
464 }
465 '!' => state = CommentState::EndBang,
466 '-' => data.push('-'),
467 _ => {
468 data.push_str("--");
469 state = CommentState::Data;
470 consume = false;
471 }
472 },
473 CommentState::EndBang => match ch {
474 '-' => {
475 data.push_str("--!");
476 state = CommentState::EndDash;
477 }
478 '>' => {
479 self.position += 1;
480 break;
481 }
482 _ => {
483 data.push_str("--!");
484 state = CommentState::Data;
485 consume = false;
486 }
487 },
488 }
489 if consume {
490 self.position += width;
491 }
492 }
493 Token::Comment(Cow::Owned(data))
494 }
495
496 fn identifier(&mut self) -> (Cow<'a, str>, bool) {
499 let quote = self.current().expect("identifier starts at a quote");
500 self.position += 1;
501 let start = self.position;
502 while self
503 .current()
504 .is_some_and(|byte| byte != quote && byte != b'>')
505 {
506 self.position += 1;
507 }
508 let value = normalize_text(&self.source[start..self.position]);
509 let closed = self.current() == Some(quote);
510 if closed {
511 self.position += 1;
512 }
513 (value, closed)
514 }
515
516 fn finish_doctype(&mut self, doctype: Doctype<'a>) -> Token<'a> {
517 while self.current().is_some_and(|byte| byte != b'>') {
518 self.position += 1;
519 }
520 if self.current().is_some() {
521 self.position += 1;
522 }
523 Token::Doctype(doctype)
524 }
525
526 #[inline(never)]
527 fn doctype(&mut self) -> Token<'a> {
528 let mut doctype = Doctype {
529 name: None,
530 public_id: None,
531 system_id: None,
532 force_quirks: false,
533 };
534 self.whitespace();
535 if matches!(self.current(), None | Some(b'>')) {
536 doctype.force_quirks = true;
537 return self.finish_doctype(doctype);
538 }
539 let start = self.position;
540 while self
541 .current()
542 .is_some_and(|byte| !is_space(byte) && byte != b'>')
543 {
544 self.position += 1;
545 }
546 let name = &self.source[start..self.position];
547 doctype.name = Some(
548 if name
549 .bytes()
550 .any(|byte| byte.is_ascii_uppercase() || byte == 0)
551 {
552 normalize_name(name)
553 } else {
554 Cow::Borrowed(name)
555 },
556 );
557 self.whitespace();
558 match self.current() {
559 Some(b'>') => return self.finish_doctype(doctype),
560 None => {
561 doctype.force_quirks = true;
562 return Token::Doctype(doctype);
563 }
564 _ => {}
565 }
566 let keyword = self.source.as_bytes().get(self.position..self.position + 6);
567 let public = keyword.is_some_and(|value| value.eq_ignore_ascii_case(b"PUBLIC"));
568 let system = keyword.is_some_and(|value| value.eq_ignore_ascii_case(b"SYSTEM"));
569 if !public && !system {
570 doctype.force_quirks = true;
571 return self.finish_doctype(doctype);
572 }
573 self.position += 6;
574 self.whitespace();
575 if !matches!(self.current(), Some(b'\'' | b'"')) {
576 doctype.force_quirks = true;
577 return self.finish_doctype(doctype);
578 }
579 let (identifier, closed) = self.identifier();
580 if public {
581 doctype.public_id = Some(identifier);
582 } else {
583 doctype.system_id = Some(identifier);
584 }
585 if !closed {
586 doctype.force_quirks = true;
587 return self.finish_doctype(doctype);
588 }
589 self.whitespace();
590 if public {
591 match self.current() {
592 Some(b'\'' | b'"') => {
593 let (identifier, closed) = self.identifier();
594 doctype.system_id = Some(identifier);
595 if !closed {
596 doctype.force_quirks = true;
597 return self.finish_doctype(doctype);
598 }
599 self.whitespace();
600 }
601 Some(b'>') => return self.finish_doctype(doctype),
602 _ => {
603 doctype.force_quirks = true;
604 return self.finish_doctype(doctype);
605 }
606 }
607 }
608 if self.current().is_none() {
609 doctype.force_quirks = true;
610 }
611 self.finish_doctype(doctype)
612 }
613
614 #[inline(never)]
616 fn processing_instruction(&mut self) -> Option<Token<'a>> {
617 let comment_start = self.position + 1;
618 self.position += 2;
619 let first = self.current()?;
620 if !first.is_ascii_alphabetic() && first != b'_' {
621 return Some(self.bogus_comment(comment_start));
622 }
623 let start = self.position;
624 while self
625 .current()
626 .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
627 {
628 self.position += 1;
629 }
630 let target = &self.source[start..self.position];
631 let next = self.current()?;
632 if !(is_space(next) || matches!(next, b'?' | b'>'))
633 || target.eq_ignore_ascii_case("xml")
634 || target.eq_ignore_ascii_case("xml-stylesheet")
635 {
636 return Some(self.bogus_comment(comment_start));
637 }
638 self.whitespace();
639 let start = self.position;
640 while self.current().is_some_and(|byte| byte != b'>') {
641 self.position += 1;
642 }
643 self.current()?;
644 let data = &self.source[start..self.position];
645 let data = normalize(data.strip_suffix('?').unwrap_or(data));
646 self.position += 1;
647 Some(Token::ProcessingInstruction {
648 target: Cow::Borrowed(target),
649 data,
650 })
651 }
652
653 #[inline(never)]
655 fn script_end(&self, start: usize) -> usize {
656 #[derive(Clone, Copy)]
657 enum Script {
658 Data,
659 Escaped,
660 Dash,
661 DashDash,
662 Double,
663 DoubleDash,
664 DoubleDashDash,
665 }
666 let bytes = self.source.as_bytes();
667 let mut at = start;
668 let mut state = Script::Data;
669 while at < bytes.len() {
670 let offset = match state {
671 Script::Data => memchr(b'<', &bytes[at..]),
672 Script::Escaped | Script::Double => memchr2(b'<', b'-', &bytes[at..]),
673 _ => Some(0),
674 };
675 let Some(offset) = offset else {
676 return bytes.len();
677 };
678 at += offset;
679 let byte = bytes[at];
680 match state {
681 Script::Data => {
682 if byte == b'<' && self.appropriate_end(at) {
683 return at;
684 }
685 if bytes[at..].starts_with(b"<!--") {
686 state = Script::DashDash;
687 at += 4;
688 continue;
689 }
690 }
691 Script::Escaped | Script::Dash | Script::DashDash => {
692 if byte == b'<' {
693 if self.appropriate_end(at) {
694 return at;
695 }
696 if bytes.get(at + 1).is_some_and(u8::is_ascii_alphabetic) {
697 let word_start = at + 1;
698 at = word_start;
699 while bytes.get(at).is_some_and(u8::is_ascii_alphabetic) {
700 at += 1;
701 }
702 state = if bytes
703 .get(at)
704 .is_some_and(|&b| is_space(b) || matches!(b, b'/' | b'>'))
705 && bytes[word_start..at].eq_ignore_ascii_case(b"script")
706 {
707 Script::Double
708 } else {
709 Script::Escaped
710 };
711 continue;
712 }
713 state = Script::Escaped;
714 } else if byte == b'-' {
715 state = if matches!(state, Script::Escaped) {
716 Script::Dash
717 } else {
718 Script::DashDash
719 };
720 } else if byte == b'>' && matches!(state, Script::DashDash) {
721 state = Script::Data;
722 } else {
723 state = Script::Escaped;
724 }
725 }
726 Script::Double | Script::DoubleDash | Script::DoubleDashDash => {
727 if byte == b'<' {
728 if bytes.get(at + 1) == Some(&b'/') {
729 let word_start = at + 2;
730 at = word_start;
731 while bytes.get(at).is_some_and(u8::is_ascii_alphabetic) {
732 at += 1;
733 }
734 state = if bytes
735 .get(at)
736 .is_some_and(|&b| is_space(b) || matches!(b, b'/' | b'>'))
737 && bytes[word_start..at].eq_ignore_ascii_case(b"script")
738 {
739 Script::Escaped
740 } else {
741 Script::Double
742 };
743 continue;
744 }
745 state = Script::Double;
746 } else if byte == b'-' {
747 state = if matches!(state, Script::Double) {
748 Script::DoubleDash
749 } else {
750 Script::DoubleDashDash
751 };
752 } else if byte == b'>' && matches!(state, Script::DoubleDashDash) {
753 state = Script::Data;
754 } else {
755 state = Script::Double;
756 }
757 }
758 }
759 at += 1;
760 }
761 at
762 }
763
764 #[inline(always)]
768 pub(crate) fn next_with_attribute_buffer(
769 &mut self,
770 attribute_buffer: &mut Vec<Attribute<'a>>,
771 ) -> Option<Token<'a>> {
772 loop {
773 self.current()?;
774 let start = self.position;
775 match self.state {
776 State::Plaintext => {
777 self.position = self.source.len();
778 return Some(Token::Text(normalize_text(&self.source[start..])));
779 }
780 State::Cdata => {
781 let rest = &self.source[start..];
782 let end = rest.find("]]>").map_or(self.source.len(), |at| start + at);
783 self.position = (end + 3).min(self.source.len());
784 self.state = State::Data;
785 if end > start {
786 return Some(Token::Text(normalize(&self.source[start..end])));
787 }
788 continue;
789 }
790 State::Rcdata | State::Rawtext | State::ScriptData => {
791 if self.appropriate_end(start) {
792 self.state = State::Data;
793 return self.tag(true, attribute_buffer);
794 }
795 let end = if self.state == State::ScriptData {
796 self.script_end(start)
797 } else {
798 let mut end = start;
799 loop {
800 let Some(offset) = memchr(b'<', &self.source.as_bytes()[end..]) else {
801 break self.source.len();
802 };
803 end += offset;
804 if self.appropriate_end(end) {
805 break end;
806 }
807 end += 1;
808 }
809 };
810 self.position = end;
811 let text = if self.state == State::Rcdata {
812 replace_null(decode(&self.source[start..end], false))
813 } else {
814 normalize_text(&self.source[start..end])
815 };
816 return Some(Token::Text(text));
817 }
818 State::Data => {}
819 }
820 if self.current() != Some(b'<') {
821 let rest = &self.source.as_bytes()[start..];
822 let prefix = rest.len().min(16);
823 let first = rest[..prefix]
824 .iter()
825 .position(|byte| matches!(byte, b'<' | b'&' | b'\r'))
826 .or_else(|| memchr3(b'<', b'&', b'\r', &rest[prefix..]).map(|at| prefix + at))
827 .unwrap_or(rest.len());
828 if first == rest.len() || rest[first] == b'<' {
829 self.position += first;
830 return Some(Token::Text(Cow::Borrowed(
831 &self.source[start..self.position],
832 )));
833 }
834 self.position = memchr(b'<', &rest[first..])
835 .map_or(self.source.len(), |offset| start + first + offset);
836 return Some(Token::Text(decode_from(
837 &self.source[start..self.position],
838 false,
839 first,
840 )));
841 }
842 let rest = &self.source.as_bytes()[start..];
843 match rest.get(1).copied() {
844 Some(byte) if byte.is_ascii_alphabetic() => {
845 return self.tag(false, attribute_buffer);
846 }
847 Some(b'/') => match rest.get(2).copied() {
848 Some(byte) if byte.is_ascii_alphabetic() => {
849 return self.tag(true, attribute_buffer);
850 }
851 Some(b'>') => {
852 self.position += 3;
853 continue;
854 }
855 None => {
856 self.position += 2;
857 return Some(Token::Text(Cow::Borrowed("</")));
858 }
859 _ => {
860 self.position += 2;
861 return Some(self.bogus_comment(start + 2));
862 }
863 },
864 Some(b'!') => {
865 self.position += 2;
866 if rest.starts_with(b"<!--") {
867 self.position += 2;
868 return Some(self.comment());
869 }
870 if rest
871 .get(2..9)
872 .is_some_and(|keyword| keyword.eq_ignore_ascii_case(b"DOCTYPE"))
873 {
874 self.position += 7;
875 return Some(self.doctype());
876 }
877 return Some(self.bogus_comment(start + 2));
878 }
879 Some(b'?') => return self.processing_instruction(),
880 _ => {
881 self.position += 1;
882 let bytes = self.source.as_bytes();
885 loop {
886 let Some(offset) = memchr(b'<', &bytes[self.position..]) else {
887 self.position = bytes.len();
888 break;
889 };
890 self.position += offset;
891 if bytes.get(self.position + 1).is_some_and(|byte| {
892 byte.is_ascii_alphabetic() || matches!(byte, b'/' | b'!' | b'?')
893 }) {
894 break;
895 }
896 self.position += 1;
897 }
898 return Some(Token::Text(decode(
899 &self.source[start..self.position],
900 false,
901 )));
902 }
903 }
904 }
905 }
906}
907
908impl<'a> Iterator for Tokenizer<'a> {
909 type Item = Token<'a>;
910
911 fn next(&mut self) -> Option<Self::Item> {
912 self.next_with_attribute_buffer(&mut Vec::new())
913 }
914}
915
916impl FusedIterator for Tokenizer<'_> {}
917
918fn can_end_text(name: &str) -> bool {
920 !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphabetic())
921}
922
923fn is_space(byte: u8) -> bool {
924 matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
925}
926
927#[inline(always)]
929pub(crate) fn matches_normalized_name(name: &[u8], candidate: &[u8]) -> bool {
930 if name.len() >= 16 {
932 return name.eq_ignore_ascii_case(candidate);
933 }
934 name.len() == candidate.len()
935 && name
936 .iter()
937 .zip(candidate)
938 .all(|(&name, &candidate)| name == candidate.to_ascii_lowercase())
939}
940
941#[inline(never)]
943fn normalize_name(input: &str) -> Cow<'_, str> {
944 if let [byte @ b'A'..=b'Z'] = input.as_bytes() {
945 let start = usize::from(byte - b'A');
946 return Cow::Borrowed(&"abcdefghijklmnopqrstuvwxyz"[start..start + 1]);
947 }
948 let names: &'static [&str] = match input.len() {
949 2 => &["br", "hr"],
950 3 => &["col", "img", "wbr", "xmp"],
951 4 if matches!(input.as_bytes()[0], b'h' | b'H') => &["head", "href", "html"],
952 4 => &["area", "base", "body", "link", "meta", "name"],
953 5 => &["embed", "input", "param", "style", "title", "track"],
954 6 => &["iframe", "script", "source"],
955 7 => &["content", "noembed"],
956 8 => &["noframes", "textarea"],
957 9 => &["plaintext"],
958 _ => &[],
959 };
960 if let Some(&name) = names
961 .iter()
962 .find(|name| matches_normalized_name(name.as_bytes(), input.as_bytes()))
963 {
964 return Cow::Borrowed(name);
965 }
966 replace_null(Cow::Owned(input.to_ascii_lowercase()))
967}
968
969#[inline]
971fn normalize_text(input: &str) -> Cow<'_, str> {
972 let Some(first) = memchr2(b'\r', 0, input.as_bytes()) else {
973 return Cow::Borrowed(input);
974 };
975 Cow::Owned(normalize_text_slow(input, first))
976}
977
978#[inline(never)]
980fn normalize_text_slow(input: &str, first: usize) -> String {
981 let nulls = memchr_iter(0, &input.as_bytes()[first..]).count();
982 let capacity = input.len().saturating_add(nulls.saturating_mul(2));
983 let mut output = String::with_capacity(capacity);
984 output.push_str(&input[..first]);
985 let mut rest = &input[first..];
986 while let Some(at) = memchr2(b'\r', 0, rest.as_bytes()) {
987 output.push_str(&rest[..at]);
988 let newline = rest.as_bytes()[at] == b'\r';
989 output.push(if newline { '\n' } else { '\u{fffd}' });
990 rest = &rest[at + 1..];
991 if newline && let Some(suffix) = rest.strip_prefix('\n') {
992 rest = suffix;
993 }
994 }
995 output.push_str(rest);
996 output
997}
998
999#[inline(never)]
1001fn replace_null(input: Cow<'_, str>) -> Cow<'_, str> {
1002 if input.as_bytes().contains(&0) {
1003 replace_null_slow(input)
1004 } else {
1005 input
1006 }
1007}
1008
1009#[inline(never)]
1011fn replace_null_slow(input: Cow<'_, str>) -> Cow<'_, str> {
1012 let Cow::Owned(input) = input else {
1013 return Cow::Owned(input.replace('\0', "\u{fffd}"));
1014 };
1015 let first = memchr(0, input.as_bytes()).expect("input contains NUL");
1016 let nulls = memchr_iter(0, &input.as_bytes()[first..]).count();
1017 let mut bytes = input.into_bytes();
1018 let read = bytes.len();
1019 bytes.reserve_exact(2 * nulls);
1020 let mut write = read + 2 * nulls;
1021 bytes.resize(write, 0);
1022 for read in (first..read).rev() {
1024 if bytes[read] == 0 {
1025 write -= 3;
1026 bytes[write..write + 3].copy_from_slice("\u{fffd}".as_bytes());
1027 } else {
1028 write -= 1;
1029 bytes[write] = bytes[read];
1030 }
1031 }
1032 Cow::Owned(String::from_utf8(bytes).expect("NUL replacement preserves UTF-8"))
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use std::borrow::Cow;
1038
1039 use super::{normalize_text, replace_null};
1040
1041 #[test]
1042 fn combined_text_normalization_preserves_unicode_and_newlines() {
1043 assert!(matches!(normalize_text("plain 🦀 text"), Cow::Borrowed(_)));
1044 for (source, expected) in [
1045 ("\r\n\r\n", "\n\n"),
1046 ("é\r\0\n🦀\0", "é\n�\n🦀�"),
1047 ("\0\0\r", "��\n"),
1048 ("prefix\r\nsuffix\0", "prefix\nsuffix�"),
1049 ] {
1050 assert_eq!(normalize_text(source), expected);
1051 }
1052 }
1053
1054 #[test]
1055 fn ordinary_comments_borrow_punctuation() {
1056 for text in [
1057 "generated-by tool <meta> and > punctuation",
1058 "-leading and mid-word",
1059 "<!",
1060 "é 🦀 - < >",
1061 ] {
1062 let source = format!("<!--{text}-->");
1063 assert!(matches!(
1064 super::Tokenizer::new(&source).next(),
1065 Some(super::Token::Comment(Cow::Borrowed(value))) if value == text
1066 ));
1067 }
1068 }
1069
1070 #[test]
1071 fn owned_null_replacement_reuses_spare_capacity() {
1072 for source in ["\0", "\0\0\0", "é\0🦀\0", "prefix\0suffix"] {
1073 let mut input = String::with_capacity(3 * source.len());
1074 input.push_str(source);
1075 let allocation = input.as_ptr();
1076 let output = replace_null(Cow::Owned(input));
1077 assert_eq!(output, source.replace('\0', "\u{fffd}"));
1078 assert_eq!(output.as_ptr(), allocation);
1079 }
1080 }
1081}