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::error::{Error, Result};
14use crate::meta::Meta;
15use crate::paths::Paths;
16use crate::segment::{RefKind, Segment};
17use crate::trigram::{self, TrigramQuery};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct SearchQuery {
23 pub pattern: String,
24 pub regex: bool,
25 pub case_insensitive: bool,
26 pub whole_word: bool,
28 pub lang: Option<String>,
29 pub path: Option<String>,
30 pub limit: usize,
31 pub offset: usize,
33 pub max_per_file: usize,
34}
35
36impl Default for SearchQuery {
37 fn default() -> Self {
38 Self {
39 pattern: String::new(),
40 regex: false,
41 case_insensitive: false,
42 whole_word: false,
43 lang: None,
44 path: None,
45 limit: 50,
46 offset: 0,
47 max_per_file: 20,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SearchHit {
55 pub path: String,
56 pub lang: String,
57 pub line: u32,
58 pub column: u32,
59 pub text: String,
60 pub score: f32,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(default)]
66pub struct SymbolQuery {
67 pub name: String,
68 pub kind: Option<String>,
69 pub exact: bool,
70 pub limit: usize,
71 pub offset: usize,
72}
73
74impl Default for SymbolQuery {
75 fn default() -> Self {
76 Self {
77 name: String::new(),
78 kind: None,
79 exact: false,
80 limit: 50,
81 offset: 0,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SymbolHit {
89 pub path: String,
90 pub lang: String,
91 pub name: String,
92 pub kind: String,
93 pub line_start: u32,
94 pub line_end: u32,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub container: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub signature: Option<String>,
99 pub score: f32,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RefHit {
106 pub path: String,
107 pub lang: String,
108 pub name: String,
109 pub kind: String,
111 pub line: u32,
112 pub column: u32,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub container: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct CallSite {
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub caller: Option<String>,
124 pub callee: String,
126 pub path: String,
127 pub lang: String,
128 pub line: u32,
129 pub column: u32,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ImpactNode {
136 pub name: String,
137 pub kind: String,
138 pub path: String,
139 pub lang: String,
140 pub line_start: u32,
141 pub line_end: u32,
142 pub distance: u32,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct DefHit {
150 pub path: String,
151 pub lang: String,
152 pub name: String,
153 pub kind: String,
154 pub line_start: u32,
155 pub line_end: u32,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub container: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub signature: Option<String>,
160 pub score: f32,
161 pub resolved: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct SymbolHistory {
168 pub name: String,
169 pub path: String,
170 pub line_start: u32,
171 pub line_end: u32,
172 pub commits: Vec<crate::git::Commit>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct ChangedSymbols {
178 pub path: String,
179 pub status: String,
180 pub symbols: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct StructHit {
186 pub path: String,
187 pub lang: String,
188 pub line_start: u32,
189 pub line_end: u32,
190 pub kind: String,
192 pub text: String,
194 pub captures: Vec<crate::structural::StructCapture>,
195}
196
197enum Matcher {
198 Literal(Vec<u8>),
199 Regex(BytesRegex),
200}
201
202impl Matcher {
203 fn build(query: &SearchQuery) -> Result<Matcher> {
204 if query.regex {
205 let re = regex::bytes::RegexBuilder::new(&query.pattern)
206 .case_insensitive(query.case_insensitive)
207 .build()?;
208 Ok(Matcher::Regex(re))
209 } else if query.case_insensitive {
210 let re = regex::bytes::RegexBuilder::new(®ex::escape(&query.pattern))
211 .case_insensitive(true)
212 .build()?;
213 Ok(Matcher::Regex(re))
214 } else {
215 Ok(Matcher::Literal(query.pattern.as_bytes().to_vec()))
216 }
217 }
218
219 fn match_starts(&self, hay: &[u8], whole_word: bool, cap: usize) -> Vec<(usize, usize)> {
224 let mut out = Vec::new();
225 match self {
226 Matcher::Literal(needle) => {
227 if needle.is_empty() {
228 return out;
229 }
230 for pos in memmem::find_iter(hay, needle) {
231 let end = pos + needle.len();
232 if !whole_word || boundary_ok(hay, pos, end) {
233 out.push((pos, end));
234 if out.len() >= cap {
235 break;
236 }
237 }
238 }
239 }
240 Matcher::Regex(re) => {
241 for m in re.find_iter(hay) {
242 if m.start() == m.end() {
245 continue;
246 }
247 if !whole_word || boundary_ok(hay, m.start(), m.end()) {
248 out.push((m.start(), m.end()));
249 if out.len() >= cap {
250 break;
251 }
252 }
253 }
254 }
255 }
256 out
257 }
258}
259
260fn is_ident_byte(b: u8) -> bool {
263 b == b'_' || b.is_ascii_alphanumeric() || b >= 0x80
264}
265
266fn boundary_ok(line: &[u8], start: usize, end: usize) -> bool {
268 let left = start == 0 || !is_ident_byte(line[start - 1]);
269 let right = end >= line.len() || !is_ident_byte(line[end]);
270 left && right
271}
272
273const CONTENT_CACHE_BYTES: u64 = 256 * 1024 * 1024;
279
280const PER_FILE_MATCH_CAP: usize = 4096;
283
284struct CacheInner {
285 map: LruCache<u64, Arc<[u8]>>,
286 bytes: u64,
287}
288
289struct ContentCache {
293 inner: Mutex<CacheInner>,
294 budget: u64,
295}
296
297impl ContentCache {
298 fn new(budget_bytes: u64) -> Self {
299 Self {
300 inner: Mutex::new(CacheInner {
301 map: LruCache::unbounded(),
302 bytes: 0,
303 }),
304 budget: budget_bytes.max(1),
305 }
306 }
307
308 fn get_or_read(&self, hash: u64, path: &Path) -> Option<Arc<[u8]>> {
311 if let Ok(mut guard) = self.inner.lock() {
312 if let Some(v) = guard.map.get(&hash) {
313 return Some(v.clone());
314 }
315 }
316 let data = std::fs::read(path).ok()?;
317 let arc: Arc<[u8]> = Arc::from(data.into_boxed_slice());
318 let len = arc.len() as u64;
319 if let Ok(mut guard) = self.inner.lock() {
320 if len <= self.budget {
323 if let Some(prev) = guard.map.put(hash, arc.clone()) {
324 guard.bytes = guard.bytes.saturating_sub(prev.len() as u64);
325 }
326 guard.bytes += len;
327 while guard.bytes > self.budget {
328 match guard.map.pop_lru() {
329 Some((_, evicted)) => {
330 guard.bytes = guard.bytes.saturating_sub(evicted.len() as u64);
331 }
332 None => break,
333 }
334 }
335 }
336 }
337 Some(arc)
338 }
339}
340
341pub struct Searcher {
343 paths: Paths,
344 segments: Vec<Segment>,
345 content: ContentCache,
346}
347
348impl Searcher {
349 pub fn open(paths: &Paths) -> Result<Searcher> {
351 if !paths.exists() {
352 return Err(Error::IndexMissing(paths.base.clone()));
353 }
354 let meta = Meta::load(&paths.meta_file())?;
355 let mut segments = Vec::with_capacity(meta.segments.len());
356 for &seg_id in &meta.segments {
357 segments.push(Segment::open(paths, seg_id)?);
358 }
359 Ok(Searcher {
360 paths: paths.clone(),
361 segments,
362 content: ContentCache::new(CONTENT_CACHE_BYTES),
363 })
364 }
365
366 pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
368 if query.pattern.is_empty() {
369 return Ok(Vec::new());
370 }
371 let matcher = Matcher::build(query)?;
372 let tq: TrigramQuery = if query.regex {
373 trigram::regex_trigrams(&query.pattern, query.case_insensitive)
374 } else if query.case_insensitive {
375 TrigramQuery::from_literal_ci(query.pattern.as_bytes())
378 } else {
379 TrigramQuery::from_literal(query.pattern.as_bytes())
380 };
381
382 let path_filter = query.path.as_deref();
383 let lang_filter = query.lang.as_deref();
384
385 let mut targets: Vec<(usize, u32)> = Vec::new();
387 for (si, seg) in self.segments.iter().enumerate() {
388 let candidates = seg.candidates(&tq)?;
389 for doc_id in candidates.iter() {
390 if !seg.is_live(doc_id) {
391 continue;
392 }
393 let doc = match seg.doc(doc_id) {
394 Some(d) => d,
395 None => continue,
396 };
397 if let Some(lf) = lang_filter {
398 if doc.lang != lf {
399 continue;
400 }
401 }
402 if let Some(pf) = path_filter {
403 if !doc.path.contains(pf) {
404 continue;
405 }
406 }
407 targets.push((si, doc_id));
408 }
409 }
410
411 let root = &self.paths.root;
414 let segments = &self.segments;
415 let content = &self.content;
416 let max_per_file = query.max_per_file;
417 let whole_word = query.whole_word;
418 let hits: Vec<SearchHit> = targets
419 .par_iter()
420 .flat_map_iter(|&(si, doc_id)| {
421 verify_doc(
422 &segments[si],
423 doc_id,
424 root,
425 content,
426 &matcher,
427 max_per_file,
428 whole_word,
429 )
430 .into_iter()
431 })
432 .collect();
433
434 let cmp = |a: &SearchHit, b: &SearchHit| {
435 b.score
436 .partial_cmp(&a.score)
437 .unwrap_or(std::cmp::Ordering::Equal)
438 .then_with(|| a.path.cmp(&b.path))
439 .then_with(|| a.line.cmp(&b.line))
440 };
441 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
442 }
443
444 pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
446 let needle = query.name.to_ascii_lowercase();
447 let mut hits: Vec<SymbolHit> = Vec::new();
448 for seg in &self.segments {
449 for (i, sym) in seg.syms.iter().enumerate() {
450 if !seg.is_live(sym.doc_id) {
451 continue;
452 }
453 if let Some(k) = &query.kind {
454 if &sym.kind != k {
455 continue;
456 }
457 }
458 let score = match_symbol(&sym.name, seg.sym_name_lower(i), &needle, query.exact);
459 let score = match score {
460 Some(s) => s,
461 None => continue,
462 };
463 let doc = match seg.doc(sym.doc_id) {
464 Some(d) => d,
465 None => continue,
466 };
467 let score = score + path_score(&doc.path);
468 hits.push(SymbolHit {
469 path: doc.path.clone(),
470 lang: doc.lang.clone(),
471 name: sym.name.clone(),
472 kind: sym.kind.clone(),
473 line_start: sym.line_start,
474 line_end: sym.line_end,
475 container: sym.container.clone(),
476 signature: sym.signature.clone(),
477 score,
478 });
479 }
480 }
481 let cmp = |a: &SymbolHit, b: &SymbolHit| {
482 b.score
483 .partial_cmp(&a.score)
484 .unwrap_or(std::cmp::Ordering::Equal)
485 .then_with(|| a.name.len().cmp(&b.name.len()))
486 .then_with(|| a.path.cmp(&b.path))
487 };
488 Ok(rank_paginate(hits, cmp, query.offset, query.limit))
489 }
490
491 pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
493 let mut out = Vec::new();
494 for seg in &self.segments {
495 for (doc_id, doc) in seg.docs.iter().enumerate() {
496 let doc_id = doc_id as u32;
497 if doc.path != rel_path || !seg.is_live(doc_id) {
498 continue;
499 }
500 for sym in seg.doc_syms(doc_id) {
501 out.push(SymbolHit {
502 path: doc.path.clone(),
503 lang: doc.lang.clone(),
504 name: sym.name.clone(),
505 kind: sym.kind.clone(),
506 line_start: sym.line_start,
507 line_end: sym.line_end,
508 container: sym.container.clone(),
509 signature: sym.signature.clone(),
510 score: 1.0,
511 });
512 }
513 }
514 }
515 out.sort_by_key(|s| s.line_start);
516 Ok(out)
517 }
518
519 pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
521 self.search(&SearchQuery {
522 pattern: name.to_string(),
523 whole_word: true,
524 limit,
525 offset,
526 ..Default::default()
527 })
528 }
529
530 fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, &crate::segment::SymbolEntry)> {
533 let mut out = Vec::new();
534 for (si, seg) in self.segments.iter().enumerate() {
535 for (idx, sym) in seg.syms.iter().enumerate() {
536 if sym.name == name && seg.is_live(sym.doc_id) {
537 out.push((si, idx, sym));
538 }
539 }
540 }
541 out
542 }
543
544 fn call_indegree(&self, name: &str) -> u32 {
547 let mut n = 0u32;
548 for seg in &self.segments {
549 for r in seg.calls_to(name) {
550 if seg.is_live(r.doc_id) {
551 n += 1;
552 }
553 }
554 }
555 n
556 }
557
558 fn enclosing_symbol<'s>(
560 &self,
561 seg: &'s Segment,
562 doc_id: u32,
563 line: u32,
564 ) -> Option<&'s crate::segment::SymbolEntry> {
565 let mut best: Option<&crate::segment::SymbolEntry> = None;
566 for sym in seg.doc_syms(doc_id) {
567 if sym.line_start <= line && line <= sym.line_end {
568 let span = sym.line_end - sym.line_start;
569 match best {
570 Some(b) if (b.line_end - b.line_start) <= span => {}
571 _ => best = Some(sym),
572 }
573 }
574 }
575 best
576 }
577
578 pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
582 let mut hits: Vec<RefHit> = Vec::new();
583 for seg in &self.segments {
584 for sym in &seg.syms {
585 if sym.name == name && seg.is_live(sym.doc_id) {
586 if let Some(doc) = seg.doc(sym.doc_id) {
587 hits.push(RefHit {
588 path: doc.path.clone(),
589 lang: doc.lang.clone(),
590 name: sym.name.clone(),
591 kind: "definition".to_string(),
592 line: sym.line_start,
593 column: 1,
594 container: sym.container.clone(),
595 });
596 }
597 }
598 }
599 for r in &seg.refs {
600 if r.name == name && seg.is_live(r.doc_id) {
601 if let Some(doc) = seg.doc(r.doc_id) {
602 let container = self
603 .enclosing_symbol(seg, r.doc_id, r.line)
604 .map(|s| s.name.clone());
605 hits.push(RefHit {
606 path: doc.path.clone(),
607 lang: doc.lang.clone(),
608 name: r.name.clone(),
609 kind: r.kind.as_str().to_string(),
610 line: r.line,
611 column: r.column,
612 container,
613 });
614 }
615 }
616 }
617 }
618 let rank = |k: &str| match k {
619 "definition" => 0,
620 "call" => 1,
621 _ => 2,
622 };
623 hits.sort_by(|a, b| {
624 rank(&a.kind)
625 .cmp(&rank(&b.kind))
626 .then_with(|| a.path.cmp(&b.path))
627 .then_with(|| a.line.cmp(&b.line))
628 });
629 paginate(hits, offset, limit)
630 }
631
632 pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
635 let mut out: Vec<CallSite> = Vec::new();
636 let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
637 for (si, _, sym) in self.defs_by_name(name) {
638 let seg = &self.segments[si];
639 let doc = match seg.doc(sym.doc_id) {
640 Some(d) => d,
641 None => continue,
642 };
643 for r in seg.doc_refs(sym.doc_id) {
644 if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
645 let key = (doc.path.clone(), r.name.clone(), r.line, r.column);
646 if !seen.insert(key) {
647 continue;
648 }
649 out.push(CallSite {
650 caller: Some(name.to_string()),
651 callee: r.name.clone(),
652 path: doc.path.clone(),
653 lang: doc.lang.clone(),
654 line: r.line,
655 column: r.column,
656 });
657 }
658 }
659 }
660 out.sort_by(|a, b| {
661 a.callee
662 .cmp(&b.callee)
663 .then_with(|| a.path.cmp(&b.path))
664 .then_with(|| a.line.cmp(&b.line))
665 });
666 paginate(out, offset, limit)
667 }
668
669 pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
672 let mut out: Vec<CallSite> = Vec::new();
673 for seg in &self.segments {
674 for r in seg.calls_to(name) {
677 if !seg.is_live(r.doc_id) {
678 continue;
679 }
680 let doc = match seg.doc(r.doc_id) {
681 Some(d) => d,
682 None => continue,
683 };
684 let caller = self
685 .enclosing_symbol(seg, r.doc_id, r.line)
686 .map(|s| s.name.clone());
687 out.push(CallSite {
688 caller,
689 callee: name.to_string(),
690 path: doc.path.clone(),
691 lang: doc.lang.clone(),
692 line: r.line,
693 column: r.column,
694 });
695 }
696 }
697 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
698 paginate(out, offset, limit)
699 }
700
701 pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
708 let mut out: Vec<ImpactNode> = Vec::new();
709 let mut visited: HashSet<String> = HashSet::new();
710 visited.insert(name.to_string());
711
712 for (si, _, sym) in self.defs_by_name(name) {
714 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
715 out.push(ImpactNode {
716 name: sym.name.clone(),
717 kind: sym.kind.clone(),
718 path: doc.path.clone(),
719 lang: doc.lang.clone(),
720 line_start: sym.line_start,
721 line_end: sym.line_end,
722 distance: 0,
723 });
724 }
725 }
726
727 let mut frontier: Vec<String> = vec![name.to_string()];
728 for dist in 1..=depth {
729 let mut next: Vec<String> = Vec::new();
730 for target in &frontier {
731 for site in self.callers(target, usize::MAX, 0) {
732 let caller = match site.caller {
733 Some(c) => c,
734 None => continue,
735 };
736 if !visited.insert(caller.clone()) {
737 continue;
738 }
739 for (si, _, sym) in self.defs_by_name(&caller) {
740 if let Some(doc) = self.segments[si].doc(sym.doc_id) {
741 out.push(ImpactNode {
742 name: sym.name.clone(),
743 kind: sym.kind.clone(),
744 path: doc.path.clone(),
745 lang: doc.lang.clone(),
746 line_start: sym.line_start,
747 line_end: sym.line_end,
748 distance: dist,
749 });
750 }
751 }
752 next.push(caller);
753 }
754 if out.len() >= limit {
755 break;
756 }
757 }
758 if next.is_empty() {
759 break;
760 }
761 frontier = next;
762 }
763 out.truncate(limit);
764 out
765 }
766
767 pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
773 let full = self.resolve_within_root(rel_path)?;
774 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
775 let ext = Path::new(rel_path)
776 .extension()
777 .and_then(|e| e.to_str())
778 .unwrap_or("");
779 let lang = crate::lang::Language::from_extension(ext);
780
781 let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
782 Some(i) => i,
783 None => {
784 return Err(Error::other(format!(
785 "no identifier at {rel_path}:{line}:{col}"
786 )))
787 }
788 };
789
790 let imported_here = self.imported_names(rel_path);
793
794 let mut cands: Vec<DefHit> = Vec::new();
795 for (si, _, sym) in self.defs_by_name(&ident.name) {
796 let seg = &self.segments[si];
797 let doc = match seg.doc(sym.doc_id) {
798 Some(d) => d,
799 None => continue,
800 };
801 let mut score = 10.0f32 + path_score(&doc.path);
802 let same_file = doc.path == rel_path;
803 if same_file {
804 score += 40.0;
805 }
806 score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
807 let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
809 if ident.is_member && method_like {
810 score += 25.0;
811 } else if !ident.is_member && !method_like {
812 score += 8.0;
813 }
814 if ident.is_call
815 && matches!(
816 sym.kind.as_str(),
817 "function" | "method" | "macro" | "constructor"
818 )
819 {
820 score += 6.0;
821 }
822 if ident.is_type
823 && matches!(
824 sym.kind.as_str(),
825 "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
826 )
827 {
828 score += 12.0;
829 }
830 if imported_here.contains(&ident.name) && !same_file {
833 score += 15.0;
834 }
835 cands.push(DefHit {
836 path: doc.path.clone(),
837 lang: doc.lang.clone(),
838 name: sym.name.clone(),
839 kind: sym.kind.clone(),
840 line_start: sym.line_start,
841 line_end: sym.line_end,
842 container: sym.container.clone(),
843 signature: sym.signature.clone(),
844 score,
845 resolved: false,
846 });
847 }
848
849 if cands.is_empty() {
850 let hits = self.references(&ident.name, 50, 0)?;
852 return Ok(hits
853 .into_iter()
854 .map(|h| DefHit {
855 path: h.path,
856 lang: h.lang,
857 name: ident.name.clone(),
858 kind: "text".to_string(),
859 line_start: h.line,
860 line_end: h.line,
861 container: None,
862 signature: Some(h.text),
863 score: h.score,
864 resolved: false,
865 })
866 .collect());
867 }
868
869 cands.sort_by(|a, b| {
870 b.score
871 .partial_cmp(&a.score)
872 .unwrap_or(std::cmp::Ordering::Equal)
873 .then_with(|| a.path.cmp(&b.path))
874 .then_with(|| a.line_start.cmp(&b.line_start))
875 });
876 let unique_top =
878 cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
879 if unique_top {
880 cands[0].resolved = true;
881 }
882 Ok(cands)
883 }
884
885 pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
888 let full = self.resolve_within_root(rel_path)?;
889 let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
890 let ext = Path::new(rel_path)
891 .extension()
892 .and_then(|e| e.to_str())
893 .unwrap_or("");
894 let lang = crate::lang::Language::from_extension(ext);
895 let ident = crate::resolve::identifier_at(lang, &source, line, col)
896 .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
897 Ok(self.references_resolved(&ident.name, usize::MAX, 0))
898 }
899
900 fn imported_names(&self, rel_path: &str) -> HashSet<String> {
902 let mut out = HashSet::new();
903 for seg in &self.segments {
904 for (doc_id, doc) in seg.docs.iter().enumerate() {
905 let doc_id = doc_id as u32;
906 if doc.path != rel_path || !seg.is_live(doc_id) {
907 continue;
908 }
909 for r in seg.doc_refs(doc_id) {
910 if r.kind == RefKind::Import {
911 out.insert(r.name.clone());
912 }
913 }
914 }
915 }
916 out
917 }
918
919 fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
924 let candidate = Path::new(rel_path);
925 if candidate.is_absolute() {
926 return Err(Error::other(format!(
927 "path {rel_path:?} must be relative to the project root"
928 )));
929 }
930 if candidate
932 .components()
933 .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
934 {
935 return Err(Error::other(format!(
936 "path {rel_path:?} escapes the project root"
937 )));
938 }
939 let root = self
942 .paths
943 .root
944 .canonicalize()
945 .map_err(|e| Error::io(&self.paths.root, e))?;
946 let full = root.join(candidate);
947 let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
948 if !resolved.starts_with(&root) {
949 return Err(Error::other(format!(
950 "path {rel_path:?} escapes the project root"
951 )));
952 }
953 Ok(resolved)
954 }
955
956 pub fn read_snippet(
958 &self,
959 rel_path: &str,
960 start_line: u32,
961 end_line: u32,
962 context: u32,
963 ) -> Result<Snippet> {
964 let full = self.resolve_within_root(rel_path)?;
965 let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
966 let lines: Vec<&str> = data.lines().collect();
967 let total = lines.len() as u32;
968 let to = end_line.saturating_add(context).min(total.max(1));
969 let from = start_line
972 .saturating_sub(context)
973 .max(1)
974 .min(total.max(1))
975 .min(to);
976 let mut out = Vec::new();
977 for ln in from..=to {
978 if let Some(text) = lines.get((ln - 1) as usize) {
979 out.push(SnippetLine {
980 line: ln,
981 text: (*text).to_string(),
982 });
983 }
984 }
985 Ok(Snippet {
986 path: rel_path.to_string(),
987 start_line: from,
988 end_line: to,
989 total_lines: total,
990 lines: out,
991 })
992 }
993
994 pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1000 use crate::context::{self, ContextPack, PackItem};
1001
1002 let terms = context::tokenize(task);
1003
1004 struct Cand {
1006 seg: usize,
1007 sym: usize,
1008 score: f32,
1009 reason: String,
1010 }
1011 let mut cands: Vec<Cand> = Vec::new();
1012 for (si, seg) in self.segments.iter().enumerate() {
1013 for (idx, sym) in seg.syms.iter().enumerate() {
1014 if !seg.is_live(sym.doc_id) {
1015 continue;
1016 }
1017 let doc = match seg.doc(sym.doc_id) {
1018 Some(d) => d,
1019 None => continue,
1020 };
1021 let mut score = context::lexical_score(
1022 &sym.name,
1023 &sym.kind,
1024 sym.signature.as_deref(),
1025 sym.container.as_deref(),
1026 &doc.path,
1027 &terms,
1028 );
1029 if score <= 0.0 {
1030 continue;
1031 }
1032 let deg = self.call_indegree(&sym.name) as f32;
1035 score += (1.0 + deg).ln() * 1.5;
1036 score += path_score(&doc.path);
1037 cands.push(Cand {
1038 seg: si,
1039 sym: idx,
1040 score,
1041 reason: "match".to_string(),
1042 });
1043 }
1044 }
1045
1046 cands.sort_by(|a, b| {
1047 b.score
1048 .partial_cmp(&a.score)
1049 .unwrap_or(std::cmp::Ordering::Equal)
1050 });
1051
1052 let mut seen: HashSet<(String, u32)> = HashSet::new();
1055 for c in &cands {
1056 let seg = &self.segments[c.seg];
1057 let sym = &seg.syms[c.sym];
1058 seen.insert((sym.name.clone(), sym.line_start));
1059 }
1060 let mut extra: Vec<Cand> = Vec::new();
1061 for c in cands.iter().take(8) {
1062 let seg = &self.segments[c.seg];
1063 let sym = &seg.syms[c.sym];
1064 for callee in self.callees(&sym.name, 12, 0) {
1065 for (si2, idx, def) in self.defs_by_name(&callee.callee) {
1066 let key = (def.name.clone(), def.line_start);
1067 if !seen.insert(key) {
1068 continue;
1069 }
1070 extra.push(Cand {
1071 seg: si2,
1072 sym: idx,
1073 score: c.score * 0.3,
1074 reason: format!("callee of {}", sym.name),
1075 });
1076 }
1077 }
1078 }
1079 cands.extend(extra);
1080 cands.sort_by(|a, b| {
1081 b.score
1082 .partial_cmp(&a.score)
1083 .unwrap_or(std::cmp::Ordering::Equal)
1084 });
1085
1086 let mut items: Vec<PackItem> = Vec::new();
1090 let mut used: u64 = 0;
1091 let mut truncated = false;
1092 let mut file_lines: std::collections::HashMap<u64, Arc<Vec<String>>> =
1093 std::collections::HashMap::new();
1094 const MAX_ITEM_LINES: u32 = 60;
1095 for c in &cands {
1096 let seg = &self.segments[c.seg];
1097 let sym = &seg.syms[c.sym];
1098 let doc = match seg.doc(sym.doc_id) {
1099 Some(d) => d,
1100 None => continue,
1101 };
1102 let end = sym
1103 .line_end
1104 .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1105 let lines = file_lines
1106 .entry(doc.hash)
1107 .or_insert_with(|| {
1108 let full = self.paths.root.join(&doc.path);
1109 let v = match self.content.get_or_read(doc.hash, &full) {
1110 Some(data) => String::from_utf8_lossy(&data)
1111 .lines()
1112 .map(|s| s.to_string())
1113 .collect(),
1114 None => Vec::new(),
1115 };
1116 Arc::new(v)
1117 })
1118 .clone();
1119 let from = sym.line_start.max(1);
1120 let to = end.min(lines.len() as u32);
1121 let mut snippet = Vec::new();
1122 for ln in from..=to {
1123 if let Some(text) = lines.get((ln - 1) as usize) {
1124 snippet.push(SnippetLine {
1125 line: ln,
1126 text: text.clone(),
1127 });
1128 }
1129 }
1130 let chars: u64 = snippet.iter().map(|l| l.text.len() as u64 + 1).sum::<u64>()
1131 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1132 let cost = context::est_tokens(chars).max(1);
1133 if used + cost > budget_tokens && !items.is_empty() {
1134 truncated = true;
1135 continue;
1136 }
1137 used += cost;
1138 items.push(PackItem {
1139 path: doc.path.clone(),
1140 lang: doc.lang.clone(),
1141 name: sym.name.clone(),
1142 kind: sym.kind.clone(),
1143 line_start: sym.line_start,
1144 line_end: sym.line_end,
1145 signature: sym.signature.clone(),
1146 snippet,
1147 reason: c.reason.clone(),
1148 score: c.score,
1149 });
1150 if used >= budget_tokens {
1151 truncated = truncated || items.len() < cands.len();
1152 break;
1153 }
1154 }
1155
1156 ContextPack {
1157 task: task.to_string(),
1158 budget_tokens,
1159 used_tokens: used,
1160 truncated,
1161 items,
1162 }
1163 }
1164
1165 pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1167 self.resolve_within_root(rel_path)?;
1169 crate::git::blame(&self.paths.root, rel_path, line)
1170 }
1171
1172 pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1175 let defs = self.defs_by_name(name);
1177 let best = defs
1178 .iter()
1179 .max_by(|a, b| {
1180 let pa = self.segments[a.0]
1181 .doc(a.2.doc_id)
1182 .map(|d| path_score(&d.path))
1183 .unwrap_or(0.0);
1184 let pb = self.segments[b.0]
1185 .doc(b.2.doc_id)
1186 .map(|d| path_score(&d.path))
1187 .unwrap_or(0.0);
1188 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1189 })
1190 .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1191 let (si, _, sym) = *best;
1192 let doc = self.segments[si]
1193 .doc(sym.doc_id)
1194 .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1195 let commits = crate::git::line_history(
1196 &self.paths.root,
1197 &doc.path,
1198 sym.line_start,
1199 sym.line_end,
1200 limit,
1201 )
1202 .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1203 Ok(SymbolHistory {
1204 name: name.to_string(),
1205 path: doc.path.clone(),
1206 line_start: sym.line_start,
1207 line_end: sym.line_end,
1208 commits,
1209 })
1210 }
1211
1212 pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1215 let changed = crate::git::changed_since(&self.paths.root, rev)?;
1216 let mut out = Vec::with_capacity(changed.len());
1217 for cf in changed {
1218 let mut symbols = Vec::new();
1219 for seg in &self.segments {
1220 for (doc_id, doc) in seg.docs.iter().enumerate() {
1221 if doc.path == cf.path && seg.is_live(doc_id as u32) {
1222 for s in seg.doc_syms(doc_id as u32) {
1223 symbols.push(s.name.clone());
1224 }
1225 }
1226 }
1227 }
1228 symbols.sort();
1229 symbols.dedup();
1230 out.push(ChangedSymbols {
1231 path: cf.path,
1232 status: cf.status,
1233 symbols,
1234 });
1235 }
1236 Ok(out)
1237 }
1238
1239 pub fn structural_search(
1243 &self,
1244 pattern: &str,
1245 lang: &str,
1246 limit: usize,
1247 offset: usize,
1248 ) -> Result<Vec<StructHit>> {
1249 let language = crate::lang::Language::from_id(lang)
1250 .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1251 if language.grammar().is_none() {
1252 return Err(Error::other(format!(
1253 "language {lang} is not parseable for structural search"
1254 )));
1255 }
1256 let compiled = crate::structural::compile(language, pattern)?;
1257
1258 let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1260 let tq = anchor
1261 .as_ref()
1262 .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1263
1264 let mut targets: Vec<(usize, u32)> = Vec::new();
1265 for (si, seg) in self.segments.iter().enumerate() {
1266 let candidates = match &tq {
1267 Some(q) => seg.candidates(q)?,
1268 None => seg.all_live(),
1269 };
1270 for doc_id in candidates.iter() {
1271 if !seg.is_live(doc_id) {
1272 continue;
1273 }
1274 match seg.doc(doc_id) {
1275 Some(d) if d.lang == lang => targets.push((si, doc_id)),
1276 _ => {}
1277 }
1278 }
1279 }
1280
1281 let root = &self.paths.root;
1282 let segments = &self.segments;
1283 let content = &self.content;
1284 let compiled_ref = &compiled;
1285 let hits: Vec<StructHit> = targets
1286 .par_iter()
1287 .flat_map_iter(|&(si, doc_id)| {
1288 let seg = &segments[si];
1289 let doc = match seg.doc(doc_id) {
1290 Some(d) => d,
1291 None => return Vec::new().into_iter(),
1292 };
1293 let full = root.join(&doc.path);
1294 let data = match content.get_or_read(doc.hash, &full) {
1295 Some(d) => d,
1296 None => return Vec::new().into_iter(),
1297 };
1298 let matches = crate::structural::run(language, compiled_ref, &data);
1299 let line_starts = line_starts(&data);
1300 let out: Vec<StructHit> = matches
1301 .into_iter()
1302 .map(|m| {
1303 let li = (m.line_start.saturating_sub(1)) as usize;
1304 let text = line_starts
1305 .get(li)
1306 .map(|_| snippet(line_slice(&data, &line_starts, li)))
1307 .unwrap_or_default();
1308 StructHit {
1309 path: doc.path.clone(),
1310 lang: doc.lang.clone(),
1311 line_start: m.line_start,
1312 line_end: m.line_end,
1313 kind: m.kind,
1314 text,
1315 captures: m.captures,
1316 }
1317 })
1318 .collect();
1319 out.into_iter()
1320 })
1321 .collect();
1322
1323 let cmp = |a: &StructHit, b: &StructHit| {
1324 a.path
1325 .cmp(&b.path)
1326 .then_with(|| a.line_start.cmp(&b.line_start))
1327 };
1328 let mut hits = hits;
1329 hits.sort_by(cmp);
1330 Ok(paginate(hits, offset, limit))
1331 }
1332
1333 pub fn summary(&self) -> RepoSummary {
1335 use std::collections::HashMap;
1336 let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1337 let mut by_dir: HashMap<String, u64> = HashMap::new();
1338 let mut files = 0u64;
1339 let mut bytes = 0u64;
1340 let mut symbols = 0u64;
1341 for seg in &self.segments {
1342 for (doc_id, doc) in seg.docs.iter().enumerate() {
1343 if !seg.is_live(doc_id as u32) {
1344 continue;
1345 }
1346 files += 1;
1347 bytes += doc.size;
1348 let e = by_lang.entry(doc.lang.clone()).or_default();
1349 e.files += 1;
1350 e.bytes += doc.size;
1351 let dir = doc.path.split('/').next().unwrap_or("").to_string();
1352 *by_dir.entry(dir).or_default() += 1;
1353 }
1354 symbols += seg.syms.iter().filter(|s| seg.is_live(s.doc_id)).count() as u64;
1355 }
1356 let mut languages: Vec<LangStat> = by_lang
1357 .into_iter()
1358 .map(|(lang, mut s)| {
1359 s.lang = lang;
1360 s
1361 })
1362 .collect();
1363 languages.sort_by_key(|s| std::cmp::Reverse(s.files));
1364 let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1365 top_dirs.sort_by_key(|d| std::cmp::Reverse(d.1));
1366 top_dirs.truncate(15);
1367 RepoSummary {
1368 files,
1369 bytes,
1370 symbols,
1371 segments: self.segments.len(),
1372 languages,
1373 top_dirs: top_dirs
1374 .into_iter()
1375 .map(|(name, files)| DirStat { name, files })
1376 .collect(),
1377 }
1378 }
1379}
1380
1381#[derive(Debug, Clone, Serialize, Deserialize)]
1383pub struct Snippet {
1384 pub path: String,
1385 pub start_line: u32,
1386 pub end_line: u32,
1387 pub total_lines: u32,
1388 pub lines: Vec<SnippetLine>,
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1392pub struct SnippetLine {
1393 pub line: u32,
1394 pub text: String,
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1399pub struct RepoSummary {
1400 pub files: u64,
1401 pub bytes: u64,
1402 pub symbols: u64,
1403 pub segments: usize,
1404 pub languages: Vec<LangStat>,
1405 pub top_dirs: Vec<DirStat>,
1406}
1407
1408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1409pub struct LangStat {
1410 pub lang: String,
1411 pub files: u64,
1412 pub bytes: u64,
1413}
1414
1415#[derive(Debug, Clone, Serialize, Deserialize)]
1416pub struct DirStat {
1417 pub name: String,
1418 pub files: u64,
1419}
1420
1421fn verify_doc(
1425 seg: &Segment,
1426 doc_id: u32,
1427 root: &Path,
1428 content: &ContentCache,
1429 matcher: &Matcher,
1430 max_per_file: usize,
1431 whole_word: bool,
1432) -> Vec<SearchHit> {
1433 let doc = match seg.doc(doc_id) {
1434 Some(d) => d,
1435 None => return Vec::new(),
1436 };
1437 let full = root.join(&doc.path);
1438 let data = match content.get_or_read(doc.hash, &full) {
1439 Some(d) => d,
1440 None => return Vec::new(),
1441 };
1442
1443 let matches = matcher.match_starts(&data, whole_word, PER_FILE_MATCH_CAP);
1444 if matches.is_empty() {
1445 return Vec::new();
1446 }
1447
1448 let line_starts = line_starts(&data);
1449 let sym_lines = symbol_lines(seg, doc_id);
1450 let base = path_score(&doc.path);
1451
1452 let mut out = Vec::new();
1453 let mut last_line = 0u32;
1454 for (start, _end) in matches {
1455 let li = line_of(start, &line_starts);
1456 let line_no = li as u32 + 1;
1457 if line_no == last_line {
1459 continue;
1460 }
1461 last_line = line_no;
1462 let col = (start - line_starts[li]) as u32 + 1;
1463 let line_bytes = line_slice(&data, &line_starts, li);
1464 let mut score = 1.0 + base;
1465 if sym_lines.contains(&line_no) {
1466 score += 3.0;
1467 }
1468 out.push(SearchHit {
1469 path: doc.path.clone(),
1470 lang: doc.lang.clone(),
1471 line: line_no,
1472 column: col,
1473 text: snippet(line_bytes),
1474 score,
1475 });
1476 }
1477
1478 if out.len() > max_per_file {
1480 out.sort_by(|a, b| {
1481 b.score
1482 .partial_cmp(&a.score)
1483 .unwrap_or(std::cmp::Ordering::Equal)
1484 .then_with(|| a.line.cmp(&b.line))
1485 });
1486 out.truncate(max_per_file);
1487 }
1488 out
1489}
1490
1491fn line_starts(data: &[u8]) -> Vec<usize> {
1493 let mut starts = Vec::with_capacity(64);
1494 starts.push(0usize);
1495 for p in memchr::memchr_iter(b'\n', data) {
1496 starts.push(p + 1);
1497 }
1498 starts
1499}
1500
1501fn line_of(off: usize, starts: &[usize]) -> usize {
1503 starts.partition_point(|&s| s <= off).saturating_sub(1)
1505}
1506
1507fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1509 let begin = starts[li];
1510 let end = if li + 1 < starts.len() {
1511 starts[li + 1].saturating_sub(1)
1512 } else {
1513 data.len()
1514 };
1515 &data[begin..end.min(data.len())]
1516}
1517
1518fn path_score(path: &str) -> f32 {
1521 let mut s = 0.0f32;
1522 let depth = path.matches('/').count() as f32;
1523 s -= depth * 0.05;
1524 let lower = path.to_ascii_lowercase();
1525 if lower.contains("test")
1526 || lower.contains("/tests/")
1527 || lower.contains("__tests__")
1528 || lower.contains(".test.")
1529 || lower.contains(".spec.")
1530 {
1531 s -= 1.0;
1532 }
1533 if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
1534 s -= 1.5;
1535 }
1536 s
1537}
1538
1539fn symbol_lines(seg: &Segment, doc_id: u32) -> HashSet<u32> {
1540 seg.doc_syms(doc_id).map(|s| s.line_start).collect()
1541}
1542
1543fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1544 if exact {
1545 return if lower == needle { Some(100.0) } else { None };
1546 }
1547 if lower == needle {
1548 Some(100.0)
1549 } else if lower.starts_with(needle) {
1550 Some(70.0)
1551 } else if acronym(name) == needle {
1552 Some(60.0)
1554 } else if lower.contains(needle) {
1555 Some(50.0)
1556 } else if is_subsequence(needle, lower) {
1557 Some(30.0)
1558 } else {
1559 None
1560 }
1561}
1562
1563fn split_identifier(s: &str) -> Vec<String> {
1565 let mut tokens = Vec::new();
1566 let mut cur = String::new();
1567 let mut prev_lower = false;
1568 for ch in s.chars() {
1569 if ch == '_' || ch == '-' || ch == ' ' {
1570 if !cur.is_empty() {
1571 tokens.push(std::mem::take(&mut cur));
1572 }
1573 prev_lower = false;
1574 continue;
1575 }
1576 if ch.is_uppercase() && prev_lower && !cur.is_empty() {
1577 tokens.push(std::mem::take(&mut cur));
1578 }
1579 cur.extend(ch.to_lowercase());
1580 prev_lower = ch.is_lowercase() || ch.is_numeric();
1581 }
1582 if !cur.is_empty() {
1583 tokens.push(cur);
1584 }
1585 tokens
1586}
1587
1588fn acronym(s: &str) -> String {
1590 split_identifier(s)
1591 .iter()
1592 .filter_map(|t| t.chars().next())
1593 .collect()
1594}
1595
1596fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
1599where
1600 F: Fn(&T, &T) -> std::cmp::Ordering,
1601{
1602 let need = offset.saturating_add(limit);
1603 if need == 0 {
1604 return Vec::new();
1605 }
1606 if need < items.len() {
1607 items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
1608 items.truncate(need);
1609 }
1610 items.sort_by(|a, b| cmp(a, b));
1611 if offset >= items.len() {
1612 return Vec::new();
1613 }
1614 items.drain(0..offset);
1615 items.truncate(limit);
1616 items
1617}
1618
1619fn shared_prefix_len(a: &str, b: &str) -> usize {
1621 a.split('/')
1622 .zip(b.split('/'))
1623 .take_while(|(x, y)| x == y)
1624 .count()
1625}
1626
1627fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
1629 if offset >= items.len() {
1630 return Vec::new();
1631 }
1632 items.drain(0..offset);
1633 items.truncate(limit);
1634 items
1635}
1636
1637fn is_subsequence(needle: &str, haystack: &str) -> bool {
1638 if needle.is_empty() {
1639 return true;
1640 }
1641 let mut chars = needle.chars();
1642 let mut cur = chars.next();
1643 for h in haystack.chars() {
1644 if let Some(c) = cur {
1645 if c == h {
1646 cur = chars.next();
1647 }
1648 } else {
1649 break;
1650 }
1651 }
1652 cur.is_none()
1653}
1654
1655fn snippet(line: &[u8]) -> String {
1657 let s = String::from_utf8_lossy(line);
1658 let trimmed = s.trim_end();
1659 const MAX: usize = 320;
1660 if trimmed.len() > MAX {
1661 let mut end = MAX;
1662 while !trimmed.is_char_boundary(end) {
1663 end -= 1;
1664 }
1665 format!("{}…", &trimmed[..end])
1666 } else {
1667 trimmed.to_string()
1668 }
1669}