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