1use crate::extract::extract_tokens;
8use crate::structural::StructuralIndexer;
9use crate::token::Token;
10use fhp_core::tag::Tag;
11
12const MAX_RESIDUAL: usize = 4096;
17
18pub const MAX_RAW_TEXT_RESIDUAL: usize = 2 * 1024 * 1024;
26
27pub struct StreamTokenizer {
53 indexer: StructuralIndexer,
54 residual: Vec<u8>,
56 working: Vec<u8>,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61struct SplitScan {
62 split: usize,
63 in_raw_text_context: bool,
64}
65
66impl StreamTokenizer {
67 pub fn new() -> Self {
69 Self {
70 indexer: StructuralIndexer::new(),
71 residual: Vec::with_capacity(256),
72 working: Vec::with_capacity(4096),
73 }
74 }
75
76 pub fn buffered_len(&self) -> usize {
80 self.residual.len()
81 }
82
83 pub fn feed(&mut self, chunk: &[u8]) -> Vec<Token<'static>> {
88 if chunk.is_empty() {
89 return Vec::new();
90 }
91
92 let mut working = std::mem::take(&mut self.working);
95 working.clear();
96 working.extend_from_slice(&self.residual);
97 working.extend_from_slice(chunk);
98 self.residual.clear();
99
100 let scan = scan_safe_split(&working);
103 let split = scan.split;
104
105 if split == 0 {
106 if working.len() > MAX_RESIDUAL
108 && (!scan.in_raw_text_context || working.len() > MAX_RAW_TEXT_RESIDUAL)
109 {
110 let tokens = self.process_chunk(&working);
112 self.working = working;
113 return tokens;
114 }
115 std::mem::swap(&mut self.residual, &mut working);
118 self.working = working;
119 return Vec::new();
120 }
121
122 let tokens = self.process_chunk(&working[..split]);
124
125 self.residual.extend_from_slice(&working[split..]);
127
128 self.working = working;
130
131 tokens
132 }
133
134 pub fn feed_str_with(&mut self, chunk: &str, mut on_token: impl FnMut(&Token<'_>)) {
139 if chunk.is_empty() {
140 return;
141 }
142
143 let mut working = std::mem::take(&mut self.working);
145 working.clear();
146 working.extend_from_slice(&self.residual);
147 working.extend_from_slice(chunk.as_bytes());
148 self.residual.clear();
149
150 let scan = scan_safe_split(&working);
151 let split = scan.split;
152
153 if split == 0 {
154 if working.len() > MAX_RESIDUAL
155 && (!scan.in_raw_text_context || working.len() > MAX_RAW_TEXT_RESIDUAL)
156 {
157 match std::str::from_utf8(&working) {
159 Ok(text) => {
160 let tokens = self.process_chunk_borrowed(text);
161 for token in &tokens {
162 on_token(token);
163 }
164 }
165 Err(_) => {
166 let text = String::from_utf8_lossy(&working).into_owned();
167 let tokens = self.process_chunk_borrowed(&text);
168 for token in &tokens {
169 on_token(token);
170 }
171 }
172 }
173 self.working = working;
174 return;
175 }
176 std::mem::swap(&mut self.residual, &mut working);
177 self.working = working;
178 return;
179 }
180
181 match std::str::from_utf8(&working[..split]) {
182 Ok(text) => {
183 let tokens = self.process_chunk_borrowed(text);
184 for token in &tokens {
185 on_token(token);
186 }
187 }
188 Err(_) => {
189 let text = String::from_utf8_lossy(&working[..split]).into_owned();
190 let tokens = self.process_chunk_borrowed(&text);
191 for token in &tokens {
192 on_token(token);
193 }
194 }
195 }
196
197 self.residual.extend_from_slice(&working[split..]);
198 self.working = working;
199 }
200
201 pub fn finish(&mut self) -> Vec<Token<'static>> {
203 if self.residual.is_empty() {
204 return Vec::new();
205 }
206 let remaining = std::mem::take(&mut self.residual);
207 self.process_chunk(&remaining)
208 }
209
210 pub fn finish_with(&mut self, mut on_token: impl FnMut(&Token<'_>)) {
212 if self.residual.is_empty() {
213 return;
214 }
215 let remaining = std::mem::take(&mut self.residual);
216 match std::str::from_utf8(&remaining) {
217 Ok(text) => {
218 let tokens = self.process_chunk_borrowed(text);
219 for token in &tokens {
220 on_token(token);
221 }
222 }
223 Err(_) => {
224 let text = String::from_utf8_lossy(&remaining).into_owned();
225 let tokens = self.process_chunk_borrowed(&text);
226 for token in &tokens {
227 on_token(token);
228 }
229 }
230 }
231 }
232
233 fn process_chunk(&mut self, data: &[u8]) -> Vec<Token<'static>> {
235 match std::str::from_utf8(data) {
236 Ok(text) => {
237 let index = self.indexer.index(text.as_bytes());
238 let tokens = extract_tokens(text, &index);
239 tokens.into_iter().map(to_owned_token).collect()
240 }
241 Err(_) => {
242 let text = String::from_utf8_lossy(data).into_owned();
243 let index = self.indexer.index(text.as_bytes());
244 let tokens = extract_tokens(&text, &index);
245 tokens.into_iter().map(to_owned_token).collect()
246 }
247 }
248 }
249
250 fn process_chunk_borrowed<'a>(&mut self, data: &'a str) -> Vec<Token<'a>> {
252 let index = self.indexer.index(data.as_bytes());
253 extract_tokens(data, &index)
254 }
255}
256
257impl Default for StreamTokenizer {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263fn scan_safe_split(data: &[u8]) -> SplitScan {
264 #[derive(Clone, Copy)]
265 enum Mode {
266 Data,
267 Tag {
268 quote: Option<u8>,
269 open: usize,
270 raw_text_close: Option<Tag>,
271 },
272 Doctype {
273 quote: Option<u8>,
274 },
275 Comment,
276 CData,
277 }
278
279 let mut mode = Mode::Data;
280 let mut raw_text = None;
281 let mut i = 0usize;
282 let mut last_safe = 0usize;
283
284 while i < data.len() {
285 match mode {
286 Mode::Data => {
287 if let Some(tag) = raw_text {
288 if data[i] == b'<' && is_raw_text_close(data, i, tag) {
289 mode = Mode::Tag {
290 quote: None,
291 open: i,
292 raw_text_close: Some(tag),
293 };
294 }
295 i += 1;
296 continue;
297 }
298
299 if data[i] == b'<' {
300 if i + 3 < data.len() && &data[i..i + 4] == b"<!--" {
302 mode = Mode::Comment;
303 i += 4;
304 continue;
305 }
306
307 if i + 8 < data.len() && &data[i..i + 9] == b"<![CDATA[" {
309 mode = Mode::CData;
310 i += 9;
311 continue;
312 }
313
314 if i + 1 < data.len() {
315 let next = data[i + 1];
316 if next == b'!' {
318 mode = Mode::Doctype { quote: None };
319 i += 2;
320 continue;
321 }
322 if next == b'/'
324 || next.is_ascii_alphabetic()
325 || next == b'_'
326 || next == b'?'
327 {
328 mode = Mode::Tag {
329 quote: None,
330 open: i,
331 raw_text_close: None,
332 };
333 i += 1;
334 continue;
335 }
336 }
337 }
338 i += 1;
339 }
340 Mode::Tag {
341 mut quote,
342 open,
343 raw_text_close,
344 } => {
345 if let Some(q) = quote {
346 if data[i] == q {
347 quote = None;
348 }
349 mode = Mode::Tag {
350 quote,
351 open,
352 raw_text_close,
353 };
354 i += 1;
355 continue;
356 }
357 match data[i] {
358 b'"' | b'\'' => {
359 mode = Mode::Tag {
360 quote: Some(data[i]),
361 open,
362 raw_text_close,
363 };
364 i += 1;
365 }
366 b'>' => {
367 if raw_text_close.is_some() {
368 last_safe = i + 1;
369 raw_text = None;
370 } else if let Some(tag) = raw_text_open_tag(&data[open + 1..i]) {
371 raw_text = Some(tag);
372 } else {
373 last_safe = i + 1;
374 }
375 mode = Mode::Data;
376 i += 1;
377 }
378 _ => i += 1,
379 }
380 }
381 Mode::Doctype { mut quote } => {
382 if let Some(q) = quote {
383 if data[i] == q {
384 quote = None;
385 }
386 mode = Mode::Doctype { quote };
387 i += 1;
388 continue;
389 }
390 match data[i] {
391 b'"' | b'\'' => {
392 mode = Mode::Doctype {
393 quote: Some(data[i]),
394 };
395 i += 1;
396 }
397 b'>' => {
398 last_safe = i + 1;
399 mode = Mode::Data;
400 i += 1;
401 }
402 _ => i += 1,
403 }
404 }
405 Mode::Comment => {
406 if i + 2 < data.len()
407 && data[i] == b'-'
408 && data[i + 1] == b'-'
409 && data[i + 2] == b'>'
410 {
411 last_safe = i + 3;
412 mode = Mode::Data;
413 i += 3;
414 } else {
415 i += 1;
416 }
417 }
418 Mode::CData => {
419 if i + 2 < data.len()
420 && data[i] == b']'
421 && data[i + 1] == b']'
422 && data[i + 2] == b'>'
423 {
424 last_safe = i + 3;
425 mode = Mode::Data;
426 i += 3;
427 } else {
428 i += 1;
429 }
430 }
431 }
432 }
433
434 SplitScan {
435 split: last_safe,
436 in_raw_text_context: raw_text.is_some()
437 || matches!(
438 mode,
439 Mode::Tag {
440 raw_text_close: Some(_),
441 ..
442 }
443 ),
444 }
445}
446
447fn raw_text_open_tag(tag_body: &[u8]) -> Option<Tag> {
448 if tag_body.is_empty() || tag_body[0] == b'/' {
449 return None;
450 }
451
452 let mut end = tag_body.len();
453 while end > 0 && tag_body[end - 1].is_ascii_whitespace() {
454 end -= 1;
455 }
456 if end == 0 || tag_body[end - 1] == b'/' {
457 return None;
458 }
459
460 let mut name_end = 0usize;
461 while name_end < end && !tag_body[name_end].is_ascii_whitespace() && tag_body[name_end] != b'/'
462 {
463 name_end += 1;
464 }
465 if name_end == 0 {
466 return None;
467 }
468
469 let tag = Tag::from_bytes(&tag_body[..name_end]);
470 tag.is_raw_text().then_some(tag)
471}
472
473fn is_raw_text_close(data: &[u8], pos: usize, tag: Tag) -> bool {
474 let remaining = &data[pos..];
475 if remaining.len() < 3 || remaining[1] != b'/' {
476 return false;
477 }
478
479 let tag_name = tag.as_str().unwrap_or("");
480 let name_len = tag_name.len();
481 if remaining.len() < 2 + name_len + 1 {
482 return false;
483 }
484
485 let candidate = &remaining[2..2 + name_len];
486 if !candidate.eq_ignore_ascii_case(tag_name.as_bytes()) {
487 return false;
488 }
489
490 let after = remaining[2 + name_len];
491 after == b'>' || after.is_ascii_whitespace()
492}
493
494fn to_owned_token(token: Token<'_>) -> Token<'static> {
496 match token {
497 Token::OpenTag {
498 tag,
499 name,
500 attributes,
501 self_closing,
502 } => Token::OpenTag {
503 tag,
504 name: std::borrow::Cow::Owned(name.into_owned()),
505 attributes: attributes.into_iter().map(to_owned_attr).collect(),
506 self_closing,
507 },
508 Token::CloseTag { tag, name } => Token::CloseTag {
509 tag,
510 name: std::borrow::Cow::Owned(name.into_owned()),
511 },
512 Token::Text { content } => Token::Text {
513 content: std::borrow::Cow::Owned(content.into_owned()),
514 },
515 Token::Comment { content } => Token::Comment {
516 content: std::borrow::Cow::Owned(content.into_owned()),
517 },
518 Token::Doctype { content } => Token::Doctype {
519 content: std::borrow::Cow::Owned(content.into_owned()),
520 },
521 Token::CData { content } => Token::CData {
522 content: std::borrow::Cow::Owned(content.into_owned()),
523 },
524 }
525}
526
527fn to_owned_attr(attr: crate::token::Attribute<'_>) -> crate::token::Attribute<'static> {
529 crate::token::Attribute {
530 name: std::borrow::Cow::Owned(attr.name.into_owned()),
531 value: attr.value.map(|v| std::borrow::Cow::Owned(v.into_owned())),
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn single_chunk() {
541 let mut tok = StreamTokenizer::new();
542 let tokens = tok.feed(b"<div>hello</div>");
543 let final_tokens = tok.finish();
544
545 let all: Vec<_> = tokens.into_iter().chain(final_tokens).collect();
546 assert!(all.iter().any(|t| matches!(t, Token::OpenTag { .. })));
547 assert!(all.iter().any(|t| matches!(t, Token::CloseTag { .. })));
548 }
549
550 #[test]
551 fn multi_chunk() {
552 let html = b"<div>hello</div>";
553 let mut tok = StreamTokenizer::new();
554 let mut all = Vec::new();
555
556 for &b in html.iter() {
558 all.extend(tok.feed(&[b]));
559 }
560 all.extend(tok.finish());
561
562 let has_open = all.iter().any(|t| matches!(t, Token::OpenTag { .. }));
563 let has_close = all.iter().any(|t| matches!(t, Token::CloseTag { .. }));
564 let has_text = all.iter().any(|t| matches!(t, Token::Text { .. }));
565
566 assert!(has_open, "should have open tag");
567 assert!(has_close, "should have close tag");
568 assert!(has_text, "should have text");
569 }
570
571 #[test]
572 fn chunk_size_7() {
573 let html = b"<div class=\"test\">hello world</div>";
574 let mut tok = StreamTokenizer::new();
575 let mut all = Vec::new();
576
577 for chunk in html.chunks(7) {
578 all.extend(tok.feed(chunk));
579 }
580 all.extend(tok.finish());
581
582 assert!(all.iter().any(|t| matches!(t, Token::OpenTag { .. })));
583 assert!(all.iter().any(|t| matches!(t, Token::CloseTag { .. })));
584 }
585
586 #[test]
587 fn chunk_size_64() {
588 let html = b"<html><head><title>Test</title></head><body><div class=\"main\"><p>Hello</p></div></body></html>";
589 let mut tok = StreamTokenizer::new();
590 let mut all = Vec::new();
591
592 for chunk in html.chunks(64) {
593 all.extend(tok.feed(chunk));
594 }
595 all.extend(tok.finish());
596
597 let open_count = all
598 .iter()
599 .filter(|t| matches!(t, Token::OpenTag { .. }))
600 .count();
601 assert!(open_count >= 5, "should have multiple open tags");
602 }
603
604 #[test]
605 fn empty_chunks() {
606 let mut tok = StreamTokenizer::new();
607 let t1 = tok.feed(b"");
608 let t2 = tok.feed(b"<br/>");
609 let t3 = tok.feed(b"");
610 let t4 = tok.finish();
611
612 let all: Vec<_> = t1.into_iter().chain(t2).chain(t3).chain(t4).collect();
613 assert!(all.iter().any(|t| matches!(t, Token::OpenTag { .. })));
614 }
615
616 #[test]
617 fn find_safe_split_basic() {
618 assert_eq!(scan_safe_split(b"<div>hello</div>").split, 16);
619 assert_eq!(scan_safe_split(b"<div>hello").split, 5);
620 assert_eq!(scan_safe_split(b"hello").split, 0);
621 }
622
623 #[test]
624 fn find_safe_split_buffers_open_raw_text_context() {
625 let scan = scan_safe_split(b"<div><script>if(a<b)");
626
627 assert_eq!(scan.split, 5);
628 assert!(scan.in_raw_text_context);
629 }
630
631 #[test]
632 fn raw_text_split_after_script_open() {
633 let mut tok = StreamTokenizer::new();
634 let mut all = Vec::new();
635
636 all.extend(tok.feed(b"<script>"));
637 all.extend(tok.feed(b"if(a<b)"));
638 all.extend(tok.feed(b"{x()}</script>"));
639 all.extend(tok.finish());
640
641 let open_tags: Vec<_> = all
642 .iter()
643 .filter_map(|token| match token {
644 Token::OpenTag { tag, .. } => Some(*tag),
645 _ => None,
646 })
647 .collect();
648 let close_tags: Vec<_> = all
649 .iter()
650 .filter_map(|token| match token {
651 Token::CloseTag { tag, .. } => Some(*tag),
652 _ => None,
653 })
654 .collect();
655 let text: Vec<_> = all
656 .iter()
657 .filter_map(|token| match token {
658 Token::Text { content } => Some(content.as_ref()),
659 _ => None,
660 })
661 .collect();
662
663 assert_eq!(open_tags, vec![Tag::Script]);
664 assert_eq!(close_tags, vec![Tag::Script]);
665 assert_eq!(text, vec!["if(a<b){x()}"]);
666 }
667}