Skip to main content

fhp_tokenizer/
structural.rs

1//! Structural character indexer for HTML input.
2//!
3//! Scans input in 64-byte blocks using SIMD dispatch, producing per-delimiter
4//! u64 bitmasks. Quote-aware masking (prefix XOR) ensures that delimiters
5//! inside quoted attribute values are not treated as structural.
6//!
7//! This is stage 1 of the two-stage tokenizer pipeline (see crate docs).
8
9use fhp_simd::dispatch::{SimdOps, ops};
10
11/// Size of each processing block in bytes.
12const BLOCK_SIZE: usize = 64;
13
14/// Per-block bitmasks for each HTML delimiter type.
15///
16/// Bit `i` of a mask is set if the byte at position `i` within the block
17/// matches that delimiter. After quote-aware masking, non-structural
18/// positions (inside quoted strings) are cleared from the relevant masks.
19#[derive(Clone, Debug, Default)]
20pub struct BlockBitmaps {
21    /// `<` positions.
22    pub lt: u64,
23    /// `>` positions.
24    pub gt: u64,
25    /// `&` positions.
26    pub amp: u64,
27    /// `"` positions.
28    pub quot: u64,
29    /// `'` positions.
30    pub apos: u64,
31    /// `=` positions.
32    pub eq: u64,
33    /// `/` positions.
34    pub slash: u64,
35}
36
37/// Result of structural indexing: a sequence of [`BlockBitmaps`] covering
38/// the entire input.
39pub struct StructuralIndex {
40    bitmaps: Vec<BlockBitmaps>,
41    len: usize,
42}
43
44impl StructuralIndex {
45    /// Iterate over all structural delimiter positions in input order.
46    pub fn iter_delimiters(&self) -> DelimiterIter<'_> {
47        let mut iter = DelimiterIter {
48            bitmaps: &self.bitmaps,
49            block_idx: 0,
50            combined: 0,
51            len: self.len,
52        };
53        // Load the first block's combined mask.
54        if !self.bitmaps.is_empty() {
55            iter.combined = Self::combined_mask(&self.bitmaps[0]);
56        }
57        iter
58    }
59
60    /// Estimated number of tokens — used for `Vec` pre-allocation.
61    ///
62    /// Heuristic: roughly one token per pair of `<`/`>` delimiters,
63    /// plus text segments between them.
64    pub fn estimated_token_count(&self) -> usize {
65        let total_lt: u32 = self.bitmaps.iter().map(|b| b.lt.count_ones()).sum();
66        // Each `<` roughly corresponds to one tag token. Add 1 for trailing text.
67        total_lt as usize + 1
68    }
69
70    /// Total input length in bytes.
71    pub fn input_len(&self) -> usize {
72        self.len
73    }
74
75    /// Number of 64-byte blocks.
76    pub fn block_count(&self) -> usize {
77        self.bitmaps.len()
78    }
79
80    /// Access the bitmaps for a specific block.
81    pub fn block(&self, index: usize) -> &BlockBitmaps {
82        &self.bitmaps[index]
83    }
84
85    /// OR of all delimiter masks for a block.
86    fn combined_mask(block: &BlockBitmaps) -> u64 {
87        block.lt | block.gt | block.amp | block.quot | block.apos | block.eq | block.slash
88    }
89}
90
91/// A structural delimiter found by the indexer.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct DelimiterEntry {
94    /// Absolute byte offset in the input.
95    pub pos: usize,
96    /// The delimiter byte at this position.
97    pub byte: u8,
98}
99
100/// Iterator over structural delimiter positions, yielded in ascending order.
101pub struct DelimiterIter<'a> {
102    bitmaps: &'a [BlockBitmaps],
103    block_idx: usize,
104    combined: u64,
105    len: usize,
106}
107
108impl<'a> Iterator for DelimiterIter<'a> {
109    type Item = DelimiterEntry;
110
111    #[inline]
112    fn next(&mut self) -> Option<Self::Item> {
113        loop {
114            if self.combined != 0 {
115                let bit = self.combined.trailing_zeros() as usize;
116                // Clear lowest set bit.
117                self.combined &= self.combined - 1;
118
119                let pos = self.block_idx * BLOCK_SIZE + bit;
120                if pos >= self.len {
121                    return None;
122                }
123
124                let block = &self.bitmaps[self.block_idx];
125                let byte = determine_byte(block, bit);
126                return Some(DelimiterEntry { pos, byte });
127            }
128
129            // Advance to next non-empty block.
130            self.block_idx += 1;
131            if self.block_idx >= self.bitmaps.len() {
132                return None;
133            }
134            self.combined = StructuralIndex::combined_mask(&self.bitmaps[self.block_idx]);
135        }
136    }
137}
138
139/// Determine which delimiter byte is at bit position `bit` within `block`.
140#[inline]
141fn determine_byte(block: &BlockBitmaps, bit: usize) -> u8 {
142    if (block.lt >> bit) & 1 == 1 {
143        b'<'
144    } else if (block.gt >> bit) & 1 == 1 {
145        b'>'
146    } else if (block.amp >> bit) & 1 == 1 {
147        b'&'
148    } else if (block.quot >> bit) & 1 == 1 {
149        b'"'
150    } else if (block.apos >> bit) & 1 == 1 {
151        b'\''
152    } else if (block.eq >> bit) & 1 == 1 {
153        b'='
154    } else {
155        b'/'
156    }
157}
158
159/// SIMD-powered structural character indexer.
160///
161/// Scans input in 64-byte blocks, producing bitmasks for each delimiter
162/// type. Quote-aware masking ensures delimiters inside `"..."` or `'...'`
163/// are not flagged as structural.
164///
165/// # Example
166///
167/// ```
168/// use fhp_tokenizer::structural::StructuralIndexer;
169///
170/// let indexer = StructuralIndexer::new();
171/// let index = indexer.index(b"<div class=\"foo\">bar</div>");
172///
173/// let delimiters: Vec<_> = index.iter_delimiters().collect();
174/// assert!(delimiters.len() > 0);
175/// ```
176pub struct StructuralIndexer {
177    dispatch: &'static SimdOps,
178}
179
180impl StructuralIndexer {
181    /// Create a new indexer using the auto-detected SIMD backend.
182    pub fn new() -> Self {
183        Self { dispatch: ops() }
184    }
185
186    /// Scan `input` and produce a [`StructuralIndex`].
187    ///
188    /// The input is processed in 64-byte blocks. For each block, a
189    /// [`BlockBitmaps`] is produced with bitmasks for every HTML delimiter.
190    /// Quote-aware masking is then applied to clear non-structural positions.
191    pub fn index(&self, input: &[u8]) -> StructuralIndex {
192        let block_count = input.len().div_ceil(BLOCK_SIZE);
193        let mut bitmaps = Vec::with_capacity(block_count);
194
195        for chunk in input.chunks(BLOCK_SIZE) {
196            // SAFETY: dispatch function pointers are initialized from backends
197            // that match the detected CPU features.
198            let compute = self.dispatch.compute_byte_mask;
199            let lt = unsafe { compute(chunk, b'<') };
200            let gt = unsafe { compute(chunk, b'>') };
201            let amp = unsafe { compute(chunk, b'&') };
202            let quot = unsafe { compute(chunk, b'"') };
203            let apos = unsafe { compute(chunk, b'\'') };
204            let eq = unsafe { compute(chunk, b'=') };
205            let slash = unsafe { compute(chunk, b'/') };
206
207            bitmaps.push(BlockBitmaps {
208                lt,
209                gt,
210                amp,
211                quot,
212                apos,
213                eq,
214                slash,
215            });
216        }
217
218        StructuralIndex {
219            bitmaps,
220            len: input.len(),
221        }
222    }
223}
224
225impl Default for StructuralIndexer {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    // ---------------------------------------------------------------
236    // StructuralIndexer — basic HTML
237    // ---------------------------------------------------------------
238
239    #[test]
240    fn basic_html_tag() {
241        let input = b"<div>hello</div>";
242        let indexer = StructuralIndexer::new();
243        let index = indexer.index(input);
244
245        let delims: Vec<_> = index.iter_delimiters().collect();
246
247        // Expected structural delimiters:
248        // 0: '<', 4: '>', 10: '<', 11: '/', 15: '>'
249        assert_eq!(
250            delims,
251            vec![
252                DelimiterEntry { pos: 0, byte: b'<' },
253                DelimiterEntry { pos: 4, byte: b'>' },
254                DelimiterEntry {
255                    pos: 10,
256                    byte: b'<'
257                },
258                DelimiterEntry {
259                    pos: 11,
260                    byte: b'/'
261                },
262                DelimiterEntry {
263                    pos: 15,
264                    byte: b'>'
265                },
266            ]
267        );
268    }
269
270    #[test]
271    fn html_with_attributes() {
272        let input = b"<div class=\"foo\">bar</div>";
273        let indexer = StructuralIndexer::new();
274        let index = indexer.index(input);
275
276        let delims: Vec<_> = index.iter_delimiters().collect();
277
278        // '<' at 0, '=' at 10, '"' at 11, '"' at 15, '>' at 16,
279        // '<' at 20, '/' at 21, '>' at 25
280        // Note: "foo" is inside quotes, but the content "foo" has no delimiters.
281        let bytes: Vec<u8> = delims.iter().map(|d| d.byte).collect();
282        assert!(bytes.contains(&b'<'));
283        assert!(bytes.contains(&b'>'));
284        assert!(bytes.contains(&b'='));
285        assert!(bytes.contains(&b'"'));
286    }
287
288    // ---------------------------------------------------------------
289    // Delimiter indexing (quote semantics belong to the parser)
290    // ---------------------------------------------------------------
291
292    #[test]
293    fn delimiters_inside_double_quotes_are_indexed() {
294        // The indexer yields ALL delimiter positions, including those inside
295        // an attribute value. Quote semantics (a `>` inside a value does not
296        // close the tag) are the parser's job, not the indexer's — see the
297        // `tests/quote_handling.rs` integration tests.
298        let input = b"<a title=\"x > y < z\">link</a>";
299        let indexer = StructuralIndexer::new();
300        let index = indexer.index(input);
301
302        let positions: Vec<usize> = index.iter_delimiters().map(|d| d.pos).collect();
303
304        // Structural delimiters of the tag itself.
305        assert!(positions.contains(&0)); // opening '<'
306        assert!(positions.contains(&20)); // closing '>'
307        assert!(positions.contains(&25)); // '<' of </a>
308
309        // Delimiters inside the quoted value are also indexed now.
310        assert!(positions.contains(&12), "> inside quotes is indexed");
311        assert!(positions.contains(&16), "< inside quotes is indexed");
312    }
313
314    #[test]
315    fn delimiters_inside_single_quotes_are_indexed() {
316        let input = b"<a title='x > y'>link</a>";
317        let indexer = StructuralIndexer::new();
318        let index = indexer.index(input);
319
320        let positions: Vec<usize> = index.iter_delimiters().map(|d| d.pos).collect();
321
322        // '>' at 12 inside single quotes is indexed; the parser decides it is
323        // not a tag-closing '>'.
324        assert!(positions.contains(&12), "> inside single quotes is indexed");
325    }
326
327    #[test]
328    fn single_quote_inside_double_quotes_ignored() {
329        // The ' inside "it's" should not affect masking.
330        let input = b"<div title=\"it's\">text</div>";
331        let indexer = StructuralIndexer::new();
332        let index = indexer.index(input);
333
334        let delims: Vec<_> = index.iter_delimiters().collect();
335        let bytes: Vec<u8> = delims.iter().map(|d| d.byte).collect();
336
337        // Should have structural delimiters for the tag structure.
338        assert!(bytes.contains(&b'<'));
339        assert!(bytes.contains(&b'>'));
340        // The tag should close properly — '>' after the closing quote.
341        let gt_positions: Vec<usize> = delims
342            .iter()
343            .filter(|d| d.byte == b'>')
344            .map(|d| d.pos)
345            .collect();
346        assert!(
347            gt_positions.contains(&17),
348            "closing > at 17 should be structural"
349        );
350    }
351
352    // ---------------------------------------------------------------
353    // Edge cases
354    // ---------------------------------------------------------------
355
356    #[test]
357    fn empty_input() {
358        let indexer = StructuralIndexer::new();
359        let index = indexer.index(b"");
360
361        assert_eq!(index.input_len(), 0);
362        assert_eq!(index.block_count(), 0);
363        assert_eq!(index.iter_delimiters().count(), 0);
364        assert_eq!(index.estimated_token_count(), 1);
365    }
366
367    #[test]
368    fn text_only_no_delimiters() {
369        let input = b"hello world this is plain text without any tags";
370        let indexer = StructuralIndexer::new();
371        let index = indexer.index(input);
372
373        assert_eq!(index.iter_delimiters().count(), 0);
374        assert_eq!(index.estimated_token_count(), 1);
375    }
376
377    #[test]
378    fn tag_only() {
379        let input = b"<br/>";
380        let indexer = StructuralIndexer::new();
381        let index = indexer.index(input);
382
383        let delims: Vec<_> = index.iter_delimiters().collect();
384        assert_eq!(
385            delims,
386            vec![
387                DelimiterEntry { pos: 0, byte: b'<' },
388                DelimiterEntry { pos: 3, byte: b'/' },
389                DelimiterEntry { pos: 4, byte: b'>' },
390            ]
391        );
392    }
393
394    #[test]
395    fn entity_reference() {
396        let input = b"a &amp; b";
397        let indexer = StructuralIndexer::new();
398        let index = indexer.index(input);
399
400        let amp_entries: Vec<_> = index.iter_delimiters().filter(|d| d.byte == b'&').collect();
401        assert_eq!(amp_entries.len(), 1);
402        assert_eq!(amp_entries[0].pos, 2);
403    }
404
405    // ---------------------------------------------------------------
406    // Long input (crosses 64-byte block boundaries)
407    // ---------------------------------------------------------------
408
409    #[test]
410    fn long_input_multiple_blocks() {
411        // Build a 1000+ byte input with tags at various positions.
412        let mut input = Vec::with_capacity(1200);
413        // Fill with text.
414        input.extend_from_slice(&[b'x'; 100]);
415        // Tag at offset 100.
416        input.extend_from_slice(b"<div>");
417        // More text.
418        input.extend_from_slice(&[b'y'; 200]);
419        // Tag at offset 305.
420        input.extend_from_slice(b"<span class=\"test\">");
421        // More text.
422        input.extend_from_slice(&[b'z'; 700]);
423        // Closing tags.
424        input.extend_from_slice(b"</span></div>");
425
426        let indexer = StructuralIndexer::new();
427        let index = indexer.index(&input);
428
429        assert!(input.len() > 1000);
430        assert!(index.block_count() > 15);
431
432        let delims: Vec<_> = index.iter_delimiters().collect();
433
434        // Verify '<' at offset 100.
435        assert!(
436            delims.iter().any(|d| d.pos == 100 && d.byte == b'<'),
437            "should find < at offset 100"
438        );
439
440        // Verify delimiters inside "test" are masked.
441        // The attribute value "test" contains no delimiters, so nothing to mask.
442        // But the = and " around it should be structural.
443        assert!(
444            delims.iter().any(|d| d.byte == b'='),
445            "should find = in attribute"
446        );
447
448        // Verify there are closing tags at the end.
449        let last_lt = delims.iter().rev().find(|d| d.byte == b'<');
450        assert!(last_lt.is_some());
451    }
452
453    #[test]
454    fn long_input_with_quotes_spanning_blocks() {
455        // Attribute value that spans a 64-byte block boundary.
456        let mut input = Vec::new();
457        input.extend_from_slice(b"<div data=\"");
458        // Pad to 60 bytes total (attribute value starts at offset 11).
459        while input.len() < 60 {
460            input.push(b'a');
461        }
462        // Add a '<' inside the string at offset 60 (inside a block boundary region).
463        input.push(b'<');
464        // Continue the string value past the 64-byte boundary.
465        while input.len() < 80 {
466            input.push(b'b');
467        }
468        // Close the attribute and tag.
469        input.extend_from_slice(b"\">end</div>");
470
471        let indexer = StructuralIndexer::new();
472        let index = indexer.index(&input);
473
474        let delims: Vec<_> = index.iter_delimiters().collect();
475        let lt_positions: Vec<usize> = delims
476            .iter()
477            .filter(|d| d.byte == b'<')
478            .map(|d| d.pos)
479            .collect();
480
481        // The '<' at offset 60 (inside the value, across a block boundary) is
482        // indexed; the parser, tracking quote state, will not treat it as a
483        // tag start.
484        assert!(
485            lt_positions.contains(&60),
486            "< at offset 60 is indexed (parser handles quote state)"
487        );
488
489        // The opening '<' at 0 is present too.
490        assert!(lt_positions.contains(&0), "opening < should be structural");
491    }
492
493    // ---------------------------------------------------------------
494    // estimated_token_count
495    // ---------------------------------------------------------------
496
497    #[test]
498    fn estimated_token_count_basic() {
499        let input = b"<div>hello</div><p>world</p>";
500        let indexer = StructuralIndexer::new();
501        let index = indexer.index(input);
502
503        // 4 '<' characters → estimate = 5.
504        assert_eq!(index.estimated_token_count(), 5);
505    }
506
507    // ---------------------------------------------------------------
508    // StructuralIndex API
509    // ---------------------------------------------------------------
510
511    #[test]
512    fn block_api() {
513        let input = b"<div>text</div>";
514        let indexer = StructuralIndexer::new();
515        let index = indexer.index(input);
516
517        assert_eq!(index.input_len(), 15);
518        assert_eq!(index.block_count(), 1);
519
520        let block = index.block(0);
521        assert_ne!(block.lt, 0, "should have < bits set");
522        assert_ne!(block.gt, 0, "should have > bits set");
523    }
524}