Skip to main content

greplm_core/
search.rs

1//! Query execution: trigram candidate filtering, then exact verification.
2
3use 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/// A content search request.
22#[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    /// Match only whole identifiers (word boundaries on both sides).
29    pub whole_word: bool,
30    pub lang: Option<String>,
31    pub path: Option<String>,
32    pub limit: usize,
33    /// Skip the first N ranked results (for pagination).
34    pub offset: usize,
35    pub max_per_file: usize,
36    /// Return EVERY match in deterministic (path, line) order: no ranking, no
37    /// global `limit`, and no per-file caps (`max_per_file` and the internal
38    /// pathological-input cap are both lifted). This is grep-equivalent
39    /// completeness; use it when "find every occurrence" matters more than
40    /// relevance ranking. `offset`/`limit` are ignored when set.
41    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/// A single content match.
62#[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/// A symbol lookup request.
73#[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/// A single symbol match.
96#[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/// A resolved reference to an identifier: a definition, a call site, or an
112/// import. Unlike text search, these come from the structural reference index.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RefHit {
115    pub path: String,
116    pub lang: String,
117    pub name: String,
118    /// "definition", "call", or "import".
119    pub kind: String,
120    pub line: u32,
121    pub column: u32,
122    /// The enclosing symbol at this location, when known.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub container: Option<String>,
125}
126
127/// One edge of the call graph: a call site linking a caller symbol to a callee.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CallSite {
130    /// The enclosing symbol the call is made from (None at file scope).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub caller: Option<String>,
133    /// The called identifier.
134    pub callee: String,
135    pub path: String,
136    pub lang: String,
137    pub line: u32,
138    pub column: u32,
139}
140
141/// A symbol affected by a change to a target symbol, with its BFS distance from
142/// the target along the reverse call graph.
143#[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    /// Hops along the caller chain from the target (0 = the target itself).
152    pub distance: u32,
153}
154
155/// A candidate definition for an identifier at a source position, ranked by
156/// resolution confidence. `resolved` marks a single high-confidence target.
157#[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    /// True when this is the unambiguous resolution target.
171    pub resolved: bool,
172}
173
174/// The git history of a resolved symbol.
175#[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/// A changed file annotated with the symbols it defines.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ChangedSymbols {
187    pub path: String,
188    pub status: String,
189    pub symbols: Vec<String>,
190}
191
192/// A structural (AST) search match, with its captured meta-variables.
193#[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    /// Kind of the matched node.
200    pub kind: String,
201    /// First line of the match, for display.
202    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(&regex::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    /// Collect the byte offsets of matches in `hay`, up to `cap`. Scanning the
229    /// whole buffer (rather than line-by-line) lets regex patterns span newlines.
230    /// When `whole_word` is set, only matches bounded by non-identifier bytes
231    /// count.
232    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                    // Skip zero-width matches (e.g. `a*`, `^`): they carry no
252                    // displayable span and would flag every line.
253                    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/// Identifier byte for word-boundary checks. Bytes >= 0x80 are treated as
270/// identifier bytes so multibyte UTF-8 (Unicode) identifiers are respected.
271fn is_ident_byte(b: u8) -> bool {
272    b == b'_' || b.is_ascii_alphanumeric() || b >= 0x80
273}
274
275/// True if the byte range `[start, end)` is bounded by non-identifier bytes.
276fn 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
282/// Memory budget (in bytes) for the verification content cache. Eviction is
283/// driven by total cached bytes rather than a file count, so a query that
284/// touches many large files can't balloon resident memory. The cache is
285/// content-addressed by hash, so stale entries fall out when files change and
286/// are re-indexed. ~256 MiB.
287const CONTENT_CACHE_BYTES: u64 = 256 * 1024 * 1024;
288
289/// Hard cap on matches collected per file before ranking, to bound work on
290/// pathological inputs (e.g. a minified file where every line matches).
291const PER_FILE_MATCH_CAP: usize = 4096;
292
293struct CacheInner {
294    map: LruCache<u64, Arc<[u8]>>,
295    bytes: u64,
296}
297
298/// A thread-safe, content-addressed, byte-budgeted cache of recently read
299/// files. Entries are evicted least-recently-used until total cached bytes fit
300/// within the budget.
301struct 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    /// Return the bytes for `path`, reusing a cached copy keyed by `hash`. The
318    /// file is read outside the lock so concurrent verifiers don't serialize.
319    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            // A single file larger than the whole budget is returned but not
330            // cached; storing it would just evict everything else and itself.
331            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
350/// Loaded, searchable index.
351pub struct Searcher {
352    paths: Paths,
353    segments: Vec<Segment>,
354    content: ContentCache,
355}
356
357impl Searcher {
358    /// Open the index described by `meta`.
359    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    /// Run a content search.
376    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            // Fold ASCII case into per-position trigram clauses so we still prune
385            // candidates instead of scanning the whole repository.
386            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        // Gather candidate (segment, doc) pairs after cheap metadata filters.
395        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        // Verify candidates in parallel: each reads its file (cache/page-cache
421        // backed) and scans the buffer with the real matcher.
422        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            // Grep-equivalent: every match, deterministic (path, line, column)
447            // order, no ranking and no offset/limit truncation.
448            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    /// Look up symbols by name.
468    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    /// Return the symbol outline for a single file (by relative path).
515    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    /// Find references to an identifier (whole-word occurrences across the repo).
543    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    /// All live symbol definitions whose name matches `name` exactly
554    /// (case-sensitive), as `(segment index, symbol index, symbol)` tuples.
555    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    /// Number of live call sites targeting `name` (call-graph in-degree),
568    /// computed via the per-segment callee-name index (no full ref scan).
569    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    /// The innermost symbol in `doc_id` whose line range contains `line`.
582    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    /// Resolved references to `name`: its definitions, call sites, and imports,
602    /// drawn from the structural reference index (not text matching). Ranked
603    /// definitions first, then calls, then imports.
604    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    /// Call sites *inside* `name`'s body: what `name` calls. Built by locating
656    /// the definition(s) of `name` and collecting "call" refs within range.
657    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    /// Call sites that target `name`: who calls it. Each is attributed to its
693    /// enclosing caller symbol when one can be determined.
694    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            // O(results) via the prebuilt callee-name index instead of a full
698            // scan of every ref — this is the inner loop of `blast_radius`.
699            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    /// Blast radius: the symbols transitively affected if `name` changes, found
725    /// by walking the reverse call graph (callers, then their callers, ...) up
726    /// to `depth` hops. Distance 0 is `name`'s own definition(s).
727    ///
728    /// Resolution is by name, so results are an approximation that can include
729    /// unrelated same-named symbols; it is a guide, not a proof.
730    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        // Distance 0: the target's own definitions.
736        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    /// Typed go-to-definition: resolve the identifier at `rel_path:line:col` to
791    /// its most likely definition(s), combining scope/usage context with the
792    /// global symbol table. Returns candidates ranked by confidence; the unique
793    /// best is flagged `resolved`. Falls back to whole-word text hits (marked
794    /// unresolved) when the name has no indexed definition.
795    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        // Imports referenced by the use-file: a name imported here is likely
814        // defined elsewhere, which lets us prefer cross-file definitions.
815        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            // Usage-context preference.
831            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 the name is imported into the use-file, a cross-file definition
854            // is the likely target.
855            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            // Fallback: whole-word text occurrences, marked unresolved.
874            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        // Mark the unique best as resolved when it clears the runner-up.
900        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    /// Resolved references for the identifier at `rel_path:line:col`: its
909    /// definitions, call sites, and imports across the repo.
910    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    /// The set of names imported into `rel_path` (from the reference index).
924    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    /// Resolve a caller-supplied path against the project root, rejecting
943    /// anything that would escape it: absolute paths (which would make
944    /// `root.join(..)` discard the root entirely), `..` traversal, and symlinks
945    /// that resolve outside the tree. Returns the absolute path to read.
946    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        // Reject parent/prefix components before touching the filesystem.
954        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        // Canonicalize both sides so symlinks can't redirect the read outside
963        // the root, then require the resolved path to stay under it.
964        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    /// Read a slice of a file with surrounding context lines.
980    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        // Clamp the start into the file as well so an out-of-range request never
993        // reports a `start_line` past EOF or an inverted (start > end) range.
994        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    /// Build a token-budgeted context pack for `task`: the symbols (with
1018    /// signatures and code snippets) most relevant to the task, ranked by
1019    /// lexical relevance and call-graph centrality, plus their immediate
1020    /// dependency neighborhood. Designed to hand an agent exactly the code it
1021    /// needs without reading whole files.
1022    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        // A candidate symbol with its location and provisional score.
1028        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                // Call-graph centrality, looked up only for the few symbols that
1056                // already cleared the lexical filter (via the call-name index).
1057                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        // Expand the dependency neighborhood of the strongest seeds: include the
1076        // callees of the top matches so the agent sees what they depend on.
1077        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        // Greedily pack within budget. Lines are read once per file through the
1110        // content cache and split once (cached by content hash), so multiple
1111        // packed symbols from the same file don't re-read or re-split it.
1112        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    /// Blame a single line: the commit and author that last touched it.
1189    pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1190        // Validate the path stays within the project root.
1191        self.resolve_within_root(rel_path)?;
1192        crate::git::blame(&self.paths.root, rel_path, line)
1193    }
1194
1195    /// The commit history of a symbol: resolve `name` to its definition and list
1196    /// the commits that touched that line range, newest first.
1197    pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1198        // Prefer the highest-ranked (non-test/vendor) definition.
1199        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    /// Files changed since `rev`, annotated with the symbols defined in each
1236    /// (from the index) so an agent sees the affected API surface at a glance.
1237    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    /// Structural (AST) search: match a tree-sitter query or `$NAME`
1263    /// meta-variable pattern across documents of one language. Literal tokens in
1264    /// the pattern prune candidates via the trigram index before parsing.
1265    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        // Prefilter on the most selective literal anchor, if any.
1282        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    /// Summarize the indexed repository.
1357    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/// A file slice with context, returned by [`Searcher::read_snippet`].
1405#[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/// Repository summary returned by [`Searcher::summary`].
1421#[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/// Read a single candidate file and collect matching lines. Matches are found
1445/// over the whole buffer (so regexes may span lines), mapped to line numbers,
1446/// then ranked so the highest-scored matches survive `max_per_file` truncation.
1447#[allow(clippy::too_many_arguments)] // hot path; threading a struct adds churn without clarity
1448fn 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    // Exhaustive search lifts the pathological-input cap so no match is dropped.
1469    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        // One hit per line; matches are in ascending offset order.
1489        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    // Keep the highest-scored matches when a file has more than the cap.
1510    // Exhaustive mode keeps every line.
1511    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
1523/// Byte offsets at which each line begins (index 0 is the start of the file).
1524fn 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
1533/// Zero-based line index containing byte offset `off`.
1534fn line_of(off: usize, starts: &[usize]) -> usize {
1535    // Greatest line start that is <= off.
1536    starts.partition_point(|&s| s <= off).saturating_sub(1)
1537}
1538
1539/// The bytes of line `li` (without the trailing newline).
1540fn 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
1550/// Ranking adjustment based on the file path: prefer shallow paths and
1551/// non-generated/non-test files.
1552fn 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        // e.g. "lc" matches loadConfig / load_config.
1585        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
1595/// Split an identifier into lowercase tokens on camelCase and snake/kebab.
1596fn 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
1620/// First letter of each identifier token, lowercased.
1621fn acronym(s: &str) -> String {
1622    split_identifier(s)
1623        .iter()
1624        .filter_map(|t| t.chars().next())
1625        .collect()
1626}
1627
1628/// Rank `items` best-first and apply offset/limit. Uses a partial selection so
1629/// we only fully sort the `offset + limit` items we actually return.
1630fn 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
1651/// Index-free fallback search: walk the working tree and scan every file with
1652/// the matcher, with no trigram prefilter. Used when the index is missing or
1653/// errors, so `search` still returns grep-equivalent results instead of failing.
1654/// Honors the same `lang`/`path` filters, `exhaustive` mode, and ordering as the
1655/// indexed path. Slower (reads every candidate file) but correct and complete.
1656pub 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
1755/// Number of leading path components shared by two relative paths.
1756fn 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
1763/// Apply offset/limit to an already-ordered vector.
1764fn 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
1791/// Trim and bound a matched line for display.
1792fn 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}