1use std::collections::{HashMap, HashSet};
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use lru::LruCache;
8use memchr::memmem;
9use rayon::prelude::*;
10use regex::bytes::Regex as BytesRegex;
11use serde::{Deserialize, Serialize};
12
13use crate::config::Config;
14use crate::error::{Error, Result};
15use crate::lang::Language;
16use crate::meta::Meta;
17use crate::paths::Paths;
18use crate::segment::{RefKind, Segment};
19use crate::trigram::{self, TrigramQuery};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(default)]
24pub struct SearchQuery {
25 pub pattern: String,
26 pub regex: bool,
27 pub case_insensitive: bool,
28 pub whole_word: bool,
30 pub lang: Option<String>,
31 pub path: Option<String>,
32 pub limit: usize,
33 pub offset: usize,
35 pub max_per_file: usize,
36 pub exhaustive: bool,
42}
43
44impl Default for SearchQuery {
45 fn default() -> Self {
46 Self {
47 pattern: String::new(),
48 regex: false,
49 case_insensitive: false,
50 whole_word: false,
51 lang: None,
52 path: None,
53 limit: 50,
54 offset: 0,
55 max_per_file: 20,
56 exhaustive: false,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SearchHit {
64 pub path: String,
65 pub lang: String,
66 pub line: u32,
67 pub column: u32,
68 pub text: String,
69 pub score: f32,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct SymbolQuery {
76 pub name: String,
77 pub kind: Option<String>,
78 pub exact: bool,
79 pub limit: usize,
80 pub offset: usize,
81}
82
83impl Default for SymbolQuery {
84 fn default() -> Self {
85 Self {
86 name: String::new(),
87 kind: None,
88 exact: false,
89 limit: 50,
90 offset: 0,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SymbolHit {
98 pub path: String,
99 pub lang: String,
100 pub name: String,
101 pub kind: String,
102 pub line_start: u32,
103 pub line_end: u32,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub container: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub signature: Option<String>,
108 pub score: f32,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RefHit {
115 pub path: String,
116 pub lang: String,
117 pub name: String,
118 pub kind: String,
120 pub line: u32,
121 pub column: u32,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub container: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CallSite {
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub caller: Option<String>,
133 pub callee: String,
135 pub path: String,
136 pub lang: String,
137 pub line: u32,
138 pub column: u32,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ImpactNode {
145 pub name: String,
146 pub kind: String,
147 pub path: String,
148 pub lang: String,
149 pub line_start: u32,
150 pub line_end: u32,
151 pub distance: u32,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct DefHit {
159 pub path: String,
160 pub lang: String,
161 pub name: String,
162 pub kind: String,
163 pub line_start: u32,
164 pub line_end: u32,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub container: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub signature: Option<String>,
169 pub score: f32,
170 pub resolved: bool,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SymbolHistory {
177 pub name: String,
178 pub path: String,
179 pub line_start: u32,
180 pub line_end: u32,
181 pub commits: Vec<crate::git::Commit>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ChangedSymbols {
187 pub path: String,
188 pub status: String,
189 pub symbols: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct StructHit {
195 pub path: String,
196 pub lang: String,
197 pub line_start: u32,
198 pub line_end: u32,
199 pub kind: String,
201 pub text: String,
203 pub captures: Vec<crate::structural::StructCapture>,
204}
205
206enum Matcher {
207 Literal(Vec<u8>),
208 Regex(BytesRegex),
209}
210
211impl Matcher {
212 fn build(query: &SearchQuery) -> Result<Matcher> {
213 if query.regex {
214 let re = regex::bytes::RegexBuilder::new(&query.pattern)
215 .case_insensitive(query.case_insensitive)
216 .build()?;
217 Ok(Matcher::Regex(re))
218 } else if query.case_insensitive {
219 let re = regex::bytes::RegexBuilder::new(®ex::escape(&query.pattern))
220 .case_insensitive(true)
221 .build()?;
222 Ok(Matcher::Regex(re))
223 } else {
224 Ok(Matcher::Literal(query.pattern.as_bytes().to_vec()))
225 }
226 }
227
228 fn match_starts(&self, hay: &[u8], whole_word: bool, cap: usize) -> Vec<(usize, usize)> {
233 let mut out = Vec::new();
234 match self {
235 Matcher::Literal(needle) => {
236 if needle.is_empty() {
237 return out;
238 }
239 for pos in memmem::find_iter(hay, needle) {
240 let end = pos + needle.len();
241 if !whole_word || boundary_ok(hay, pos, end) {
242 out.push((pos, end));
243 if out.len() >= cap {
244 break;
245 }
246 }
247 }
248 }
249 Matcher::Regex(re) => {
250 for m in re.find_iter(hay) {
251 if m.start() == m.end() {
254 continue;
255 }
256 if !whole_word || boundary_ok(hay, m.start(), m.end()) {
257 out.push((m.start(), m.end()));
258 if out.len() >= cap {
259 break;
260 }
261 }
262 }
263 }
264 }
265 out
266 }
267}
268
269#[doc(hidden)]
271pub fn fuzz_match_starts(
272 pattern: &str,
273 hay: &[u8],
274 regex: bool,
275 case_insensitive: bool,
276 whole_word: bool,
277) {
278 let query = SearchQuery {
279 pattern: pattern.to_string(),
280 regex,
281 case_insensitive,
282 whole_word,
283 ..Default::default()
284 };
285 if let Ok(m) = Matcher::build(&query) {
286 let _ = m.match_starts(hay, whole_word, PER_FILE_MATCH_CAP);
287 }
288}
289
290fn is_ident_byte(b: u8) -> bool {
293 b == b'_' || b.is_ascii_alphanumeric() || b >= 0x80
294}
295
296fn boundary_ok(line: &[u8], start: usize, end: usize) -> bool {
298 let left = start == 0 || !is_ident_byte(line[start - 1]);
299 let right = end >= line.len() || !is_ident_byte(line[end]);
300 left && right
301}
302
303const CONTENT_CACHE_BYTES: u64 = 256 * 1024 * 1024;
309
310const PER_FILE_MATCH_CAP: usize = 4096;
313
314struct CacheInner {
315 map: LruCache<u64, Arc<[u8]>>,
316 bytes: u64,
317}
318
319struct ContentCache {
323 inner: Mutex<CacheInner>,
324 budget: u64,
325}
326
327impl ContentCache {
328 fn new(budget_bytes: u64) -> Self {
329 Self {
330 inner: Mutex::new(CacheInner {
331 map: LruCache::unbounded(),
332 bytes: 0,
333 }),
334 budget: budget_bytes.max(1),
335 }
336 }
337
338 fn get_or_read(&self, hash: u64, path: &Path) -> Option<Arc<[u8]>> {
341 if let Ok(mut guard) = self.inner.lock() {
342 if let Some(v) = guard.map.get(&hash) {
343 return Some(v.clone());
344 }
345 }
346 let data = std::fs::read(path).ok()?;
347 let arc: Arc<[u8]> = Arc::from(data.into_boxed_slice());
348 let len = arc.len() as u64;
349 if let Ok(mut guard) = self.inner.lock() {
350 if len <= self.budget {
353 if let Some(prev) = guard.map.put(hash, arc.clone()) {
354 guard.bytes = guard.bytes.saturating_sub(prev.len() as u64);
355 }
356 guard.bytes += len;
357 while guard.bytes > self.budget {
358 match guard.map.pop_lru() {
359 Some((_, evicted)) => {
360 guard.bytes = guard.bytes.saturating_sub(evicted.len() as u64);
361 }
362 None => break,
363 }
364 }
365 }
366 }
367 Some(arc)
368 }
369}
370
371pub struct Searcher {
373 paths: Paths,
374 segments: Vec<Segment>,
375 by_path: std::sync::OnceLock<HashMap<String, (usize, u32)>>,
384 content: Arc<ContentCache>,
387}
388
389impl Searcher {
390 pub fn open(paths: &Paths) -> Result<Searcher> {
392 Self::open_inner(paths, None)
393 }
394
395 pub fn open_reusing(paths: &Paths, prev: &Searcher) -> Result<Searcher> {
402 Self::open_inner(paths, Some(prev))
403 }
404
405 fn open_inner(paths: &Paths, prev: Option<&Searcher>) -> Result<Searcher> {
406 if !paths.exists() {
407 return Err(Error::IndexMissing(paths.base.clone()));
408 }
409 let meta = Meta::load(&paths.meta_file())?;
410 let mut segments: Vec<Segment> = meta
414 .segments
415 .par_iter()
416 .map(|&seg_id| {
417 match prev.and_then(|p| p.segments.iter().find(|s| s.id == seg_id)) {
418 Some(seg) => seg.reopen(paths),
421 None => Segment::open(paths, seg_id),
422 }
423 })
424 .collect::<Result<_>>()?;
425 for pt in &meta.pending_tombstones {
428 if let Some(seg) = segments.iter_mut().find(|s| s.id == pt.segment_id) {
429 seg.subtract_live(&pt.doc_ids);
430 }
431 }
432 let content = match prev {
433 Some(p) => p.content.clone(),
434 None => Arc::new(ContentCache::new(CONTENT_CACHE_BYTES)),
435 };
436 Ok(Searcher {
437 paths: paths.clone(),
438 segments,
439 by_path: std::sync::OnceLock::new(),
440 content,
441 })
442 }
443
444 fn by_path(&self) -> &HashMap<String, (usize, u32)> {
446 self.by_path
447 .get_or_init(|| build_path_index(&self.segments))
448 }
449
450 pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
452 if query.pattern.is_empty() {
453 return Ok(Vec::new());
454 }
455 let matcher = Matcher::build(query)?;
456 let tq: TrigramQuery = if query.regex {
457 trigram::regex_trigrams(&query.pattern, query.case_insensitive)
458 } else if query.case_insensitive {
459 TrigramQuery::from_literal_ci(query.pattern.as_bytes())
462 } else {
463 TrigramQuery::from_literal(query.pattern.as_bytes())
464 };
465
466 let path_filter = query.path.as_deref();
467 let lang_filter = query.lang.as_deref();
468
469 let mut targets: Vec<(usize, u32, f32)> = Vec::new();
471 for (si, seg) in self.segments.iter().enumerate() {
472 let candidates = seg.candidates(&tq)?;
473 for doc_id in candidates.iter() {
474 if !seg.is_live(doc_id) {
475 continue;
476 }
477 let doc = match seg.doc(doc_id) {
478 Some(d) => d,
479 None => continue,
480 };
481 if let Some(lf) = lang_filter {
482 if doc.lang != lf {
483 continue;
484 }
485 }
486 if let Some(pf) = path_filter {
487 if !doc.path.contains(pf) {
488 continue;
489 }
490 }
491 targets.push((si, doc_id, 0.0));
492 }
493 }
494
495 let root = &self.paths.root;
498 let segments = &self.segments;
499 let content: &ContentCache = &self.content;
500 let max_per_file = query.max_per_file;
501 let whole_word = query.whole_word;
502 let exhaustive = query.exhaustive;
503 let verify = |&(si, doc_id, _): &(usize, u32, f32)| {
504 verify_doc(
505 &segments[si],
506 doc_id,
507 root,
508 content,
509 &matcher,
510 max_per_file,
511 whole_word,
512 exhaustive,
513 )
514 .into_iter()
515 };
516
517 if query.exhaustive {
518 let mut hits: Vec<SearchHit> = targets.par_iter().flat_map_iter(verify).collect();
521 hits.sort_by(|a, b| {
522 a.path
523 .cmp(&b.path)
524 .then_with(|| a.line.cmp(&b.line))
525 .then_with(|| a.column.cmp(&b.column))
526 });
527 return Ok(hits);
528 }
529
530 let need = query.offset.saturating_add(query.limit);
531 if need == 0 {
532 return Ok(Vec::new());
533 }
534
535 let chunk = need.saturating_mul(4).clamp(256, 4096);
543 let mut hits: Vec<SearchHit>;
544 if targets.len() <= chunk {
545 hits = targets.par_iter().flat_map_iter(verify).collect();
546 } else {
547 for t in &mut targets {
548 t.2 = self.segments[t.0].doc_path_score(t.1);
549 }
550 targets.sort_unstable_by(|a, b| {
551 b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
552 });
553 hits = Vec::new();
554 let mut start = 0usize;
555 while start < targets.len() {
556 let end = (start + chunk).min(targets.len());
557 let mut batch: Vec<SearchHit> = targets[start..end]
558 .par_iter()
559 .flat_map_iter(verify)
560 .collect();
561 hits.append(&mut batch);
562 start = end;
563 if start < targets.len() {
564 let remaining_max = targets[start].2 + 4.0;
565 let outranking = hits.iter().filter(|h| h.score > remaining_max).count();
566 if outranking >= need {
567 break;
568 }
569 }
570 }
571 }
572
573 let cmp = |a: &SearchHit, b: &SearchHit| {
574 b.score
575 .partial_cmp(&a.score)
576 .unwrap_or(std::cmp::Ordering::Equal)
577 .then_with(|| a.path.cmp(&b.path))
578 .then_with(|| a.line.cmp(&b.line))
579 };
580 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
581 }
582
583 pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
587 let needle = query.name.to_ascii_lowercase();
588 let mut hits: Vec<SymbolHit> = Vec::new();
589 let mut consider = |seg: &Segment, i: u32, score: f32| {
592 let sym = match seg.sym(i) {
593 Some(s) => s,
594 None => return,
595 };
596 if !seg.is_live(sym.doc_id) {
597 return;
598 }
599 if let Some(k) = &query.kind {
600 if &sym.kind != k {
601 return;
602 }
603 }
604 let doc = match seg.doc(sym.doc_id) {
605 Some(d) => d,
606 None => return,
607 };
608 hits.push(SymbolHit {
609 path: doc.path.clone(),
610 lang: doc.lang.clone(),
611 name: sym.name,
612 kind: sym.kind,
613 line_start: sym.line_start,
614 line_end: sym.line_end,
615 container: sym.container,
616 signature: sym.signature,
617 score: score + seg.doc_path_score(sym.doc_id),
618 });
619 };
620 for seg in &self.segments {
621 if query.exact {
622 let rows: Vec<u32> = seg.syms_by_lower(&needle).collect();
623 for i in rows {
624 let score =
625 match match_symbol(seg.sym_name(i), seg.sym_name_lower(i), &needle, true) {
626 Some(s) => s,
627 None => continue,
628 };
629 consider(seg, i, score);
630 }
631 } else {
632 let matches: Vec<(u32, f32)> = (0..seg.sym_count() as u32)
639 .into_par_iter()
640 .filter_map(|i| {
641 match_symbol(seg.sym_name(i), seg.sym_name_lower(i), &needle, false)
642 .map(|s| (i, s))
643 })
644 .collect();
645 for (i, score) in matches {
646 consider(seg, i, score);
647 }
648 }
649 }
650 let cmp = |a: &SymbolHit, b: &SymbolHit| {
651 b.score
652 .partial_cmp(&a.score)
653 .unwrap_or(std::cmp::Ordering::Equal)
654 .then_with(|| a.name.len().cmp(&b.name.len()))
655 .then_with(|| a.path.cmp(&b.path))
656 };
657 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
658 }
659
660 pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
662 let mut out = Vec::new();
663 if let Some(&(si, doc_id)) = self.by_path().get(rel_path) {
664 let seg = &self.segments[si];
665 if let Some(doc) = seg.doc(doc_id) {
666 for sym in seg.doc_syms(doc_id) {
667 out.push(SymbolHit {
668 path: doc.path.clone(),
669 lang: doc.lang.clone(),
670 name: sym.name.clone(),
671 kind: sym.kind.clone(),
672 line_start: sym.line_start,
673 line_end: sym.line_end,
674 container: sym.container.clone(),
675 signature: sym.signature.clone(),
676 score: 1.0,
677 });
678 }
679 }
680 }
681 out.sort_by_key(|s| s.line_start);
682 Ok(out)
683 }
684
685 pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
687 self.search(&SearchQuery {
688 pattern: name.to_string(),
689 whole_word: true,
690 limit,
691 offset,
692 ..Default::default()
693 })
694 }
695
696 fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, crate::segment::SymbolEntry)> {
701 let lower = name.to_ascii_lowercase();
702 let mut out = Vec::new();
703 for (si, seg) in self.segments.iter().enumerate() {
704 for idx in seg.syms_by_lower(&lower) {
705 if seg.sym_name(idx) != name {
707 continue;
708 }
709 if let Some(sym) = seg.sym(idx) {
710 if seg.is_live(sym.doc_id) {
711 out.push((si, idx as usize, sym));
712 }
713 }
714 }
715 }
716 out
717 }
718
719 fn call_indegree(&self, name: &str) -> u32 {
726 let mut n = 0u32;
727 for seg in &self.segments {
728 for r in seg.ref_views_named(name) {
729 if r.kind == RefKind::Call && seg.is_live(r.doc_id) {
730 n += 1;
731 }
732 }
733 }
734 n
735 }
736
737 pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
741 let lower = name.to_ascii_lowercase();
742 let mut hits: Vec<RefHit> = Vec::new();
743 for seg in &self.segments {
744 let def_rows: Vec<u32> = seg.syms_by_lower(&lower).collect();
747 for i in def_rows {
748 if seg.sym_name(i) != name {
749 continue;
750 }
751 let sym = match seg.sym(i) {
752 Some(s) => s,
753 None => continue,
754 };
755 if seg.is_live(sym.doc_id) {
756 if let Some(doc) = seg.doc(sym.doc_id) {
757 hits.push(RefHit {
758 path: doc.path.clone(),
759 lang: doc.lang.clone(),
760 name: sym.name,
761 kind: "definition".to_string(),
762 line: sym.line_start,
763 column: 1,
764 container: sym.container,
765 });
766 }
767 }
768 }
769 let mut ranges: HashMap<u32, DocSymbolRanges> = HashMap::new();
772 let mut container_names: HashMap<u32, Option<String>> = HashMap::new();
773 for r in seg.ref_views_named(name) {
774 if seg.is_live(r.doc_id) {
775 if let Some(doc) = seg.doc(r.doc_id) {
776 let row = ranges
777 .entry(r.doc_id)
778 .or_insert_with(|| DocSymbolRanges::load(seg, r.doc_id))
779 .enclosing_row(r.line);
780 let container = match row {
781 Some(i) => container_names
782 .entry(i)
783 .or_insert_with(|| seg.sym(i).map(|s| s.name))
784 .clone(),
785 None => None,
786 };
787 hits.push(RefHit {
788 path: doc.path.clone(),
789 lang: doc.lang.clone(),
790 name: r.name.to_string(),
791 kind: r.kind.as_str().to_string(),
792 line: r.line,
793 column: r.column,
794 container,
795 });
796 }
797 }
798 }
799 }
800 let rank = |k: &str| match k {
801 "definition" => 0,
802 "call" => 1,
803 _ => 2,
804 };
805 hits.sort_by(|a, b| {
806 rank(&a.kind)
807 .cmp(&rank(&b.kind))
808 .then_with(|| a.path.cmp(&b.path))
809 .then_with(|| a.line.cmp(&b.line))
810 });
811 paginate(hits, offset, limit)
812 }
813
814 pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
817 let mut out: Vec<CallSite> = Vec::new();
818 let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
819 for (si, _, sym) in self.defs_by_name(name) {
820 let seg = &self.segments[si];
821 let doc = match seg.doc(sym.doc_id) {
822 Some(d) => d,
823 None => continue,
824 };
825 for r in seg.doc_ref_views(sym.doc_id) {
826 if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
827 let key = (doc.path.clone(), r.name.to_string(), r.line, r.column);
828 if !seen.insert(key) {
829 continue;
830 }
831 out.push(CallSite {
832 caller: Some(name.to_string()),
833 callee: r.name.to_string(),
834 path: doc.path.clone(),
835 lang: doc.lang.clone(),
836 line: r.line,
837 column: r.column,
838 });
839 }
840 }
841 }
842 out.sort_by(|a, b| {
843 a.callee
844 .cmp(&b.callee)
845 .then_with(|| a.path.cmp(&b.path))
846 .then_with(|| a.line.cmp(&b.line))
847 });
848 paginate(out, offset, limit)
849 }
850
851 pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
854 let mut out: Vec<CallSite> = Vec::new();
855 for seg in &self.segments {
856 let mut ranges: HashMap<u32, DocSymbolRanges> = HashMap::new();
860 let mut caller_names: HashMap<u32, Option<String>> = HashMap::new();
861 for r in seg.ref_views_named(name) {
864 if r.kind != RefKind::Call || !seg.is_live(r.doc_id) {
865 continue;
866 }
867 let doc = match seg.doc(r.doc_id) {
868 Some(d) => d,
869 None => continue,
870 };
871 let row = ranges
872 .entry(r.doc_id)
873 .or_insert_with(|| DocSymbolRanges::load(seg, r.doc_id))
874 .enclosing_row(r.line);
875 let caller = match row {
876 Some(i) => caller_names
877 .entry(i)
878 .or_insert_with(|| seg.sym(i).map(|s| s.name))
879 .clone(),
880 None => None,
881 };
882 out.push(CallSite {
883 caller,
884 callee: name.to_string(),
885 path: doc.path.clone(),
886 lang: doc.lang.clone(),
887 line: r.line,
888 column: r.column,
889 });
890 }
891 }
892 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
893 paginate(out, offset, limit)
894 }
895
896 pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
903 let mut out: Vec<ImpactNode> = Vec::new();
904 let mut visited: HashSet<String> = HashSet::new();
905 visited.insert(name.to_string());
906
907 for (si, _, sym) in self.defs_by_name(name) {
909 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
910 out.push(ImpactNode {
911 name: sym.name.clone(),
912 kind: sym.kind.clone(),
913 path: doc.path.clone(),
914 lang: doc.lang.clone(),
915 line_start: sym.line_start,
916 line_end: sym.line_end,
917 distance: 0,
918 });
919 }
920 }
921
922 let mut frontier: Vec<String> = vec![name.to_string()];
923 'expand: for dist in 1..=depth {
924 let mut next: Vec<String> = Vec::new();
925 for target in &frontier {
926 for site in self.callers(target, usize::MAX, 0) {
927 let caller = match site.caller {
928 Some(c) => c,
929 None => continue,
930 };
931 if !visited.insert(caller.clone()) {
932 continue;
933 }
934 for (si, _, sym) in self.defs_by_name(&caller) {
935 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
936 out.push(ImpactNode {
937 name: sym.name.clone(),
938 kind: sym.kind.clone(),
939 path: doc.path.clone(),
940 lang: doc.lang.clone(),
941 line_start: sym.line_start,
942 line_end: sym.line_end,
943 distance: dist,
944 });
945 }
946 }
947 next.push(caller);
948 }
949 if out.len() >= limit {
952 break 'expand;
953 }
954 }
955 if next.is_empty() {
956 break;
957 }
958 frontier = next;
959 }
960 out.truncate(limit);
961 out
962 }
963
964 pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
970 let full = self.resolve_within_root(rel_path)?;
971 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
972 let ext = Path::new(rel_path)
973 .extension()
974 .and_then(|e| e.to_str())
975 .unwrap_or("");
976 let lang = crate::lang::Language::from_extension(ext);
977
978 let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
979 Some(i) => i,
980 None => {
981 return Err(Error::other(format!(
982 "no identifier at {rel_path}:{line}:{col}"
983 )))
984 }
985 };
986
987 let imported_here = self.imported_names(rel_path);
990
991 let mut cands: Vec<DefHit> = Vec::new();
992 for (si, _, sym) in self.defs_by_name(&ident.name) {
993 let seg = &self.segments[si];
994 let doc = match seg.doc(sym.doc_id) {
995 Some(d) => d,
996 None => continue,
997 };
998 let mut score = 10.0f32 + seg.doc_path_score(sym.doc_id);
999 let same_file = doc.path == rel_path;
1000 if same_file {
1001 score += 40.0;
1002 }
1003 score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
1004 let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
1006 if ident.is_member && method_like {
1007 score += 25.0;
1008 } else if !ident.is_member && !method_like {
1009 score += 8.0;
1010 }
1011 if ident.is_call
1012 && matches!(
1013 sym.kind.as_str(),
1014 "function" | "method" | "macro" | "constructor"
1015 )
1016 {
1017 score += 6.0;
1018 }
1019 if ident.is_type
1020 && matches!(
1021 sym.kind.as_str(),
1022 "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
1023 )
1024 {
1025 score += 12.0;
1026 }
1027 if imported_here.contains(&ident.name) && !same_file {
1030 score += 15.0;
1031 }
1032 cands.push(DefHit {
1033 path: doc.path.clone(),
1034 lang: doc.lang.clone(),
1035 name: sym.name.clone(),
1036 kind: sym.kind.clone(),
1037 line_start: sym.line_start,
1038 line_end: sym.line_end,
1039 container: sym.container.clone(),
1040 signature: sym.signature.clone(),
1041 score,
1042 resolved: false,
1043 });
1044 }
1045
1046 if cands.is_empty() {
1047 let hits = self.references(&ident.name, 50, 0)?;
1049 return Ok(hits
1050 .into_iter()
1051 .map(|h| DefHit {
1052 path: h.path,
1053 lang: h.lang,
1054 name: ident.name.clone(),
1055 kind: "text".to_string(),
1056 line_start: h.line,
1057 line_end: h.line,
1058 container: None,
1059 signature: Some(h.text),
1060 score: h.score,
1061 resolved: false,
1062 })
1063 .collect());
1064 }
1065
1066 cands.sort_by(|a, b| {
1067 b.score
1068 .partial_cmp(&a.score)
1069 .unwrap_or(std::cmp::Ordering::Equal)
1070 .then_with(|| a.path.cmp(&b.path))
1071 .then_with(|| a.line_start.cmp(&b.line_start))
1072 });
1073 let unique_top =
1075 cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
1076 if unique_top {
1077 cands[0].resolved = true;
1078 }
1079 Ok(cands)
1080 }
1081
1082 pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
1085 let full = self.resolve_within_root(rel_path)?;
1086 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
1087 let ext = Path::new(rel_path)
1088 .extension()
1089 .and_then(|e| e.to_str())
1090 .unwrap_or("");
1091 let lang = crate::lang::Language::from_extension(ext);
1092 let ident = crate::resolve::identifier_at(lang, &source, line, col)
1093 .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
1094 Ok(self.references_resolved(&ident.name, usize::MAX, 0))
1095 }
1096
1097 fn imported_names(&self, rel_path: &str) -> HashSet<String> {
1099 let mut out = HashSet::new();
1100 if let Some(&(si, doc_id)) = self.by_path().get(rel_path) {
1101 for r in self.segments[si].doc_ref_views(doc_id) {
1102 if r.kind == RefKind::Import {
1103 out.insert(r.name.to_string());
1104 }
1105 }
1106 }
1107 out
1108 }
1109
1110 fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
1115 let candidate = Path::new(rel_path);
1116 if candidate.is_absolute() {
1117 return Err(Error::other(format!(
1118 "path {rel_path:?} must be relative to the project root"
1119 )));
1120 }
1121 if candidate
1123 .components()
1124 .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
1125 {
1126 return Err(Error::other(format!(
1127 "path {rel_path:?} escapes the project root"
1128 )));
1129 }
1130 let root = self
1133 .paths
1134 .root
1135 .canonicalize()
1136 .map_err(|e| Error::io(&self.paths.root, e))?;
1137 let full = root.join(candidate);
1138 let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
1139 if !resolved.starts_with(&root) {
1140 return Err(Error::other(format!(
1141 "path {rel_path:?} escapes the project root"
1142 )));
1143 }
1144 Ok(resolved)
1145 }
1146
1147 pub fn read_snippet(
1149 &self,
1150 rel_path: &str,
1151 start_line: u32,
1152 end_line: u32,
1153 context: u32,
1154 ) -> Result<Snippet> {
1155 let full = self.resolve_within_root(rel_path)?;
1156 let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
1157 let lines: Vec<&str> = data.lines().collect();
1158 let total = lines.len() as u32;
1159 let to = end_line.saturating_add(context).min(total.max(1));
1160 let from = start_line
1163 .saturating_sub(context)
1164 .max(1)
1165 .min(total.max(1))
1166 .min(to);
1167 let mut body = String::new();
1168 let mut last = from;
1169 for ln in from..=to {
1170 if let Some(text) = lines.get((ln - 1) as usize) {
1171 if !body.is_empty() {
1172 body.push('\n');
1173 }
1174 body.push_str(text);
1175 last = ln;
1176 }
1177 }
1178 Ok(Snippet {
1179 path: rel_path.to_string(),
1180 start_line: from,
1181 end_line: last,
1182 total_lines: total,
1183 text: body,
1184 })
1185 }
1186
1187 pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1193 use crate::context::{self, ContextPack, PackItem};
1194
1195 let terms = context::tokenize(task);
1196
1197 struct Cand {
1199 seg: usize,
1200 sym: crate::segment::SymbolEntry,
1201 score: f32,
1202 reason: String,
1203 }
1204 let mut doc_targets: Vec<(usize, u32)> = Vec::new();
1225 for (si, seg) in self.segments.iter().enumerate() {
1226 for doc_id in 0..seg.docs.len() as u32 {
1227 if seg.is_live(doc_id) {
1228 doc_targets.push((si, doc_id));
1229 }
1230 }
1231 }
1232
1233 let mut cands: Vec<Cand> = doc_targets
1234 .par_iter()
1235 .flat_map_iter(|&(si, doc_id)| {
1236 let seg = &self.segments[si];
1237 let mut out: Vec<Cand> = Vec::new();
1238 let doc = match seg.doc(doc_id) {
1239 Some(d) => d,
1240 None => return out.into_iter(),
1241 };
1242 let mut scratch = context::ScoreScratch::default();
1243 let path_bonus = context::path_term_bonus(&doc.path, &terms, &mut scratch);
1244 for view in seg.doc_sym_views(doc_id) {
1245 let score = context::lexical_score_with(
1246 view.name,
1247 view.kind,
1248 view.signature,
1249 view.container,
1250 path_bonus,
1251 &terms,
1252 &mut scratch,
1253 );
1254 if score <= 0.0 {
1255 continue;
1256 }
1257 out.push(Cand {
1258 seg: si,
1259 sym: view.to_entry(),
1260 score,
1261 reason: "match".to_string(),
1262 });
1263 }
1264 out.into_iter()
1265 })
1266 .collect();
1267
1268 cands.par_iter_mut().for_each(|c| {
1277 let deg = self.call_indegree(&c.sym.name) as f32;
1278 c.score += (1.0 + deg).ln() * 1.5;
1279 c.score += self.segments[c.seg].doc_path_score(c.sym.doc_id);
1280 });
1281
1282 cands.sort_by(|a, b| {
1283 b.score
1284 .partial_cmp(&a.score)
1285 .unwrap_or(std::cmp::Ordering::Equal)
1286 });
1287
1288 let mut seen: HashSet<(String, u32)> = HashSet::new();
1291 for c in &cands {
1292 seen.insert((c.sym.name.clone(), c.sym.line_start));
1293 }
1294 let mut extra: Vec<Cand> = Vec::new();
1295 for c in cands.iter().take(8) {
1296 for callee in self.callees(&c.sym.name, 12, 0) {
1297 for (si2, _, def) in self.defs_by_name(&callee.callee) {
1298 let key = (def.name.clone(), def.line_start);
1299 if !seen.insert(key) {
1300 continue;
1301 }
1302 extra.push(Cand {
1303 seg: si2,
1304 sym: def,
1305 score: c.score * 0.3,
1306 reason: format!("callee of {}", c.sym.name),
1307 });
1308 }
1309 }
1310 }
1311 cands.extend(extra);
1312 cands.sort_by(|a, b| {
1313 b.score
1314 .partial_cmp(&a.score)
1315 .unwrap_or(std::cmp::Ordering::Equal)
1316 });
1317
1318 let mut items: Vec<PackItem> = Vec::new();
1332 let mut used: u64 = 0;
1333 let mut truncated = false;
1334 let mut file_lines: HashMap<u64, Arc<FileLines>> = HashMap::new();
1335 const MAX_ITEM_LINES: u32 = 60;
1336 const PACK_PREFETCH_CHUNK: usize = 256;
1339 'packing: for chunk in cands.chunks(PACK_PREFETCH_CHUNK) {
1340 let mut wanted: Vec<(u64, &str)> = Vec::new();
1341 let mut seen_hash: HashSet<u64> = HashSet::new();
1342 for c in chunk {
1343 if let Some(doc) = self.segments[c.seg].doc(c.sym.doc_id) {
1344 if !file_lines.contains_key(&doc.hash) && seen_hash.insert(doc.hash) {
1345 wanted.push((doc.hash, doc.path.as_str()));
1346 }
1347 }
1348 }
1349 let loaded: Vec<(u64, Arc<FileLines>)> = wanted
1350 .par_iter()
1351 .map(|&(hash, path)| {
1352 let full = self.paths.root.join(path);
1353 let data = self.content.get_or_read(hash, &full);
1354 (
1355 hash,
1356 Arc::new(FileLines::new(data.as_deref().unwrap_or(&[]))),
1357 )
1358 })
1359 .collect();
1360 file_lines.extend(loaded);
1361
1362 for c in chunk {
1363 let seg = &self.segments[c.seg];
1364 let sym = &c.sym;
1365 let doc = match seg.doc(sym.doc_id) {
1366 Some(d) => d,
1367 None => continue,
1368 };
1369 let end = sym
1370 .line_end
1371 .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1372 let lines = match file_lines.get(&doc.hash) {
1375 Some(l) => l.clone(),
1376 None => {
1377 let full = self.paths.root.join(&doc.path);
1378 let data = self.content.get_or_read(doc.hash, &full);
1379 let l = Arc::new(FileLines::new(data.as_deref().unwrap_or(&[])));
1380 file_lines.insert(doc.hash, l.clone());
1381 l
1382 }
1383 };
1384 let from = sym.line_start.max(1);
1385 let to = end.min(lines.len() as u32);
1386 let mut code_len = 0usize;
1395 for ln in from..=to {
1396 if let Some(text) = lines.get((ln - 1) as usize) {
1397 if code_len > 0 {
1398 code_len += 1;
1399 }
1400 code_len += text.len();
1401 }
1402 }
1403 let chars: u64 =
1404 code_len as u64 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1405 let cost = context::est_tokens(chars).max(1);
1406 if used + cost > budget_tokens && !items.is_empty() {
1407 truncated = true;
1408 continue;
1409 }
1410 let mut code = String::with_capacity(code_len);
1411 for ln in from..=to {
1412 if let Some(text) = lines.get((ln - 1) as usize) {
1413 if !code.is_empty() {
1414 code.push('\n');
1415 }
1416 code.push_str(text);
1417 }
1418 }
1419 debug_assert_eq!(code.len(), code_len, "snippet cost estimate must be exact");
1420 used += cost;
1421 items.push(PackItem {
1422 path: doc.path.clone(),
1423 lang: doc.lang.clone(),
1424 name: sym.name.clone(),
1425 kind: sym.kind.clone(),
1426 line_start: sym.line_start,
1427 line_end: sym.line_end,
1428 signature: sym.signature.clone(),
1429 snippet_start: from,
1430 code,
1431 reason: c.reason.clone(),
1432 score: c.score,
1433 });
1434 if used >= budget_tokens {
1435 truncated = truncated || items.len() < cands.len();
1436 break 'packing;
1437 }
1438 }
1439 }
1440
1441 ContextPack {
1442 task: task.to_string(),
1443 budget_tokens,
1444 used_tokens: used,
1445 truncated,
1446 items,
1447 }
1448 }
1449
1450 pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1452 self.resolve_within_root(rel_path)?;
1454 crate::git::blame(&self.paths.root, rel_path, line)
1455 }
1456
1457 pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1460 let defs = self.defs_by_name(name);
1462 let best = defs
1463 .iter()
1464 .max_by(|a, b| {
1465 let pa = self.segments[a.0].doc_path_score(a.2.doc_id);
1466 let pb = self.segments[b.0].doc_path_score(b.2.doc_id);
1467 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1468 })
1469 .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1470 let (si, _, sym) = best;
1471 let si = *si;
1472 let doc = self.segments[si]
1473 .doc(sym.doc_id)
1474 .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1475 let commits = crate::git::line_history(
1476 &self.paths.root,
1477 &doc.path,
1478 sym.line_start,
1479 sym.line_end,
1480 limit,
1481 )
1482 .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1483 Ok(SymbolHistory {
1484 name: name.to_string(),
1485 path: doc.path.clone(),
1486 line_start: sym.line_start,
1487 line_end: sym.line_end,
1488 commits,
1489 })
1490 }
1491
1492 pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1495 let changed = crate::git::changed_since(&self.paths.root, rev)?;
1496 let mut out = Vec::with_capacity(changed.len());
1497 for cf in changed {
1498 let mut symbols = Vec::new();
1499 if let Some(&(si, doc_id)) = self.by_path().get(&cf.path) {
1500 for s in self.segments[si].doc_syms(doc_id) {
1501 symbols.push(s.name.clone());
1502 }
1503 }
1504 symbols.sort();
1505 symbols.dedup();
1506 out.push(ChangedSymbols {
1507 path: cf.path,
1508 status: cf.status,
1509 symbols,
1510 });
1511 }
1512 Ok(out)
1513 }
1514
1515 pub fn structural_search(
1519 &self,
1520 pattern: &str,
1521 lang: &str,
1522 limit: usize,
1523 offset: usize,
1524 ) -> Result<Vec<StructHit>> {
1525 let language = crate::lang::Language::from_id(lang)
1526 .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1527 if language.grammar().is_none() {
1528 return Err(Error::other(format!(
1529 "language {lang} is not parseable for structural search"
1530 )));
1531 }
1532 let compiled = crate::structural::compile(language, pattern)?;
1533
1534 let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1536 let tq = anchor
1537 .as_ref()
1538 .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1539
1540 let mut targets: Vec<(usize, u32)> = Vec::new();
1541 for (si, seg) in self.segments.iter().enumerate() {
1542 let candidates = match &tq {
1543 Some(q) => seg.candidates(q)?,
1544 None => seg.all_live(),
1545 };
1546 for doc_id in candidates.iter() {
1547 if !seg.is_live(doc_id) {
1548 continue;
1549 }
1550 match seg.doc(doc_id) {
1551 Some(d) if d.lang == lang => targets.push((si, doc_id)),
1552 _ => {}
1553 }
1554 }
1555 }
1556
1557 let root = &self.paths.root;
1558 let segments = &self.segments;
1559 let content = &self.content;
1560 let compiled_ref = &compiled;
1561 let hits: Vec<StructHit> = targets
1562 .par_iter()
1563 .flat_map_iter(|&(si, doc_id)| {
1564 let seg = &segments[si];
1565 let doc = match seg.doc(doc_id) {
1566 Some(d) => d,
1567 None => return Vec::new().into_iter(),
1568 };
1569 let full = root.join(&doc.path);
1570 let data = match content.get_or_read(doc.hash, &full) {
1571 Some(d) => d,
1572 None => return Vec::new().into_iter(),
1573 };
1574 let matches = crate::structural::run(language, compiled_ref, &data);
1575 let line_starts = line_starts(&data);
1576 let out: Vec<StructHit> = matches
1577 .into_iter()
1578 .map(|m| {
1579 let li = (m.line_start.saturating_sub(1)) as usize;
1580 let text = line_starts
1581 .get(li)
1582 .map(|_| snippet(line_slice(&data, &line_starts, li)))
1583 .unwrap_or_default();
1584 StructHit {
1585 path: doc.path.clone(),
1586 lang: doc.lang.clone(),
1587 line_start: m.line_start,
1588 line_end: m.line_end,
1589 kind: m.kind,
1590 text,
1591 captures: m.captures,
1592 }
1593 })
1594 .collect();
1595 out.into_iter()
1596 })
1597 .collect();
1598
1599 let cmp = |a: &StructHit, b: &StructHit| {
1600 a.path
1601 .cmp(&b.path)
1602 .then_with(|| a.line_start.cmp(&b.line_start))
1603 };
1604 let mut hits = hits;
1605 hits.sort_by(cmp);
1606 Ok(paginate(hits, offset, limit))
1607 }
1608
1609 pub fn summary(&self) -> RepoSummary {
1611 use std::collections::HashMap;
1612 let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1613 let mut by_dir: HashMap<String, u64> = HashMap::new();
1614 let mut files = 0u64;
1615 let mut bytes = 0u64;
1616 let mut symbols = 0u64;
1617 for seg in &self.segments {
1618 for (doc_id, doc) in seg.docs.iter().enumerate() {
1619 if !seg.is_live(doc_id as u32) {
1620 continue;
1621 }
1622 files += 1;
1623 bytes += doc.size;
1624 let e = by_lang.entry(doc.lang.clone()).or_default();
1625 e.files += 1;
1626 e.bytes += doc.size;
1627 let dir = doc.path.split('/').next().unwrap_or("").to_string();
1628 *by_dir.entry(dir).or_default() += 1;
1629 symbols += u64::from(seg.doc_sym_count(doc_id as u32));
1631 }
1632 }
1633 let mut languages: Vec<LangStat> = by_lang
1634 .into_iter()
1635 .map(|(lang, mut s)| {
1636 s.lang = lang;
1637 s
1638 })
1639 .collect();
1640 languages.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.lang.cmp(&b.lang)));
1645 let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1646 top_dirs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1647 top_dirs.truncate(15);
1648 RepoSummary {
1649 files,
1650 bytes,
1651 symbols,
1652 segments: self.segments.len(),
1653 languages,
1654 top_dirs: top_dirs
1655 .into_iter()
1656 .map(|(name, files)| DirStat { name, files })
1657 .collect(),
1658 }
1659 }
1660}
1661
1662#[derive(Debug, Clone, Serialize, Deserialize)]
1669pub struct Snippet {
1670 pub path: String,
1671 pub start_line: u32,
1672 pub end_line: u32,
1673 pub total_lines: u32,
1674 pub text: String,
1675}
1676
1677#[derive(Debug, Clone, Serialize, Deserialize)]
1679pub struct RepoSummary {
1680 pub files: u64,
1681 pub bytes: u64,
1682 pub symbols: u64,
1683 pub segments: usize,
1684 pub languages: Vec<LangStat>,
1685 pub top_dirs: Vec<DirStat>,
1686}
1687
1688#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1689pub struct LangStat {
1690 pub lang: String,
1691 pub files: u64,
1692 pub bytes: u64,
1693}
1694
1695#[derive(Debug, Clone, Serialize, Deserialize)]
1696pub struct DirStat {
1697 pub name: String,
1698 pub files: u64,
1699}
1700
1701#[allow(clippy::too_many_arguments)] fn verify_doc(
1706 seg: &Segment,
1707 doc_id: u32,
1708 root: &Path,
1709 content: &ContentCache,
1710 matcher: &Matcher,
1711 max_per_file: usize,
1712 whole_word: bool,
1713 exhaustive: bool,
1714) -> Vec<SearchHit> {
1715 let doc = match seg.doc(doc_id) {
1716 Some(d) => d,
1717 None => return Vec::new(),
1718 };
1719 let full = root.join(&doc.path);
1720 let data = match content.get_or_read(doc.hash, &full) {
1721 Some(d) => d,
1722 None => return Vec::new(),
1723 };
1724
1725 let cap = if exhaustive {
1727 usize::MAX
1728 } else {
1729 PER_FILE_MATCH_CAP
1730 };
1731 let matches = matcher.match_starts(&data, whole_word, cap);
1732 if matches.is_empty() {
1733 return Vec::new();
1734 }
1735
1736 let line_starts = line_starts(&data);
1737 let sym_lines = symbol_lines(seg, doc_id);
1738 let base = seg.doc_path_score(doc_id);
1739
1740 let mut out = Vec::new();
1741 let mut last_line = 0u32;
1742 for (start, _end) in matches {
1743 let li = line_of(start, &line_starts);
1744 let line_no = li as u32 + 1;
1745 if line_no == last_line {
1747 continue;
1748 }
1749 last_line = line_no;
1750 let col = (start - line_starts[li]) as u32 + 1;
1751 let line_bytes = line_slice(&data, &line_starts, li);
1752 let mut score = 1.0 + base;
1753 if sym_lines.binary_search(&line_no).is_ok() {
1754 score += 3.0;
1755 }
1756 out.push(SearchHit {
1757 path: doc.path.clone(),
1758 lang: doc.lang.clone(),
1759 line: line_no,
1760 column: col,
1761 text: snippet(line_bytes),
1762 score,
1763 });
1764 }
1765
1766 if !exhaustive && out.len() > max_per_file {
1769 out.sort_by(|a, b| {
1770 b.score
1771 .partial_cmp(&a.score)
1772 .unwrap_or(std::cmp::Ordering::Equal)
1773 .then_with(|| a.line.cmp(&b.line))
1774 });
1775 out.truncate(max_per_file);
1776 }
1777 out
1778}
1779
1780struct FileLines {
1789 text: String,
1790 spans: Vec<(usize, usize)>,
1792}
1793
1794impl FileLines {
1795 fn new(data: &[u8]) -> FileLines {
1796 let text = match simdutf8::basic::from_utf8(data) {
1801 Ok(s) => s.to_owned(),
1802 Err(_) => String::from_utf8_lossy(data).into_owned(),
1803 };
1804 let base = text.as_ptr() as usize;
1813 let spans = text
1814 .lines()
1815 .map(|line| {
1816 let start = line.as_ptr() as usize - base;
1817 (start, start + line.len())
1818 })
1819 .collect();
1820 FileLines { text, spans }
1821 }
1822
1823 fn len(&self) -> usize {
1824 self.spans.len()
1825 }
1826
1827 fn get(&self, i: usize) -> Option<&str> {
1828 self.spans.get(i).map(|&(s, e)| &self.text[s..e])
1829 }
1830}
1831
1832struct DocSymbolRanges {
1841 rows: Vec<(u32, u32, u32)>,
1843}
1844
1845impl DocSymbolRanges {
1846 fn load(seg: &Segment, doc_id: u32) -> DocSymbolRanges {
1847 let rows = seg
1848 .doc_sym_rows(doc_id)
1849 .filter_map(|i| seg.sym_view(i).map(|v| (v.line_start, v.line_end, i)))
1850 .collect();
1851 DocSymbolRanges { rows }
1852 }
1853
1854 fn enclosing_row(&self, line: u32) -> Option<u32> {
1859 let mut best: Option<(u32, u32)> = None; for &(start, end, i) in &self.rows {
1861 if start <= line && line <= end {
1862 let span = end - start;
1863 match best {
1864 Some((_, best_span)) if best_span <= span => {}
1865 _ => best = Some((i, span)),
1866 }
1867 }
1868 }
1869 best.map(|(i, _)| i)
1870 }
1871}
1872
1873fn build_path_index(segments: &[Segment]) -> HashMap<String, (usize, u32)> {
1877 let mut map = HashMap::new();
1878 for (si, seg) in segments.iter().enumerate() {
1879 for (doc_id, doc) in seg.docs.iter().enumerate() {
1880 let doc_id = doc_id as u32;
1881 if seg.is_live(doc_id) {
1882 map.insert(doc.path.clone(), (si, doc_id));
1883 }
1884 }
1885 }
1886 map
1887}
1888
1889fn line_starts(data: &[u8]) -> Vec<usize> {
1891 let mut starts = Vec::with_capacity(64);
1892 starts.push(0usize);
1893 for p in memchr::memchr_iter(b'\n', data) {
1894 starts.push(p + 1);
1895 }
1896 starts
1897}
1898
1899fn line_of(off: usize, starts: &[usize]) -> usize {
1901 starts.partition_point(|&s| s <= off).saturating_sub(1)
1903}
1904
1905fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1907 let begin = starts[li];
1908 let end = if li + 1 < starts.len() {
1909 starts[li + 1].saturating_sub(1)
1910 } else {
1911 data.len()
1912 };
1913 &data[begin..end.min(data.len())]
1914}
1915
1916fn contains_ascii_ci(hay: &str, needle: &[u8]) -> bool {
1923 let h = hay.as_bytes();
1924 if needle.len() > h.len() {
1925 return false;
1926 }
1927 (0..=h.len() - needle.len()).any(|i| {
1928 h[i..i + needle.len()]
1929 .iter()
1930 .zip(needle)
1931 .all(|(a, b)| a.to_ascii_lowercase() == *b)
1932 })
1933}
1934
1935pub(crate) fn path_score(path: &str) -> f32 {
1944 let mut s = 0.0f32;
1945 let depth = memchr::memchr_iter(b'/', path.as_bytes()).count() as f32;
1946 s -= depth * 0.05;
1947 if contains_ascii_ci(path, b"test") || contains_ascii_ci(path, b".spec.") {
1948 s -= 1.0;
1949 }
1950 if contains_ascii_ci(path, b"/vendor/")
1951 || contains_ascii_ci(path, b"/generated/")
1952 || contains_ascii_ci(path, b".min.")
1953 {
1954 s -= 1.5;
1955 }
1956 s
1957}
1958
1959fn symbol_lines(seg: &Segment, doc_id: u32) -> Vec<u32> {
1967 let mut lines: Vec<u32> = seg.doc_sym_views(doc_id).map(|s| s.line_start).collect();
1968 lines.sort_unstable();
1969 lines.dedup();
1970 lines
1971}
1972
1973fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1974 if exact {
1975 return if lower == needle { Some(100.0) } else { None };
1976 }
1977 if lower == needle {
1978 Some(100.0)
1979 } else if lower.starts_with(needle) {
1980 Some(70.0)
1981 } else if acronym_eq(name, needle) {
1982 Some(60.0)
1984 } else if lower.contains(needle) {
1985 Some(50.0)
1986 } else if is_subsequence(needle, lower) {
1987 Some(30.0)
1988 } else {
1989 None
1990 }
1991}
1992
1993fn acronym_eq(name: &str, needle: &str) -> bool {
2002 let mut want = needle.chars();
2003 let mut prev_lower = false;
2004 let mut open = false;
2006 for ch in name.chars() {
2007 if ch == '_' || ch == '-' || ch == ' ' {
2008 open = false;
2009 prev_lower = false;
2010 continue;
2011 }
2012 if ch.is_uppercase() && prev_lower && open {
2013 open = false;
2014 }
2015 if !open {
2016 open = true;
2017 let c = match ch.to_lowercase().next() {
2020 Some(c) => c,
2021 None => continue,
2022 };
2023 if want.next() != Some(c) {
2024 return false;
2025 }
2026 }
2027 prev_lower = ch.is_lowercase() || ch.is_numeric();
2028 }
2029 want.next().is_none()
2031}
2032
2033fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
2036where
2037 F: Fn(&T, &T) -> std::cmp::Ordering,
2038{
2039 let need = offset.saturating_add(limit);
2040 if need == 0 {
2041 return Vec::new();
2042 }
2043 if need < items.len() {
2044 items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
2045 items.truncate(need);
2046 }
2047 items.sort_by(|a, b| cmp(a, b));
2048 if offset >= items.len() {
2049 return Vec::new();
2050 }
2051 items.drain(0..offset);
2052 items.truncate(limit);
2053 items
2054}
2055
2056pub fn grep_walk(paths: &Paths, config: &Config, query: &SearchQuery) -> Result<Vec<SearchHit>> {
2062 if query.pattern.is_empty() {
2063 return Ok(Vec::new());
2064 }
2065 let matcher = Matcher::build(query)?;
2066 let walked = crate::walk::walk(paths, config)?;
2067 let path_filter = query.path.as_deref();
2068 let lang_filter = query.lang.as_deref();
2069 let max_per_file = query.max_per_file;
2070 let whole_word = query.whole_word;
2071 let exhaustive = query.exhaustive;
2072 let index_binary = config.index_binary;
2073 let cap = if exhaustive {
2074 usize::MAX
2075 } else {
2076 PER_FILE_MATCH_CAP
2077 };
2078
2079 let mut hits: Vec<SearchHit> = walked
2080 .entries
2081 .par_iter()
2082 .flat_map_iter(|e| {
2083 if path_filter.is_some_and(|pf| !e.rel.contains(pf)) {
2084 return Vec::new().into_iter();
2085 }
2086 let ext = e
2087 .path
2088 .extension()
2089 .and_then(|x| x.to_str())
2090 .unwrap_or("")
2091 .to_ascii_lowercase();
2092 let lang_id = Language::from_extension(&ext).id().to_string();
2093 if lang_filter.is_some_and(|lf| lang_id != lf) {
2094 return Vec::new().into_iter();
2095 }
2096 let data = match std::fs::read(&e.path) {
2097 Ok(d) => d,
2098 Err(_) => return Vec::new().into_iter(),
2099 };
2100 if !index_binary && memchr::memchr(0, &data).is_some() {
2101 return Vec::new().into_iter();
2102 }
2103 let matches = matcher.match_starts(&data, whole_word, cap);
2104 if matches.is_empty() {
2105 return Vec::new().into_iter();
2106 }
2107 let starts = line_starts(&data);
2108 let base = path_score(&e.rel);
2109 let mut out = Vec::new();
2110 let mut last_line = 0u32;
2111 for (start, _end) in matches {
2112 let li = line_of(start, &starts);
2113 let line_no = li as u32 + 1;
2114 if line_no == last_line {
2115 continue;
2116 }
2117 last_line = line_no;
2118 let col = (start - starts[li]) as u32 + 1;
2119 out.push(SearchHit {
2120 path: e.rel.clone(),
2121 lang: lang_id.clone(),
2122 line: line_no,
2123 column: col,
2124 text: snippet(line_slice(&data, &starts, li)),
2125 score: 1.0 + base,
2126 });
2127 }
2128 if !exhaustive && out.len() > max_per_file {
2129 out.sort_by(|a, b| {
2130 b.score
2131 .partial_cmp(&a.score)
2132 .unwrap_or(std::cmp::Ordering::Equal)
2133 .then_with(|| a.line.cmp(&b.line))
2134 });
2135 out.truncate(max_per_file);
2136 }
2137 out.into_iter()
2138 })
2139 .collect();
2140
2141 if exhaustive {
2142 hits.sort_by(|a, b| {
2143 a.path
2144 .cmp(&b.path)
2145 .then_with(|| a.line.cmp(&b.line))
2146 .then_with(|| a.column.cmp(&b.column))
2147 });
2148 return Ok(hits);
2149 }
2150 let cmp = |a: &SearchHit, b: &SearchHit| {
2151 b.score
2152 .partial_cmp(&a.score)
2153 .unwrap_or(std::cmp::Ordering::Equal)
2154 .then_with(|| a.path.cmp(&b.path))
2155 .then_with(|| a.line.cmp(&b.line))
2156 };
2157 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
2158}
2159
2160fn shared_prefix_len(a: &str, b: &str) -> usize {
2162 a.split('/')
2163 .zip(b.split('/'))
2164 .take_while(|(x, y)| x == y)
2165 .count()
2166}
2167
2168fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
2170 if offset >= items.len() {
2171 return Vec::new();
2172 }
2173 items.drain(0..offset);
2174 items.truncate(limit);
2175 items
2176}
2177
2178fn is_subsequence(needle: &str, haystack: &str) -> bool {
2179 if needle.is_empty() {
2180 return true;
2181 }
2182 let mut chars = needle.chars();
2183 let mut cur = chars.next();
2184 for h in haystack.chars() {
2185 if let Some(c) = cur {
2186 if c == h {
2187 cur = chars.next();
2188 }
2189 } else {
2190 break;
2191 }
2192 }
2193 cur.is_none()
2194}
2195
2196fn snippet(line: &[u8]) -> String {
2198 let s = String::from_utf8_lossy(line);
2199 let trimmed = s.trim_end();
2200 const MAX: usize = 320;
2201 if trimmed.len() > MAX {
2202 let mut end = MAX;
2203 while !trimmed.is_char_boundary(end) {
2204 end -= 1;
2205 }
2206 format!("{}…", &trimmed[..end])
2207 } else {
2208 trimmed.to_string()
2209 }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214 use super::*;
2215
2216 fn reference_acronym(s: &str) -> String {
2219 crate::context::split_identifier(s)
2220 .iter()
2221 .filter_map(|t| t.chars().next())
2222 .collect()
2223 }
2224
2225 const NAMES: &[&str] = &[
2228 "",
2229 "x",
2230 "flush",
2231 "loadConfig",
2232 "load_config",
2233 "LoadConfig",
2234 "HTTPServer",
2235 "parseHTTP2Frame",
2236 "v2Handler",
2237 "vfs_read",
2238 "__init_waitqueue_head",
2239 "trailing__",
2240 "a__b",
2241 "kebab-case-name",
2242 "with space",
2243 "snake_And_Camel",
2244 "ALLCAPS",
2245 "ÄÖÜ_grüß",
2246 "İstanbul",
2247 "page_cache_sync_readahead",
2248 ];
2249
2250 #[test]
2253 fn acronym_eq_matches_reference() {
2254 for name in NAMES {
2255 let want = reference_acronym(name);
2256 assert!(
2257 acronym_eq(name, &want),
2258 "{name:?} should match its own acronym {want:?}"
2259 );
2260 let mut wrong = vec![format!("{want}z"), format!("z{want}")];
2262 if !want.is_empty() {
2263 wrong.push(want[..want.len() - 1].to_string());
2264 wrong.push(want.to_uppercase());
2265 }
2266 for w in wrong {
2267 if w == want {
2268 continue;
2269 }
2270 assert_eq!(
2271 acronym_eq(name, &w),
2272 reference_acronym(name) == w,
2273 "{name:?} vs needle {w:?}"
2274 );
2275 }
2276 }
2277 }
2278
2279 #[test]
2282 fn acronym_eq_agrees_on_all_pairs() {
2283 for name in NAMES {
2284 for other in NAMES {
2285 let needle = reference_acronym(other);
2286 assert_eq!(
2287 acronym_eq(name, &needle),
2288 reference_acronym(name) == needle,
2289 "name {name:?} vs needle {needle:?}"
2290 );
2291 }
2292 }
2293 }
2294
2295 fn reference_path_score(path: &str) -> f32 {
2298 let mut s = 0.0f32;
2299 let depth = path.matches('/').count() as f32;
2300 s -= depth * 0.05;
2301 let lower = path.to_ascii_lowercase();
2302 if lower.contains("test")
2303 || lower.contains("/tests/")
2304 || lower.contains("__tests__")
2305 || lower.contains(".test.")
2306 || lower.contains(".spec.")
2307 {
2308 s -= 1.0;
2309 }
2310 if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
2311 s -= 1.5;
2312 }
2313 s
2314 }
2315
2316 #[test]
2319 fn path_score_matches_reference() {
2320 let paths = [
2321 "",
2322 "a.c",
2323 "fs/read_write.c",
2324 "a/b/c/d/e/f/g.rs",
2325 "src/tests/mod.rs",
2326 "src/TESTS/mod.rs",
2327 "Test.java",
2328 "TEST.java",
2329 "foo/__tests__/bar.js",
2330 "foo/bar.test.ts",
2331 "foo/bar.spec.ts",
2332 "foo/bar.SPEC.ts",
2333 "protest/attestation.c",
2334 "third_party/vendor/lib.go",
2335 "third_party/VENDOR/lib.go",
2336 "out/generated/api.rs",
2337 "web/app.min.js",
2338 "web/app.MIN.js",
2339 "vendor/nested/test/.spec.x",
2340 "no_slashes_or_markers",
2341 "spec.rs",
2342 ".spec.",
2343 "tes",
2344 "t",
2345 ];
2346 for p in paths {
2347 assert_eq!(
2348 path_score(p),
2349 reference_path_score(p),
2350 "path_score mismatch for {p:?}"
2351 );
2352 }
2353 }
2354
2355 #[test]
2357 fn contains_ascii_ci_matches_std() {
2358 let hays = [
2359 "", "a", "Test", "tEsT", "xxtestxx", "TES", "/Vendor/", ".MIN.", "aaa",
2360 ];
2361 for h in hays {
2362 for n in [
2363 &b"test"[..],
2364 b".spec.",
2365 b"/vendor/",
2366 b"/generated/",
2367 b".min.",
2368 b"a",
2369 ] {
2370 let needle = std::str::from_utf8(n).unwrap();
2371 assert_eq!(
2372 contains_ascii_ci(h, n),
2373 h.to_ascii_lowercase().contains(needle),
2374 "{h:?} contains {needle:?}"
2375 );
2376 }
2377 }
2378 }
2379
2380 #[test]
2386 fn file_lines_match_str_lines() {
2387 let cases: &[&[u8]] = &[
2388 b"",
2389 b"\n",
2390 b"a",
2391 b"a\n",
2392 b"a\nb",
2393 b"a\nb\n",
2394 b"\na",
2395 b"a\n\nb\n",
2396 b"a\r\nb\r\n",
2397 b"a\r\nb",
2398 b"a\r",
2399 b"\r\n\r\n",
2400 b"no terminator at all",
2401 b"trailing blank lines\n\n\n",
2402 b"tabs\tand spaces\n indented\n",
2403 &[0xC3, 0x28, b'\n', b'o', b'k'], &[b'a', b'\n', 0xFF, 0xFE, b'\n', b'z'], "héllo\nwörld\n".as_bytes(), ];
2407 for data in cases {
2408 let want: Vec<String> = String::from_utf8_lossy(data)
2409 .lines()
2410 .map(|s| s.to_string())
2411 .collect();
2412 let got = FileLines::new(data);
2413 assert_eq!(got.len(), want.len(), "line count for {data:?}");
2414 for (i, line) in want.iter().enumerate() {
2415 assert_eq!(got.get(i), Some(line.as_str()), "line {i} of {data:?}");
2416 }
2417 assert_eq!(got.get(want.len()), None, "past-the-end for {data:?}");
2418 }
2419 }
2420
2421 #[test]
2426 fn enclosing_row_picks_innermost_then_earliest() {
2427 let r = DocSymbolRanges {
2430 rows: vec![
2431 (1, 100, 0),
2432 (10, 20, 1),
2433 (30, 40, 2),
2434 (30, 40, 3),
2435 (50, 50, 4),
2436 ],
2437 };
2438 assert_eq!(
2439 r.enclosing_row(5),
2440 Some(0),
2441 "only the outer range contains it"
2442 );
2443 assert_eq!(
2444 r.enclosing_row(15),
2445 Some(1),
2446 "innermost wins over the outer"
2447 );
2448 assert_eq!(r.enclosing_row(35), Some(2), "earliest of two equal spans");
2449 assert_eq!(
2450 r.enclosing_row(50),
2451 Some(4),
2452 "single-line range is tightest"
2453 );
2454 assert_eq!(r.enclosing_row(200), None, "outside every range");
2455 assert_eq!(DocSymbolRanges { rows: vec![] }.enclosing_row(1), None);
2456 assert_eq!(r.enclosing_row(10), Some(1));
2458 assert_eq!(r.enclosing_row(20), Some(1));
2459 assert_eq!(r.enclosing_row(9), Some(0));
2460 }
2461
2462 #[test]
2464 fn acronym_matches_camel_and_snake() {
2465 assert!(acronym_eq("loadConfig", "lc"));
2466 assert!(acronym_eq("load_config", "lc"));
2467 assert!(acronym_eq("vfs_read", "vr"));
2468 assert!(!acronym_eq("loadConfig", "l"));
2469 assert!(!acronym_eq("loadConfig", "lcx"));
2470 }
2471}