Skip to main content

mq_db/
indexes.rs

1//! Secondary indexes for fast block lookups in the SQL engine.
2//!
3//! Four index types, matching the characteristics of each column:
4//!
5//! | Index | Column(s) | Type | Why |
6//! |---|---|---|---|
7//! | [`BitmapIndex`] | `block_type` | Inverted list per type | 15 variants → very low cardinality |
8//! | [`BTreeIndex`] | `pre`, `post` | Sorted Vec + binary search | Monotonically increasing integers, range queries |
9//! | [`HashIndex`] | `content`, `lang`, `depth` | HashMap | Point/equality lookups |
10//! | [`TermIndex`] | `content` (tokenized) | Inverted postings list | Full-text `match()`/`score()` |
11//!
12//! ## How it compares to DuckDB
13//!
14//! DuckDB uses an **ART (Adaptive Radix Tree)** for general indexes and
15//! **RoaringBitmap** for low-cardinality columns. Here we use simpler
16//! structures that achieve the same asymptotic complexity for the query
17//! patterns in mq-db:
18//!
19//! - Bitmap lookup: `O(1)` key + `O(k)` iteration (k = matching blocks)
20//! - B-Tree range: `O(log n)` to find start + `O(k)` iteration
21//! - Hash lookup: `O(1)` average
22//!
23//! All three beat the `O(n)` full-scan baseline when `k << n`.
24//!
25//! ## Zone Maps (already implemented)
26//!
27//! [`crate::document::ZoneMaps`] provides document-level skipping (skip entire
28//! files that cannot match). These indexes operate *within* a document once
29//! Zone Maps have decided it is worth scanning.
30//!
31//! `SqlEngine` applies this automatically (see `zone_map_skip` in
32//! `src/sql.rs`) for `lang =` / `depth =` / heading `content =` conjuncts,
33//! but only for a single, non-`JOIN`ed `FROM blocks`.
34
35use std::collections::BTreeMap;
36
37use rustc_hash::FxHashMap;
38
39use crate::{
40    block::{Block, BlockType},
41    error::MqdbError,
42};
43
44// BitmapIndex — block_type → sorted Vec of block positions
45
46/// Bitmap-style inverted index on `block_type`.
47///
48/// Each entry maps a [`BlockType`] to a sorted list of block indices
49/// (positions in `Document::blocks`). Equivalent to a RoaringBitmap but
50/// using plain `Vec<u32>` since block counts per document are small.
51///
52/// Best for: `WHERE block_type = 'heading'`  
53/// Complexity: build O(n), lookup O(1) key + O(k) iterate
54#[derive(Debug, Default, Clone)]
55pub struct BitmapIndex {
56    map: FxHashMap<BlockType, Vec<u32>>,
57}
58
59impl BitmapIndex {
60    pub fn build(blocks: &[Block]) -> Self {
61        let mut map: FxHashMap<BlockType, Vec<u32>> = FxHashMap::default();
62        for (idx, block) in blocks.iter().enumerate() {
63            map.entry(block.block_type.clone())
64                .or_default()
65                .push(idx as u32);
66        }
67        Self { map }
68    }
69
70    /// Returns block indices for a single type. O(1).
71    pub fn get(&self, block_type: &BlockType) -> &[u32] {
72        self.map.get(block_type).map(Vec::as_slice).unwrap_or(&[])
73    }
74
75    /// Returns block indices matching any of the given types (union). O(k).
76    pub fn get_any(&self, types: &[BlockType]) -> Vec<u32> {
77        let mut result: Vec<u32> = types
78            .iter()
79            .flat_map(|t| self.get(t).iter().copied())
80            .collect();
81        result.sort_unstable();
82        result.dedup();
83        result
84    }
85
86    /// Returns whether any block of the given type exists.
87    pub fn contains_type(&self, block_type: &BlockType) -> bool {
88        self.map.contains_key(block_type)
89    }
90}
91
92// BTreeIndex — pre/post → block position
93
94/// B-Tree index on `pre` (and a secondary one on `post`).
95///
96/// Since blocks produced by [`crate::index::build_blocks`] are already in
97/// DFS pre-order, `blocks[i].pre` is *not* necessarily equal to `i` — the
98/// pre counter increments for every tree slot, including heading scopes.
99/// We need an explicit map for O(log n) lookup.
100///
101/// Best for: `WHERE pre = X`, `WHERE pre BETWEEN X AND Y`,
102///           `JOIN … ON b.pre = h.post + 1` (next-sibling join)  
103/// Complexity: build O(n log n), point O(log n), range O(log n + k)
104#[derive(Debug, Default, Clone)]
105pub struct BTreeIndex {
106    /// pre value → index in `Document::blocks`
107    by_pre: BTreeMap<u32, u32>,
108    /// post value → index in `Document::blocks`
109    by_post: BTreeMap<u32, u32>,
110}
111
112impl BTreeIndex {
113    pub fn build(blocks: &[Block]) -> Self {
114        let mut by_pre = BTreeMap::new();
115        let mut by_post = BTreeMap::new();
116        for (idx, block) in blocks.iter().enumerate() {
117            by_pre.insert(block.pre, idx as u32);
118            by_post.insert(block.post, idx as u32);
119        }
120        Self { by_pre, by_post }
121    }
122
123    /// O(log n) point lookup by `pre`.
124    pub fn get_by_pre(&self, pre: u32) -> Option<u32> {
125        self.by_pre.get(&pre).copied()
126    }
127
128    /// O(log n) point lookup by `post`.
129    pub fn get_by_post(&self, post: u32) -> Option<u32> {
130        self.by_post.get(&post).copied()
131    }
132
133    /// O(log n + k) range scan over `pre` values in `[lo, hi]`.
134    pub fn range_by_pre(&self, lo: u32, hi: u32) -> impl Iterator<Item = u32> + '_ {
135        self.by_pre.range(lo..=hi).map(|(_, &idx)| idx)
136    }
137
138    /// O(log n + k) range scan over `post` values in `[lo, hi]`.
139    pub fn range_by_post(&self, lo: u32, hi: u32) -> impl Iterator<Item = u32> + '_ {
140        self.by_post.range(lo..=hi).map(|(_, &idx)| idx)
141    }
142}
143
144// HashIndex — content / lang / depth → block positions
145
146/// Hash index for point-equality lookups on string/integer columns.
147///
148/// Covers `content` (exact match), `lang` (code language), and heading `depth`.
149///
150/// Best for: `WHERE content = 'Architecture'`, `WHERE lang = 'rust'`,
151///           `WHERE depth = 2`  
152/// Complexity: build O(n), lookup O(1) average
153#[derive(Debug, Default, Clone)]
154pub struct HashIndex {
155    /// content (exact lowercase) → block indices
156    pub by_content: FxHashMap<String, Vec<u32>>,
157    /// lang tag → block indices (code blocks only)
158    pub by_lang: FxHashMap<String, Vec<u32>>,
159    /// heading depth → block indices
160    pub by_depth: FxHashMap<u8, Vec<u32>>,
161}
162
163impl HashIndex {
164    pub fn build(blocks: &[Block]) -> Self {
165        let mut by_content: FxHashMap<String, Vec<u32>> = FxHashMap::default();
166        let mut by_lang: FxHashMap<String, Vec<u32>> = FxHashMap::default();
167        let mut by_depth: FxHashMap<u8, Vec<u32>> = FxHashMap::default();
168
169        for (idx, block) in blocks.iter().enumerate() {
170            let i = idx as u32;
171            by_content
172                .entry(block.content.to_lowercase())
173                .or_default()
174                .push(i);
175
176            if let Some(lang) = block.code_lang() {
177                by_lang.entry(lang.to_string()).or_default().push(i);
178            }
179            if let Some(depth) = block.heading_depth() {
180                by_depth.entry(depth).or_default().push(i);
181            }
182        }
183
184        Self {
185            by_content,
186            by_lang,
187            by_depth,
188        }
189    }
190
191    /// Exact content match (case-insensitive). O(1).
192    pub fn by_content(&self, content: &str) -> &[u32] {
193        self.by_content
194            .get(&content.to_lowercase())
195            .map(Vec::as_slice)
196            .unwrap_or(&[])
197    }
198
199    /// Exact lang match. O(1).
200    pub fn by_lang(&self, lang: &str) -> &[u32] {
201        self.by_lang.get(lang).map(Vec::as_slice).unwrap_or(&[])
202    }
203
204    /// Heading depth lookup. O(1).
205    pub fn by_depth(&self, depth: u8) -> &[u32] {
206        self.by_depth.get(&depth).map(Vec::as_slice).unwrap_or(&[])
207    }
208}
209
210/// Lowercase + split on non-alphanumeric (Unicode-aware via
211/// `char::is_alphanumeric`).
212///
213/// This is used both to build [`TermIndex`]'s postings at index time and to
214/// tokenize `match()`/`score()`'s arguments at query time (see `src/sql.rs`)
215/// — the two **must** use this same function. `WHERE match(...)` uses the
216/// index purely as a pre-filter with no full-scan fallback to catch a
217/// mismatch, so if the two tokenizers ever disagreed, the index would
218/// silently *drop* true matches rather than just mis-rank them.
219///
220/// Known limitations (intentional, dependency-free, documented rather than
221/// fixed): no stemming, no stopword removal, no sub-splitting of
222/// `camelCase`/`snake_case` beyond punctuation, and no CJK word segmentation
223/// (a run of CJK characters with no ASCII punctuation between them tokenizes
224/// as a single "word").
225pub fn tokenize(text: &str) -> Vec<String> {
226    text.to_lowercase()
227        .split(|c: char| !c.is_alphanumeric())
228        .filter(|s| !s.is_empty())
229        .map(str::to_string)
230        .collect()
231}
232
233/// Inverted index on tokenized `content`: term → sorted, deduped block
234/// indices containing that term at least once.
235///
236/// Best for: `WHERE match(content, 'foo bar')` (AND intersection across
237/// query terms).
238/// Complexity: build `O(n * avg_tokens)`, intersect `O(k)` for the rarest
239/// term's postings length.
240#[derive(Debug, Default, Clone)]
241pub struct TermIndex {
242    postings: FxHashMap<String, Vec<u32>>,
243}
244
245impl TermIndex {
246    pub fn build(blocks: &[Block]) -> Self {
247        let mut postings: FxHashMap<String, Vec<u32>> = FxHashMap::default();
248        for (idx, block) in blocks.iter().enumerate() {
249            // Sort + dedup the token list itself rather than allocating a
250            // side `HashSet` per block — cheaper for the small token counts
251            // typical of one block, and avoids an allocation per block.
252            let mut terms = tokenize(&block.content);
253            terms.sort_unstable();
254            terms.dedup();
255            for term in terms {
256                postings.entry(term).or_default().push(idx as u32);
257            }
258        }
259        Self { postings }
260    }
261
262    /// AND-intersection of postings for `terms`. Empty `terms` → empty
263    /// result (mirrors `match()`'s "no terms → no match" semantics).
264    ///
265    /// Each per-term postings list is already sorted ascending and deduped
266    /// (see `build`), so this intersects them with a plain sorted merge —
267    /// no hashing, no per-call `BTreeSet`/`HashSet` allocation. Terms are
268    /// processed shortest-postings-first so the accumulator shrinks as fast
269    /// as possible and a term with an empty postings list short-circuits
270    /// immediately.
271    pub fn intersect(&self, terms: &[String]) -> Vec<u32> {
272        if terms.is_empty() {
273            return Vec::new();
274        }
275        let mut lists: Vec<&[u32]> = Vec::with_capacity(terms.len());
276        for term in terms {
277            match self.postings.get(term) {
278                Some(list) if !list.is_empty() => lists.push(list),
279                _ => return Vec::new(),
280            }
281        }
282        lists.sort_unstable_by_key(|l| l.len());
283
284        let mut acc: Vec<u32> = lists[0].to_vec();
285        for list in &lists[1..] {
286            if acc.is_empty() {
287                break;
288            }
289            acc = merge_intersect(&acc, list);
290        }
291        acc
292    }
293}
294
295/// Two-pointer intersection of two sorted, deduped slices. O(a.len() + b.len()).
296fn merge_intersect(a: &[u32], b: &[u32]) -> Vec<u32> {
297    let mut out = Vec::with_capacity(a.len().min(b.len()));
298    let (mut i, mut j) = (0usize, 0usize);
299    while i < a.len() && j < b.len() {
300        match a[i].cmp(&b[j]) {
301            std::cmp::Ordering::Less => i += 1,
302            std::cmp::Ordering::Greater => j += 1,
303            std::cmp::Ordering::Equal => {
304                out.push(a[i]);
305                i += 1;
306                j += 1;
307            }
308        }
309    }
310    out
311}
312
313// DocumentIndex — all four indexes bundled for one document
314
315/// All secondary indexes for a single [`crate::document::Document`].
316///
317/// Built once when the document is added to the store (O(n) construction),
318/// then consulted by the SQL engine's predicate pushdown to skip full scans.
319#[derive(Debug, Default, Clone)]
320pub struct DocumentIndex {
321    pub bitmap: BitmapIndex,
322    pub btree: BTreeIndex,
323    pub hash: HashIndex,
324    pub term: TermIndex,
325}
326
327impl DocumentIndex {
328    pub fn build(blocks: &[Block]) -> Self {
329        Self {
330            bitmap: BitmapIndex::build(blocks),
331            btree: BTreeIndex::build(blocks),
332            hash: HashIndex::build(blocks),
333            term: TermIndex::build(blocks),
334        }
335    }
336
337    /// Serialize the index to bytes for persistent storage.
338    pub fn to_bytes(&self) -> Vec<u8> {
339        let mut out = Vec::new();
340
341        // BitmapIndex
342        let mut bitmap_entries: Vec<(&BlockType, &Vec<u32>)> = self.bitmap.map.iter().collect();
343        bitmap_entries.sort_by_key(|(bt, _)| block_type_ord(bt));
344        out.extend_from_slice(&(bitmap_entries.len() as u32).to_le_bytes());
345        for (bt, indices) in &bitmap_entries {
346            out.push(block_type_ord(bt));
347            out.extend_from_slice(&(indices.len() as u32).to_le_bytes());
348            for &idx in indices.iter() {
349                out.extend_from_slice(&idx.to_le_bytes());
350            }
351        }
352
353        // BTreeIndex by_pre
354        out.extend_from_slice(&(self.btree.by_pre.len() as u32).to_le_bytes());
355        for (&pre, &idx) in &self.btree.by_pre {
356            out.extend_from_slice(&pre.to_le_bytes());
357            out.extend_from_slice(&idx.to_le_bytes());
358        }
359
360        // BTreeIndex by_post
361        out.extend_from_slice(&(self.btree.by_post.len() as u32).to_le_bytes());
362        for (&post, &idx) in &self.btree.by_post {
363            out.extend_from_slice(&post.to_le_bytes());
364            out.extend_from_slice(&idx.to_le_bytes());
365        }
366
367        // HashIndex by_content
368        let mut content_entries: Vec<(&String, &Vec<u32>)> = self.hash.by_content.iter().collect();
369        content_entries.sort_by_key(|(k, _)| k.as_str());
370        out.extend_from_slice(&(content_entries.len() as u32).to_le_bytes());
371        for (key, indices) in &content_entries {
372            let kb = key.as_bytes();
373            // Block content is unbounded (e.g. a large code block or table cell can
374            // exceed 64KB), so the length prefix must be u32 — a u16 here would
375            // silently wrap and desync the rest of the index stream.
376            out.extend_from_slice(&(kb.len() as u32).to_le_bytes());
377            out.extend_from_slice(kb);
378            out.extend_from_slice(&(indices.len() as u32).to_le_bytes());
379            for &idx in indices.iter() {
380                out.extend_from_slice(&idx.to_le_bytes());
381            }
382        }
383
384        // HashIndex by_lang
385        let mut lang_entries: Vec<(&String, &Vec<u32>)> = self.hash.by_lang.iter().collect();
386        lang_entries.sort_by_key(|(k, _)| k.as_str());
387        out.extend_from_slice(&(lang_entries.len() as u32).to_le_bytes());
388        for (key, indices) in &lang_entries {
389            let kb = key.as_bytes();
390            out.extend_from_slice(&(kb.len() as u32).to_le_bytes());
391            out.extend_from_slice(kb);
392            out.extend_from_slice(&(indices.len() as u32).to_le_bytes());
393            for &idx in indices.iter() {
394                out.extend_from_slice(&idx.to_le_bytes());
395            }
396        }
397
398        // HashIndex by_depth
399        let mut depth_entries: Vec<(&u8, &Vec<u32>)> = self.hash.by_depth.iter().collect();
400        depth_entries.sort_by_key(|&(&d, _)| d);
401        out.extend_from_slice(&(depth_entries.len() as u32).to_le_bytes());
402        for &(&depth, indices) in &depth_entries {
403            out.push(depth);
404            out.extend_from_slice(&(indices.len() as u32).to_le_bytes());
405            for &idx in indices.iter() {
406                out.extend_from_slice(&idx.to_le_bytes());
407            }
408        }
409
410        // TermIndex postings — appended after the four pre-existing sections
411        // above; each of those is self-length-prefixed, so this is purely
412        // additive and doesn't disturb their encoding/decoding order.
413        let mut term_entries: Vec<(&String, &Vec<u32>)> = self.term.postings.iter().collect();
414        term_entries.sort_by_key(|(k, _)| k.as_str());
415        out.extend_from_slice(&(term_entries.len() as u32).to_le_bytes());
416        for (term, indices) in &term_entries {
417            let tb = term.as_bytes();
418            out.extend_from_slice(&(tb.len() as u32).to_le_bytes());
419            out.extend_from_slice(tb);
420            out.extend_from_slice(&(indices.len() as u32).to_le_bytes());
421            for &idx in indices.iter() {
422                out.extend_from_slice(&idx.to_le_bytes());
423            }
424        }
425
426        out
427    }
428
429    /// Deserialize an index from bytes previously produced by [`to_bytes`].
430    pub fn from_bytes(data: &[u8]) -> Result<Self, MqdbError> {
431        let mut pos = 0usize;
432
433        macro_rules! read_u8 {
434            () => {{
435                if pos >= data.len() {
436                    return Err(MqdbError::Storage("unexpected end of index data".into()));
437                }
438                let v = data[pos];
439                pos += 1;
440                v
441            }};
442        }
443        macro_rules! read_u32 {
444            () => {{
445                let end = pos + 4;
446                if end > data.len() {
447                    return Err(MqdbError::Storage("unexpected end of index data".into()));
448                }
449                let v = u32::from_le_bytes(data[pos..end].try_into().unwrap());
450                pos = end;
451                v
452            }};
453        }
454        macro_rules! read_str {
455            ($len:expr) => {{
456                let end = pos + $len;
457                if end > data.len() {
458                    return Err(MqdbError::Storage("unexpected end of index data".into()));
459                }
460                let s = String::from_utf8(data[pos..end].to_vec())
461                    .map_err(|_| MqdbError::Storage("invalid UTF-8 in index".into()))?;
462                pos = end;
463                s
464            }};
465        }
466
467        // BitmapIndex
468        let num_bitmap = read_u32!() as usize;
469        let mut bitmap_map: FxHashMap<BlockType, Vec<u32>> = FxHashMap::default();
470        for _ in 0..num_bitmap {
471            let bt = block_type_from_ord(read_u8!())?;
472            let count = read_u32!() as usize;
473            let mut indices = Vec::with_capacity(count);
474            for _ in 0..count {
475                indices.push(read_u32!());
476            }
477            bitmap_map.insert(bt, indices);
478        }
479
480        // BTreeIndex by_pre
481        let num_pre = read_u32!() as usize;
482        let mut by_pre = BTreeMap::new();
483        for _ in 0..num_pre {
484            let pre = read_u32!();
485            let idx = read_u32!();
486            by_pre.insert(pre, idx);
487        }
488
489        // BTreeIndex by_post
490        let num_post = read_u32!() as usize;
491        let mut by_post = BTreeMap::new();
492        for _ in 0..num_post {
493            let post = read_u32!();
494            let idx = read_u32!();
495            by_post.insert(post, idx);
496        }
497
498        // HashIndex by_content
499        let num_content = read_u32!() as usize;
500        let mut by_content: FxHashMap<String, Vec<u32>> = FxHashMap::default();
501        for _ in 0..num_content {
502            let key_len = read_u32!() as usize;
503            let key = read_str!(key_len);
504            let count = read_u32!() as usize;
505            let mut indices = Vec::with_capacity(count);
506            for _ in 0..count {
507                indices.push(read_u32!());
508            }
509            by_content.insert(key, indices);
510        }
511
512        // HashIndex by_lang
513        let num_lang = read_u32!() as usize;
514        let mut by_lang: FxHashMap<String, Vec<u32>> = FxHashMap::default();
515        for _ in 0..num_lang {
516            let key_len = read_u32!() as usize;
517            let key = read_str!(key_len);
518            let count = read_u32!() as usize;
519            let mut indices = Vec::with_capacity(count);
520            for _ in 0..count {
521                indices.push(read_u32!());
522            }
523            by_lang.insert(key, indices);
524        }
525
526        // HashIndex by_depth
527        let num_depth = read_u32!() as usize;
528        let mut by_depth: FxHashMap<u8, Vec<u32>> = FxHashMap::default();
529        for _ in 0..num_depth {
530            let depth = read_u8!();
531            let count = read_u32!() as usize;
532            let mut indices = Vec::with_capacity(count);
533            for _ in 0..count {
534                indices.push(read_u32!());
535            }
536            by_depth.insert(depth, indices);
537        }
538
539        // TermIndex postings
540        let num_terms = read_u32!() as usize;
541        let mut postings: FxHashMap<String, Vec<u32>> = FxHashMap::default();
542        for _ in 0..num_terms {
543            let term_len = read_u32!() as usize;
544            let term = read_str!(term_len);
545            let count = read_u32!() as usize;
546            let mut indices = Vec::with_capacity(count);
547            for _ in 0..count {
548                indices.push(read_u32!());
549            }
550            postings.insert(term, indices);
551        }
552
553        Ok(DocumentIndex {
554            bitmap: BitmapIndex { map: bitmap_map },
555            btree: BTreeIndex { by_pre, by_post },
556            hash: HashIndex {
557                by_content,
558                by_lang,
559                by_depth,
560            },
561            term: TermIndex { postings },
562        })
563    }
564}
565
566fn block_type_ord(bt: &BlockType) -> u8 {
567    match bt {
568        BlockType::Heading => 0,
569        BlockType::Paragraph => 1,
570        BlockType::Code => 2,
571        BlockType::List => 3,
572        BlockType::TableCell => 4,
573        BlockType::TableRow => 5,
574        BlockType::TableAlign => 6,
575        BlockType::Blockquote => 7,
576        BlockType::HorizontalRule => 8,
577        BlockType::Html => 9,
578        BlockType::Yaml => 10,
579        BlockType::Toml => 11,
580        BlockType::Math => 12,
581        BlockType::Definition => 13,
582        BlockType::Footnote => 14,
583    }
584}
585
586fn block_type_from_ord(v: u8) -> Result<BlockType, MqdbError> {
587    match v {
588        0 => Ok(BlockType::Heading),
589        1 => Ok(BlockType::Paragraph),
590        2 => Ok(BlockType::Code),
591        3 => Ok(BlockType::List),
592        4 => Ok(BlockType::TableCell),
593        5 => Ok(BlockType::TableRow),
594        6 => Ok(BlockType::TableAlign),
595        7 => Ok(BlockType::Blockquote),
596        8 => Ok(BlockType::HorizontalRule),
597        9 => Ok(BlockType::Html),
598        10 => Ok(BlockType::Yaml),
599        11 => Ok(BlockType::Toml),
600        12 => Ok(BlockType::Math),
601        13 => Ok(BlockType::Definition),
602        14 => Ok(BlockType::Footnote),
603        _ => Err(MqdbError::Storage(format!("unknown block type ord: {v}"))),
604    }
605}
606
607// IndexHint — what the SQL planner decided to use
608
609/// A candidate (or chosen) access plan for a WHERE clause. Multiple viable
610/// candidates for the same query are cost-compared — see
611/// `SqlEngine::choose_best_hint` — rather than picked by syntax alone.
612#[derive(Debug, Clone, PartialEq)]
613pub enum IndexHint {
614    /// Use the bitmap index: `WHERE block_type = 'X'` or `IN (...)`.
615    BlockType(Vec<BlockType>),
616    /// Use the btree index: `WHERE pre = X`.
617    PreExact(u32),
618    /// Use the btree index: `WHERE pre BETWEEN lo AND hi`.
619    PreRange(u32, u32),
620    /// Use the hash index: `WHERE content = 'X'`.
621    ContentExact(String),
622    /// Use the hash index: `WHERE lang = 'X'`.
623    LangExact(String),
624    /// Use the hash index: `WHERE depth = N`.
625    DepthExact(u8),
626    /// Use the term index: `WHERE match(content, 'foo bar')` (AND of tokens).
627    TermMatch(Vec<String>),
628    /// No applicable index — fall back to full scan.
629    FullScan,
630}
631
632impl IndexHint {
633    /// Apply the hint against a `DocumentIndex` to get matching block indices.
634    ///
635    /// Returns `None` if the hint is `FullScan` (caller does the scan).
636    pub fn resolve(&self, idx: &DocumentIndex) -> Option<Vec<u32>> {
637        match self {
638            IndexHint::BlockType(types) => Some(idx.bitmap.get_any(types)),
639            IndexHint::PreExact(pre) => Some(idx.btree.get_by_pre(*pre).into_iter().collect()),
640            IndexHint::PreRange(lo, hi) => Some(idx.btree.range_by_pre(*lo, *hi).collect()),
641            IndexHint::ContentExact(c) => Some(idx.hash.by_content(c).to_vec()),
642            IndexHint::LangExact(l) => Some(idx.hash.by_lang(l).to_vec()),
643            IndexHint::DepthExact(d) => Some(idx.hash.by_depth(*d).to_vec()),
644            IndexHint::TermMatch(terms) => Some(idx.term.intersect(terms)),
645            IndexHint::FullScan => None,
646        }
647    }
648}
649
650// Tests
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use mq_markdown::Markdown;
656    use rstest::rstest;
657
658    use crate::index::build_blocks;
659
660    fn blocks_from(md: &str) -> Vec<Block> {
661        let doc = md.parse::<Markdown>().unwrap();
662        build_blocks(0, &doc.nodes)
663    }
664
665    #[test]
666    fn test_bitmap_heading_lookup() {
667        let blocks = blocks_from("# H1\n\n## H2\n\nParagraph\n\n```rust\ncode\n```\n");
668        let idx = DocumentIndex::build(&blocks);
669
670        let headings = idx.bitmap.get(&BlockType::Heading);
671        assert_eq!(headings.len(), 2);
672
673        let codes = idx.bitmap.get(&BlockType::Code);
674        assert_eq!(codes.len(), 1);
675
676        let paras = idx.bitmap.get(&BlockType::Paragraph);
677        assert_eq!(paras.len(), 1);
678    }
679
680    #[test]
681    fn test_bitmap_get_any() {
682        let blocks = blocks_from("# H1\n\nParagraph\n\n```rust\ncode\n```\n");
683        let idx = DocumentIndex::build(&blocks);
684
685        let result = idx.bitmap.get_any(&[BlockType::Heading, BlockType::Code]);
686        assert_eq!(result.len(), 2);
687    }
688
689    #[test]
690    fn test_btree_pre_lookup() {
691        let blocks = blocks_from("# H1\n\nParagraph\n");
692        let idx = DocumentIndex::build(&blocks);
693
694        // Every block's pre must be findable
695        for (i, block) in blocks.iter().enumerate() {
696            let found = idx.btree.get_by_pre(block.pre);
697            assert_eq!(
698                found,
699                Some(i as u32),
700                "pre={} not found in btree",
701                block.pre
702            );
703        }
704    }
705
706    #[test]
707    fn test_btree_pre_range() {
708        let blocks = blocks_from("# A\n\n## B\n\n### C\n\nParagraph\n");
709        let idx = DocumentIndex::build(&blocks);
710
711        let max_pre = blocks.iter().map(|b| b.pre).max().unwrap_or(0);
712        let all: Vec<u32> = idx.btree.range_by_pre(0, max_pre).collect();
713        assert_eq!(
714            all.len(),
715            blocks.len(),
716            "range scan should cover all blocks"
717        );
718    }
719
720    #[test]
721    fn test_hash_content_lookup() {
722        let blocks = blocks_from("## Architecture\n\nDetails\n");
723        let idx = DocumentIndex::build(&blocks);
724
725        let found = idx.hash.by_content("architecture");
726        assert_eq!(found.len(), 1);
727        assert_eq!(blocks[found[0] as usize].content, "Architecture");
728    }
729
730    #[test]
731    fn test_hash_lang_lookup() {
732        let blocks = blocks_from("```rust\nfn main(){}\n```\n\n```python\npass\n```\n");
733        let idx = DocumentIndex::build(&blocks);
734
735        assert_eq!(idx.hash.by_lang("rust").len(), 1);
736        assert_eq!(idx.hash.by_lang("python").len(), 1);
737        assert_eq!(idx.hash.by_lang("go").len(), 0);
738    }
739
740    #[test]
741    fn test_hash_depth_lookup() {
742        let blocks = blocks_from("# H1\n\n## H2\n\n## H2b\n\n### H3\n");
743        let idx = DocumentIndex::build(&blocks);
744
745        assert_eq!(idx.hash.by_depth(1).len(), 1);
746        assert_eq!(idx.hash.by_depth(2).len(), 2);
747        assert_eq!(idx.hash.by_depth(3).len(), 1);
748    }
749
750    #[test]
751    fn test_index_hint_resolve_block_type() {
752        let blocks = blocks_from("# H1\n\nPara\n\n```rust\ncode\n```\n");
753        let idx = DocumentIndex::build(&blocks);
754
755        let hint = IndexHint::BlockType(vec![BlockType::Heading]);
756        let result = hint.resolve(&idx).unwrap();
757        assert_eq!(result.len(), 1);
758        assert_eq!(blocks[result[0] as usize].block_type, BlockType::Heading);
759    }
760
761    #[test]
762    fn test_index_hint_fullscan_returns_none() {
763        let blocks = blocks_from("# H1\n");
764        let idx = DocumentIndex::build(&blocks);
765        assert!(IndexHint::FullScan.resolve(&idx).is_none());
766    }
767
768    #[rstest]
769    #[case(BlockType::Heading, 2)]
770    #[case(BlockType::Paragraph, 1)]
771    #[case(BlockType::Code, 1)]
772    #[case(BlockType::List, 1)]
773    #[case(BlockType::Blockquote, 0)]
774    fn test_bitmap_block_type_count_param(#[case] block_type: BlockType, #[case] expected: usize) {
775        let blocks = blocks_from("# H1\n\n## H2\n\nParagraph\n\n```rust\ncode\n```\n\n- item\n");
776        let idx = DocumentIndex::build(&blocks);
777        assert_eq!(idx.bitmap.get(&block_type).len(), expected);
778    }
779
780    #[rstest]
781    #[case(1, 1)]
782    #[case(2, 2)]
783    #[case(3, 1)]
784    #[case(4, 0)]
785    fn test_hash_depth_count_param(#[case] depth: u8, #[case] expected: usize) {
786        let blocks = blocks_from("# H1\n\n## H2a\n\n## H2b\n\n### H3\n");
787        let idx = DocumentIndex::build(&blocks);
788        assert_eq!(idx.hash.by_depth(depth).len(), expected);
789    }
790
791    #[rstest]
792    #[case("rust", 1)]
793    #[case("python", 1)]
794    #[case("go", 0)]
795    fn test_hash_lang_count_param(#[case] lang: &str, #[case] expected: usize) {
796        let blocks = blocks_from("```rust\nfn main(){}\n```\n\n```python\npass\n```\n");
797        let idx = DocumentIndex::build(&blocks);
798        assert_eq!(idx.hash.by_lang(lang).len(), expected);
799    }
800
801    #[rstest]
802    #[case(vec![BlockType::Heading], 2)]
803    #[case(vec![BlockType::Paragraph], 1)]
804    #[case(vec![BlockType::Code], 1)]
805    #[case(vec![BlockType::Heading, BlockType::Code], 3)]
806    fn test_index_hint_block_type_count_param(
807        #[case] types: Vec<BlockType>,
808        #[case] expected: usize,
809    ) {
810        let blocks = blocks_from("# H1\n\n## H2\n\nParagraph\n\n```rust\ncode\n```\n");
811        let idx = DocumentIndex::build(&blocks);
812        let result = IndexHint::BlockType(types).resolve(&idx).unwrap();
813        assert_eq!(result.len(), expected);
814    }
815
816    #[rstest]
817    #[case("fn main() {}", vec!["fn", "main"])]
818    #[case("v1.2.3", vec!["v1", "2", "3"])]
819    #[case("", vec![])]
820    #[case("CamelCase HTML_tag", vec!["camelcase", "html", "tag"])]
821    fn test_tokenize_param(#[case] input: &str, #[case] expected: Vec<&str>) {
822        let expected: Vec<String> = expected.into_iter().map(str::to_string).collect();
823        assert_eq!(tokenize(input), expected);
824    }
825
826    #[test]
827    fn test_term_index_build_and_postings() {
828        let blocks = blocks_from("# Hello World\n\nSome prose about Rust\n");
829        let idx = DocumentIndex::build(&blocks);
830        let hits = idx.term.intersect(&["rust".to_string()]);
831        assert_eq!(hits.len(), 1);
832        assert!(blocks[hits[0] as usize].content.contains("Rust"));
833    }
834
835    #[test]
836    fn test_term_index_intersect_and_semantics() {
837        let blocks = blocks_from("# H1\n\nfoo bar baz\n\nfoo only\n");
838        let idx = DocumentIndex::build(&blocks);
839
840        let both = idx.term.intersect(&["foo".to_string(), "bar".to_string()]);
841        assert_eq!(both.len(), 1);
842
843        let missing = idx
844            .term
845            .intersect(&["foo".to_string(), "nonexistent".to_string()]);
846        assert!(missing.is_empty());
847
848        assert!(idx.term.intersect(&[]).is_empty());
849    }
850
851    #[test]
852    fn test_document_index_to_bytes_from_bytes_roundtrip_includes_term_index() {
853        let blocks =
854            blocks_from("# Title\n\nSome prose here.\n\n```rust\nfn main() { let x = 1; }\n```\n");
855        let idx = DocumentIndex::build(&blocks);
856        let restored = DocumentIndex::from_bytes(&idx.to_bytes()).unwrap();
857
858        let mut original: Vec<(String, Vec<u32>)> = idx
859            .term
860            .postings
861            .iter()
862            .map(|(k, v)| (k.clone(), v.clone()))
863            .collect();
864        let mut round_tripped: Vec<(String, Vec<u32>)> = restored
865            .term
866            .postings
867            .iter()
868            .map(|(k, v)| (k.clone(), v.clone()))
869            .collect();
870        original.sort();
871        round_tripped.sort();
872
873        assert!(!original.is_empty());
874        assert_eq!(original, round_tripped);
875    }
876}