1use std::collections::{HashMap, HashSet};
4
5use rayon::prelude::*;
6
7use crate::cache::{fast_hash, stat_key, Cache, FileRecord};
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::io_backend::IoBackend;
11use crate::lang::Language;
12use crate::meta::{Meta, PendingTombstones};
13use crate::paths::Paths;
14use crate::segment::{
15 merge_postings, read_bitmap, write_bitmap, write_segment_files, DocMeta, RawRef, RawSymbol,
16 Segment, SegmentWriter,
17};
18use crate::trigram;
19use crate::walk::{self, SkipReason, Skipped, WalkEntry};
20
21const SKIP_SAMPLE_CAP: usize = 100;
25
26fn max_segment_id_on_disk(paths: &Paths) -> Option<u64> {
32 let entries = std::fs::read_dir(paths.segments_dir()).ok()?;
33 entries
34 .flatten()
35 .filter_map(|e| {
36 let name = e.file_name();
37 let name = name.to_str()?;
38 let rest = name.strip_prefix("seg-")?;
39 let digits = rest.split('.').next()?;
40 digits.parse::<u64>().ok()
41 })
42 .max()
43}
44
45#[derive(Debug, Default, Clone)]
47pub struct IndexStats {
48 pub files_indexed: usize,
49 pub files_skipped: usize,
53 pub files_removed: usize,
54 pub symbols: usize,
55 pub segments: usize,
56 pub skipped_by_reason: std::collections::BTreeMap<SkipReason, usize>,
58 pub skipped_sample: Vec<Skipped>,
60}
61
62impl IndexStats {
63 fn record_skips(&mut self, skips: impl IntoIterator<Item = Skipped>) {
66 for s in skips {
67 *self.skipped_by_reason.entry(s.reason).or_default() += 1;
68 self.files_skipped += 1;
69 if self.skipped_sample.len() < SKIP_SAMPLE_CAP {
70 self.skipped_sample.push(s);
71 }
72 }
73 }
74}
75
76struct Processed {
78 rel: String,
79 inode: u64,
80 mtime_ns: i64,
81 size: u64,
82 hash: u64,
83 doc: DocMeta,
84 trigram_keys: Vec<u32>,
86 symbols: Vec<RawSymbol>,
87 refs: Vec<RawRef>,
88 rename_from: Option<(u64, u32)>,
92}
93
94struct RenameSrc {
97 segment_id: u64,
98 doc_id: u32,
99 lang: String,
100}
101
102type RenameSources = HashMap<(u64, u64), RenameSrc>;
104
105fn is_binary(data: &[u8]) -> bool {
107 memchr::memchr(0, data).is_some()
108}
109
110fn count_lines(data: &[u8]) -> u32 {
112 if data.is_empty() {
113 return 0;
114 }
115 let nl = memchr::memchr_iter(b'\n', data).count();
116 if data.last() == Some(&b'\n') {
117 nl as u32
118 } else {
119 (nl + 1) as u32
120 }
121}
122
123enum Outcome {
125 Indexed(Box<Processed>),
127 Skipped(Skipped),
129}
130
131fn process(
132 entry: &WalkEntry,
133 backend: &dyn IoBackend,
134 config: &Config,
135 renames: &RenameSources,
136) -> Outcome {
137 let skip = |reason| {
138 Outcome::Skipped(Skipped {
139 rel: entry.rel.clone(),
140 reason,
141 })
142 };
143 let data = match backend.read(&entry.path) {
144 Ok(d) => d,
145 Err(_) => return skip(SkipReason::ReadError),
148 };
149 if !config.index_binary && is_binary(&data) {
150 return skip(SkipReason::Binary);
151 }
152 let ext = entry
153 .path
154 .extension()
155 .and_then(|e| e.to_str())
156 .unwrap_or("")
157 .to_ascii_lowercase();
158 let lang = Language::from_extension(&ext);
159 let (inode, mtime_ns, size) = stat_key(&entry.metadata);
160 let hash = fast_hash(&data);
161 let trigram_keys = trigram::extract_keys(&data);
162 let rename_from = renames
166 .get(&(hash, size))
167 .filter(|src| src.lang == lang.id())
168 .map(|src| (src.segment_id, src.doc_id));
169 let (symbols, refs) = if rename_from.is_none() && lang.grammar().is_some() {
170 crate::symbol::extract_all(lang, &data)
171 } else {
172 (Vec::new(), Vec::new())
173 };
174 let doc = DocMeta {
175 path: entry.rel.clone(),
176 lang: lang.id().to_string(),
177 size,
178 hash,
179 lines: count_lines(&data),
180 };
181 Outcome::Indexed(Box::new(Processed {
182 rel: entry.rel.clone(),
183 inode,
184 mtime_ns,
185 size,
186 hash,
187 doc,
188 trigram_keys,
189 symbols,
190 refs,
191 rename_from,
192 }))
193}
194
195fn process_all(
198 candidates: &[&WalkEntry],
199 backend: &dyn IoBackend,
200 config: &Config,
201 renames: &RenameSources,
202) -> (Vec<Processed>, Vec<Skipped>) {
203 let outcomes: Vec<Outcome> = candidates
204 .par_iter()
205 .map(|e| process(e, backend, config, renames))
206 .collect();
207 let mut processed = Vec::with_capacity(outcomes.len());
208 let mut skipped = Vec::new();
209 for outcome in outcomes {
210 match outcome {
211 Outcome::Indexed(p) => processed.push(*p),
212 Outcome::Skipped(s) => skipped.push(s),
213 }
214 }
215 (processed, skipped)
216}
217
218pub struct Indexer<'a> {
220 pub paths: &'a Paths,
221 pub config: &'a Config,
222 pub backend: &'a dyn IoBackend,
223}
224
225impl<'a> Indexer<'a> {
226 pub fn new(paths: &'a Paths, config: &'a Config, backend: &'a dyn IoBackend) -> Self {
227 Self {
228 paths,
229 config,
230 backend,
231 }
232 }
233
234 pub fn index_full(&self) -> Result<IndexStats> {
242 std::fs::create_dir_all(self.paths.segments_dir())
243 .map_err(|e| Error::io(self.paths.segments_dir(), e))?;
244
245 let mut meta = match Meta::load(&self.paths.meta_file()) {
252 Ok(meta) => meta,
253 Err(e @ (Error::Corrupt(_) | Error::Json(_))) => {
254 tracing::warn!("index manifest unusable ({e}); rebuilding from scratch");
255 Meta {
256 next_segment_id: max_segment_id_on_disk(self.paths).map_or(0, |id| id + 1),
257 ..Meta::default()
258 }
259 }
260 Err(e) => return Err(e),
261 };
262 let old_segments = std::mem::take(&mut meta.segments);
263 meta.pending_tombstones.clear();
266
267 let cache = Cache::open(&self.paths.cache_file())?;
268
269 let walk::WalkResult {
270 entries,
271 skipped: walk_skips,
272 } = walk::walk(self.paths, self.config)?;
273 let candidates: Vec<&WalkEntry> = entries.iter().collect();
274 let (processed, proc_skips) = process_all(
275 &candidates,
276 self.backend,
277 self.config,
278 &RenameSources::new(),
279 );
280
281 let seg_id = meta.alloc_segment();
282 let mut writer = SegmentWriter::new();
283 let mut upserts = Vec::with_capacity(processed.len());
284 for pf in processed {
287 let symbols = pf.symbols.len() as u32;
288 let doc_id = writer.add_doc(pf.doc, &pf.trigram_keys, pf.symbols, pf.refs);
289 upserts.push((
290 pf.rel,
291 FileRecord {
292 inode: pf.inode,
293 mtime_ns: pf.mtime_ns,
294 size: pf.size,
295 hash: pf.hash,
296 segment_id: seg_id,
297 doc_id,
298 symbols,
299 },
300 ));
301 }
302
303 let mut stats = IndexStats {
304 files_indexed: writer.doc_count(),
305 files_removed: 0,
306 symbols: writer.symbol_count(),
307 segments: 0,
308 ..Default::default()
309 };
310 stats.record_skips(walk_skips);
311 stats.record_skips(proc_skips);
312
313 if writer.is_empty() {
314 meta.segments = Vec::new();
316 } else {
317 writer.write(self.paths, seg_id)?;
318 meta.segments = vec![seg_id];
319 }
320 stats.segments = meta.segments.len();
321
322 meta.doc_count = stats.files_indexed as u64;
325 meta.symbol_count = stats.symbols as u64;
326 meta.record_git_head(&self.paths.root);
327 meta.touch_now();
328 meta.save(&self.paths.meta_file())?;
329
330 cache.replace_all(&upserts)?;
331
332 drop(old_segments);
338 self.sweep_unreferenced_segments(&meta.segments);
339 Ok(stats)
340 }
341
342 pub fn index_incremental(&self) -> Result<IndexStats> {
344 let mut meta = match Meta::load(&self.paths.meta_file()) {
345 Ok(meta) => meta,
346 Err(e @ (Error::Corrupt(_) | Error::Json(_))) => {
353 tracing::warn!("index manifest unusable ({e}); rebuilding from scratch");
354 return self.index_full();
355 }
356 Err(e) => return Err(e),
357 };
358 if meta.segments.is_empty() {
359 return self.index_full();
360 }
361
362 self.apply_pending_tombstones(&mut meta)?;
365
366 let cache = Cache::open(&self.paths.cache_file())?;
367 let existing = match cache.load_all() {
368 Ok(existing) => existing,
369 Err(e @ Error::Postcard(_)) => {
375 tracing::warn!("cache unreadable ({e}); rebuilding from scratch");
376 drop(cache);
377 return self.index_full();
378 }
379 Err(e) => return Err(e),
380 };
381
382 let live_segs: HashSet<u64> = meta.segments.iter().copied().collect();
389 if existing
390 .values()
391 .any(|r| !live_segs.contains(&r.segment_id))
392 {
393 drop(existing);
395 drop(cache);
396 return self.index_full();
397 }
398
399 let referenced: HashSet<u64> = existing.values().map(|r| r.segment_id).collect();
406 for &seg_id in &meta.segments {
407 if !referenced.contains(&seg_id)
408 && read_bitmap(&self.paths.live_file(seg_id)).is_ok_and(|bm| !bm.is_empty())
409 {
410 drop(existing);
411 drop(cache);
412 return self.index_full();
413 }
414 }
415
416 let walk::WalkResult {
417 entries,
418 skipped: walk_skips,
419 } = walk::walk(self.paths, self.config)?;
420 let mut seen: HashMap<String, &WalkEntry> = HashMap::with_capacity(entries.len());
421 for e in &entries {
422 seen.insert(e.rel.clone(), e);
423 }
424
425 let deleted: Vec<String> = existing
429 .keys()
430 .filter(|p| !seen.contains_key(*p))
431 .cloned()
432 .collect();
433 let (renames, rename_segs) = self.rename_sources(&existing, &deleted);
434
435 let candidates: Vec<&WalkEntry> = entries
437 .iter()
438 .filter(|e| {
439 let (_, mtime_ns, size) = stat_key(&e.metadata);
440 match existing.get(&e.rel) {
441 Some(rec) => rec.size != size || rec.mtime_ns != mtime_ns,
442 None => true,
443 }
444 })
445 .collect();
446
447 let (processed, proc_skips) = process_all(&candidates, self.backend, self.config, &renames);
448
449 let mut changed: Vec<Processed> = Vec::new();
451 let mut touch_only: Vec<(String, FileRecord)> = Vec::new();
452 for mut pf in processed {
453 if let Some((src_seg, src_doc)) = pf.rename_from {
457 if let Some(seg) = rename_segs.get(&src_seg) {
458 pf.symbols = seg
459 .doc_syms(src_doc)
460 .map(|s| RawSymbol {
461 name: s.name,
462 kind: s.kind,
463 line_start: s.line_start,
464 line_end: s.line_end,
465 container: s.container,
466 signature: s.signature,
467 })
468 .collect();
469 pf.refs = seg
470 .doc_refs(src_doc)
471 .map(|r| RawRef {
472 name: r.name,
473 kind: r.kind,
474 line: r.line,
475 column: r.column,
476 })
477 .collect();
478 }
479 }
480 match existing.get(&pf.rel) {
481 Some(rec) if rec.hash == pf.hash => {
482 touch_only.push((
484 pf.rel.clone(),
485 FileRecord {
486 inode: pf.inode,
487 mtime_ns: pf.mtime_ns,
488 size: pf.size,
489 hash: pf.hash,
490 segment_id: rec.segment_id,
491 doc_id: rec.doc_id,
492 symbols: rec.symbols,
493 },
494 ));
495 }
496 _ => changed.push(pf),
497 }
498 }
499
500 let mut tombstones: HashMap<u64, Vec<u32>> = HashMap::new();
504 for pf in &changed {
505 if let Some(rec) = existing.get(&pf.rel) {
506 tombstones
507 .entry(rec.segment_id)
508 .or_default()
509 .push(rec.doc_id);
510 }
511 }
512 for path in &deleted {
513 if let Some(rec) = existing.get(path) {
514 tombstones
515 .entry(rec.segment_id)
516 .or_default()
517 .push(rec.doc_id);
518 }
519 }
520
521 let mut doc_count = meta.doc_count as i64;
526 let mut sym_count = meta.symbol_count as i64;
527 for path in &deleted {
528 if let Some(rec) = existing.get(path) {
529 doc_count -= 1;
530 sym_count -= rec.symbols as i64;
531 }
532 }
533 for pf in &changed {
534 if let Some(rec) = existing.get(&pf.rel) {
535 sym_count -= rec.symbols as i64;
537 } else {
538 doc_count += 1;
540 }
541 sym_count += pf.symbols.len() as i64;
542 }
543
544 let changed_count = changed.len();
545 let changed_symbols: usize = changed.iter().map(|p| p.symbols.len()).sum();
546
547 let mut upserts = touch_only;
553 if !changed.is_empty() {
554 let seg_id = meta.alloc_segment();
555 let mut writer = SegmentWriter::new();
556 for pf in changed {
557 let symbols = pf.symbols.len() as u32;
558 let doc_id = writer.add_doc(pf.doc, &pf.trigram_keys, pf.symbols, pf.refs);
559 upserts.push((
560 pf.rel,
561 FileRecord {
562 inode: pf.inode,
563 mtime_ns: pf.mtime_ns,
564 size: pf.size,
565 hash: pf.hash,
566 segment_id: seg_id,
567 doc_id,
568 symbols,
569 },
570 ));
571 }
572 writer.write(self.paths, seg_id)?;
573 meta.segments.push(seg_id);
574 }
575
576 meta.pending_tombstones = tombstones
582 .iter()
583 .map(|(&segment_id, doc_ids)| PendingTombstones {
584 segment_id,
585 doc_ids: doc_ids.clone(),
586 })
587 .collect();
588 meta.doc_count = doc_count.max(0) as u64;
589 meta.symbol_count = sym_count.max(0) as u64;
590 meta.record_git_head(&self.paths.root);
591 meta.touch_now();
592 meta.save(&self.paths.meta_file())?;
593
594 for (seg_id, doc_ids) in &tombstones {
600 self.tombstone(*seg_id, doc_ids)?;
601 }
602 cache.apply(&upserts, &deleted)?;
603 if !meta.pending_tombstones.is_empty() {
604 meta.pending_tombstones.clear();
605 meta.save(&self.paths.meta_file())?;
606 }
607
608 let mut stats = IndexStats {
609 files_indexed: changed_count,
610 files_removed: deleted.len(),
611 symbols: changed_symbols,
612 segments: meta.segments.len(),
613 ..Default::default()
614 };
615 stats.record_skips(walk_skips);
616 stats.record_skips(proc_skips);
617
618 if meta.segments.len() > self.config.merge_threshold {
622 drop(cache);
623 self.compact_auto()?;
624 }
625 Ok(stats)
626 }
627
628 fn apply_pending_tombstones(&self, meta: &mut Meta) -> Result<()> {
634 if meta.pending_tombstones.is_empty() {
635 return Ok(());
636 }
637 let pending = std::mem::take(&mut meta.pending_tombstones);
638 for pt in &pending {
639 if meta.segments.contains(&pt.segment_id) {
641 self.tombstone(pt.segment_id, &pt.doc_ids)?;
642 }
643 }
644 meta.save(&self.paths.meta_file())
645 }
646
647 fn rename_sources(
654 &self,
655 existing: &HashMap<String, FileRecord>,
656 deleted: &[String],
657 ) -> (RenameSources, HashMap<u64, Segment>) {
658 let mut wanted: HashMap<u64, Vec<&FileRecord>> = HashMap::new();
659 for path in deleted {
660 if let Some(rec) = existing.get(path) {
661 wanted.entry(rec.segment_id).or_default().push(rec);
662 }
663 }
664 let mut renames = RenameSources::new();
665 let mut segs: HashMap<u64, Segment> = HashMap::new();
666 for (seg_id, recs) in wanted {
667 let seg = match Segment::open(self.paths, seg_id) {
668 Ok(s) => s,
669 Err(e) => {
670 tracing::debug!("rename fast-path disabled for segment {seg_id}: {e}");
671 continue;
672 }
673 };
674 for rec in recs {
675 if let Some(doc) = seg.doc(rec.doc_id) {
676 renames.insert(
677 (rec.hash, rec.size),
678 RenameSrc {
679 segment_id: seg_id,
680 doc_id: rec.doc_id,
681 lang: doc.lang.clone(),
682 },
683 );
684 }
685 }
686 segs.insert(seg_id, seg);
687 }
688 (renames, segs)
689 }
690
691 pub fn compact(&self) -> Result<IndexStats> {
695 match self.merge_segments(true) {
696 Ok(stats) => Ok(stats),
697 Err(e) => {
698 tracing::warn!("merge compaction failed ({e}); falling back to full rebuild");
699 self.index_full()
700 }
701 }
702 }
703
704 fn compact_auto(&self) -> Result<IndexStats> {
710 match self.merge_segments(false) {
711 Ok(stats) => Ok(stats),
712 Err(e) => {
713 tracing::warn!("auto compaction failed ({e}); falling back to full rebuild");
714 self.index_full()
715 }
716 }
717 }
718
719 fn merge_segments(&self, all: bool) -> Result<IndexStats> {
725 let mut meta = Meta::load(&self.paths.meta_file())?;
726 if meta.segments.is_empty() {
727 return Ok(IndexStats::default());
728 }
729 self.apply_pending_tombstones(&mut meta)?;
732
733 let cache = Cache::open(&self.paths.cache_file())?;
734 let existing = cache.load_all()?;
735
736 let victim_ids: Vec<u64> = if all {
740 meta.segments.clone()
741 } else {
742 let target = (self.config.merge_threshold / 2).max(1);
743 if meta.segments.len() <= target {
744 return Ok(IndexStats::default());
745 }
746 let mut by_live: Vec<(u64, u64)> = meta
747 .segments
748 .iter()
749 .map(|&id| Ok((id, read_bitmap(&self.paths.live_file(id))?.len())))
750 .collect::<Result<_>>()?;
751 by_live.sort_by_key(|&(_, n)| n);
752 by_live.truncate(meta.segments.len() - target + 1);
753 by_live.into_iter().map(|(id, _)| id).collect()
754 };
755 let victims: HashSet<u64> = victim_ids.iter().copied().collect();
756
757 let segments: Vec<Segment> = victim_ids
758 .iter()
759 .map(|&id| Segment::open(self.paths, id))
760 .collect::<Result<_>>()?;
761
762 let mut docs: Vec<DocMeta> = Vec::new();
763 let mut syms = crate::table::SymTableBuilder::new();
766 let mut refs = crate::table::RefTableBuilder::new();
767 let mut remaps: Vec<Vec<u32>> = Vec::with_capacity(segments.len());
768 let mut upserts: Vec<(String, FileRecord)> = Vec::new();
769 let new_seg_id = meta.alloc_segment();
770
771 for seg in &segments {
772 let mut remap: Vec<u32> = vec![u32::MAX; seg.docs.len()];
774 for old_id in seg.all_live().iter() {
775 let doc = match seg.doc(old_id) {
776 Some(d) => d,
777 None => continue,
778 };
779 let new_id = docs.len() as u32;
780 remap[old_id as usize] = new_id;
781 let syms_before = syms.len();
782 for s in seg.doc_syms(old_id) {
783 syms.push(
784 new_id,
785 &s.name,
786 &s.kind,
787 s.line_start,
788 s.line_end,
789 s.container.as_deref(),
790 s.signature.as_deref(),
791 )?;
792 }
793 for r in seg.doc_refs(old_id) {
794 refs.push(new_id, &r.name, r.kind, r.line, r.column)?;
795 }
796 let doc_sym_count = (syms.len() - syms_before) as u32;
797 let (inode, mtime_ns) = existing
798 .get(&doc.path)
799 .map(|r| (r.inode, r.mtime_ns))
800 .unwrap_or((0, 0));
801 upserts.push((
802 doc.path.clone(),
803 FileRecord {
804 inode,
805 mtime_ns,
806 size: doc.size,
807 hash: doc.hash,
808 segment_id: new_seg_id,
809 doc_id: new_id,
810 symbols: doc_sym_count,
811 },
812 ));
813 docs.push(doc.clone());
814 }
815 remaps.push(remap);
816 }
817
818 let symbol_count = syms.len();
819 let doc_count = docs.len();
820
821 let (postings, tables) = rayon::join(
824 || merge_postings(&segments, &remaps),
825 || rayon::join(|| syms.finish(doc_count), || refs.finish(doc_count)),
826 );
827 let (post_blob, fst_entries) = postings?;
828 let (syms_enc, refs_enc) = tables;
829 drop(segments);
830
831 meta.segments.retain(|id| !victims.contains(id));
832 if !docs.is_empty() {
833 write_segment_files(
834 self.paths,
835 new_seg_id,
836 &docs,
837 syms_enc?,
838 refs_enc?,
839 &fst_entries,
840 post_blob,
841 )?;
842 meta.segments.push(new_seg_id);
843 }
844
845 if all {
848 meta.doc_count = doc_count as u64;
850 meta.symbol_count = symbol_count as u64;
851 }
852 meta.touch_now();
853 meta.save(&self.paths.meta_file())?;
854
855 if all {
856 cache.replace_all(&upserts)?;
857 } else {
858 cache.apply(&upserts, &[])?;
861 }
862
863 for id in victim_ids {
864 self.remove_segment_files(id);
865 }
866
867 Ok(IndexStats {
868 files_indexed: doc_count,
869 files_removed: 0,
870 symbols: symbol_count,
871 segments: meta.segments.len(),
872 ..Default::default()
873 })
874 }
875
876 fn sweep_unreferenced_segments(&self, live: &[u64]) {
880 let Ok(entries) = std::fs::read_dir(self.paths.segments_dir()) else {
881 return;
882 };
883 for e in entries.flatten() {
884 let name = e.file_name();
885 let Some(id) = name
886 .to_str()
887 .and_then(|n| n.strip_prefix("seg-"))
888 .and_then(|rest| rest.split('.').next())
889 .and_then(|digits| digits.parse::<u64>().ok())
890 else {
891 continue;
892 };
893 if !live.contains(&id) {
894 let _ = std::fs::remove_file(e.path());
895 }
896 }
897 }
898
899 fn remove_segment_files(&self, seg_id: u64) {
901 for path in [
902 self.paths.fst_file(seg_id),
903 self.paths.post_file(seg_id),
904 self.paths.docs_file(seg_id),
905 self.paths.syms_file(seg_id),
906 self.paths.refs_file(seg_id),
907 self.paths.live_file(seg_id),
908 ] {
909 let _ = std::fs::remove_file(path);
910 }
911 }
912
913 fn tombstone(&self, seg_id: u64, doc_ids: &[u32]) -> Result<()> {
915 let live_path = self.paths.live_file(seg_id);
916 let mut live = read_bitmap(&live_path)?;
917 for id in doc_ids {
918 live.remove(*id);
919 }
920 write_bitmap(&live_path, &live)
921 }
922}