Skip to main content

fhp_tokenizer/
lib.rs

1//! SIMD-accelerated HTML tokenizer.
2//!
3//! Uses a two-stage pipeline inspired by simdjson:
4//!
5//! 1. **Structural indexing** (SIMD): scan input in 64-byte blocks, produce
6//!    per-delimiter bitmasks, then apply quote-aware masking.
7//! 2. **Token extraction** (scalar): walk the structural index to emit tokens
8//!    via a branchless state machine.
9//!
10//! # Quick Start
11//!
12//! ```
13//! use fhp_tokenizer::tokenize;
14//!
15//! let tokens = tokenize("<div>hello</div>");
16//! assert!(tokens.len() >= 3);
17//! ```
18
19/// Entity decoding with SIMD fast-path.
20pub mod entity;
21/// Token extraction — stage 2 (scalar state machine).
22pub mod extract;
23/// Branchless state machine for token extraction.
24pub mod state_machine;
25/// Streaming (chunk-based) tokenizer — see [`crate::streaming::StreamTokenizer`].
26pub mod streaming;
27/// Structural character indexer — SIMD-powered bitmask generation (stage 1).
28pub mod structural;
29/// Token types emitted by the tokenizer.
30pub mod token;
31
32use extract::extract_tokens;
33use structural::StructuralIndexer;
34use token::Token;
35
36use fhp_core::tag::Tag;
37use fhp_simd::AllMasks;
38use fhp_simd::dispatch::ops;
39
40/// Trait for receiving parsed HTML events directly, bypassing Token allocation.
41///
42/// Implementors receive raw parse events (open/close tags, text, etc.) and
43/// can build their output structure in a single pass without intermediate
44/// `Token` enum or `Vec<Attribute>` allocations.
45pub trait TreeSink {
46    /// An opening tag was encountered.
47    ///
48    /// `attr_raw` is the raw attribute region between the tag name and `>`.
49    /// The implementor is responsible for parsing attributes from this slice.
50    fn open_tag(&mut self, tag: Tag, name: &str, attr_raw: &str, self_closing: bool);
51
52    /// A closing tag was encountered.
53    fn close_tag(&mut self, tag: Tag, name: &str);
54
55    /// Raw text content (not entity-decoded).
56    ///
57    /// The implementor is responsible for entity decoding if desired.
58    fn text(&mut self, content: &str);
59
60    /// A comment was encountered.
61    fn comment(&mut self, content: &str);
62
63    /// A DOCTYPE declaration was encountered.
64    fn doctype(&mut self, content: &str);
65
66    /// A CDATA section was encountered.
67    fn cdata(&mut self, content: &str);
68}
69
70/// Tokenize an HTML string into a sequence of tokens.
71///
72/// Convenience wrapper that runs both pipeline stages:
73/// 1. SIMD structural indexing
74/// 2. State-machine token extraction
75///
76/// # Example
77///
78/// ```
79/// use fhp_tokenizer::tokenize;
80/// use fhp_tokenizer::token::Token;
81///
82/// let tokens = tokenize("<p>Hello &amp; world</p>");
83///
84/// // Should contain OpenTag, Text, CloseTag
85/// assert!(tokens.iter().any(|t| matches!(t, Token::OpenTag { .. })));
86/// assert!(tokens.iter().any(|t| matches!(t, Token::Text { .. })));
87/// assert!(tokens.iter().any(|t| matches!(t, Token::CloseTag { .. })));
88/// ```
89pub fn tokenize<'a>(input: &'a str) -> Vec<Token<'a>> {
90    let indexer = StructuralIndexer::new();
91    let index = indexer.index(input.as_bytes());
92    extract_tokens(input, &index)
93}
94
95/// Size of each processing block in bytes.
96const BLOCK_SIZE: usize = 64;
97
98/// Tokenize HTML and feed each token to a callback — zero intermediate allocation.
99///
100/// Fuses SIMD structural indexing, quote-aware masking, and token extraction
101/// into a single streaming pass. No `Vec<BlockBitmaps>` or `Vec<Token>` is
102/// allocated.
103///
104/// # Example
105///
106/// ```
107/// use fhp_tokenizer::tokenize_with;
108/// use fhp_tokenizer::token::Token;
109///
110/// let mut count = 0;
111/// tokenize_with("<div>hello</div>", |_token| { count += 1; });
112/// assert!(count >= 3); // OpenTag, Text, CloseTag
113/// ```
114pub fn tokenize_with<'a>(input: &'a str, mut on_token: impl FnMut(Token<'a>)) {
115    let bytes = input.as_bytes();
116    let len = bytes.len();
117
118    if len == 0 {
119        return;
120    }
121
122    let dispatch = ops();
123    let compute = dispatch.compute_all_masks;
124    let mut parser = extract::Parser::new(input);
125
126    let mut block_start = 0;
127
128    while block_start < len {
129        let block_end = (block_start + BLOCK_SIZE).min(len);
130        let chunk = &bytes[block_start..block_end];
131
132        // Step 1: Compute all bitmasks in a single SIMD pass.
133        // SAFETY: dispatch function pointer matches detected CPU features.
134        let masks: AllMasks = unsafe { compute(chunk) };
135
136        // Step 2: Iterate `<`, `>`, and quote bytes. The parser tracks
137        // attribute-quote state itself, so a `>` inside a quoted attribute
138        // value does not close the tag; quotes in text content are harmless
139        // no-ops in Data mode. (No global string masking — that incorrectly
140        // treated quotes in text/comments as attribute strings.)
141        let combined = masks.lt | masks.gt | masks.quot | masks.apos;
142
143        // Pre-mask: clear bits beyond the valid range for this chunk,
144        // eliminating the per-iteration bounds check inside the loop.
145        let valid_bits = block_end - block_start;
146        let mut bits = if valid_bits < 64 {
147            combined & ((1u64 << valid_bits) - 1)
148        } else {
149            combined
150        };
151        while bits != 0 {
152            let bit_pos = bits.trailing_zeros() as usize;
153            bits &= bits - 1; // clear lowest set bit
154
155            let abs_pos = block_start + bit_pos;
156            let byte = bytes[abs_pos];
157            parser.on_delimiter_cb(abs_pos, byte, &mut on_token);
158        }
159
160        block_start = block_end;
161    }
162
163    // Flush trailing text.
164    parser.flush_trailing_cb(&mut on_token);
165}
166
167/// Tokenize HTML directly into a [`TreeSink`], bypassing all intermediate allocations.
168///
169/// Fuses SIMD structural indexing, quote-aware masking, and token extraction
170/// into a single streaming pass. Instead of constructing `Token` enums, calls
171/// sink methods directly — eliminating `Vec<Attribute>`, `Cow` wrappers, and
172/// entity-decode `String` allocations in the hot loop.
173///
174/// # Example
175///
176/// ```
177/// use fhp_tokenizer::{TreeSink, tokenize_into};
178/// use fhp_core::tag::Tag;
179///
180/// struct Counter { tags: usize, texts: usize }
181/// impl TreeSink for Counter {
182///     fn open_tag(&mut self, _: Tag, _: &str, _: &str, _: bool) { self.tags += 1; }
183///     fn close_tag(&mut self, _: Tag, _: &str) {}
184///     fn text(&mut self, _: &str) { self.texts += 1; }
185///     fn comment(&mut self, _: &str) {}
186///     fn doctype(&mut self, _: &str) {}
187///     fn cdata(&mut self, _: &str) {}
188/// }
189///
190/// let mut c = Counter { tags: 0, texts: 0 };
191/// tokenize_into("<div>hello</div>", &mut c);
192/// assert_eq!(c.tags, 1);
193/// assert_eq!(c.texts, 1);
194/// ```
195pub fn tokenize_into<S: TreeSink>(input: &str, sink: &mut S) {
196    let bytes = input.as_bytes();
197    let len = bytes.len();
198
199    if len == 0 {
200        return;
201    }
202
203    let dispatch = ops();
204    let compute = dispatch.compute_all_masks;
205    let mut parser = extract::Parser::new(input);
206
207    let mut block_start = 0;
208
209    while block_start < len {
210        let block_end = (block_start + BLOCK_SIZE).min(len);
211        let chunk = &bytes[block_start..block_end];
212
213        // Step 1: Compute all bitmasks in a single SIMD pass.
214        // SAFETY: dispatch function pointer matches detected CPU features.
215        let masks: AllMasks = unsafe { compute(chunk) };
216
217        // Step 2: Iterate `<`, `>`, and quote bytes. The parser tracks
218        // attribute-quote state itself, so a `>` inside a quoted attribute
219        // value does not close the tag; quotes in text content are harmless
220        // no-ops in Data mode. (No global string masking — that incorrectly
221        // treated quotes in text/comments as attribute strings.)
222        let combined = masks.lt | masks.gt | masks.quot | masks.apos;
223
224        // Pre-mask: clear bits beyond the valid range for this chunk,
225        // eliminating the per-iteration bounds check inside the loop.
226        let valid_bits = block_end - block_start;
227        let mut bits = if valid_bits < 64 {
228            combined & ((1u64 << valid_bits) - 1)
229        } else {
230            combined
231        };
232        while bits != 0 {
233            let bit_pos = bits.trailing_zeros() as usize;
234            bits &= bits - 1; // clear lowest set bit
235
236            let abs_pos = block_start + bit_pos;
237            let byte = bytes[abs_pos];
238            parser.on_delimiter_sink(abs_pos, byte, sink);
239        }
240
241        block_start = block_end;
242    }
243
244    // Flush trailing text.
245    parser.flush_trailing_sink(sink);
246}
247
248#[cfg(test)]
249mod tokenize_with_tests {
250    use super::*;
251    use token::Token;
252
253    /// Collect tokens from `tokenize_with` into a Vec for comparison.
254    fn collect_with(input: &str) -> Vec<Token<'_>> {
255        let mut tokens = Vec::new();
256        tokenize_with(input, |t| tokens.push(t));
257        tokens
258    }
259
260    /// Compare `tokenize` and `tokenize_with` for equivalence.
261    fn assert_equivalent(html: &str) {
262        let vec_tokens = tokenize(html);
263        let cb_tokens = collect_with(html);
264        assert_eq!(
265            vec_tokens.len(),
266            cb_tokens.len(),
267            "token count mismatch for {:?}",
268            html
269        );
270        for (i, (a, b)) in vec_tokens.iter().zip(cb_tokens.iter()).enumerate() {
271            assert_eq!(
272                std::mem::discriminant(a),
273                std::mem::discriminant(b),
274                "token type mismatch at index {i} for {:?}: {a:?} vs {b:?}",
275                html
276            );
277        }
278    }
279
280    #[test]
281    fn equiv_simple_div() {
282        assert_equivalent("<div>hello</div>");
283    }
284
285    #[test]
286    fn equiv_self_closing() {
287        assert_equivalent("<br/>");
288    }
289
290    #[test]
291    fn equiv_attributes() {
292        assert_equivalent("<a href=\"url\" class=\"link\">text</a>");
293    }
294
295    #[test]
296    fn equiv_comment() {
297        assert_equivalent("<!-- hello -->");
298    }
299
300    #[test]
301    fn equiv_doctype() {
302        assert_equivalent("<!DOCTYPE html>");
303    }
304
305    #[test]
306    fn equiv_script() {
307        assert_equivalent("<script>var x = 1 < 2;</script>");
308    }
309
310    #[test]
311    fn equiv_entities() {
312        assert_equivalent("a &amp; b");
313    }
314
315    #[test]
316    fn equiv_entity_in_attr() {
317        assert_equivalent("<div title=\"a &amp; b\">x</div>");
318    }
319
320    #[test]
321    fn equiv_empty() {
322        assert_equivalent("");
323    }
324
325    #[test]
326    fn equiv_text_only() {
327        assert_equivalent("just plain text");
328    }
329
330    #[test]
331    fn equiv_nested() {
332        assert_equivalent("<div><span>text</span></div>");
333    }
334
335    #[test]
336    fn equiv_quotes_spanning_blocks() {
337        // Build input with attribute value spanning a 64-byte block boundary.
338        let mut input = String::from("<div data=\"");
339        while input.len() < 60 {
340            input.push('a');
341        }
342        input.push('<'); // delimiter inside quotes
343        while input.len() < 80 {
344            input.push('b');
345        }
346        input.push_str("\">end</div>");
347        assert_equivalent(&input);
348    }
349
350    #[test]
351    fn equiv_long_input() {
352        let mut input = String::new();
353        for i in 0..100 {
354            input.push_str(&format!("<p class=\"c{i}\">text {i}</p>"));
355        }
356        assert_equivalent(&input);
357    }
358
359    #[test]
360    fn equiv_mixed_content() {
361        assert_equivalent(
362            "<!DOCTYPE html><!-- comment --><div id=\"main\" class='header' disabled>\
363             <p>Hello &amp; world</p><br/><script>if(a<b){}</script></div>",
364        );
365    }
366}