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