Skip to main content

greplm_core/
segment.rs

1//! On-disk index segments.
2//!
3//! Each segment is a set of files:
4//!   * `seg-N.fst`  - an FST mapping each trigram (3 bytes) to a byte offset
5//!   * `seg-N.post` - concatenated roaring bitmaps (posting lists) at those offsets
6//!   * `seg-N.docs` - JSON array of [`DocMeta`] (one per document)
7//!   * `seg-N.syms` - JSON array of [`SymbolEntry`]
8//!   * `seg-N.refs` - JSON array of [`RefEntry`] (call sites + imports)
9//!   * `seg-N.live` - a roaring bitmap of live (non-tombstoned) doc IDs
10//!
11//! The FST and postings blob are mmap'd for zero-copy, page-cache-backed reads.
12//! Doc and symbol tables are small relative to content and loaded into memory.
13
14use std::collections::{BTreeMap, BTreeSet, HashMap};
15use std::io::BufWriter;
16
17use memmap2::Mmap;
18use roaring::RoaringBitmap;
19use serde::{Deserialize, Serialize};
20
21use crate::error::{Error, Result};
22use crate::fsutil::{write_atomic, AtomicFile};
23use crate::paths::Paths;
24use crate::trigram::{Trigram, TrigramQuery};
25
26/// Metadata for one indexed document.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct DocMeta {
29    /// Path relative to the project root.
30    pub path: String,
31    /// Language id (see [`crate::lang::Language::id`]).
32    pub lang: String,
33    pub size: u64,
34    /// Fast content hash at index time (xxh3).
35    pub hash: u64,
36    pub lines: u32,
37}
38
39/// A symbol definition extracted from a document.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SymbolEntry {
42    pub doc_id: u32,
43    pub name: String,
44    /// Kind, e.g. "function", "class", "struct".
45    pub kind: String,
46    pub line_start: u32,
47    pub line_end: u32,
48    /// Enclosing named container (e.g. the class a method belongs to).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub container: Option<String>,
51    /// Compact one-line signature.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub signature: Option<String>,
54}
55
56/// A symbol before a document id is assigned.
57#[derive(Debug, Clone)]
58pub struct RawSymbol {
59    pub name: String,
60    pub kind: String,
61    pub line_start: u32,
62    pub line_end: u32,
63    pub container: Option<String>,
64    pub signature: Option<String>,
65}
66
67/// The kind of a structural reference. Stored as a 1-byte enum (rather than a
68/// heap `String`) since refs are the most numerous index records; serializes to
69/// the same `"call"`/`"import"` tokens on disk.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum RefKind {
73    Call,
74    Import,
75}
76
77impl RefKind {
78    pub fn as_str(self) -> &'static str {
79        match self {
80            RefKind::Call => "call",
81            RefKind::Import => "import",
82        }
83    }
84}
85
86/// A structural reference (call site or import) extracted from a document.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct RefEntry {
89    pub doc_id: u32,
90    pub name: String,
91    pub kind: RefKind,
92    pub line: u32,
93    pub column: u32,
94}
95
96/// A reference before a document id is assigned.
97#[derive(Debug, Clone)]
98pub struct RawRef {
99    pub name: String,
100    pub kind: RefKind,
101    pub line: u32,
102    pub column: u32,
103}
104
105/// Accumulates documents and builds a segment on disk.
106#[derive(Default)]
107pub struct SegmentWriter {
108    docs: Vec<DocMeta>,
109    syms: Vec<SymbolEntry>,
110    refs: Vec<RefEntry>,
111    postings: BTreeMap<Trigram, RoaringBitmap>,
112}
113
114impl SegmentWriter {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    pub fn is_empty(&self) -> bool {
120        self.docs.is_empty()
121    }
122
123    pub fn doc_count(&self) -> usize {
124        self.docs.len()
125    }
126
127    pub fn symbol_count(&self) -> usize {
128        self.syms.len()
129    }
130
131    /// Add a document and return its assigned doc id.
132    pub fn add_doc(
133        &mut self,
134        meta: DocMeta,
135        trigrams: &BTreeSet<Trigram>,
136        symbols: Vec<RawSymbol>,
137        refs: Vec<RawRef>,
138    ) -> u32 {
139        let doc_id = self.docs.len() as u32;
140        self.docs.push(meta);
141        for t in trigrams {
142            self.postings.entry(*t).or_default().insert(doc_id);
143        }
144        for s in symbols {
145            self.syms.push(SymbolEntry {
146                doc_id,
147                name: s.name,
148                kind: s.kind,
149                line_start: s.line_start,
150                line_end: s.line_end,
151                container: s.container,
152                signature: s.signature,
153            });
154        }
155        for r in refs {
156            self.refs.push(RefEntry {
157                doc_id,
158                name: r.name,
159                kind: r.kind,
160                line: r.line,
161                column: r.column,
162            });
163        }
164        doc_id
165    }
166
167    /// Serialize this segment to disk under the given segment id.
168    pub fn write(self, paths: &Paths, seg_id: u64) -> Result<()> {
169        std::fs::create_dir_all(paths.segments_dir())
170            .map_err(|e| Error::io(paths.segments_dir(), e))?;
171        let (post_blob, fst_entries) = build_postings_blob(self.postings)?;
172        write_segment_files(
173            paths,
174            seg_id,
175            &self.docs,
176            &self.syms,
177            &self.refs,
178            &fst_entries,
179            &post_blob,
180        )
181    }
182}
183
184/// Serialize the components of a segment to disk atomically. Shared by the
185/// incremental writer and by compaction's merge path.
186fn write_segment_files(
187    paths: &Paths,
188    seg_id: u64,
189    docs: &[DocMeta],
190    syms: &[SymbolEntry],
191    refs: &[RefEntry],
192    fst_entries: &[(Trigram, u64)],
193    post_blob: &[u8],
194) -> Result<()> {
195    // FST keys must be inserted in lexicographic order; callers pass entries
196    // sorted by trigram (BTreeMap iteration over [u8; 3] yields that order).
197    let fst_path = paths.fst_file(seg_id);
198    let mut fst_out = AtomicFile::create(&fst_path)?;
199    let mut builder = fst::MapBuilder::new(BufWriter::new(fst_out.file()))?;
200    for (tri, offset) in fst_entries {
201        builder.insert(tri, *offset)?;
202    }
203    builder.finish()?;
204    fst_out.commit()?;
205
206    write_atomic(&paths.post_file(seg_id), post_blob)?;
207    write_atomic(&paths.docs_file(seg_id), &serde_json::to_vec(docs)?)?;
208    write_atomic(&paths.syms_file(seg_id), &serde_json::to_vec(syms)?)?;
209    write_atomic(&paths.refs_file(seg_id), &serde_json::to_vec(refs)?)?;
210
211    // Initially every doc is live.
212    let mut live = RoaringBitmap::new();
213    live.insert_range(0..docs.len() as u32);
214    write_bitmap(&paths.live_file(seg_id), &live)?;
215
216    Ok(())
217}
218
219/// A serialized postings blob paired with the (trigram, offset) entries that
220/// index into it for the FST.
221type PostingsBlob = (Vec<u8>, Vec<(Trigram, u64)>);
222
223/// Build the postings blob and the (trigram, offset) FST entries from an
224/// in-memory posting map.
225fn build_postings_blob(postings: BTreeMap<Trigram, RoaringBitmap>) -> Result<PostingsBlob> {
226    let mut post_blob: Vec<u8> = Vec::new();
227    let mut fst_entries: Vec<(Trigram, u64)> = Vec::with_capacity(postings.len());
228    for (tri, mut bm) in postings.into_iter() {
229        bm.optimize();
230        let offset = post_blob.len() as u64;
231        bm.serialize_into(&mut post_blob)
232            .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
233        fst_entries.push((tri, offset));
234    }
235    Ok((post_blob, fst_entries))
236}
237
238/// Write a segment directly from prebuilt parts (used by compaction).
239pub(crate) fn write_segment_from_parts(
240    paths: &Paths,
241    seg_id: u64,
242    docs: &[DocMeta],
243    syms: &[SymbolEntry],
244    refs: &[RefEntry],
245    postings: BTreeMap<Trigram, RoaringBitmap>,
246) -> Result<()> {
247    std::fs::create_dir_all(paths.segments_dir())
248        .map_err(|e| Error::io(paths.segments_dir(), e))?;
249    let (post_blob, fst_entries) = build_postings_blob(postings)?;
250    write_segment_files(paths, seg_id, docs, syms, refs, &fst_entries, &post_blob)
251}
252
253/// A read-only, mmap-backed view of a segment.
254pub struct Segment {
255    pub id: u64,
256    fst: fst::Map<Mmap>,
257    post: Mmap,
258    pub docs: Vec<DocMeta>,
259    pub syms: Vec<SymbolEntry>,
260    pub refs: Vec<RefEntry>,
261    live: RoaringBitmap,
262    /// Symbol indices grouped by `doc_id` (a flattened CSR layout). Together with
263    /// `sym_start` this gives O(1) access to a document's symbols instead of a
264    /// full scan of `syms`.
265    sym_order: Vec<u32>,
266    /// Prefix offsets into `sym_order`; `sym_start[d]..sym_start[d + 1]` is the
267    /// slice of symbol indices belonging to doc `d`. Length is `docs.len() + 1`.
268    sym_start: Vec<u32>,
269    /// Reference indices grouped by `doc_id` (CSR layout, mirrors `sym_order`).
270    ref_order: Vec<u32>,
271    /// Prefix offsets into `ref_order`; length is `docs.len() + 1`.
272    ref_start: Vec<u32>,
273    /// Lowercased symbol names, parallel to `syms`, precomputed once at open.
274    sym_name_lower: Vec<String>,
275    /// Call refs grouped by callee name -> indices into `refs`. Lets callers /
276    /// references / blast-radius look up by name in O(results) instead of
277    /// scanning every ref each query.
278    call_by_name: HashMap<Box<str>, Vec<u32>>,
279}
280
281impl Segment {
282    pub fn open(paths: &Paths, seg_id: u64) -> Result<Segment> {
283        let fst_path = paths.fst_file(seg_id);
284        let fst_file = std::fs::File::open(&fst_path).map_err(|e| Error::io(&fst_path, e))?;
285        let fst_mmap = unsafe { Mmap::map(&fst_file).map_err(|e| Error::io(&fst_path, e))? };
286        let fst = fst::Map::new(fst_mmap)?;
287
288        let post_path = paths.post_file(seg_id);
289        let post_file = std::fs::File::open(&post_path).map_err(|e| Error::io(&post_path, e))?;
290        let post = unsafe { Mmap::map(&post_file).map_err(|e| Error::io(&post_path, e))? };
291
292        let docs_path = paths.docs_file(seg_id);
293        let docs: Vec<DocMeta> = serde_json::from_slice(
294            &std::fs::read(&docs_path).map_err(|e| Error::io(&docs_path, e))?,
295        )?;
296
297        let syms_path = paths.syms_file(seg_id);
298        let syms: Vec<SymbolEntry> = serde_json::from_slice(
299            &std::fs::read(&syms_path).map_err(|e| Error::io(&syms_path, e))?,
300        )?;
301
302        // Refs were added in schema v2. Tolerate a missing file (treat as no
303        // refs) so an older segment can still be opened read-only.
304        let refs_path = paths.refs_file(seg_id);
305        let refs: Vec<RefEntry> = match std::fs::read(&refs_path) {
306            Ok(bytes) => serde_json::from_slice(&bytes)?,
307            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
308            Err(e) => return Err(Error::io(&refs_path, e)),
309        };
310
311        let live = read_bitmap(&paths.live_file(seg_id))?;
312
313        let (sym_order, sym_start) = build_doc_index(docs.len(), syms.iter().map(|s| s.doc_id));
314        let (ref_order, ref_start) = build_doc_index(docs.len(), refs.iter().map(|r| r.doc_id));
315        let sym_name_lower = syms.iter().map(|s| s.name.to_ascii_lowercase()).collect();
316
317        let mut call_by_name: HashMap<Box<str>, Vec<u32>> = HashMap::new();
318        for (i, r) in refs.iter().enumerate() {
319            if r.kind == RefKind::Call {
320                call_by_name
321                    .entry(r.name.as_str().into())
322                    .or_default()
323                    .push(i as u32);
324            }
325        }
326
327        Ok(Segment {
328            id: seg_id,
329            fst,
330            post,
331            docs,
332            syms,
333            refs,
334            live,
335            sym_order,
336            sym_start,
337            ref_order,
338            ref_start,
339            sym_name_lower,
340            call_by_name,
341        })
342    }
343
344    pub fn doc(&self, doc_id: u32) -> Option<&DocMeta> {
345        self.docs.get(doc_id as usize)
346    }
347
348    /// The symbols defined in `doc_id`, in storage order. O(1) lookup.
349    pub fn doc_syms(&self, doc_id: u32) -> impl Iterator<Item = &SymbolEntry> {
350        let d = doc_id as usize;
351        let (lo, hi) = if d + 1 < self.sym_start.len() {
352            (self.sym_start[d] as usize, self.sym_start[d + 1] as usize)
353        } else {
354            (0, 0)
355        };
356        self.sym_order[lo..hi]
357            .iter()
358            .map(move |&i| &self.syms[i as usize])
359    }
360
361    /// The references (calls + imports) originating in `doc_id`. O(1) lookup.
362    pub fn doc_refs(&self, doc_id: u32) -> impl Iterator<Item = &RefEntry> {
363        let d = doc_id as usize;
364        let (lo, hi) = if d + 1 < self.ref_start.len() {
365            (self.ref_start[d] as usize, self.ref_start[d + 1] as usize)
366        } else {
367            (0, 0)
368        };
369        self.ref_order[lo..hi]
370            .iter()
371            .map(move |&i| &self.refs[i as usize])
372    }
373
374    /// Call sites whose callee is exactly `name`, via the prebuilt name index
375    /// (O(results), no full scan). Liveness is not filtered here; callers that
376    /// care should check [`Segment::is_live`].
377    pub fn calls_to(&self, name: &str) -> impl Iterator<Item = &RefEntry> {
378        self.call_by_name
379            .get(name)
380            .into_iter()
381            .flatten()
382            .map(move |&i| &self.refs[i as usize])
383    }
384
385    /// Lowercased name of the symbol at index `i` in `syms`.
386    pub fn sym_name_lower(&self, i: usize) -> &str {
387        &self.sym_name_lower[i]
388    }
389
390    pub fn is_live(&self, doc_id: u32) -> bool {
391        self.live.contains(doc_id)
392    }
393
394    pub fn live_count(&self) -> u64 {
395        self.live.len()
396    }
397
398    /// Read the posting list for a single trigram.
399    fn posting(&self, tri: Trigram) -> Result<Option<RoaringBitmap>> {
400        match self.fst.get(tri) {
401            Some(offset) => Ok(Some(self.posting_at(offset)?)),
402            None => Ok(None),
403        }
404    }
405
406    /// Deserialize the posting list stored at `offset` in the postings blob.
407    fn posting_at(&self, offset: u64) -> Result<RoaringBitmap> {
408        let slice = &self.post[offset as usize..];
409        RoaringBitmap::deserialize_from(slice)
410            .map_err(|e| Error::Corrupt(format!("posting list: {e}")))
411    }
412
413    /// Add this segment's postings to `out`, remapping doc ids via `remap` and
414    /// dropping any doc not present in the map (i.e. tombstoned/non-live).
415    pub(crate) fn remap_postings(
416        &self,
417        remap: &std::collections::HashMap<u32, u32>,
418        out: &mut BTreeMap<Trigram, RoaringBitmap>,
419    ) -> Result<()> {
420        use fst::Streamer;
421        let mut stream = self.fst.stream();
422        while let Some((key, offset)) = stream.next() {
423            if key.len() != 3 {
424                continue;
425            }
426            let tri: Trigram = [key[0], key[1], key[2]];
427            let bm = self.posting_at(offset)?;
428            let dest = out.entry(tri).or_default();
429            for old in bm.iter() {
430                if let Some(&new_id) = remap.get(&old) {
431                    dest.insert(new_id);
432                }
433            }
434        }
435        Ok(())
436    }
437
438    /// All live doc ids in this segment.
439    pub fn all_live(&self) -> RoaringBitmap {
440        self.live.clone()
441    }
442
443    /// AND together a group of trigrams (rarest-first so the smallest posting
444    /// list drives the intersection and we short-circuit on an empty result).
445    fn intersect_group(&self, group: &[Trigram]) -> Result<RoaringBitmap> {
446        let mut lists: Vec<RoaringBitmap> = Vec::with_capacity(group.len());
447        for tri in group {
448            lists.push(self.posting(*tri)?.unwrap_or_default());
449        }
450        lists.sort_by_key(|b| b.len());
451        let mut acc: Option<RoaringBitmap> = None;
452        for p in lists {
453            acc = Some(match acc.take() {
454                None => p,
455                Some(a) => a & p,
456            });
457            if acc.as_ref().map(|b| b.is_empty()).unwrap_or(false) {
458                break;
459            }
460        }
461        Ok(acc.unwrap_or_default())
462    }
463
464    /// OR together a clause of trigrams (the union of their posting lists).
465    fn union_clause(&self, clause: &[Trigram]) -> Result<RoaringBitmap> {
466        let mut acc = RoaringBitmap::new();
467        for tri in clause {
468            if let Some(bm) = self.posting(*tri)? {
469                acc |= bm;
470            }
471        }
472        Ok(acc)
473    }
474
475    /// Compute candidate doc ids satisfying the trigram query, intersected with
476    /// the live set. An unconstrained query yields all live docs.
477    pub fn candidates(&self, query: &TrigramQuery) -> Result<RoaringBitmap> {
478        if query.is_unconstrained() {
479            return Ok(self.all_live());
480        }
481
482        // DNF part: OR of (AND of group). When absent (or any group is empty),
483        // it can't filter, so we start from the full live set.
484        let dnf_active =
485            !query.or_groups.is_empty() && query.or_groups.iter().all(|g| !g.is_empty());
486        let mut result = if dnf_active {
487            let mut acc: Option<RoaringBitmap> = None;
488            for group in &query.or_groups {
489                let g = self.intersect_group(group)?;
490                acc = Some(match acc.take() {
491                    None => g,
492                    Some(a) => a | g,
493                });
494            }
495            acc.unwrap_or_default()
496        } else {
497            self.all_live()
498        };
499
500        // CNF part: AND of (OR within clause).
501        let cnf_active =
502            !query.and_clauses.is_empty() && query.and_clauses.iter().all(|c| !c.is_empty());
503        if cnf_active {
504            for clause in &query.and_clauses {
505                result &= self.union_clause(clause)?;
506                if result.is_empty() {
507                    break;
508                }
509            }
510        }
511
512        result &= &self.live;
513        Ok(result)
514    }
515}
516
517/// Build a CSR-style index grouping row indices by their `doc_id`. Shared by
518/// the symbol and reference per-document lookups.
519fn build_doc_index(n: usize, doc_ids: impl Iterator<Item = u32> + Clone) -> (Vec<u32>, Vec<u32>) {
520    let mut counts = vec![0u32; n + 1];
521    let mut total = 0usize;
522    for d in doc_ids.clone() {
523        let d = d as usize;
524        if d < n {
525            counts[d] += 1;
526            total += 1;
527        }
528    }
529    // Prefix-sum into start offsets.
530    let mut start = vec![0u32; n + 1];
531    let mut acc = 0u32;
532    for d in 0..n {
533        start[d] = acc;
534        acc += counts[d];
535    }
536    start[n] = acc;
537    // Scatter row indices into their doc's slot.
538    let mut order = vec![0u32; total];
539    let mut cursor: Vec<u32> = start[..n].to_vec();
540    for (i, d) in doc_ids.enumerate() {
541        let d = d as usize;
542        if d < n {
543            order[cursor[d] as usize] = i as u32;
544            cursor[d] += 1;
545        }
546    }
547    (order, start)
548}
549
550pub(crate) fn write_bitmap(path: &std::path::Path, bm: &RoaringBitmap) -> Result<()> {
551    let mut buf = Vec::with_capacity(bm.serialized_size());
552    bm.serialize_into(&mut buf)
553        .map_err(|e| Error::other(format!("roaring serialize: {e}")))?;
554    write_atomic(path, &buf)
555}
556
557pub(crate) fn read_bitmap(path: &std::path::Path) -> Result<RoaringBitmap> {
558    let bytes = std::fs::read(path).map_err(|e| Error::io(path, e))?;
559    RoaringBitmap::deserialize_from(&bytes[..])
560        .map_err(|e| Error::Corrupt(format!("live bitmap: {e}")))
561}