1use memchr::{memchr, memchr2};
26
27use super::context::{ContextTracker, ParsingAmbiguityError, TextMode};
28use super::name::LocalNameHash;
29use super::sink::TokenSink;
30use super::token::{AttrRange, Cdata, Comment, Doctype, EndTag, Span, StartTag, Text};
31
32const MARKUP_DECL_PREFIX: &[u8] = b"<!";
34const COMMENT_PREFIX: &[u8] = b"<!--";
36const COMMENT_SUFFIX: &[u8] = b"-->";
38const CDATA_PREFIX: &[u8] = b"<![CDATA[";
40const CDATA_SUFFIX: &[u8] = b"]]>";
42const DOCTYPE_KEYWORD: &[u8] = b"doctype";
44const SCRIPT_NAME: &[u8] = b"script";
46
47#[derive(Debug)]
49pub struct Tokenizer {
50 buffer: Vec<u8>,
52 attributes: Vec<AttrRange>,
54 context: ContextTracker,
55 strict: bool,
56}
57
58impl Default for Tokenizer {
59 fn default() -> Self {
60 Self {
61 buffer: Vec::new(),
62 attributes: Vec::new(),
63 context: ContextTracker::new(false),
64 strict: false,
65 }
66 }
67}
68
69pub fn tokenize<S: TokenSink>(input: &[u8], sink: &mut S) -> Result<(), ParsingAmbiguityError> {
78 Tokenizer::new().tokenize(input, sink)
79}
80
81impl Tokenizer {
82 #[must_use]
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 rama_utils::macros::generate_set_and_with! {
93 pub fn strict(mut self, strict: bool) -> Self {
96 self.strict = strict;
97 self.context = ContextTracker::new(strict);
98 self
99 }
100 }
101
102 pub fn write<S: TokenSink>(
108 &mut self,
109 chunk: &[u8],
110 sink: &mut S,
111 ) -> Result<(), ParsingAmbiguityError> {
112 self.buffer.extend_from_slice(chunk);
113 self.run(false, sink)
114 }
115
116 pub fn end<S: TokenSink>(&mut self, sink: &mut S) -> Result<(), ParsingAmbiguityError> {
123 let result = self.run(true, sink);
124 self.reset();
125 result
126 }
127
128 pub fn tokenize<S: TokenSink>(
134 &mut self,
135 input: &[u8],
136 sink: &mut S,
137 ) -> Result<(), ParsingAmbiguityError> {
138 if let Err(err) = self.write(input, sink) {
139 self.reset();
140 return Err(err);
141 }
142 self.end(sink)
143 }
144
145 fn reset(&mut self) {
146 self.buffer.clear();
147 self.context = ContextTracker::new(self.strict);
148 }
149
150 fn run<S: TokenSink>(
154 &mut self,
155 is_final: bool,
156 sink: &mut S,
157 ) -> Result<(), ParsingAmbiguityError> {
158 let Self {
161 buffer,
162 attributes,
163 context,
164 ..
165 } = self;
166
167 let mut pos = 0;
168 let mut text_start = 0;
170
171 loop {
172 let Some(rel) = memchr(b'<', buffer.get(pos..).unwrap_or(&[])) else {
173 emit_text(buffer, text_start, buffer.len(), sink);
175 text_start = buffer.len();
176 break;
177 };
178 let lt = pos + rel;
179
180 match classify(buffer, lt, context.cdata_allowed()) {
181 Resolve::Text => pos = lt + 1, Resolve::NeedMore => {
183 if is_final {
184 pos = lt + 1; } else {
186 emit_text(buffer, text_start, lt, sink);
187 text_start = lt;
188 break;
189 }
190 }
191 Resolve::Construct(construct) => {
192 if !is_final && !terminator_available(construct, buffer, lt, context) {
193 emit_text(buffer, text_start, lt, sink);
194 text_start = lt;
195 break;
196 }
197 emit_text(buffer, text_start, lt, sink);
198 pos = process(construct, attributes, context, buffer, lt, sink)?;
199 text_start = pos;
200 }
201 }
202 }
203
204 buffer.drain(..text_start);
205 Ok(())
206 }
207}
208
209enum Resolve {
211 Text,
213 NeedMore,
215 Construct(Construct),
216}
217
218#[derive(Debug, Clone, Copy)]
219enum Construct {
220 StartTag,
221 EndTag,
222 Comment,
223 Cdata,
224 Doctype,
225 BogusComment(usize),
227}
228
229#[derive(Debug, Clone, Copy)]
231struct RawScan {
232 mode: TextMode,
233 name: Span,
235}
236
237fn classify(input: &[u8], lt: usize, cdata_allowed: bool) -> Resolve {
241 match input.get(lt + 1) {
242 None => Resolve::NeedMore,
243 Some(b) if b.is_ascii_alphabetic() => Resolve::Construct(Construct::StartTag),
244 Some(b'/') => match input.get(lt + 2) {
245 None => Resolve::NeedMore,
246 Some(c) if c.is_ascii_alphabetic() => Resolve::Construct(Construct::EndTag),
247 Some(_) => Resolve::Construct(Construct::BogusComment(2)),
248 },
249 Some(b'!') => classify_markup_declaration(input, lt, cdata_allowed),
250 Some(b'?') => Resolve::Construct(Construct::BogusComment(1)),
251 Some(_) => Resolve::Text,
252 }
253}
254
255fn classify_markup_declaration(input: &[u8], lt: usize, cdata_allowed: bool) -> Resolve {
256 let rest = input.get(lt + MARKUP_DECL_PREFIX.len()..).unwrap_or(&[]);
258
259 if rest.starts_with(b"--") {
260 return Resolve::Construct(Construct::Comment);
261 }
262 if is_strict_prefix(rest, b"--") {
263 return Resolve::NeedMore;
264 }
265 if cdata_allowed {
266 let cdata_inner = &CDATA_PREFIX[MARKUP_DECL_PREFIX.len()..]; if rest.starts_with(cdata_inner) {
268 return Resolve::Construct(Construct::Cdata);
269 }
270 if is_strict_prefix(rest, cdata_inner) {
271 return Resolve::NeedMore;
272 }
273 }
274 if starts_with_ci(rest, DOCTYPE_KEYWORD) {
275 return Resolve::Construct(Construct::Doctype);
276 }
277 if is_strict_prefix_ci(rest, DOCTYPE_KEYWORD) {
278 return Resolve::NeedMore;
279 }
280 Resolve::Construct(Construct::BogusComment(MARKUP_DECL_PREFIX.len()))
281}
282
283fn terminator_available(
287 construct: Construct,
288 input: &[u8],
289 lt: usize,
290 context: &ContextTracker,
291) -> bool {
292 match construct {
293 Construct::StartTag => {
294 let name_end = scan_tag_name(input, lt + 1);
295 let (close, _self_closing, complete) = scan_attributes(None, input, name_end);
296 if !complete {
297 return false;
298 }
299 let name = slice(input, lt + 1, name_end);
300 match context.peek_text_mode(LocalNameHash::of(name)) {
301 None => true,
302 Some(mode) => find_body_end(mode, input, close, name).is_some(),
303 }
304 }
305 Construct::EndTag => scan_attributes(None, input, scan_tag_name(input, lt + 2)).2,
306 Construct::Comment => find_seq(input, lt + COMMENT_PREFIX.len(), COMMENT_SUFFIX).is_some(),
307 Construct::Cdata => find_seq(input, lt + CDATA_PREFIX.len(), CDATA_SUFFIX).is_some(),
308 Construct::Doctype => {
309 let from = lt + MARKUP_DECL_PREFIX.len() + DOCTYPE_KEYWORD.len();
310 memchr(b'>', input.get(from..).unwrap_or(&[])).is_some()
311 }
312 Construct::BogusComment(open) => {
313 memchr(b'>', input.get(lt + open..).unwrap_or(&[])).is_some()
314 }
315 }
316}
317
318fn process<S: TokenSink>(
320 construct: Construct,
321 attributes: &mut Vec<AttrRange>,
322 context: &mut ContextTracker,
323 input: &[u8],
324 lt: usize,
325 sink: &mut S,
326) -> Result<usize, ParsingAmbiguityError> {
327 Ok(match construct {
328 Construct::StartTag => {
329 let (close, name_hash, name, self_closing) =
330 scan_start_tag(attributes, input, lt, sink);
331 let text_mode = context.on_start_tag(
332 name_hash,
333 slice(input, name.start, name.end),
334 attributes,
335 input,
336 self_closing,
337 )?;
338 if let Some(mode) = text_mode {
339 scan_raw_text(attributes, input, close, RawScan { mode, name }, sink)
340 } else {
341 close
342 }
343 }
344 Construct::EndTag => {
345 let (close, name_hash, name) = scan_end_tag(attributes, input, lt, sink);
346 context.on_end_tag(name_hash, slice(input, name.start, name.end));
347 close
348 }
349 Construct::Comment => scan_comment(input, lt, sink),
350 Construct::Cdata => scan_cdata(input, lt, sink),
351 Construct::Doctype => scan_doctype(input, lt, sink),
352 Construct::BogusComment(open) => scan_bogus_comment(input, lt, open, sink),
353 })
354}
355
356fn scan_start_tag<S: TokenSink>(
357 attributes: &mut Vec<AttrRange>,
358 input: &[u8],
359 lt: usize,
360 sink: &mut S,
361) -> (usize, LocalNameHash, Span, bool) {
362 let name_start = lt + 1;
363 let name_end = scan_tag_name(input, name_start);
364 let name = Span::new(name_start, name_end);
365 let name_hash = LocalNameHash::of(slice(input, name_start, name_end));
366
367 attributes.clear();
368 let (close, self_closing, _complete) = scan_attributes(Some(attributes), input, name_end);
369
370 let tag = StartTag {
371 input,
372 raw: Span::new(lt, close),
373 name,
374 name_hash,
375 attributes,
376 self_closing,
377 };
378 sink.start_tag(&tag);
379 (close, name_hash, name, self_closing)
380}
381
382fn scan_end_tag<S: TokenSink>(
383 attributes: &mut Vec<AttrRange>,
384 input: &[u8],
385 lt: usize,
386 sink: &mut S,
387) -> (usize, LocalNameHash, Span) {
388 let name_start = lt + 2;
389 let name_end = scan_tag_name(input, name_start);
390 let name = Span::new(name_start, name_end);
391 let name_hash = LocalNameHash::of(slice(input, name_start, name_end));
392
393 attributes.clear();
396 let (close, _self_closing, _complete) = scan_attributes(Some(attributes), input, name_end);
397
398 let tag = EndTag {
399 input,
400 raw: Span::new(lt, close),
401 name,
402 name_hash,
403 };
404 sink.end_tag(&tag);
405 (close, name_hash, name)
406}
407
408fn scan_raw_text<S: TokenSink>(
411 attributes: &mut Vec<AttrRange>,
412 input: &[u8],
413 content_start: usize,
414 scan: RawScan,
415 sink: &mut S,
416) -> usize {
417 let name = slice(input, scan.name.start, scan.name.end);
418 if let Some(lt) = find_body_end(scan.mode, input, content_start, name) {
419 emit_text(input, content_start, lt, sink);
420 let (close, _name_hash, _name) = scan_end_tag(attributes, input, lt, sink);
421 close
422 } else {
423 emit_text(input, content_start, input.len(), sink);
424 input.len()
425 }
426}
427
428fn find_body_end(mode: TextMode, input: &[u8], content_start: usize, name: &[u8]) -> Option<usize> {
429 match mode {
430 TextMode::PlainText => None,
431 TextMode::RawText | TextMode::RcData => {
432 find_appropriate_end_tag(input, content_start, name)
433 }
434 TextMode::ScriptData => find_script_data_end(input, content_start),
435 }
436}
437
438fn scan_attributes(
447 mut attributes: Option<&mut Vec<AttrRange>>,
448 input: &[u8],
449 from: usize,
450) -> (usize, bool, bool) {
451 let mut i = from;
452 loop {
453 i = skip_space(input, i);
454 match input.get(i) {
455 None => return (input.len(), false, false),
456 Some(b'>') => return (i + 1, false, true),
457 Some(b'/') => {
458 if input.get(i + 1) == Some(&b'>') {
459 return (i + 2, true, true);
460 }
461 i += 1; }
463 Some(_) => i = scan_one_attribute(attributes.as_deref_mut(), input, i),
464 }
465 }
466}
467
468fn scan_one_attribute(attributes: Option<&mut Vec<AttrRange>>, input: &[u8], i: usize) -> usize {
469 let name_start = i;
470 let name_end = scan_attribute_name(input, i);
471
472 let after_ws = skip_space(input, name_end);
473 let (value, has_value, after) = if input.get(after_ws) == Some(&b'=') {
474 let value_pos = skip_space(input, after_ws + 1);
475 match input.get(value_pos) {
476 Some(&q @ (b'"' | b'\'')) => {
477 let (value, after) = scan_quoted_value(input, value_pos, q);
478 (value, true, after)
479 }
480 None => (Span::empty(value_pos), true, value_pos),
481 Some(_) => {
482 let (value, after) = scan_unquoted_value(input, value_pos);
483 (value, true, after)
484 }
485 }
486 } else {
487 (Span::empty(name_end), false, name_end)
488 };
489
490 if let Some(attributes) = attributes {
491 attributes.push(AttrRange {
492 name: Span::new(name_start, name_end),
493 value,
494 has_value,
495 });
496 }
497 after
498}
499
500fn emit_text<S: TokenSink>(input: &[u8], start: usize, end: usize, sink: &mut S) {
501 if end > start {
502 sink.text(&Text {
503 input,
504 raw: Span::new(start, end),
505 });
506 }
507}
508
509fn scan_comment<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
510 let data_start = lt + COMMENT_PREFIX.len();
511 let (data_end, close) = match find_seq(input, data_start, COMMENT_SUFFIX) {
512 Some(at) => (at, at + COMMENT_SUFFIX.len()),
513 None => (input.len(), input.len()),
514 };
515 sink.comment(&Comment {
516 input,
517 raw: Span::new(lt, close),
518 data: Span::new(data_start.min(data_end), data_end),
519 });
520 close
521}
522
523fn scan_cdata<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
524 let data_start = lt + CDATA_PREFIX.len();
525 let (data_end, close) = match find_seq(input, data_start, CDATA_SUFFIX) {
526 Some(at) => (at, at + CDATA_SUFFIX.len()),
527 None => (input.len(), input.len()),
528 };
529 sink.cdata(&Cdata {
530 input,
531 raw: Span::new(lt, close),
532 data: Span::new(data_start.min(data_end), data_end),
533 });
534 close
535}
536
537fn scan_doctype<S: TokenSink>(input: &[u8], lt: usize, sink: &mut S) -> usize {
538 let after_keyword = lt + MARKUP_DECL_PREFIX.len() + DOCTYPE_KEYWORD.len();
539 let (content_end, close) = match memchr(b'>', input.get(after_keyword..).unwrap_or(&[])) {
540 Some(rel) => (after_keyword + rel, after_keyword + rel + 1),
541 None => (input.len(), input.len()),
542 };
543 let name = parse_doctype_name(input, after_keyword, content_end);
544 sink.doctype(&Doctype {
545 input,
546 raw: Span::new(lt, close),
547 name,
548 });
549 close
550}
551
552fn parse_doctype_name(input: &[u8], from: usize, end: usize) -> Option<Span> {
553 let start = skip_space(input, from).min(end);
554 let mut i = start;
555 while i < end {
556 match input.get(i) {
557 Some(b) if is_html_space(*b) => break,
558 Some(_) => i += 1,
559 None => break,
560 }
561 }
562 (i > start).then(|| Span::new(start, i))
563}
564
565fn scan_bogus_comment<S: TokenSink>(
566 input: &[u8],
567 lt: usize,
568 open_len: usize,
569 sink: &mut S,
570) -> usize {
571 let data_start = lt + open_len;
572 let (data_end, close) = match memchr(b'>', input.get(data_start..).unwrap_or(&[])) {
573 Some(rel) => (data_start + rel, data_start + rel + 1),
574 None => (input.len(), input.len()),
575 };
576 sink.comment(&Comment {
577 input,
578 raw: Span::new(lt, close),
579 data: Span::new(data_start.min(data_end), data_end),
580 });
581 close
582}
583
584fn scan_tag_name(input: &[u8], from: usize) -> usize {
585 let mut i = from;
586 while let Some(&b) = input.get(i) {
587 if is_html_space(b) || b == b'/' || b == b'>' {
588 break;
589 }
590 i += 1;
591 }
592 i
593}
594
595fn scan_attribute_name(input: &[u8], from: usize) -> usize {
596 let mut i = from;
597 while let Some(&b) = input.get(i) {
598 if is_html_space(b) || b == b'=' || b == b'/' || b == b'>' {
599 break;
600 }
601 i += 1;
602 }
603 i
604}
605
606fn scan_quoted_value(input: &[u8], quote_pos: usize, quote: u8) -> (Span, usize) {
607 let start = quote_pos + 1;
608 match memchr(quote, input.get(start..).unwrap_or(&[])) {
609 Some(rel) => (Span::new(start, start + rel), start + rel + 1),
610 None => (Span::new(start, input.len()), input.len()),
611 }
612}
613
614fn scan_unquoted_value(input: &[u8], from: usize) -> (Span, usize) {
615 let mut i = from;
616 while let Some(&b) = input.get(i) {
617 if is_html_space(b) || b == b'>' {
618 break;
619 }
620 i += 1;
621 }
622 (Span::new(from, i), i)
623}
624
625fn skip_space(input: &[u8], from: usize) -> usize {
626 let mut i = from;
627 while let Some(&b) = input.get(i) {
628 if is_html_space(b) {
629 i += 1;
630 } else {
631 break;
632 }
633 }
634 i
635}
636
637const HTML_SPACE: [bool; 256] = rama_utils::byte_set::set_each([false; 256], b" \t\n\x0c\r");
640
641#[inline]
642const fn is_html_space(b: u8) -> bool {
643 HTML_SPACE[b as usize]
644}
645
646fn starts_with_ci(haystack: &[u8], lower_needle: &[u8]) -> bool {
647 haystack
648 .get(..lower_needle.len())
649 .is_some_and(|head| head.eq_ignore_ascii_case(lower_needle))
650}
651
652fn is_strict_prefix(bytes: &[u8], whole: &[u8]) -> bool {
654 bytes.len() < whole.len() && whole.starts_with(bytes)
655}
656
657fn is_strict_prefix_ci(bytes: &[u8], whole: &[u8]) -> bool {
659 bytes.len() < whole.len()
660 && whole
661 .get(..bytes.len())
662 .is_some_and(|head| head.eq_ignore_ascii_case(bytes))
663}
664
665fn find_seq(input: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
667 let Some((&first, rest)) = needle.split_first() else {
668 return Some(from);
669 };
670 let mut i = from;
671 while let Some(rel) = memchr(first, input.get(i..).unwrap_or(&[])) {
672 let at = i + rel;
673 if input.get(at + 1..at + 1 + rest.len()) == Some(rest) {
674 return Some(at);
675 }
676 i = at + 1;
677 }
678 None
679}
680
681fn is_tag_terminator(b: u8) -> bool {
682 is_html_space(b) || b == b'/' || b == b'>'
683}
684
685fn is_appropriate_end_tag(input: &[u8], lt: usize, name: &[u8]) -> bool {
688 if input.get(lt + 1) != Some(&b'/') {
689 return false;
690 }
691 let start = lt + 2;
692 let end = start + name.len();
693 if !input
694 .get(start..end)
695 .is_some_and(|s| s.eq_ignore_ascii_case(name))
696 {
697 return false;
698 }
699 matches!(input.get(end), Some(&b) if is_tag_terminator(b))
700}
701
702fn find_appropriate_end_tag(input: &[u8], from: usize, name: &[u8]) -> Option<usize> {
705 let mut i = from;
706 while let Some(rel) = memchr(b'<', input.get(i..).unwrap_or(&[])) {
707 let lt = i + rel;
708 if is_appropriate_end_tag(input, lt, name) {
709 return Some(lt);
710 }
711 i = lt + 1;
712 }
713 None
714}
715
716#[derive(Debug, Clone, Copy)]
717enum ScriptState {
718 Data,
719 Escaped,
720 DoubleEscaped,
721}
722
723fn find_script_data_end(input: &[u8], from: usize) -> Option<usize> {
727 let mut state = ScriptState::Data;
728 let mut i = from;
729 loop {
730 match state {
731 ScriptState::Data => {
732 let lt = i + memchr(b'<', input.get(i..).unwrap_or(&[]))?;
733 if is_appropriate_end_tag(input, lt, SCRIPT_NAME) {
734 return Some(lt);
735 }
736 if input.get(lt + 1) == Some(&b'!')
737 && input.get(lt + 2..).is_some_and(|s| s.starts_with(b"--"))
738 {
739 state = ScriptState::Escaped;
740 i = lt + 4;
741 } else {
742 i = lt + 1;
743 }
744 }
745 ScriptState::Escaped => {
746 let at = i + memchr2(b'<', b'-', input.get(i..).unwrap_or(&[]))?;
747 if input.get(at) == Some(&b'-') {
748 i = consume_comment_close(input, at, &mut state);
749 } else if is_appropriate_end_tag(input, at, SCRIPT_NAME) {
750 return Some(at);
752 } else if let Some(after) = double_escape_start(input, at) {
753 state = ScriptState::DoubleEscaped;
754 i = after;
755 } else {
756 i = at + 1;
757 }
758 }
759 ScriptState::DoubleEscaped => {
760 let at = i + memchr2(b'<', b'-', input.get(i..).unwrap_or(&[]))?;
761 if input.get(at) == Some(&b'-') {
762 i = consume_comment_close(input, at, &mut state);
763 } else if let Some(after) = double_escape_end(input, at) {
764 state = ScriptState::Escaped;
765 i = after;
766 } else {
767 i = at + 1;
768 }
769 }
770 }
771 }
772}
773
774fn consume_comment_close(input: &[u8], at: usize, state: &mut ScriptState) -> usize {
777 if input.get(at..).is_some_and(|s| s.starts_with(b"-->")) {
778 *state = ScriptState::Data;
779 at + 3
780 } else {
781 at + 1
782 }
783}
784
785fn double_escape_start(input: &[u8], lt: usize) -> Option<usize> {
788 script_word_boundary(input, lt + 1)
789}
790
791fn double_escape_end(input: &[u8], lt: usize) -> Option<usize> {
794 if input.get(lt + 1) != Some(&b'/') {
795 return None;
796 }
797 script_word_boundary(input, lt + 2)
798}
799
800fn script_word_boundary(input: &[u8], from: usize) -> Option<usize> {
803 let end = scan_ascii_alpha(input, from);
804 if end == from || !slice(input, from, end).eq_ignore_ascii_case(SCRIPT_NAME) {
805 return None;
806 }
807 matches!(input.get(end), Some(&b) if is_tag_terminator(b)).then_some(end)
808}
809
810fn scan_ascii_alpha(input: &[u8], from: usize) -> usize {
811 let mut i = from;
812 while input.get(i).is_some_and(u8::is_ascii_alphabetic) {
813 i += 1;
814 }
815 i
816}
817
818fn slice(input: &[u8], start: usize, end: usize) -> &[u8] {
819 input.get(start..end).unwrap_or(&[])
820}