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