1use std::collections::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
269fn is_ident_byte(b: u8) -> bool {
272 b == b'_' || b.is_ascii_alphanumeric() || b >= 0x80
273}
274
275fn boundary_ok(line: &[u8], start: usize, end: usize) -> bool {
277 let left = start == 0 || !is_ident_byte(line[start - 1]);
278 let right = end >= line.len() || !is_ident_byte(line[end]);
279 left && right
280}
281
282const CONTENT_CACHE_BYTES: u64 = 256 * 1024 * 1024;
288
289const PER_FILE_MATCH_CAP: usize = 4096;
292
293struct CacheInner {
294 map: LruCache<u64, Arc<[u8]>>,
295 bytes: u64,
296}
297
298struct ContentCache {
302 inner: Mutex<CacheInner>,
303 budget: u64,
304}
305
306impl ContentCache {
307 fn new(budget_bytes: u64) -> Self {
308 Self {
309 inner: Mutex::new(CacheInner {
310 map: LruCache::unbounded(),
311 bytes: 0,
312 }),
313 budget: budget_bytes.max(1),
314 }
315 }
316
317 fn get_or_read(&self, hash: u64, path: &Path) -> Option<Arc<[u8]>> {
320 if let Ok(mut guard) = self.inner.lock() {
321 if let Some(v) = guard.map.get(&hash) {
322 return Some(v.clone());
323 }
324 }
325 let data = std::fs::read(path).ok()?;
326 let arc: Arc<[u8]> = Arc::from(data.into_boxed_slice());
327 let len = arc.len() as u64;
328 if let Ok(mut guard) = self.inner.lock() {
329 if len <= self.budget {
332 if let Some(prev) = guard.map.put(hash, arc.clone()) {
333 guard.bytes = guard.bytes.saturating_sub(prev.len() as u64);
334 }
335 guard.bytes += len;
336 while guard.bytes > self.budget {
337 match guard.map.pop_lru() {
338 Some((_, evicted)) => {
339 guard.bytes = guard.bytes.saturating_sub(evicted.len() as u64);
340 }
341 None => break,
342 }
343 }
344 }
345 }
346 Some(arc)
347 }
348}
349
350pub struct Searcher {
352 paths: Paths,
353 segments: Vec<Segment>,
354 content: ContentCache,
355}
356
357impl Searcher {
358 pub fn open(paths: &Paths) -> Result<Searcher> {
360 if !paths.exists() {
361 return Err(Error::IndexMissing(paths.base.clone()));
362 }
363 let meta = Meta::load(&paths.meta_file())?;
364 let mut segments = Vec::with_capacity(meta.segments.len());
365 for &seg_id in &meta.segments {
366 segments.push(Segment::open(paths, seg_id)?);
367 }
368 Ok(Searcher {
369 paths: paths.clone(),
370 segments,
371 content: ContentCache::new(CONTENT_CACHE_BYTES),
372 })
373 }
374
375 pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
377 if query.pattern.is_empty() {
378 return Ok(Vec::new());
379 }
380 let matcher = Matcher::build(query)?;
381 let tq: TrigramQuery = if query.regex {
382 trigram::regex_trigrams(&query.pattern, query.case_insensitive)
383 } else if query.case_insensitive {
384 TrigramQuery::from_literal_ci(query.pattern.as_bytes())
387 } else {
388 TrigramQuery::from_literal(query.pattern.as_bytes())
389 };
390
391 let path_filter = query.path.as_deref();
392 let lang_filter = query.lang.as_deref();
393
394 let mut targets: Vec<(usize, u32)> = Vec::new();
396 for (si, seg) in self.segments.iter().enumerate() {
397 let candidates = seg.candidates(&tq)?;
398 for doc_id in candidates.iter() {
399 if !seg.is_live(doc_id) {
400 continue;
401 }
402 let doc = match seg.doc(doc_id) {
403 Some(d) => d,
404 None => continue,
405 };
406 if let Some(lf) = lang_filter {
407 if doc.lang != lf {
408 continue;
409 }
410 }
411 if let Some(pf) = path_filter {
412 if !doc.path.contains(pf) {
413 continue;
414 }
415 }
416 targets.push((si, doc_id));
417 }
418 }
419
420 let root = &self.paths.root;
423 let segments = &self.segments;
424 let content = &self.content;
425 let max_per_file = query.max_per_file;
426 let whole_word = query.whole_word;
427 let exhaustive = query.exhaustive;
428 let mut hits: Vec<SearchHit> = targets
429 .par_iter()
430 .flat_map_iter(|&(si, doc_id)| {
431 verify_doc(
432 &segments[si],
433 doc_id,
434 root,
435 content,
436 &matcher,
437 max_per_file,
438 whole_word,
439 exhaustive,
440 )
441 .into_iter()
442 })
443 .collect();
444
445 if query.exhaustive {
446 hits.sort_by(|a, b| {
449 a.path
450 .cmp(&b.path)
451 .then_with(|| a.line.cmp(&b.line))
452 .then_with(|| a.column.cmp(&b.column))
453 });
454 return Ok(hits);
455 }
456
457 let cmp = |a: &SearchHit, b: &SearchHit| {
458 b.score
459 .partial_cmp(&a.score)
460 .unwrap_or(std::cmp::Ordering::Equal)
461 .then_with(|| a.path.cmp(&b.path))
462 .then_with(|| a.line.cmp(&b.line))
463 };
464 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
465 }
466
467 pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
469 let needle = query.name.to_ascii_lowercase();
470 let mut hits: Vec<SymbolHit> = Vec::new();
471 for seg in &self.segments {
472 for (i, sym) in seg.syms.iter().enumerate() {
473 if !seg.is_live(sym.doc_id) {
474 continue;
475 }
476 if let Some(k) = &query.kind {
477 if &sym.kind != k {
478 continue;
479 }
480 }
481 let score = match_symbol(&sym.name, seg.sym_name_lower(i), &needle, query.exact);
482 let score = match score {
483 Some(s) => s,
484 None => continue,
485 };
486 let doc = match seg.doc(sym.doc_id) {
487 Some(d) => d,
488 None => continue,
489 };
490 let score = score + path_score(&doc.path);
491 hits.push(SymbolHit {
492 path: doc.path.clone(),
493 lang: doc.lang.clone(),
494 name: sym.name.clone(),
495 kind: sym.kind.clone(),
496 line_start: sym.line_start,
497 line_end: sym.line_end,
498 container: sym.container.clone(),
499 signature: sym.signature.clone(),
500 score,
501 });
502 }
503 }
504 let cmp = |a: &SymbolHit, b: &SymbolHit| {
505 b.score
506 .partial_cmp(&a.score)
507 .unwrap_or(std::cmp::Ordering::Equal)
508 .then_with(|| a.name.len().cmp(&b.name.len()))
509 .then_with(|| a.path.cmp(&b.path))
510 };
511 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
512 }
513
514 pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
516 let mut out = Vec::new();
517 for seg in &self.segments {
518 for (doc_id, doc) in seg.docs.iter().enumerate() {
519 let doc_id = doc_id as u32;
520 if doc.path != rel_path || !seg.is_live(doc_id) {
521 continue;
522 }
523 for sym in seg.doc_syms(doc_id) {
524 out.push(SymbolHit {
525 path: doc.path.clone(),
526 lang: doc.lang.clone(),
527 name: sym.name.clone(),
528 kind: sym.kind.clone(),
529 line_start: sym.line_start,
530 line_end: sym.line_end,
531 container: sym.container.clone(),
532 signature: sym.signature.clone(),
533 score: 1.0,
534 });
535 }
536 }
537 }
538 out.sort_by_key(|s| s.line_start);
539 Ok(out)
540 }
541
542 pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
544 self.search(&SearchQuery {
545 pattern: name.to_string(),
546 whole_word: true,
547 limit,
548 offset,
549 ..Default::default()
550 })
551 }
552
553 fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, &crate::segment::SymbolEntry)> {
556 let mut out = Vec::new();
557 for (si, seg) in self.segments.iter().enumerate() {
558 for (idx, sym) in seg.syms.iter().enumerate() {
559 if sym.name == name && seg.is_live(sym.doc_id) {
560 out.push((si, idx, sym));
561 }
562 }
563 }
564 out
565 }
566
567 fn call_indegree(&self, name: &str) -> u32 {
570 let mut n = 0u32;
571 for seg in &self.segments {
572 for r in seg.calls_to(name) {
573 if seg.is_live(r.doc_id) {
574 n += 1;
575 }
576 }
577 }
578 n
579 }
580
581 fn enclosing_symbol<'s>(
583 &self,
584 seg: &'s Segment,
585 doc_id: u32,
586 line: u32,
587 ) -> Option<&'s crate::segment::SymbolEntry> {
588 let mut best: Option<&crate::segment::SymbolEntry> = None;
589 for sym in seg.doc_syms(doc_id) {
590 if sym.line_start <= line && line <= sym.line_end {
591 let span = sym.line_end - sym.line_start;
592 match best {
593 Some(b) if (b.line_end - b.line_start) <= span => {}
594 _ => best = Some(sym),
595 }
596 }
597 }
598 best
599 }
600
601 pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
605 let mut hits: Vec<RefHit> = Vec::new();
606 for seg in &self.segments {
607 for sym in &seg.syms {
608 if sym.name == name && seg.is_live(sym.doc_id) {
609 if let Some(doc) = seg.doc(sym.doc_id) {
610 hits.push(RefHit {
611 path: doc.path.clone(),
612 lang: doc.lang.clone(),
613 name: sym.name.clone(),
614 kind: "definition".to_string(),
615 line: sym.line_start,
616 column: 1,
617 container: sym.container.clone(),
618 });
619 }
620 }
621 }
622 for r in &seg.refs {
623 if r.name == name && seg.is_live(r.doc_id) {
624 if let Some(doc) = seg.doc(r.doc_id) {
625 let container = self
626 .enclosing_symbol(seg, r.doc_id, r.line)
627 .map(|s| s.name.clone());
628 hits.push(RefHit {
629 path: doc.path.clone(),
630 lang: doc.lang.clone(),
631 name: r.name.clone(),
632 kind: r.kind.as_str().to_string(),
633 line: r.line,
634 column: r.column,
635 container,
636 });
637 }
638 }
639 }
640 }
641 let rank = |k: &str| match k {
642 "definition" => 0,
643 "call" => 1,
644 _ => 2,
645 };
646 hits.sort_by(|a, b| {
647 rank(&a.kind)
648 .cmp(&rank(&b.kind))
649 .then_with(|| a.path.cmp(&b.path))
650 .then_with(|| a.line.cmp(&b.line))
651 });
652 paginate(hits, offset, limit)
653 }
654
655 pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
658 let mut out: Vec<CallSite> = Vec::new();
659 let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
660 for (si, _, sym) in self.defs_by_name(name) {
661 let seg = &self.segments[si];
662 let doc = match seg.doc(sym.doc_id) {
663 Some(d) => d,
664 None => continue,
665 };
666 for r in seg.doc_refs(sym.doc_id) {
667 if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
668 let key = (doc.path.clone(), r.name.clone(), r.line, r.column);
669 if !seen.insert(key) {
670 continue;
671 }
672 out.push(CallSite {
673 caller: Some(name.to_string()),
674 callee: r.name.clone(),
675 path: doc.path.clone(),
676 lang: doc.lang.clone(),
677 line: r.line,
678 column: r.column,
679 });
680 }
681 }
682 }
683 out.sort_by(|a, b| {
684 a.callee
685 .cmp(&b.callee)
686 .then_with(|| a.path.cmp(&b.path))
687 .then_with(|| a.line.cmp(&b.line))
688 });
689 paginate(out, offset, limit)
690 }
691
692 pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
695 let mut out: Vec<CallSite> = Vec::new();
696 for seg in &self.segments {
697 for r in seg.calls_to(name) {
700 if !seg.is_live(r.doc_id) {
701 continue;
702 }
703 let doc = match seg.doc(r.doc_id) {
704 Some(d) => d,
705 None => continue,
706 };
707 let caller = self
708 .enclosing_symbol(seg, r.doc_id, r.line)
709 .map(|s| s.name.clone());
710 out.push(CallSite {
711 caller,
712 callee: name.to_string(),
713 path: doc.path.clone(),
714 lang: doc.lang.clone(),
715 line: r.line,
716 column: r.column,
717 });
718 }
719 }
720 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
721 paginate(out, offset, limit)
722 }
723
724 pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
731 let mut out: Vec<ImpactNode> = Vec::new();
732 let mut visited: HashSet<String> = HashSet::new();
733 visited.insert(name.to_string());
734
735 for (si, _, sym) in self.defs_by_name(name) {
737 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
738 out.push(ImpactNode {
739 name: sym.name.clone(),
740 kind: sym.kind.clone(),
741 path: doc.path.clone(),
742 lang: doc.lang.clone(),
743 line_start: sym.line_start,
744 line_end: sym.line_end,
745 distance: 0,
746 });
747 }
748 }
749
750 let mut frontier: Vec<String> = vec![name.to_string()];
751 for dist in 1..=depth {
752 let mut next: Vec<String> = Vec::new();
753 for target in &frontier {
754 for site in self.callers(target, usize::MAX, 0) {
755 let caller = match site.caller {
756 Some(c) => c,
757 None => continue,
758 };
759 if !visited.insert(caller.clone()) {
760 continue;
761 }
762 for (si, _, sym) in self.defs_by_name(&caller) {
763 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
764 out.push(ImpactNode {
765 name: sym.name.clone(),
766 kind: sym.kind.clone(),
767 path: doc.path.clone(),
768 lang: doc.lang.clone(),
769 line_start: sym.line_start,
770 line_end: sym.line_end,
771 distance: dist,
772 });
773 }
774 }
775 next.push(caller);
776 }
777 if out.len() >= limit {
778 break;
779 }
780 }
781 if next.is_empty() {
782 break;
783 }
784 frontier = next;
785 }
786 out.truncate(limit);
787 out
788 }
789
790 pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
796 let full = self.resolve_within_root(rel_path)?;
797 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
798 let ext = Path::new(rel_path)
799 .extension()
800 .and_then(|e| e.to_str())
801 .unwrap_or("");
802 let lang = crate::lang::Language::from_extension(ext);
803
804 let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
805 Some(i) => i,
806 None => {
807 return Err(Error::other(format!(
808 "no identifier at {rel_path}:{line}:{col}"
809 )))
810 }
811 };
812
813 let imported_here = self.imported_names(rel_path);
816
817 let mut cands: Vec<DefHit> = Vec::new();
818 for (si, _, sym) in self.defs_by_name(&ident.name) {
819 let seg = &self.segments[si];
820 let doc = match seg.doc(sym.doc_id) {
821 Some(d) => d,
822 None => continue,
823 };
824 let mut score = 10.0f32 + path_score(&doc.path);
825 let same_file = doc.path == rel_path;
826 if same_file {
827 score += 40.0;
828 }
829 score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
830 let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
832 if ident.is_member && method_like {
833 score += 25.0;
834 } else if !ident.is_member && !method_like {
835 score += 8.0;
836 }
837 if ident.is_call
838 && matches!(
839 sym.kind.as_str(),
840 "function" | "method" | "macro" | "constructor"
841 )
842 {
843 score += 6.0;
844 }
845 if ident.is_type
846 && matches!(
847 sym.kind.as_str(),
848 "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
849 )
850 {
851 score += 12.0;
852 }
853 if imported_here.contains(&ident.name) && !same_file {
856 score += 15.0;
857 }
858 cands.push(DefHit {
859 path: doc.path.clone(),
860 lang: doc.lang.clone(),
861 name: sym.name.clone(),
862 kind: sym.kind.clone(),
863 line_start: sym.line_start,
864 line_end: sym.line_end,
865 container: sym.container.clone(),
866 signature: sym.signature.clone(),
867 score,
868 resolved: false,
869 });
870 }
871
872 if cands.is_empty() {
873 let hits = self.references(&ident.name, 50, 0)?;
875 return Ok(hits
876 .into_iter()
877 .map(|h| DefHit {
878 path: h.path,
879 lang: h.lang,
880 name: ident.name.clone(),
881 kind: "text".to_string(),
882 line_start: h.line,
883 line_end: h.line,
884 container: None,
885 signature: Some(h.text),
886 score: h.score,
887 resolved: false,
888 })
889 .collect());
890 }
891
892 cands.sort_by(|a, b| {
893 b.score
894 .partial_cmp(&a.score)
895 .unwrap_or(std::cmp::Ordering::Equal)
896 .then_with(|| a.path.cmp(&b.path))
897 .then_with(|| a.line_start.cmp(&b.line_start))
898 });
899 let unique_top =
901 cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
902 if unique_top {
903 cands[0].resolved = true;
904 }
905 Ok(cands)
906 }
907
908 pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
911 let full = self.resolve_within_root(rel_path)?;
912 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
913 let ext = Path::new(rel_path)
914 .extension()
915 .and_then(|e| e.to_str())
916 .unwrap_or("");
917 let lang = crate::lang::Language::from_extension(ext);
918 let ident = crate::resolve::identifier_at(lang, &source, line, col)
919 .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
920 Ok(self.references_resolved(&ident.name, usize::MAX, 0))
921 }
922
923 fn imported_names(&self, rel_path: &str) -> HashSet<String> {
925 let mut out = HashSet::new();
926 for seg in &self.segments {
927 for (doc_id, doc) in seg.docs.iter().enumerate() {
928 let doc_id = doc_id as u32;
929 if doc.path != rel_path || !seg.is_live(doc_id) {
930 continue;
931 }
932 for r in seg.doc_refs(doc_id) {
933 if r.kind == RefKind::Import {
934 out.insert(r.name.clone());
935 }
936 }
937 }
938 }
939 out
940 }
941
942 fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
947 let candidate = Path::new(rel_path);
948 if candidate.is_absolute() {
949 return Err(Error::other(format!(
950 "path {rel_path:?} must be relative to the project root"
951 )));
952 }
953 if candidate
955 .components()
956 .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
957 {
958 return Err(Error::other(format!(
959 "path {rel_path:?} escapes the project root"
960 )));
961 }
962 let root = self
965 .paths
966 .root
967 .canonicalize()
968 .map_err(|e| Error::io(&self.paths.root, e))?;
969 let full = root.join(candidate);
970 let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
971 if !resolved.starts_with(&root) {
972 return Err(Error::other(format!(
973 "path {rel_path:?} escapes the project root"
974 )));
975 }
976 Ok(resolved)
977 }
978
979 pub fn read_snippet(
981 &self,
982 rel_path: &str,
983 start_line: u32,
984 end_line: u32,
985 context: u32,
986 ) -> Result<Snippet> {
987 let full = self.resolve_within_root(rel_path)?;
988 let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
989 let lines: Vec<&str> = data.lines().collect();
990 let total = lines.len() as u32;
991 let to = end_line.saturating_add(context).min(total.max(1));
992 let from = start_line
995 .saturating_sub(context)
996 .max(1)
997 .min(total.max(1))
998 .min(to);
999 let mut out = Vec::new();
1000 for ln in from..=to {
1001 if let Some(text) = lines.get((ln - 1) as usize) {
1002 out.push(SnippetLine {
1003 line: ln,
1004 text: (*text).to_string(),
1005 });
1006 }
1007 }
1008 Ok(Snippet {
1009 path: rel_path.to_string(),
1010 start_line: from,
1011 end_line: to,
1012 total_lines: total,
1013 lines: out,
1014 })
1015 }
1016
1017 pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1023 use crate::context::{self, ContextPack, PackItem};
1024
1025 let terms = context::tokenize(task);
1026
1027 struct Cand {
1029 seg: usize,
1030 sym: usize,
1031 score: f32,
1032 reason: String,
1033 }
1034 let mut cands: Vec<Cand> = Vec::new();
1035 for (si, seg) in self.segments.iter().enumerate() {
1036 for (idx, sym) in seg.syms.iter().enumerate() {
1037 if !seg.is_live(sym.doc_id) {
1038 continue;
1039 }
1040 let doc = match seg.doc(sym.doc_id) {
1041 Some(d) => d,
1042 None => continue,
1043 };
1044 let mut score = context::lexical_score(
1045 &sym.name,
1046 &sym.kind,
1047 sym.signature.as_deref(),
1048 sym.container.as_deref(),
1049 &doc.path,
1050 &terms,
1051 );
1052 if score <= 0.0 {
1053 continue;
1054 }
1055 let deg = self.call_indegree(&sym.name) as f32;
1058 score += (1.0 + deg).ln() * 1.5;
1059 score += path_score(&doc.path);
1060 cands.push(Cand {
1061 seg: si,
1062 sym: idx,
1063 score,
1064 reason: "match".to_string(),
1065 });
1066 }
1067 }
1068
1069 cands.sort_by(|a, b| {
1070 b.score
1071 .partial_cmp(&a.score)
1072 .unwrap_or(std::cmp::Ordering::Equal)
1073 });
1074
1075 let mut seen: HashSet<(String, u32)> = HashSet::new();
1078 for c in &cands {
1079 let seg = &self.segments[c.seg];
1080 let sym = &seg.syms[c.sym];
1081 seen.insert((sym.name.clone(), sym.line_start));
1082 }
1083 let mut extra: Vec<Cand> = Vec::new();
1084 for c in cands.iter().take(8) {
1085 let seg = &self.segments[c.seg];
1086 let sym = &seg.syms[c.sym];
1087 for callee in self.callees(&sym.name, 12, 0) {
1088 for (si2, idx, def) in self.defs_by_name(&callee.callee) {
1089 let key = (def.name.clone(), def.line_start);
1090 if !seen.insert(key) {
1091 continue;
1092 }
1093 extra.push(Cand {
1094 seg: si2,
1095 sym: idx,
1096 score: c.score * 0.3,
1097 reason: format!("callee of {}", sym.name),
1098 });
1099 }
1100 }
1101 }
1102 cands.extend(extra);
1103 cands.sort_by(|a, b| {
1104 b.score
1105 .partial_cmp(&a.score)
1106 .unwrap_or(std::cmp::Ordering::Equal)
1107 });
1108
1109 let mut items: Vec<PackItem> = Vec::new();
1113 let mut used: u64 = 0;
1114 let mut truncated = false;
1115 let mut file_lines: std::collections::HashMap<u64, Arc<Vec<String>>> =
1116 std::collections::HashMap::new();
1117 const MAX_ITEM_LINES: u32 = 60;
1118 for c in &cands {
1119 let seg = &self.segments[c.seg];
1120 let sym = &seg.syms[c.sym];
1121 let doc = match seg.doc(sym.doc_id) {
1122 Some(d) => d,
1123 None => continue,
1124 };
1125 let end = sym
1126 .line_end
1127 .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1128 let lines = file_lines
1129 .entry(doc.hash)
1130 .or_insert_with(|| {
1131 let full = self.paths.root.join(&doc.path);
1132 let v = match self.content.get_or_read(doc.hash, &full) {
1133 Some(data) => String::from_utf8_lossy(&data)
1134 .lines()
1135 .map(|s| s.to_string())
1136 .collect(),
1137 None => Vec::new(),
1138 };
1139 Arc::new(v)
1140 })
1141 .clone();
1142 let from = sym.line_start.max(1);
1143 let to = end.min(lines.len() as u32);
1144 let mut snippet = Vec::new();
1145 for ln in from..=to {
1146 if let Some(text) = lines.get((ln - 1) as usize) {
1147 snippet.push(SnippetLine {
1148 line: ln,
1149 text: text.clone(),
1150 });
1151 }
1152 }
1153 let chars: u64 = snippet.iter().map(|l| l.text.len() as u64 + 1).sum::<u64>()
1154 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1155 let cost = context::est_tokens(chars).max(1);
1156 if used + cost > budget_tokens && !items.is_empty() {
1157 truncated = true;
1158 continue;
1159 }
1160 used += cost;
1161 items.push(PackItem {
1162 path: doc.path.clone(),
1163 lang: doc.lang.clone(),
1164 name: sym.name.clone(),
1165 kind: sym.kind.clone(),
1166 line_start: sym.line_start,
1167 line_end: sym.line_end,
1168 signature: sym.signature.clone(),
1169 snippet,
1170 reason: c.reason.clone(),
1171 score: c.score,
1172 });
1173 if used >= budget_tokens {
1174 truncated = truncated || items.len() < cands.len();
1175 break;
1176 }
1177 }
1178
1179 ContextPack {
1180 task: task.to_string(),
1181 budget_tokens,
1182 used_tokens: used,
1183 truncated,
1184 items,
1185 }
1186 }
1187
1188 pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1190 self.resolve_within_root(rel_path)?;
1192 crate::git::blame(&self.paths.root, rel_path, line)
1193 }
1194
1195 pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1198 let defs = self.defs_by_name(name);
1200 let best = defs
1201 .iter()
1202 .max_by(|a, b| {
1203 let pa = self.segments[a.0]
1204 .doc(a.2.doc_id)
1205 .map(|d| path_score(&d.path))
1206 .unwrap_or(0.0);
1207 let pb = self.segments[b.0]
1208 .doc(b.2.doc_id)
1209 .map(|d| path_score(&d.path))
1210 .unwrap_or(0.0);
1211 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1212 })
1213 .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1214 let (si, _, sym) = *best;
1215 let doc = self.segments[si]
1216 .doc(sym.doc_id)
1217 .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1218 let commits = crate::git::line_history(
1219 &self.paths.root,
1220 &doc.path,
1221 sym.line_start,
1222 sym.line_end,
1223 limit,
1224 )
1225 .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1226 Ok(SymbolHistory {
1227 name: name.to_string(),
1228 path: doc.path.clone(),
1229 line_start: sym.line_start,
1230 line_end: sym.line_end,
1231 commits,
1232 })
1233 }
1234
1235 pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1238 let changed = crate::git::changed_since(&self.paths.root, rev)?;
1239 let mut out = Vec::with_capacity(changed.len());
1240 for cf in changed {
1241 let mut symbols = Vec::new();
1242 for seg in &self.segments {
1243 for (doc_id, doc) in seg.docs.iter().enumerate() {
1244 if doc.path == cf.path && seg.is_live(doc_id as u32) {
1245 for s in seg.doc_syms(doc_id as u32) {
1246 symbols.push(s.name.clone());
1247 }
1248 }
1249 }
1250 }
1251 symbols.sort();
1252 symbols.dedup();
1253 out.push(ChangedSymbols {
1254 path: cf.path,
1255 status: cf.status,
1256 symbols,
1257 });
1258 }
1259 Ok(out)
1260 }
1261
1262 pub fn structural_search(
1266 &self,
1267 pattern: &str,
1268 lang: &str,
1269 limit: usize,
1270 offset: usize,
1271 ) -> Result<Vec<StructHit>> {
1272 let language = crate::lang::Language::from_id(lang)
1273 .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1274 if language.grammar().is_none() {
1275 return Err(Error::other(format!(
1276 "language {lang} is not parseable for structural search"
1277 )));
1278 }
1279 let compiled = crate::structural::compile(language, pattern)?;
1280
1281 let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1283 let tq = anchor
1284 .as_ref()
1285 .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1286
1287 let mut targets: Vec<(usize, u32)> = Vec::new();
1288 for (si, seg) in self.segments.iter().enumerate() {
1289 let candidates = match &tq {
1290 Some(q) => seg.candidates(q)?,
1291 None => seg.all_live(),
1292 };
1293 for doc_id in candidates.iter() {
1294 if !seg.is_live(doc_id) {
1295 continue;
1296 }
1297 match seg.doc(doc_id) {
1298 Some(d) if d.lang == lang => targets.push((si, doc_id)),
1299 _ => {}
1300 }
1301 }
1302 }
1303
1304 let root = &self.paths.root;
1305 let segments = &self.segments;
1306 let content = &self.content;
1307 let compiled_ref = &compiled;
1308 let hits: Vec<StructHit> = targets
1309 .par_iter()
1310 .flat_map_iter(|&(si, doc_id)| {
1311 let seg = &segments[si];
1312 let doc = match seg.doc(doc_id) {
1313 Some(d) => d,
1314 None => return Vec::new().into_iter(),
1315 };
1316 let full = root.join(&doc.path);
1317 let data = match content.get_or_read(doc.hash, &full) {
1318 Some(d) => d,
1319 None => return Vec::new().into_iter(),
1320 };
1321 let matches = crate::structural::run(language, compiled_ref, &data);
1322 let line_starts = line_starts(&data);
1323 let out: Vec<StructHit> = matches
1324 .into_iter()
1325 .map(|m| {
1326 let li = (m.line_start.saturating_sub(1)) as usize;
1327 let text = line_starts
1328 .get(li)
1329 .map(|_| snippet(line_slice(&data, &line_starts, li)))
1330 .unwrap_or_default();
1331 StructHit {
1332 path: doc.path.clone(),
1333 lang: doc.lang.clone(),
1334 line_start: m.line_start,
1335 line_end: m.line_end,
1336 kind: m.kind,
1337 text,
1338 captures: m.captures,
1339 }
1340 })
1341 .collect();
1342 out.into_iter()
1343 })
1344 .collect();
1345
1346 let cmp = |a: &StructHit, b: &StructHit| {
1347 a.path
1348 .cmp(&b.path)
1349 .then_with(|| a.line_start.cmp(&b.line_start))
1350 };
1351 let mut hits = hits;
1352 hits.sort_by(cmp);
1353 Ok(paginate(hits, offset, limit))
1354 }
1355
1356 pub fn summary(&self) -> RepoSummary {
1358 use std::collections::HashMap;
1359 let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1360 let mut by_dir: HashMap<String, u64> = HashMap::new();
1361 let mut files = 0u64;
1362 let mut bytes = 0u64;
1363 let mut symbols = 0u64;
1364 for seg in &self.segments {
1365 for (doc_id, doc) in seg.docs.iter().enumerate() {
1366 if !seg.is_live(doc_id as u32) {
1367 continue;
1368 }
1369 files += 1;
1370 bytes += doc.size;
1371 let e = by_lang.entry(doc.lang.clone()).or_default();
1372 e.files += 1;
1373 e.bytes += doc.size;
1374 let dir = doc.path.split('/').next().unwrap_or("").to_string();
1375 *by_dir.entry(dir).or_default() += 1;
1376 }
1377 symbols += seg.syms.iter().filter(|s| seg.is_live(s.doc_id)).count() as u64;
1378 }
1379 let mut languages: Vec<LangStat> = by_lang
1380 .into_iter()
1381 .map(|(lang, mut s)| {
1382 s.lang = lang;
1383 s
1384 })
1385 .collect();
1386 languages.sort_by_key(|s| std::cmp::Reverse(s.files));
1387 let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1388 top_dirs.sort_by_key(|d| std::cmp::Reverse(d.1));
1389 top_dirs.truncate(15);
1390 RepoSummary {
1391 files,
1392 bytes,
1393 symbols,
1394 segments: self.segments.len(),
1395 languages,
1396 top_dirs: top_dirs
1397 .into_iter()
1398 .map(|(name, files)| DirStat { name, files })
1399 .collect(),
1400 }
1401 }
1402}
1403
1404#[derive(Debug, Clone, Serialize, Deserialize)]
1406pub struct Snippet {
1407 pub path: String,
1408 pub start_line: u32,
1409 pub end_line: u32,
1410 pub total_lines: u32,
1411 pub lines: Vec<SnippetLine>,
1412}
1413
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415pub struct SnippetLine {
1416 pub line: u32,
1417 pub text: String,
1418}
1419
1420#[derive(Debug, Clone, Serialize, Deserialize)]
1422pub struct RepoSummary {
1423 pub files: u64,
1424 pub bytes: u64,
1425 pub symbols: u64,
1426 pub segments: usize,
1427 pub languages: Vec<LangStat>,
1428 pub top_dirs: Vec<DirStat>,
1429}
1430
1431#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1432pub struct LangStat {
1433 pub lang: String,
1434 pub files: u64,
1435 pub bytes: u64,
1436}
1437
1438#[derive(Debug, Clone, Serialize, Deserialize)]
1439pub struct DirStat {
1440 pub name: String,
1441 pub files: u64,
1442}
1443
1444#[allow(clippy::too_many_arguments)] fn verify_doc(
1449 seg: &Segment,
1450 doc_id: u32,
1451 root: &Path,
1452 content: &ContentCache,
1453 matcher: &Matcher,
1454 max_per_file: usize,
1455 whole_word: bool,
1456 exhaustive: bool,
1457) -> Vec<SearchHit> {
1458 let doc = match seg.doc(doc_id) {
1459 Some(d) => d,
1460 None => return Vec::new(),
1461 };
1462 let full = root.join(&doc.path);
1463 let data = match content.get_or_read(doc.hash, &full) {
1464 Some(d) => d,
1465 None => return Vec::new(),
1466 };
1467
1468 let cap = if exhaustive {
1470 usize::MAX
1471 } else {
1472 PER_FILE_MATCH_CAP
1473 };
1474 let matches = matcher.match_starts(&data, whole_word, cap);
1475 if matches.is_empty() {
1476 return Vec::new();
1477 }
1478
1479 let line_starts = line_starts(&data);
1480 let sym_lines = symbol_lines(seg, doc_id);
1481 let base = path_score(&doc.path);
1482
1483 let mut out = Vec::new();
1484 let mut last_line = 0u32;
1485 for (start, _end) in matches {
1486 let li = line_of(start, &line_starts);
1487 let line_no = li as u32 + 1;
1488 if line_no == last_line {
1490 continue;
1491 }
1492 last_line = line_no;
1493 let col = (start - line_starts[li]) as u32 + 1;
1494 let line_bytes = line_slice(&data, &line_starts, li);
1495 let mut score = 1.0 + base;
1496 if sym_lines.contains(&line_no) {
1497 score += 3.0;
1498 }
1499 out.push(SearchHit {
1500 path: doc.path.clone(),
1501 lang: doc.lang.clone(),
1502 line: line_no,
1503 column: col,
1504 text: snippet(line_bytes),
1505 score,
1506 });
1507 }
1508
1509 if !exhaustive && out.len() > max_per_file {
1512 out.sort_by(|a, b| {
1513 b.score
1514 .partial_cmp(&a.score)
1515 .unwrap_or(std::cmp::Ordering::Equal)
1516 .then_with(|| a.line.cmp(&b.line))
1517 });
1518 out.truncate(max_per_file);
1519 }
1520 out
1521}
1522
1523fn line_starts(data: &[u8]) -> Vec<usize> {
1525 let mut starts = Vec::with_capacity(64);
1526 starts.push(0usize);
1527 for p in memchr::memchr_iter(b'\n', data) {
1528 starts.push(p + 1);
1529 }
1530 starts
1531}
1532
1533fn line_of(off: usize, starts: &[usize]) -> usize {
1535 starts.partition_point(|&s| s <= off).saturating_sub(1)
1537}
1538
1539fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1541 let begin = starts[li];
1542 let end = if li + 1 < starts.len() {
1543 starts[li + 1].saturating_sub(1)
1544 } else {
1545 data.len()
1546 };
1547 &data[begin..end.min(data.len())]
1548}
1549
1550fn path_score(path: &str) -> f32 {
1553 let mut s = 0.0f32;
1554 let depth = path.matches('/').count() as f32;
1555 s -= depth * 0.05;
1556 let lower = path.to_ascii_lowercase();
1557 if lower.contains("test")
1558 || lower.contains("/tests/")
1559 || lower.contains("__tests__")
1560 || lower.contains(".test.")
1561 || lower.contains(".spec.")
1562 {
1563 s -= 1.0;
1564 }
1565 if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
1566 s -= 1.5;
1567 }
1568 s
1569}
1570
1571fn symbol_lines(seg: &Segment, doc_id: u32) -> HashSet<u32> {
1572 seg.doc_syms(doc_id).map(|s| s.line_start).collect()
1573}
1574
1575fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1576 if exact {
1577 return if lower == needle { Some(100.0) } else { None };
1578 }
1579 if lower == needle {
1580 Some(100.0)
1581 } else if lower.starts_with(needle) {
1582 Some(70.0)
1583 } else if acronym(name) == needle {
1584 Some(60.0)
1586 } else if lower.contains(needle) {
1587 Some(50.0)
1588 } else if is_subsequence(needle, lower) {
1589 Some(30.0)
1590 } else {
1591 None
1592 }
1593}
1594
1595fn split_identifier(s: &str) -> Vec<String> {
1597 let mut tokens = Vec::new();
1598 let mut cur = String::new();
1599 let mut prev_lower = false;
1600 for ch in s.chars() {
1601 if ch == '_' || ch == '-' || ch == ' ' {
1602 if !cur.is_empty() {
1603 tokens.push(std::mem::take(&mut cur));
1604 }
1605 prev_lower = false;
1606 continue;
1607 }
1608 if ch.is_uppercase() && prev_lower && !cur.is_empty() {
1609 tokens.push(std::mem::take(&mut cur));
1610 }
1611 cur.extend(ch.to_lowercase());
1612 prev_lower = ch.is_lowercase() || ch.is_numeric();
1613 }
1614 if !cur.is_empty() {
1615 tokens.push(cur);
1616 }
1617 tokens
1618}
1619
1620fn acronym(s: &str) -> String {
1622 split_identifier(s)
1623 .iter()
1624 .filter_map(|t| t.chars().next())
1625 .collect()
1626}
1627
1628fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
1631where
1632 F: Fn(&T, &T) -> std::cmp::Ordering,
1633{
1634 let need = offset.saturating_add(limit);
1635 if need == 0 {
1636 return Vec::new();
1637 }
1638 if need < items.len() {
1639 items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
1640 items.truncate(need);
1641 }
1642 items.sort_by(|a, b| cmp(a, b));
1643 if offset >= items.len() {
1644 return Vec::new();
1645 }
1646 items.drain(0..offset);
1647 items.truncate(limit);
1648 items
1649}
1650
1651pub fn grep_walk(paths: &Paths, config: &Config, query: &SearchQuery) -> Result<Vec<SearchHit>> {
1657 if query.pattern.is_empty() {
1658 return Ok(Vec::new());
1659 }
1660 let matcher = Matcher::build(query)?;
1661 let walked = crate::walk::walk(paths, config)?;
1662 let path_filter = query.path.as_deref();
1663 let lang_filter = query.lang.as_deref();
1664 let max_per_file = query.max_per_file;
1665 let whole_word = query.whole_word;
1666 let exhaustive = query.exhaustive;
1667 let index_binary = config.index_binary;
1668 let cap = if exhaustive {
1669 usize::MAX
1670 } else {
1671 PER_FILE_MATCH_CAP
1672 };
1673
1674 let mut hits: Vec<SearchHit> = walked
1675 .entries
1676 .par_iter()
1677 .flat_map_iter(|e| {
1678 if path_filter.is_some_and(|pf| !e.rel.contains(pf)) {
1679 return Vec::new().into_iter();
1680 }
1681 let ext = e
1682 .path
1683 .extension()
1684 .and_then(|x| x.to_str())
1685 .unwrap_or("")
1686 .to_ascii_lowercase();
1687 let lang_id = Language::from_extension(&ext).id().to_string();
1688 if lang_filter.is_some_and(|lf| lang_id != lf) {
1689 return Vec::new().into_iter();
1690 }
1691 let data = match std::fs::read(&e.path) {
1692 Ok(d) => d,
1693 Err(_) => return Vec::new().into_iter(),
1694 };
1695 if !index_binary && memchr::memchr(0, &data).is_some() {
1696 return Vec::new().into_iter();
1697 }
1698 let matches = matcher.match_starts(&data, whole_word, cap);
1699 if matches.is_empty() {
1700 return Vec::new().into_iter();
1701 }
1702 let starts = line_starts(&data);
1703 let base = path_score(&e.rel);
1704 let mut out = Vec::new();
1705 let mut last_line = 0u32;
1706 for (start, _end) in matches {
1707 let li = line_of(start, &starts);
1708 let line_no = li as u32 + 1;
1709 if line_no == last_line {
1710 continue;
1711 }
1712 last_line = line_no;
1713 let col = (start - starts[li]) as u32 + 1;
1714 out.push(SearchHit {
1715 path: e.rel.clone(),
1716 lang: lang_id.clone(),
1717 line: line_no,
1718 column: col,
1719 text: snippet(line_slice(&data, &starts, li)),
1720 score: 1.0 + base,
1721 });
1722 }
1723 if !exhaustive && out.len() > max_per_file {
1724 out.sort_by(|a, b| {
1725 b.score
1726 .partial_cmp(&a.score)
1727 .unwrap_or(std::cmp::Ordering::Equal)
1728 .then_with(|| a.line.cmp(&b.line))
1729 });
1730 out.truncate(max_per_file);
1731 }
1732 out.into_iter()
1733 })
1734 .collect();
1735
1736 if exhaustive {
1737 hits.sort_by(|a, b| {
1738 a.path
1739 .cmp(&b.path)
1740 .then_with(|| a.line.cmp(&b.line))
1741 .then_with(|| a.column.cmp(&b.column))
1742 });
1743 return Ok(hits);
1744 }
1745 let cmp = |a: &SearchHit, b: &SearchHit| {
1746 b.score
1747 .partial_cmp(&a.score)
1748 .unwrap_or(std::cmp::Ordering::Equal)
1749 .then_with(|| a.path.cmp(&b.path))
1750 .then_with(|| a.line.cmp(&b.line))
1751 };
1752 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
1753}
1754
1755fn shared_prefix_len(a: &str, b: &str) -> usize {
1757 a.split('/')
1758 .zip(b.split('/'))
1759 .take_while(|(x, y)| x == y)
1760 .count()
1761}
1762
1763fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
1765 if offset >= items.len() {
1766 return Vec::new();
1767 }
1768 items.drain(0..offset);
1769 items.truncate(limit);
1770 items
1771}
1772
1773fn is_subsequence(needle: &str, haystack: &str) -> bool {
1774 if needle.is_empty() {
1775 return true;
1776 }
1777 let mut chars = needle.chars();
1778 let mut cur = chars.next();
1779 for h in haystack.chars() {
1780 if let Some(c) = cur {
1781 if c == h {
1782 cur = chars.next();
1783 }
1784 } else {
1785 break;
1786 }
1787 }
1788 cur.is_none()
1789}
1790
1791fn snippet(line: &[u8]) -> String {
1793 let s = String::from_utf8_lossy(line);
1794 let trimmed = s.trim_end();
1795 const MAX: usize = 320;
1796 if trimmed.len() > MAX {
1797 let mut end = MAX;
1798 while !trimmed.is_char_boundary(end) {
1799 end -= 1;
1800 }
1801 format!("{}…", &trimmed[..end])
1802 } else {
1803 trimmed.to_string()
1804 }
1805}