Skip to main content

fhp_tokenizer/
streaming.rs

1//! Streaming (chunk-based) tokenizer.
2//!
3//! [`StreamTokenizer`](crate::streaming::StreamTokenizer) processes input in arbitrary-sized chunks, carrying
4//! state across chunk boundaries. This enables parsing large files or
5//! network streams without loading the entire document into memory.
6
7use crate::extract::extract_tokens;
8use crate::structural::StructuralIndexer;
9use crate::token::Token;
10use fhp_core::tag::Tag;
11
12/// Maximum size of the residual buffer.
13///
14/// When a chunk boundary falls in the middle of a tag, we buffer up to
15/// this many bytes and prepend them to the next chunk.
16const MAX_RESIDUAL: usize = 4096;
17
18/// Hard upper bound on the residual buffer, enforced even inside raw-text
19/// (`<script>`/`<style>`) context.
20///
21/// Without it, an unterminated raw-text element would let the residual grow
22/// without limit (a denial-of-service vector). Legitimate inline scripts and
23/// styles are far smaller than this; once the bound is crossed the buffered
24/// bytes are force-processed rather than accumulated forever.
25pub const MAX_RAW_TEXT_RESIDUAL: usize = 2 * 1024 * 1024;
26
27/// A streaming tokenizer that processes input chunk by chunk.
28///
29/// Maintains internal state so that token boundaries that span chunk
30/// boundaries are handled correctly.
31///
32/// # Example
33///
34/// ```
35/// use fhp_tokenizer::streaming::StreamTokenizer;
36/// use fhp_tokenizer::token::Token;
37///
38/// let mut tokenizer = StreamTokenizer::new();
39/// let mut all_tokens: Vec<Token<'static>> = Vec::new();
40///
41/// let html = b"<div>hello</div>";
42/// // Feed in small chunks.
43/// let owned = tokenizer.feed(&html[..5]);
44/// all_tokens.extend(owned);
45/// let owned = tokenizer.feed(&html[5..]);
46/// all_tokens.extend(owned);
47/// let owned = tokenizer.finish();
48/// all_tokens.extend(owned);
49///
50/// assert!(all_tokens.iter().any(|t| matches!(t, Token::OpenTag { .. })));
51/// ```
52pub struct StreamTokenizer {
53    indexer: StructuralIndexer,
54    /// Residual bytes from the previous chunk (partial tag).
55    residual: Vec<u8>,
56    /// Reusable working buffer to avoid per-feed allocation.
57    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    /// Create a new streaming tokenizer.
68    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    /// Number of bytes currently buffered as residual (not yet processed).
77    ///
78    /// Bounded by [`MAX_RAW_TEXT_RESIDUAL`] plus the size of one fed chunk.
79    pub fn buffered_len(&self) -> usize {
80        self.residual.len()
81    }
82
83    /// Feed a chunk of UTF-8 input and return any complete tokens.
84    ///
85    /// Tokens are returned as owned (`'static` lifetime) since the chunk
86    /// data may not live long enough. Text content is cloned into `Cow::Owned`.
87    pub fn feed(&mut self, chunk: &[u8]) -> Vec<Token<'static>> {
88        if chunk.is_empty() {
89            return Vec::new();
90        }
91
92        // Combine residual + new chunk into reusable working buffer.
93        // Take ownership to avoid borrow conflicts with process_chunk.
94        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        // Find the last safe split point.
101        // Safe = end of a '>' that's not inside a string.
102        let scan = scan_safe_split(&working);
103        let split = scan.split;
104
105        if split == 0 {
106            // No complete tag boundary — buffer everything.
107            if working.len() > MAX_RESIDUAL
108                && (!scan.in_raw_text_context || working.len() > MAX_RAW_TEXT_RESIDUAL)
109            {
110                // Too large to buffer — force-process what we have.
111                let tokens = self.process_chunk(&working);
112                self.working = working;
113                return tokens;
114            }
115            // Swap: working becomes the residual, residual (now empty) becomes the
116            // reusable working buffer — no new allocation.
117            std::mem::swap(&mut self.residual, &mut working);
118            self.working = working;
119            return Vec::new();
120        }
121
122        // Process the safe portion.
123        let tokens = self.process_chunk(&working[..split]);
124
125        // Buffer the rest.
126        self.residual.extend_from_slice(&working[split..]);
127
128        // Return the working buffer for reuse.
129        self.working = working;
130
131        tokens
132    }
133
134    /// Feed a UTF-8 chunk and process complete tokens via callback without cloning.
135    ///
136    /// This path is intended for internal high-throughput consumers (e.g. tree
137    /// building) that can consume tokens immediately.
138    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        // Combine residual + new chunk into reusable working buffer.
144        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                // Too large to buffer — force-process what we have.
158                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    /// Signal end of input and flush any remaining buffered data.
202    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    /// Signal end of input and flush buffered tokens via callback without cloning.
211    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    /// Process a complete chunk through the structural indexer + extractor.
234    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    /// Process a complete UTF-8 chunk and return borrowed tokens.
251    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                    // <!-- ... -->
301                    if i + 3 < data.len() && &data[i..i + 4] == b"<!--" {
302                        mode = Mode::Comment;
303                        i += 4;
304                        continue;
305                    }
306
307                    // <![CDATA[ ... ]]>
308                    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                        // <!DOCTYPE ...> or other <! ... >
317                        if next == b'!' {
318                            mode = Mode::Doctype { quote: None };
319                            i += 2;
320                            continue;
321                        }
322                        // Normal open/close tags. Ignore stray '<' in text.
323                        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
494/// Convert a borrowed token to an owned ('static) token.
495fn 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
527/// Convert a borrowed attribute to owned.
528fn 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        // Feed byte by byte.
557        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}