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 & 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 & b");
313 }
314
315 #[test]
316 fn equiv_entity_in_attr() {
317 assert_equivalent("<div title=\"a & 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 & world</p><br/><script>if(a<b){}</script></div>",
364 );
365 }
366}