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: HashMap<String, (usize, u32)>,
379 content: Arc<ContentCache>,
382}
383
384impl Searcher {
385 pub fn open(paths: &Paths) -> Result<Searcher> {
387 Self::open_inner(paths, None)
388 }
389
390 pub fn open_reusing(paths: &Paths, prev: &Searcher) -> Result<Searcher> {
397 Self::open_inner(paths, Some(prev))
398 }
399
400 fn open_inner(paths: &Paths, prev: Option<&Searcher>) -> Result<Searcher> {
401 if !paths.exists() {
402 return Err(Error::IndexMissing(paths.base.clone()));
403 }
404 let meta = Meta::load(&paths.meta_file())?;
405 let mut segments = Vec::with_capacity(meta.segments.len());
406 for &seg_id in &meta.segments {
407 let reusable = prev.and_then(|p| p.segments.iter().find(|s| s.id == seg_id));
408 segments.push(match reusable {
409 Some(seg) => seg.reopen(paths)?,
410 None => Segment::open(paths, seg_id)?,
411 });
412 }
413 for pt in &meta.pending_tombstones {
416 if let Some(seg) = segments.iter_mut().find(|s| s.id == pt.segment_id) {
417 seg.subtract_live(&pt.doc_ids);
418 }
419 }
420 let by_path = build_path_index(&segments);
421 let content = match prev {
422 Some(p) => p.content.clone(),
423 None => Arc::new(ContentCache::new(CONTENT_CACHE_BYTES)),
424 };
425 Ok(Searcher {
426 paths: paths.clone(),
427 segments,
428 by_path,
429 content,
430 })
431 }
432
433 pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
435 if query.pattern.is_empty() {
436 return Ok(Vec::new());
437 }
438 let matcher = Matcher::build(query)?;
439 let tq: TrigramQuery = if query.regex {
440 trigram::regex_trigrams(&query.pattern, query.case_insensitive)
441 } else if query.case_insensitive {
442 TrigramQuery::from_literal_ci(query.pattern.as_bytes())
445 } else {
446 TrigramQuery::from_literal(query.pattern.as_bytes())
447 };
448
449 let path_filter = query.path.as_deref();
450 let lang_filter = query.lang.as_deref();
451
452 let mut targets: Vec<(usize, u32, f32)> = Vec::new();
454 for (si, seg) in self.segments.iter().enumerate() {
455 let candidates = seg.candidates(&tq)?;
456 for doc_id in candidates.iter() {
457 if !seg.is_live(doc_id) {
458 continue;
459 }
460 let doc = match seg.doc(doc_id) {
461 Some(d) => d,
462 None => continue,
463 };
464 if let Some(lf) = lang_filter {
465 if doc.lang != lf {
466 continue;
467 }
468 }
469 if let Some(pf) = path_filter {
470 if !doc.path.contains(pf) {
471 continue;
472 }
473 }
474 targets.push((si, doc_id, 0.0));
475 }
476 }
477
478 let root = &self.paths.root;
481 let segments = &self.segments;
482 let content: &ContentCache = &self.content;
483 let max_per_file = query.max_per_file;
484 let whole_word = query.whole_word;
485 let exhaustive = query.exhaustive;
486 let verify = |&(si, doc_id, _): &(usize, u32, f32)| {
487 verify_doc(
488 &segments[si],
489 doc_id,
490 root,
491 content,
492 &matcher,
493 max_per_file,
494 whole_word,
495 exhaustive,
496 )
497 .into_iter()
498 };
499
500 if query.exhaustive {
501 let mut hits: Vec<SearchHit> = targets.par_iter().flat_map_iter(verify).collect();
504 hits.sort_by(|a, b| {
505 a.path
506 .cmp(&b.path)
507 .then_with(|| a.line.cmp(&b.line))
508 .then_with(|| a.column.cmp(&b.column))
509 });
510 return Ok(hits);
511 }
512
513 let need = query.offset.saturating_add(query.limit);
514 if need == 0 {
515 return Ok(Vec::new());
516 }
517
518 let chunk = need.saturating_mul(4).clamp(256, 4096);
526 let mut hits: Vec<SearchHit>;
527 if targets.len() <= chunk {
528 hits = targets.par_iter().flat_map_iter(verify).collect();
529 } else {
530 for t in &mut targets {
531 if let Some(doc) = self.segments[t.0].doc(t.1) {
532 t.2 = path_score(&doc.path);
533 }
534 }
535 targets.sort_unstable_by(|a, b| {
536 b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
537 });
538 hits = Vec::new();
539 let mut start = 0usize;
540 while start < targets.len() {
541 let end = (start + chunk).min(targets.len());
542 let mut batch: Vec<SearchHit> = targets[start..end]
543 .par_iter()
544 .flat_map_iter(verify)
545 .collect();
546 hits.append(&mut batch);
547 start = end;
548 if start < targets.len() {
549 let remaining_max = targets[start].2 + 4.0;
550 let outranking = hits.iter().filter(|h| h.score > remaining_max).count();
551 if outranking >= need {
552 break;
553 }
554 }
555 }
556 }
557
558 let cmp = |a: &SearchHit, b: &SearchHit| {
559 b.score
560 .partial_cmp(&a.score)
561 .unwrap_or(std::cmp::Ordering::Equal)
562 .then_with(|| a.path.cmp(&b.path))
563 .then_with(|| a.line.cmp(&b.line))
564 };
565 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
566 }
567
568 pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
572 let needle = query.name.to_ascii_lowercase();
573 let mut hits: Vec<SymbolHit> = Vec::new();
574 let mut consider = |seg: &Segment, i: u32, score: f32| {
577 let sym = match seg.sym(i) {
578 Some(s) => s,
579 None => return,
580 };
581 if !seg.is_live(sym.doc_id) {
582 return;
583 }
584 if let Some(k) = &query.kind {
585 if &sym.kind != k {
586 return;
587 }
588 }
589 let doc = match seg.doc(sym.doc_id) {
590 Some(d) => d,
591 None => return,
592 };
593 hits.push(SymbolHit {
594 path: doc.path.clone(),
595 lang: doc.lang.clone(),
596 name: sym.name,
597 kind: sym.kind,
598 line_start: sym.line_start,
599 line_end: sym.line_end,
600 container: sym.container,
601 signature: sym.signature,
602 score: score + path_score(&doc.path),
603 });
604 };
605 for seg in &self.segments {
606 if query.exact {
607 let rows: Vec<u32> = seg.syms_by_lower(&needle).collect();
608 for i in rows {
609 let score =
610 match match_symbol(seg.sym_name(i), seg.sym_name_lower(i), &needle, true) {
611 Some(s) => s,
612 None => continue,
613 };
614 consider(seg, i, score);
615 }
616 } else {
617 let matches: Vec<(u32, f32)> = seg
620 .sym_names()
621 .filter_map(|(i, name, lower)| {
622 match_symbol(name, lower, &needle, false).map(|s| (i, s))
623 })
624 .collect();
625 for (i, score) in matches {
626 consider(seg, i, score);
627 }
628 }
629 }
630 let cmp = |a: &SymbolHit, b: &SymbolHit| {
631 b.score
632 .partial_cmp(&a.score)
633 .unwrap_or(std::cmp::Ordering::Equal)
634 .then_with(|| a.name.len().cmp(&b.name.len()))
635 .then_with(|| a.path.cmp(&b.path))
636 };
637 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
638 }
639
640 pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
642 let mut out = Vec::new();
643 if let Some(&(si, doc_id)) = self.by_path.get(rel_path) {
644 let seg = &self.segments[si];
645 if let Some(doc) = seg.doc(doc_id) {
646 for sym in seg.doc_syms(doc_id) {
647 out.push(SymbolHit {
648 path: doc.path.clone(),
649 lang: doc.lang.clone(),
650 name: sym.name.clone(),
651 kind: sym.kind.clone(),
652 line_start: sym.line_start,
653 line_end: sym.line_end,
654 container: sym.container.clone(),
655 signature: sym.signature.clone(),
656 score: 1.0,
657 });
658 }
659 }
660 }
661 out.sort_by_key(|s| s.line_start);
662 Ok(out)
663 }
664
665 pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
667 self.search(&SearchQuery {
668 pattern: name.to_string(),
669 whole_word: true,
670 limit,
671 offset,
672 ..Default::default()
673 })
674 }
675
676 fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, crate::segment::SymbolEntry)> {
681 let lower = name.to_ascii_lowercase();
682 let mut out = Vec::new();
683 for (si, seg) in self.segments.iter().enumerate() {
684 for idx in seg.syms_by_lower(&lower) {
685 if seg.sym_name(idx) != name {
687 continue;
688 }
689 if let Some(sym) = seg.sym(idx) {
690 if seg.is_live(sym.doc_id) {
691 out.push((si, idx as usize, sym));
692 }
693 }
694 }
695 }
696 out
697 }
698
699 fn call_indegree(&self, name: &str) -> u32 {
702 let mut n = 0u32;
703 for seg in &self.segments {
704 for r in seg.calls_to(name) {
705 if seg.is_live(r.doc_id) {
706 n += 1;
707 }
708 }
709 }
710 n
711 }
712
713 fn enclosing_symbol(
715 &self,
716 seg: &Segment,
717 doc_id: u32,
718 line: u32,
719 ) -> Option<crate::segment::SymbolEntry> {
720 let mut best: Option<crate::segment::SymbolEntry> = None;
721 for sym in seg.doc_syms(doc_id) {
722 if sym.line_start <= line && line <= sym.line_end {
723 let span = sym.line_end - sym.line_start;
724 match &best {
725 Some(b) if (b.line_end - b.line_start) <= span => {}
726 _ => best = Some(sym),
727 }
728 }
729 }
730 best
731 }
732
733 pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
737 let lower = name.to_ascii_lowercase();
738 let mut hits: Vec<RefHit> = Vec::new();
739 for seg in &self.segments {
740 let def_rows: Vec<u32> = seg.syms_by_lower(&lower).collect();
743 for i in def_rows {
744 if seg.sym_name(i) != name {
745 continue;
746 }
747 let sym = match seg.sym(i) {
748 Some(s) => s,
749 None => continue,
750 };
751 if seg.is_live(sym.doc_id) {
752 if let Some(doc) = seg.doc(sym.doc_id) {
753 hits.push(RefHit {
754 path: doc.path.clone(),
755 lang: doc.lang.clone(),
756 name: sym.name,
757 kind: "definition".to_string(),
758 line: sym.line_start,
759 column: 1,
760 container: sym.container,
761 });
762 }
763 }
764 }
765 for r in seg.refs_named(name) {
766 if seg.is_live(r.doc_id) {
767 if let Some(doc) = seg.doc(r.doc_id) {
768 let container = self
769 .enclosing_symbol(seg, r.doc_id, r.line)
770 .map(|s| s.name.clone());
771 hits.push(RefHit {
772 path: doc.path.clone(),
773 lang: doc.lang.clone(),
774 name: r.name.clone(),
775 kind: r.kind.as_str().to_string(),
776 line: r.line,
777 column: r.column,
778 container,
779 });
780 }
781 }
782 }
783 }
784 let rank = |k: &str| match k {
785 "definition" => 0,
786 "call" => 1,
787 _ => 2,
788 };
789 hits.sort_by(|a, b| {
790 rank(&a.kind)
791 .cmp(&rank(&b.kind))
792 .then_with(|| a.path.cmp(&b.path))
793 .then_with(|| a.line.cmp(&b.line))
794 });
795 paginate(hits, offset, limit)
796 }
797
798 pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
801 let mut out: Vec<CallSite> = Vec::new();
802 let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
803 for (si, _, sym) in self.defs_by_name(name) {
804 let seg = &self.segments[si];
805 let doc = match seg.doc(sym.doc_id) {
806 Some(d) => d,
807 None => continue,
808 };
809 for r in seg.doc_refs(sym.doc_id) {
810 if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
811 let key = (doc.path.clone(), r.name.clone(), r.line, r.column);
812 if !seen.insert(key) {
813 continue;
814 }
815 out.push(CallSite {
816 caller: Some(name.to_string()),
817 callee: r.name.clone(),
818 path: doc.path.clone(),
819 lang: doc.lang.clone(),
820 line: r.line,
821 column: r.column,
822 });
823 }
824 }
825 }
826 out.sort_by(|a, b| {
827 a.callee
828 .cmp(&b.callee)
829 .then_with(|| a.path.cmp(&b.path))
830 .then_with(|| a.line.cmp(&b.line))
831 });
832 paginate(out, offset, limit)
833 }
834
835 pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
838 let mut out: Vec<CallSite> = Vec::new();
839 for seg in &self.segments {
840 for r in seg.calls_to(name) {
843 if !seg.is_live(r.doc_id) {
844 continue;
845 }
846 let doc = match seg.doc(r.doc_id) {
847 Some(d) => d,
848 None => continue,
849 };
850 let caller = self
851 .enclosing_symbol(seg, r.doc_id, r.line)
852 .map(|s| s.name.clone());
853 out.push(CallSite {
854 caller,
855 callee: name.to_string(),
856 path: doc.path.clone(),
857 lang: doc.lang.clone(),
858 line: r.line,
859 column: r.column,
860 });
861 }
862 }
863 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
864 paginate(out, offset, limit)
865 }
866
867 pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
874 let mut out: Vec<ImpactNode> = Vec::new();
875 let mut visited: HashSet<String> = HashSet::new();
876 visited.insert(name.to_string());
877
878 for (si, _, sym) in self.defs_by_name(name) {
880 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
881 out.push(ImpactNode {
882 name: sym.name.clone(),
883 kind: sym.kind.clone(),
884 path: doc.path.clone(),
885 lang: doc.lang.clone(),
886 line_start: sym.line_start,
887 line_end: sym.line_end,
888 distance: 0,
889 });
890 }
891 }
892
893 let mut frontier: Vec<String> = vec![name.to_string()];
894 'expand: for dist in 1..=depth {
895 let mut next: Vec<String> = Vec::new();
896 for target in &frontier {
897 for site in self.callers(target, usize::MAX, 0) {
898 let caller = match site.caller {
899 Some(c) => c,
900 None => continue,
901 };
902 if !visited.insert(caller.clone()) {
903 continue;
904 }
905 for (si, _, sym) in self.defs_by_name(&caller) {
906 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
907 out.push(ImpactNode {
908 name: sym.name.clone(),
909 kind: sym.kind.clone(),
910 path: doc.path.clone(),
911 lang: doc.lang.clone(),
912 line_start: sym.line_start,
913 line_end: sym.line_end,
914 distance: dist,
915 });
916 }
917 }
918 next.push(caller);
919 }
920 if out.len() >= limit {
923 break 'expand;
924 }
925 }
926 if next.is_empty() {
927 break;
928 }
929 frontier = next;
930 }
931 out.truncate(limit);
932 out
933 }
934
935 pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
941 let full = self.resolve_within_root(rel_path)?;
942 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
943 let ext = Path::new(rel_path)
944 .extension()
945 .and_then(|e| e.to_str())
946 .unwrap_or("");
947 let lang = crate::lang::Language::from_extension(ext);
948
949 let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
950 Some(i) => i,
951 None => {
952 return Err(Error::other(format!(
953 "no identifier at {rel_path}:{line}:{col}"
954 )))
955 }
956 };
957
958 let imported_here = self.imported_names(rel_path);
961
962 let mut cands: Vec<DefHit> = Vec::new();
963 for (si, _, sym) in self.defs_by_name(&ident.name) {
964 let seg = &self.segments[si];
965 let doc = match seg.doc(sym.doc_id) {
966 Some(d) => d,
967 None => continue,
968 };
969 let mut score = 10.0f32 + path_score(&doc.path);
970 let same_file = doc.path == rel_path;
971 if same_file {
972 score += 40.0;
973 }
974 score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
975 let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
977 if ident.is_member && method_like {
978 score += 25.0;
979 } else if !ident.is_member && !method_like {
980 score += 8.0;
981 }
982 if ident.is_call
983 && matches!(
984 sym.kind.as_str(),
985 "function" | "method" | "macro" | "constructor"
986 )
987 {
988 score += 6.0;
989 }
990 if ident.is_type
991 && matches!(
992 sym.kind.as_str(),
993 "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
994 )
995 {
996 score += 12.0;
997 }
998 if imported_here.contains(&ident.name) && !same_file {
1001 score += 15.0;
1002 }
1003 cands.push(DefHit {
1004 path: doc.path.clone(),
1005 lang: doc.lang.clone(),
1006 name: sym.name.clone(),
1007 kind: sym.kind.clone(),
1008 line_start: sym.line_start,
1009 line_end: sym.line_end,
1010 container: sym.container.clone(),
1011 signature: sym.signature.clone(),
1012 score,
1013 resolved: false,
1014 });
1015 }
1016
1017 if cands.is_empty() {
1018 let hits = self.references(&ident.name, 50, 0)?;
1020 return Ok(hits
1021 .into_iter()
1022 .map(|h| DefHit {
1023 path: h.path,
1024 lang: h.lang,
1025 name: ident.name.clone(),
1026 kind: "text".to_string(),
1027 line_start: h.line,
1028 line_end: h.line,
1029 container: None,
1030 signature: Some(h.text),
1031 score: h.score,
1032 resolved: false,
1033 })
1034 .collect());
1035 }
1036
1037 cands.sort_by(|a, b| {
1038 b.score
1039 .partial_cmp(&a.score)
1040 .unwrap_or(std::cmp::Ordering::Equal)
1041 .then_with(|| a.path.cmp(&b.path))
1042 .then_with(|| a.line_start.cmp(&b.line_start))
1043 });
1044 let unique_top =
1046 cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
1047 if unique_top {
1048 cands[0].resolved = true;
1049 }
1050 Ok(cands)
1051 }
1052
1053 pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
1056 let full = self.resolve_within_root(rel_path)?;
1057 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
1058 let ext = Path::new(rel_path)
1059 .extension()
1060 .and_then(|e| e.to_str())
1061 .unwrap_or("");
1062 let lang = crate::lang::Language::from_extension(ext);
1063 let ident = crate::resolve::identifier_at(lang, &source, line, col)
1064 .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
1065 Ok(self.references_resolved(&ident.name, usize::MAX, 0))
1066 }
1067
1068 fn imported_names(&self, rel_path: &str) -> HashSet<String> {
1070 let mut out = HashSet::new();
1071 if let Some(&(si, doc_id)) = self.by_path.get(rel_path) {
1072 for r in self.segments[si].doc_refs(doc_id) {
1073 if r.kind == RefKind::Import {
1074 out.insert(r.name.clone());
1075 }
1076 }
1077 }
1078 out
1079 }
1080
1081 fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
1086 let candidate = Path::new(rel_path);
1087 if candidate.is_absolute() {
1088 return Err(Error::other(format!(
1089 "path {rel_path:?} must be relative to the project root"
1090 )));
1091 }
1092 if candidate
1094 .components()
1095 .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
1096 {
1097 return Err(Error::other(format!(
1098 "path {rel_path:?} escapes the project root"
1099 )));
1100 }
1101 let root = self
1104 .paths
1105 .root
1106 .canonicalize()
1107 .map_err(|e| Error::io(&self.paths.root, e))?;
1108 let full = root.join(candidate);
1109 let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
1110 if !resolved.starts_with(&root) {
1111 return Err(Error::other(format!(
1112 "path {rel_path:?} escapes the project root"
1113 )));
1114 }
1115 Ok(resolved)
1116 }
1117
1118 pub fn read_snippet(
1120 &self,
1121 rel_path: &str,
1122 start_line: u32,
1123 end_line: u32,
1124 context: u32,
1125 ) -> Result<Snippet> {
1126 let full = self.resolve_within_root(rel_path)?;
1127 let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
1128 let lines: Vec<&str> = data.lines().collect();
1129 let total = lines.len() as u32;
1130 let to = end_line.saturating_add(context).min(total.max(1));
1131 let from = start_line
1134 .saturating_sub(context)
1135 .max(1)
1136 .min(total.max(1))
1137 .min(to);
1138 let mut body = String::new();
1139 let mut last = from;
1140 for ln in from..=to {
1141 if let Some(text) = lines.get((ln - 1) as usize) {
1142 if !body.is_empty() {
1143 body.push('\n');
1144 }
1145 body.push_str(text);
1146 last = ln;
1147 }
1148 }
1149 Ok(Snippet {
1150 path: rel_path.to_string(),
1151 start_line: from,
1152 end_line: last,
1153 total_lines: total,
1154 text: body,
1155 })
1156 }
1157
1158 pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1164 use crate::context::{self, ContextPack, PackItem};
1165
1166 let terms = context::tokenize(task);
1167
1168 struct Cand {
1170 seg: usize,
1171 sym: crate::segment::SymbolEntry,
1172 score: f32,
1173 reason: String,
1174 }
1175 let mut cands: Vec<Cand> = Vec::new();
1176 for (si, seg) in self.segments.iter().enumerate() {
1177 for doc_id in 0..seg.docs.len() as u32 {
1179 if !seg.is_live(doc_id) {
1180 continue;
1181 }
1182 let doc = match seg.doc(doc_id) {
1183 Some(d) => d,
1184 None => continue,
1185 };
1186 for sym in seg.doc_syms(doc_id) {
1187 let mut score = context::lexical_score(
1188 &sym.name,
1189 &sym.kind,
1190 sym.signature.as_deref(),
1191 sym.container.as_deref(),
1192 &doc.path,
1193 &terms,
1194 );
1195 if score <= 0.0 {
1196 continue;
1197 }
1198 let deg = self.call_indegree(&sym.name) as f32;
1202 score += (1.0 + deg).ln() * 1.5;
1203 score += path_score(&doc.path);
1204 cands.push(Cand {
1205 seg: si,
1206 sym,
1207 score,
1208 reason: "match".to_string(),
1209 });
1210 }
1211 }
1212 }
1213
1214 cands.sort_by(|a, b| {
1215 b.score
1216 .partial_cmp(&a.score)
1217 .unwrap_or(std::cmp::Ordering::Equal)
1218 });
1219
1220 let mut seen: HashSet<(String, u32)> = HashSet::new();
1223 for c in &cands {
1224 seen.insert((c.sym.name.clone(), c.sym.line_start));
1225 }
1226 let mut extra: Vec<Cand> = Vec::new();
1227 for c in cands.iter().take(8) {
1228 for callee in self.callees(&c.sym.name, 12, 0) {
1229 for (si2, _, def) in self.defs_by_name(&callee.callee) {
1230 let key = (def.name.clone(), def.line_start);
1231 if !seen.insert(key) {
1232 continue;
1233 }
1234 extra.push(Cand {
1235 seg: si2,
1236 sym: def,
1237 score: c.score * 0.3,
1238 reason: format!("callee of {}", c.sym.name),
1239 });
1240 }
1241 }
1242 }
1243 cands.extend(extra);
1244 cands.sort_by(|a, b| {
1245 b.score
1246 .partial_cmp(&a.score)
1247 .unwrap_or(std::cmp::Ordering::Equal)
1248 });
1249
1250 let mut items: Vec<PackItem> = Vec::new();
1254 let mut used: u64 = 0;
1255 let mut truncated = false;
1256 let mut file_lines: std::collections::HashMap<u64, Arc<Vec<String>>> =
1257 std::collections::HashMap::new();
1258 const MAX_ITEM_LINES: u32 = 60;
1259 for c in &cands {
1260 let seg = &self.segments[c.seg];
1261 let sym = &c.sym;
1262 let doc = match seg.doc(sym.doc_id) {
1263 Some(d) => d,
1264 None => continue,
1265 };
1266 let end = sym
1267 .line_end
1268 .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1269 let lines = file_lines
1270 .entry(doc.hash)
1271 .or_insert_with(|| {
1272 let full = self.paths.root.join(&doc.path);
1273 let v = match self.content.get_or_read(doc.hash, &full) {
1274 Some(data) => String::from_utf8_lossy(&data)
1275 .lines()
1276 .map(|s| s.to_string())
1277 .collect(),
1278 None => Vec::new(),
1279 };
1280 Arc::new(v)
1281 })
1282 .clone();
1283 let from = sym.line_start.max(1);
1284 let to = end.min(lines.len() as u32);
1285 let mut code = String::new();
1286 for ln in from..=to {
1287 if let Some(text) = lines.get((ln - 1) as usize) {
1288 if !code.is_empty() {
1289 code.push('\n');
1290 }
1291 code.push_str(text);
1292 }
1293 }
1294 let chars: u64 =
1295 code.len() as u64 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1296 let cost = context::est_tokens(chars).max(1);
1297 if used + cost > budget_tokens && !items.is_empty() {
1298 truncated = true;
1299 continue;
1300 }
1301 used += cost;
1302 items.push(PackItem {
1303 path: doc.path.clone(),
1304 lang: doc.lang.clone(),
1305 name: sym.name.clone(),
1306 kind: sym.kind.clone(),
1307 line_start: sym.line_start,
1308 line_end: sym.line_end,
1309 signature: sym.signature.clone(),
1310 snippet_start: from,
1311 code,
1312 reason: c.reason.clone(),
1313 score: c.score,
1314 });
1315 if used >= budget_tokens {
1316 truncated = truncated || items.len() < cands.len();
1317 break;
1318 }
1319 }
1320
1321 ContextPack {
1322 task: task.to_string(),
1323 budget_tokens,
1324 used_tokens: used,
1325 truncated,
1326 items,
1327 }
1328 }
1329
1330 pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1332 self.resolve_within_root(rel_path)?;
1334 crate::git::blame(&self.paths.root, rel_path, line)
1335 }
1336
1337 pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1340 let defs = self.defs_by_name(name);
1342 let best = defs
1343 .iter()
1344 .max_by(|a, b| {
1345 let pa = self.segments[a.0]
1346 .doc(a.2.doc_id)
1347 .map(|d| path_score(&d.path))
1348 .unwrap_or(0.0);
1349 let pb = self.segments[b.0]
1350 .doc(b.2.doc_id)
1351 .map(|d| path_score(&d.path))
1352 .unwrap_or(0.0);
1353 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1354 })
1355 .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1356 let (si, _, sym) = best;
1357 let si = *si;
1358 let doc = self.segments[si]
1359 .doc(sym.doc_id)
1360 .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1361 let commits = crate::git::line_history(
1362 &self.paths.root,
1363 &doc.path,
1364 sym.line_start,
1365 sym.line_end,
1366 limit,
1367 )
1368 .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1369 Ok(SymbolHistory {
1370 name: name.to_string(),
1371 path: doc.path.clone(),
1372 line_start: sym.line_start,
1373 line_end: sym.line_end,
1374 commits,
1375 })
1376 }
1377
1378 pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1381 let changed = crate::git::changed_since(&self.paths.root, rev)?;
1382 let mut out = Vec::with_capacity(changed.len());
1383 for cf in changed {
1384 let mut symbols = Vec::new();
1385 if let Some(&(si, doc_id)) = self.by_path.get(&cf.path) {
1386 for s in self.segments[si].doc_syms(doc_id) {
1387 symbols.push(s.name.clone());
1388 }
1389 }
1390 symbols.sort();
1391 symbols.dedup();
1392 out.push(ChangedSymbols {
1393 path: cf.path,
1394 status: cf.status,
1395 symbols,
1396 });
1397 }
1398 Ok(out)
1399 }
1400
1401 pub fn structural_search(
1405 &self,
1406 pattern: &str,
1407 lang: &str,
1408 limit: usize,
1409 offset: usize,
1410 ) -> Result<Vec<StructHit>> {
1411 let language = crate::lang::Language::from_id(lang)
1412 .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1413 if language.grammar().is_none() {
1414 return Err(Error::other(format!(
1415 "language {lang} is not parseable for structural search"
1416 )));
1417 }
1418 let compiled = crate::structural::compile(language, pattern)?;
1419
1420 let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1422 let tq = anchor
1423 .as_ref()
1424 .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1425
1426 let mut targets: Vec<(usize, u32)> = Vec::new();
1427 for (si, seg) in self.segments.iter().enumerate() {
1428 let candidates = match &tq {
1429 Some(q) => seg.candidates(q)?,
1430 None => seg.all_live(),
1431 };
1432 for doc_id in candidates.iter() {
1433 if !seg.is_live(doc_id) {
1434 continue;
1435 }
1436 match seg.doc(doc_id) {
1437 Some(d) if d.lang == lang => targets.push((si, doc_id)),
1438 _ => {}
1439 }
1440 }
1441 }
1442
1443 let root = &self.paths.root;
1444 let segments = &self.segments;
1445 let content = &self.content;
1446 let compiled_ref = &compiled;
1447 let hits: Vec<StructHit> = targets
1448 .par_iter()
1449 .flat_map_iter(|&(si, doc_id)| {
1450 let seg = &segments[si];
1451 let doc = match seg.doc(doc_id) {
1452 Some(d) => d,
1453 None => return Vec::new().into_iter(),
1454 };
1455 let full = root.join(&doc.path);
1456 let data = match content.get_or_read(doc.hash, &full) {
1457 Some(d) => d,
1458 None => return Vec::new().into_iter(),
1459 };
1460 let matches = crate::structural::run(language, compiled_ref, &data);
1461 let line_starts = line_starts(&data);
1462 let out: Vec<StructHit> = matches
1463 .into_iter()
1464 .map(|m| {
1465 let li = (m.line_start.saturating_sub(1)) as usize;
1466 let text = line_starts
1467 .get(li)
1468 .map(|_| snippet(line_slice(&data, &line_starts, li)))
1469 .unwrap_or_default();
1470 StructHit {
1471 path: doc.path.clone(),
1472 lang: doc.lang.clone(),
1473 line_start: m.line_start,
1474 line_end: m.line_end,
1475 kind: m.kind,
1476 text,
1477 captures: m.captures,
1478 }
1479 })
1480 .collect();
1481 out.into_iter()
1482 })
1483 .collect();
1484
1485 let cmp = |a: &StructHit, b: &StructHit| {
1486 a.path
1487 .cmp(&b.path)
1488 .then_with(|| a.line_start.cmp(&b.line_start))
1489 };
1490 let mut hits = hits;
1491 hits.sort_by(cmp);
1492 Ok(paginate(hits, offset, limit))
1493 }
1494
1495 pub fn summary(&self) -> RepoSummary {
1497 use std::collections::HashMap;
1498 let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1499 let mut by_dir: HashMap<String, u64> = HashMap::new();
1500 let mut files = 0u64;
1501 let mut bytes = 0u64;
1502 let mut symbols = 0u64;
1503 for seg in &self.segments {
1504 for (doc_id, doc) in seg.docs.iter().enumerate() {
1505 if !seg.is_live(doc_id as u32) {
1506 continue;
1507 }
1508 files += 1;
1509 bytes += doc.size;
1510 let e = by_lang.entry(doc.lang.clone()).or_default();
1511 e.files += 1;
1512 e.bytes += doc.size;
1513 let dir = doc.path.split('/').next().unwrap_or("").to_string();
1514 *by_dir.entry(dir).or_default() += 1;
1515 symbols += u64::from(seg.doc_sym_count(doc_id as u32));
1517 }
1518 }
1519 let mut languages: Vec<LangStat> = by_lang
1520 .into_iter()
1521 .map(|(lang, mut s)| {
1522 s.lang = lang;
1523 s
1524 })
1525 .collect();
1526 languages.sort_by_key(|s| std::cmp::Reverse(s.files));
1527 let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1528 top_dirs.sort_by_key(|d| std::cmp::Reverse(d.1));
1529 top_dirs.truncate(15);
1530 RepoSummary {
1531 files,
1532 bytes,
1533 symbols,
1534 segments: self.segments.len(),
1535 languages,
1536 top_dirs: top_dirs
1537 .into_iter()
1538 .map(|(name, files)| DirStat { name, files })
1539 .collect(),
1540 }
1541 }
1542}
1543
1544#[derive(Debug, Clone, Serialize, Deserialize)]
1551pub struct Snippet {
1552 pub path: String,
1553 pub start_line: u32,
1554 pub end_line: u32,
1555 pub total_lines: u32,
1556 pub text: String,
1557}
1558
1559#[derive(Debug, Clone, Serialize, Deserialize)]
1561pub struct RepoSummary {
1562 pub files: u64,
1563 pub bytes: u64,
1564 pub symbols: u64,
1565 pub segments: usize,
1566 pub languages: Vec<LangStat>,
1567 pub top_dirs: Vec<DirStat>,
1568}
1569
1570#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1571pub struct LangStat {
1572 pub lang: String,
1573 pub files: u64,
1574 pub bytes: u64,
1575}
1576
1577#[derive(Debug, Clone, Serialize, Deserialize)]
1578pub struct DirStat {
1579 pub name: String,
1580 pub files: u64,
1581}
1582
1583#[allow(clippy::too_many_arguments)] fn verify_doc(
1588 seg: &Segment,
1589 doc_id: u32,
1590 root: &Path,
1591 content: &ContentCache,
1592 matcher: &Matcher,
1593 max_per_file: usize,
1594 whole_word: bool,
1595 exhaustive: bool,
1596) -> Vec<SearchHit> {
1597 let doc = match seg.doc(doc_id) {
1598 Some(d) => d,
1599 None => return Vec::new(),
1600 };
1601 let full = root.join(&doc.path);
1602 let data = match content.get_or_read(doc.hash, &full) {
1603 Some(d) => d,
1604 None => return Vec::new(),
1605 };
1606
1607 let cap = if exhaustive {
1609 usize::MAX
1610 } else {
1611 PER_FILE_MATCH_CAP
1612 };
1613 let matches = matcher.match_starts(&data, whole_word, cap);
1614 if matches.is_empty() {
1615 return Vec::new();
1616 }
1617
1618 let line_starts = line_starts(&data);
1619 let sym_lines = symbol_lines(seg, doc_id);
1620 let base = path_score(&doc.path);
1621
1622 let mut out = Vec::new();
1623 let mut last_line = 0u32;
1624 for (start, _end) in matches {
1625 let li = line_of(start, &line_starts);
1626 let line_no = li as u32 + 1;
1627 if line_no == last_line {
1629 continue;
1630 }
1631 last_line = line_no;
1632 let col = (start - line_starts[li]) as u32 + 1;
1633 let line_bytes = line_slice(&data, &line_starts, li);
1634 let mut score = 1.0 + base;
1635 if sym_lines.contains(&line_no) {
1636 score += 3.0;
1637 }
1638 out.push(SearchHit {
1639 path: doc.path.clone(),
1640 lang: doc.lang.clone(),
1641 line: line_no,
1642 column: col,
1643 text: snippet(line_bytes),
1644 score,
1645 });
1646 }
1647
1648 if !exhaustive && out.len() > max_per_file {
1651 out.sort_by(|a, b| {
1652 b.score
1653 .partial_cmp(&a.score)
1654 .unwrap_or(std::cmp::Ordering::Equal)
1655 .then_with(|| a.line.cmp(&b.line))
1656 });
1657 out.truncate(max_per_file);
1658 }
1659 out
1660}
1661
1662fn build_path_index(segments: &[Segment]) -> HashMap<String, (usize, u32)> {
1666 let mut map = HashMap::new();
1667 for (si, seg) in segments.iter().enumerate() {
1668 for (doc_id, doc) in seg.docs.iter().enumerate() {
1669 let doc_id = doc_id as u32;
1670 if seg.is_live(doc_id) {
1671 map.insert(doc.path.clone(), (si, doc_id));
1672 }
1673 }
1674 }
1675 map
1676}
1677
1678fn line_starts(data: &[u8]) -> Vec<usize> {
1680 let mut starts = Vec::with_capacity(64);
1681 starts.push(0usize);
1682 for p in memchr::memchr_iter(b'\n', data) {
1683 starts.push(p + 1);
1684 }
1685 starts
1686}
1687
1688fn line_of(off: usize, starts: &[usize]) -> usize {
1690 starts.partition_point(|&s| s <= off).saturating_sub(1)
1692}
1693
1694fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1696 let begin = starts[li];
1697 let end = if li + 1 < starts.len() {
1698 starts[li + 1].saturating_sub(1)
1699 } else {
1700 data.len()
1701 };
1702 &data[begin..end.min(data.len())]
1703}
1704
1705fn path_score(path: &str) -> f32 {
1708 let mut s = 0.0f32;
1709 let depth = path.matches('/').count() as f32;
1710 s -= depth * 0.05;
1711 let lower = path.to_ascii_lowercase();
1712 if lower.contains("test")
1713 || lower.contains("/tests/")
1714 || lower.contains("__tests__")
1715 || lower.contains(".test.")
1716 || lower.contains(".spec.")
1717 {
1718 s -= 1.0;
1719 }
1720 if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
1721 s -= 1.5;
1722 }
1723 s
1724}
1725
1726fn symbol_lines(seg: &Segment, doc_id: u32) -> HashSet<u32> {
1727 seg.doc_syms(doc_id).map(|s| s.line_start).collect()
1728}
1729
1730fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1731 if exact {
1732 return if lower == needle { Some(100.0) } else { None };
1733 }
1734 if lower == needle {
1735 Some(100.0)
1736 } else if lower.starts_with(needle) {
1737 Some(70.0)
1738 } else if acronym(name) == needle {
1739 Some(60.0)
1741 } else if lower.contains(needle) {
1742 Some(50.0)
1743 } else if is_subsequence(needle, lower) {
1744 Some(30.0)
1745 } else {
1746 None
1747 }
1748}
1749
1750fn split_identifier(s: &str) -> Vec<String> {
1752 let mut tokens = Vec::new();
1753 let mut cur = String::new();
1754 let mut prev_lower = false;
1755 for ch in s.chars() {
1756 if ch == '_' || ch == '-' || ch == ' ' {
1757 if !cur.is_empty() {
1758 tokens.push(std::mem::take(&mut cur));
1759 }
1760 prev_lower = false;
1761 continue;
1762 }
1763 if ch.is_uppercase() && prev_lower && !cur.is_empty() {
1764 tokens.push(std::mem::take(&mut cur));
1765 }
1766 cur.extend(ch.to_lowercase());
1767 prev_lower = ch.is_lowercase() || ch.is_numeric();
1768 }
1769 if !cur.is_empty() {
1770 tokens.push(cur);
1771 }
1772 tokens
1773}
1774
1775fn acronym(s: &str) -> String {
1777 split_identifier(s)
1778 .iter()
1779 .filter_map(|t| t.chars().next())
1780 .collect()
1781}
1782
1783fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
1786where
1787 F: Fn(&T, &T) -> std::cmp::Ordering,
1788{
1789 let need = offset.saturating_add(limit);
1790 if need == 0 {
1791 return Vec::new();
1792 }
1793 if need < items.len() {
1794 items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
1795 items.truncate(need);
1796 }
1797 items.sort_by(|a, b| cmp(a, b));
1798 if offset >= items.len() {
1799 return Vec::new();
1800 }
1801 items.drain(0..offset);
1802 items.truncate(limit);
1803 items
1804}
1805
1806pub fn grep_walk(paths: &Paths, config: &Config, query: &SearchQuery) -> Result<Vec<SearchHit>> {
1812 if query.pattern.is_empty() {
1813 return Ok(Vec::new());
1814 }
1815 let matcher = Matcher::build(query)?;
1816 let walked = crate::walk::walk(paths, config)?;
1817 let path_filter = query.path.as_deref();
1818 let lang_filter = query.lang.as_deref();
1819 let max_per_file = query.max_per_file;
1820 let whole_word = query.whole_word;
1821 let exhaustive = query.exhaustive;
1822 let index_binary = config.index_binary;
1823 let cap = if exhaustive {
1824 usize::MAX
1825 } else {
1826 PER_FILE_MATCH_CAP
1827 };
1828
1829 let mut hits: Vec<SearchHit> = walked
1830 .entries
1831 .par_iter()
1832 .flat_map_iter(|e| {
1833 if path_filter.is_some_and(|pf| !e.rel.contains(pf)) {
1834 return Vec::new().into_iter();
1835 }
1836 let ext = e
1837 .path
1838 .extension()
1839 .and_then(|x| x.to_str())
1840 .unwrap_or("")
1841 .to_ascii_lowercase();
1842 let lang_id = Language::from_extension(&ext).id().to_string();
1843 if lang_filter.is_some_and(|lf| lang_id != lf) {
1844 return Vec::new().into_iter();
1845 }
1846 let data = match std::fs::read(&e.path) {
1847 Ok(d) => d,
1848 Err(_) => return Vec::new().into_iter(),
1849 };
1850 if !index_binary && memchr::memchr(0, &data).is_some() {
1851 return Vec::new().into_iter();
1852 }
1853 let matches = matcher.match_starts(&data, whole_word, cap);
1854 if matches.is_empty() {
1855 return Vec::new().into_iter();
1856 }
1857 let starts = line_starts(&data);
1858 let base = path_score(&e.rel);
1859 let mut out = Vec::new();
1860 let mut last_line = 0u32;
1861 for (start, _end) in matches {
1862 let li = line_of(start, &starts);
1863 let line_no = li as u32 + 1;
1864 if line_no == last_line {
1865 continue;
1866 }
1867 last_line = line_no;
1868 let col = (start - starts[li]) as u32 + 1;
1869 out.push(SearchHit {
1870 path: e.rel.clone(),
1871 lang: lang_id.clone(),
1872 line: line_no,
1873 column: col,
1874 text: snippet(line_slice(&data, &starts, li)),
1875 score: 1.0 + base,
1876 });
1877 }
1878 if !exhaustive && out.len() > max_per_file {
1879 out.sort_by(|a, b| {
1880 b.score
1881 .partial_cmp(&a.score)
1882 .unwrap_or(std::cmp::Ordering::Equal)
1883 .then_with(|| a.line.cmp(&b.line))
1884 });
1885 out.truncate(max_per_file);
1886 }
1887 out.into_iter()
1888 })
1889 .collect();
1890
1891 if exhaustive {
1892 hits.sort_by(|a, b| {
1893 a.path
1894 .cmp(&b.path)
1895 .then_with(|| a.line.cmp(&b.line))
1896 .then_with(|| a.column.cmp(&b.column))
1897 });
1898 return Ok(hits);
1899 }
1900 let cmp = |a: &SearchHit, b: &SearchHit| {
1901 b.score
1902 .partial_cmp(&a.score)
1903 .unwrap_or(std::cmp::Ordering::Equal)
1904 .then_with(|| a.path.cmp(&b.path))
1905 .then_with(|| a.line.cmp(&b.line))
1906 };
1907 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
1908}
1909
1910fn shared_prefix_len(a: &str, b: &str) -> usize {
1912 a.split('/')
1913 .zip(b.split('/'))
1914 .take_while(|(x, y)| x == y)
1915 .count()
1916}
1917
1918fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
1920 if offset >= items.len() {
1921 return Vec::new();
1922 }
1923 items.drain(0..offset);
1924 items.truncate(limit);
1925 items
1926}
1927
1928fn is_subsequence(needle: &str, haystack: &str) -> bool {
1929 if needle.is_empty() {
1930 return true;
1931 }
1932 let mut chars = needle.chars();
1933 let mut cur = chars.next();
1934 for h in haystack.chars() {
1935 if let Some(c) = cur {
1936 if c == h {
1937 cur = chars.next();
1938 }
1939 } else {
1940 break;
1941 }
1942 }
1943 cur.is_none()
1944}
1945
1946fn snippet(line: &[u8]) -> String {
1948 let s = String::from_utf8_lossy(line);
1949 let trimmed = s.trim_end();
1950 const MAX: usize = 320;
1951 if trimmed.len() > MAX {
1952 let mut end = MAX;
1953 while !trimmed.is_char_boundary(end) {
1954 end -= 1;
1955 }
1956 format!("{}…", &trimmed[..end])
1957 } else {
1958 trimmed.to_string()
1959 }
1960}