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/// Fuzz-only entry: build a literal/regex matcher and scan `hay` for matches.
270#[doc(hidden)]
271pub fn fuzz_match_starts(
272    pattern: &str,
273    hay: &[u8],
274    regex: bool,
275    case_insensitive: bool,
276    whole_word: bool,
277) {
278    let query = SearchQuery {
279        pattern: pattern.to_string(),
280        regex,
281        case_insensitive,
282        whole_word,
283        ..Default::default()
284    };
285    if let Ok(m) = Matcher::build(&query) {
286        let _ = m.match_starts(hay, whole_word, PER_FILE_MATCH_CAP);
287    }
288}
289
290/// Identifier byte for word-boundary checks. Bytes >= 0x80 are treated as
291/// identifier bytes so multibyte UTF-8 (Unicode) identifiers are respected.
292fn is_ident_byte(b: u8) -> bool {
293    b == b'_' || b.is_ascii_alphanumeric() || b >= 0x80
294}
295
296/// True if the byte range `[start, end)` is bounded by non-identifier bytes.
297fn boundary_ok(line: &[u8], start: usize, end: usize) -> bool {
298    let left = start == 0 || !is_ident_byte(line[start - 1]);
299    let right = end >= line.len() || !is_ident_byte(line[end]);
300    left && right
301}
302
303/// Memory budget (in bytes) for the verification content cache. Eviction is
304/// driven by total cached bytes rather than a file count, so a query that
305/// touches many large files can't balloon resident memory. The cache is
306/// content-addressed by hash, so stale entries fall out when files change and
307/// are re-indexed. ~256 MiB.
308const CONTENT_CACHE_BYTES: u64 = 256 * 1024 * 1024;
309
310/// Hard cap on matches collected per file before ranking, to bound work on
311/// pathological inputs (e.g. a minified file where every line matches).
312const PER_FILE_MATCH_CAP: usize = 4096;
313
314struct CacheInner {
315    map: LruCache<u64, Arc<[u8]>>,
316    bytes: u64,
317}
318
319/// A thread-safe, content-addressed, byte-budgeted cache of recently read
320/// files. Entries are evicted least-recently-used until total cached bytes fit
321/// within the budget.
322struct ContentCache {
323    inner: Mutex<CacheInner>,
324    budget: u64,
325}
326
327impl ContentCache {
328    fn new(budget_bytes: u64) -> Self {
329        Self {
330            inner: Mutex::new(CacheInner {
331                map: LruCache::unbounded(),
332                bytes: 0,
333            }),
334            budget: budget_bytes.max(1),
335        }
336    }
337
338    /// Return the bytes for `path`, reusing a cached copy keyed by `hash`. The
339    /// file is read outside the lock so concurrent verifiers don't serialize.
340    fn get_or_read(&self, hash: u64, path: &Path) -> Option<Arc<[u8]>> {
341        if let Ok(mut guard) = self.inner.lock() {
342            if let Some(v) = guard.map.get(&hash) {
343                return Some(v.clone());
344            }
345        }
346        let data = std::fs::read(path).ok()?;
347        let arc: Arc<[u8]> = Arc::from(data.into_boxed_slice());
348        let len = arc.len() as u64;
349        if let Ok(mut guard) = self.inner.lock() {
350            // A single file larger than the whole budget is returned but not
351            // cached; storing it would just evict everything else and itself.
352            if len <= self.budget {
353                if let Some(prev) = guard.map.put(hash, arc.clone()) {
354                    guard.bytes = guard.bytes.saturating_sub(prev.len() as u64);
355                }
356                guard.bytes += len;
357                while guard.bytes > self.budget {
358                    match guard.map.pop_lru() {
359                        Some((_, evicted)) => {
360                            guard.bytes = guard.bytes.saturating_sub(evicted.len() as u64);
361                        }
362                        None => break,
363                    }
364                }
365            }
366        }
367        Some(arc)
368    }
369}
370
371/// Loaded, searchable index.
372pub struct Searcher {
373    paths: Paths,
374    segments: Vec<Segment>,
375    content: ContentCache,
376}
377
378impl Searcher {
379    /// Open the index described by `meta`.
380    pub fn open(paths: &Paths) -> Result<Searcher> {
381        if !paths.exists() {
382            return Err(Error::IndexMissing(paths.base.clone()));
383        }
384        let meta = Meta::load(&paths.meta_file())?;
385        let mut segments = Vec::with_capacity(meta.segments.len());
386        for &seg_id in &meta.segments {
387            segments.push(Segment::open(paths, seg_id)?);
388        }
389        Ok(Searcher {
390            paths: paths.clone(),
391            segments,
392            content: ContentCache::new(CONTENT_CACHE_BYTES),
393        })
394    }
395
396    /// Run a content search.
397    pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
398        if query.pattern.is_empty() {
399            return Ok(Vec::new());
400        }
401        let matcher = Matcher::build(query)?;
402        let tq: TrigramQuery = if query.regex {
403            trigram::regex_trigrams(&query.pattern, query.case_insensitive)
404        } else if query.case_insensitive {
405            // Fold ASCII case into per-position trigram clauses so we still prune
406            // candidates instead of scanning the whole repository.
407            TrigramQuery::from_literal_ci(query.pattern.as_bytes())
408        } else {
409            TrigramQuery::from_literal(query.pattern.as_bytes())
410        };
411
412        let path_filter = query.path.as_deref();
413        let lang_filter = query.lang.as_deref();
414
415        // Gather candidate (segment, doc) pairs after cheap metadata filters.
416        let mut targets: Vec<(usize, u32)> = Vec::new();
417        for (si, seg) in self.segments.iter().enumerate() {
418            let candidates = seg.candidates(&tq)?;
419            for doc_id in candidates.iter() {
420                if !seg.is_live(doc_id) {
421                    continue;
422                }
423                let doc = match seg.doc(doc_id) {
424                    Some(d) => d,
425                    None => continue,
426                };
427                if let Some(lf) = lang_filter {
428                    if doc.lang != lf {
429                        continue;
430                    }
431                }
432                if let Some(pf) = path_filter {
433                    if !doc.path.contains(pf) {
434                        continue;
435                    }
436                }
437                targets.push((si, doc_id));
438            }
439        }
440
441        // Verify candidates in parallel: each reads its file (cache/page-cache
442        // backed) and scans the buffer with the real matcher.
443        let root = &self.paths.root;
444        let segments = &self.segments;
445        let content = &self.content;
446        let max_per_file = query.max_per_file;
447        let whole_word = query.whole_word;
448        let exhaustive = query.exhaustive;
449        let mut hits: Vec<SearchHit> = targets
450            .par_iter()
451            .flat_map_iter(|&(si, doc_id)| {
452                verify_doc(
453                    &segments[si],
454                    doc_id,
455                    root,
456                    content,
457                    &matcher,
458                    max_per_file,
459                    whole_word,
460                    exhaustive,
461                )
462                .into_iter()
463            })
464            .collect();
465
466        if query.exhaustive {
467            // Grep-equivalent: every match, deterministic (path, line, column)
468            // order, no ranking and no offset/limit truncation.
469            hits.sort_by(|a, b| {
470                a.path
471                    .cmp(&b.path)
472                    .then_with(|| a.line.cmp(&b.line))
473                    .then_with(|| a.column.cmp(&b.column))
474            });
475            return Ok(hits);
476        }
477
478        let cmp = |a: &SearchHit, b: &SearchHit| {
479            b.score
480                .partial_cmp(&a.score)
481                .unwrap_or(std::cmp::Ordering::Equal)
482                .then_with(|| a.path.cmp(&b.path))
483                .then_with(|| a.line.cmp(&b.line))
484        };
485        Ok(rank_paginate(hits, cmp, query.offset, query.limit))
486    }
487
488    /// Look up symbols by name.
489    pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
490        let needle = query.name.to_ascii_lowercase();
491        let mut hits: Vec<SymbolHit> = Vec::new();
492        for seg in &self.segments {
493            for (i, sym) in seg.syms.iter().enumerate() {
494                if !seg.is_live(sym.doc_id) {
495                    continue;
496                }
497                if let Some(k) = &query.kind {
498                    if &sym.kind != k {
499                        continue;
500                    }
501                }
502                let score = match_symbol(&sym.name, seg.sym_name_lower(i), &needle, query.exact);
503                let score = match score {
504                    Some(s) => s,
505                    None => continue,
506                };
507                let doc = match seg.doc(sym.doc_id) {
508                    Some(d) => d,
509                    None => continue,
510                };
511                let score = score + path_score(&doc.path);
512                hits.push(SymbolHit {
513                    path: doc.path.clone(),
514                    lang: doc.lang.clone(),
515                    name: sym.name.clone(),
516                    kind: sym.kind.clone(),
517                    line_start: sym.line_start,
518                    line_end: sym.line_end,
519                    container: sym.container.clone(),
520                    signature: sym.signature.clone(),
521                    score,
522                });
523            }
524        }
525        let cmp = |a: &SymbolHit, b: &SymbolHit| {
526            b.score
527                .partial_cmp(&a.score)
528                .unwrap_or(std::cmp::Ordering::Equal)
529                .then_with(|| a.name.len().cmp(&b.name.len()))
530                .then_with(|| a.path.cmp(&b.path))
531        };
532        Ok(rank_paginate(hits, cmp, query.offset, query.limit))
533    }
534
535    /// Return the symbol outline for a single file (by relative path).
536    pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
537        let mut out = Vec::new();
538        for seg in &self.segments {
539            for (doc_id, doc) in seg.docs.iter().enumerate() {
540                let doc_id = doc_id as u32;
541                if doc.path != rel_path || !seg.is_live(doc_id) {
542                    continue;
543                }
544                for sym in seg.doc_syms(doc_id) {
545                    out.push(SymbolHit {
546                        path: doc.path.clone(),
547                        lang: doc.lang.clone(),
548                        name: sym.name.clone(),
549                        kind: sym.kind.clone(),
550                        line_start: sym.line_start,
551                        line_end: sym.line_end,
552                        container: sym.container.clone(),
553                        signature: sym.signature.clone(),
554                        score: 1.0,
555                    });
556                }
557            }
558        }
559        out.sort_by_key(|s| s.line_start);
560        Ok(out)
561    }
562
563    /// Find references to an identifier (whole-word occurrences across the repo).
564    pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
565        self.search(&SearchQuery {
566            pattern: name.to_string(),
567            whole_word: true,
568            limit,
569            offset,
570            ..Default::default()
571        })
572    }
573
574    /// All live symbol definitions whose name matches `name` exactly
575    /// (case-sensitive), as `(segment index, symbol index, symbol)` tuples.
576    fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, &crate::segment::SymbolEntry)> {
577        let mut out = Vec::new();
578        for (si, seg) in self.segments.iter().enumerate() {
579            for (idx, sym) in seg.syms.iter().enumerate() {
580                if sym.name == name && seg.is_live(sym.doc_id) {
581                    out.push((si, idx, sym));
582                }
583            }
584        }
585        out
586    }
587
588    /// Number of live call sites targeting `name` (call-graph in-degree),
589    /// computed via the per-segment callee-name index (no full ref scan).
590    fn call_indegree(&self, name: &str) -> u32 {
591        let mut n = 0u32;
592        for seg in &self.segments {
593            for r in seg.calls_to(name) {
594                if seg.is_live(r.doc_id) {
595                    n += 1;
596                }
597            }
598        }
599        n
600    }
601
602    /// The innermost symbol in `doc_id` whose line range contains `line`.
603    fn enclosing_symbol<'s>(
604        &self,
605        seg: &'s Segment,
606        doc_id: u32,
607        line: u32,
608    ) -> Option<&'s crate::segment::SymbolEntry> {
609        let mut best: Option<&crate::segment::SymbolEntry> = None;
610        for sym in seg.doc_syms(doc_id) {
611            if sym.line_start <= line && line <= sym.line_end {
612                let span = sym.line_end - sym.line_start;
613                match best {
614                    Some(b) if (b.line_end - b.line_start) <= span => {}
615                    _ => best = Some(sym),
616                }
617            }
618        }
619        best
620    }
621
622    /// Resolved references to `name`: its definitions, call sites, and imports,
623    /// drawn from the structural reference index (not text matching). Ranked
624    /// definitions first, then calls, then imports.
625    pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
626        let mut hits: Vec<RefHit> = Vec::new();
627        for seg in &self.segments {
628            for sym in &seg.syms {
629                if sym.name == name && seg.is_live(sym.doc_id) {
630                    if let Some(doc) = seg.doc(sym.doc_id) {
631                        hits.push(RefHit {
632                            path: doc.path.clone(),
633                            lang: doc.lang.clone(),
634                            name: sym.name.clone(),
635                            kind: "definition".to_string(),
636                            line: sym.line_start,
637                            column: 1,
638                            container: sym.container.clone(),
639                        });
640                    }
641                }
642            }
643            for r in &seg.refs {
644                if r.name == name && seg.is_live(r.doc_id) {
645                    if let Some(doc) = seg.doc(r.doc_id) {
646                        let container = self
647                            .enclosing_symbol(seg, r.doc_id, r.line)
648                            .map(|s| s.name.clone());
649                        hits.push(RefHit {
650                            path: doc.path.clone(),
651                            lang: doc.lang.clone(),
652                            name: r.name.clone(),
653                            kind: r.kind.as_str().to_string(),
654                            line: r.line,
655                            column: r.column,
656                            container,
657                        });
658                    }
659                }
660            }
661        }
662        let rank = |k: &str| match k {
663            "definition" => 0,
664            "call" => 1,
665            _ => 2,
666        };
667        hits.sort_by(|a, b| {
668            rank(&a.kind)
669                .cmp(&rank(&b.kind))
670                .then_with(|| a.path.cmp(&b.path))
671                .then_with(|| a.line.cmp(&b.line))
672        });
673        paginate(hits, offset, limit)
674    }
675
676    /// Call sites *inside* `name`'s body: what `name` calls. Built by locating
677    /// the definition(s) of `name` and collecting "call" refs within range.
678    pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
679        let mut out: Vec<CallSite> = Vec::new();
680        let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
681        for (si, _, sym) in self.defs_by_name(name) {
682            let seg = &self.segments[si];
683            let doc = match seg.doc(sym.doc_id) {
684                Some(d) => d,
685                None => continue,
686            };
687            for r in seg.doc_refs(sym.doc_id) {
688                if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
689                    let key = (doc.path.clone(), r.name.clone(), r.line, r.column);
690                    if !seen.insert(key) {
691                        continue;
692                    }
693                    out.push(CallSite {
694                        caller: Some(name.to_string()),
695                        callee: r.name.clone(),
696                        path: doc.path.clone(),
697                        lang: doc.lang.clone(),
698                        line: r.line,
699                        column: r.column,
700                    });
701                }
702            }
703        }
704        out.sort_by(|a, b| {
705            a.callee
706                .cmp(&b.callee)
707                .then_with(|| a.path.cmp(&b.path))
708                .then_with(|| a.line.cmp(&b.line))
709        });
710        paginate(out, offset, limit)
711    }
712
713    /// Call sites that target `name`: who calls it. Each is attributed to its
714    /// enclosing caller symbol when one can be determined.
715    pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
716        let mut out: Vec<CallSite> = Vec::new();
717        for seg in &self.segments {
718            // O(results) via the prebuilt callee-name index instead of a full
719            // scan of every ref — this is the inner loop of `blast_radius`.
720            for r in seg.calls_to(name) {
721                if !seg.is_live(r.doc_id) {
722                    continue;
723                }
724                let doc = match seg.doc(r.doc_id) {
725                    Some(d) => d,
726                    None => continue,
727                };
728                let caller = self
729                    .enclosing_symbol(seg, r.doc_id, r.line)
730                    .map(|s| s.name.clone());
731                out.push(CallSite {
732                    caller,
733                    callee: name.to_string(),
734                    path: doc.path.clone(),
735                    lang: doc.lang.clone(),
736                    line: r.line,
737                    column: r.column,
738                });
739            }
740        }
741        out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
742        paginate(out, offset, limit)
743    }
744
745    /// Blast radius: the symbols transitively affected if `name` changes, found
746    /// by walking the reverse call graph (callers, then their callers, ...) up
747    /// to `depth` hops. Distance 0 is `name`'s own definition(s).
748    ///
749    /// Resolution is by name, so results are an approximation that can include
750    /// unrelated same-named symbols; it is a guide, not a proof.
751    pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
752        let mut out: Vec<ImpactNode> = Vec::new();
753        let mut visited: HashSet<String> = HashSet::new();
754        visited.insert(name.to_string());
755
756        // Distance 0: the target's own definitions.
757        for (si, _, sym) in self.defs_by_name(name) {
758            if let Some(doc) = self.segments[si].doc(sym.doc_id) {
759                out.push(ImpactNode {
760                    name: sym.name.clone(),
761                    kind: sym.kind.clone(),
762                    path: doc.path.clone(),
763                    lang: doc.lang.clone(),
764                    line_start: sym.line_start,
765                    line_end: sym.line_end,
766                    distance: 0,
767                });
768            }
769        }
770
771        let mut frontier: Vec<String> = vec![name.to_string()];
772        for dist in 1..=depth {
773            let mut next: Vec<String> = Vec::new();
774            for target in &frontier {
775                for site in self.callers(target, usize::MAX, 0) {
776                    let caller = match site.caller {
777                        Some(c) => c,
778                        None => continue,
779                    };
780                    if !visited.insert(caller.clone()) {
781                        continue;
782                    }
783                    for (si, _, sym) in self.defs_by_name(&caller) {
784                        if let Some(doc) = self.segments[si].doc(sym.doc_id) {
785                            out.push(ImpactNode {
786                                name: sym.name.clone(),
787                                kind: sym.kind.clone(),
788                                path: doc.path.clone(),
789                                lang: doc.lang.clone(),
790                                line_start: sym.line_start,
791                                line_end: sym.line_end,
792                                distance: dist,
793                            });
794                        }
795                    }
796                    next.push(caller);
797                }
798                if out.len() >= limit {
799                    break;
800                }
801            }
802            if next.is_empty() {
803                break;
804            }
805            frontier = next;
806        }
807        out.truncate(limit);
808        out
809    }
810
811    /// Typed go-to-definition: resolve the identifier at `rel_path:line:col` to
812    /// its most likely definition(s), combining scope/usage context with the
813    /// global symbol table. Returns candidates ranked by confidence; the unique
814    /// best is flagged `resolved`. Falls back to whole-word text hits (marked
815    /// unresolved) when the name has no indexed definition.
816    pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
817        let full = self.resolve_within_root(rel_path)?;
818        let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
819        let ext = Path::new(rel_path)
820            .extension()
821            .and_then(|e| e.to_str())
822            .unwrap_or("");
823        let lang = crate::lang::Language::from_extension(ext);
824
825        let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
826            Some(i) => i,
827            None => {
828                return Err(Error::other(format!(
829                    "no identifier at {rel_path}:{line}:{col}"
830                )))
831            }
832        };
833
834        // Imports referenced by the use-file: a name imported here is likely
835        // defined elsewhere, which lets us prefer cross-file definitions.
836        let imported_here = self.imported_names(rel_path);
837
838        let mut cands: Vec<DefHit> = Vec::new();
839        for (si, _, sym) in self.defs_by_name(&ident.name) {
840            let seg = &self.segments[si];
841            let doc = match seg.doc(sym.doc_id) {
842                Some(d) => d,
843                None => continue,
844            };
845            let mut score = 10.0f32 + path_score(&doc.path);
846            let same_file = doc.path == rel_path;
847            if same_file {
848                score += 40.0;
849            }
850            score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
851            // Usage-context preference.
852            let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
853            if ident.is_member && method_like {
854                score += 25.0;
855            } else if !ident.is_member && !method_like {
856                score += 8.0;
857            }
858            if ident.is_call
859                && matches!(
860                    sym.kind.as_str(),
861                    "function" | "method" | "macro" | "constructor"
862                )
863            {
864                score += 6.0;
865            }
866            if ident.is_type
867                && matches!(
868                    sym.kind.as_str(),
869                    "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
870                )
871            {
872                score += 12.0;
873            }
874            // If the name is imported into the use-file, a cross-file definition
875            // is the likely target.
876            if imported_here.contains(&ident.name) && !same_file {
877                score += 15.0;
878            }
879            cands.push(DefHit {
880                path: doc.path.clone(),
881                lang: doc.lang.clone(),
882                name: sym.name.clone(),
883                kind: sym.kind.clone(),
884                line_start: sym.line_start,
885                line_end: sym.line_end,
886                container: sym.container.clone(),
887                signature: sym.signature.clone(),
888                score,
889                resolved: false,
890            });
891        }
892
893        if cands.is_empty() {
894            // Fallback: whole-word text occurrences, marked unresolved.
895            let hits = self.references(&ident.name, 50, 0)?;
896            return Ok(hits
897                .into_iter()
898                .map(|h| DefHit {
899                    path: h.path,
900                    lang: h.lang,
901                    name: ident.name.clone(),
902                    kind: "text".to_string(),
903                    line_start: h.line,
904                    line_end: h.line,
905                    container: None,
906                    signature: Some(h.text),
907                    score: h.score,
908                    resolved: false,
909                })
910                .collect());
911        }
912
913        cands.sort_by(|a, b| {
914            b.score
915                .partial_cmp(&a.score)
916                .unwrap_or(std::cmp::Ordering::Equal)
917                .then_with(|| a.path.cmp(&b.path))
918                .then_with(|| a.line_start.cmp(&b.line_start))
919        });
920        // Mark the unique best as resolved when it clears the runner-up.
921        let unique_top =
922            cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
923        if unique_top {
924            cands[0].resolved = true;
925        }
926        Ok(cands)
927    }
928
929    /// Resolved references for the identifier at `rel_path:line:col`: its
930    /// definitions, call sites, and imports across the repo.
931    pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
932        let full = self.resolve_within_root(rel_path)?;
933        let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
934        let ext = Path::new(rel_path)
935            .extension()
936            .and_then(|e| e.to_str())
937            .unwrap_or("");
938        let lang = crate::lang::Language::from_extension(ext);
939        let ident = crate::resolve::identifier_at(lang, &source, line, col)
940            .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
941        Ok(self.references_resolved(&ident.name, usize::MAX, 0))
942    }
943
944    /// The set of names imported into `rel_path` (from the reference index).
945    fn imported_names(&self, rel_path: &str) -> HashSet<String> {
946        let mut out = HashSet::new();
947        for seg in &self.segments {
948            for (doc_id, doc) in seg.docs.iter().enumerate() {
949                let doc_id = doc_id as u32;
950                if doc.path != rel_path || !seg.is_live(doc_id) {
951                    continue;
952                }
953                for r in seg.doc_refs(doc_id) {
954                    if r.kind == RefKind::Import {
955                        out.insert(r.name.clone());
956                    }
957                }
958            }
959        }
960        out
961    }
962
963    /// Resolve a caller-supplied path against the project root, rejecting
964    /// anything that would escape it: absolute paths (which would make
965    /// `root.join(..)` discard the root entirely), `..` traversal, and symlinks
966    /// that resolve outside the tree. Returns the absolute path to read.
967    fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
968        let candidate = Path::new(rel_path);
969        if candidate.is_absolute() {
970            return Err(Error::other(format!(
971                "path {rel_path:?} must be relative to the project root"
972            )));
973        }
974        // Reject parent/prefix components before touching the filesystem.
975        if candidate
976            .components()
977            .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
978        {
979            return Err(Error::other(format!(
980                "path {rel_path:?} escapes the project root"
981            )));
982        }
983        // Canonicalize both sides so symlinks can't redirect the read outside
984        // the root, then require the resolved path to stay under it.
985        let root = self
986            .paths
987            .root
988            .canonicalize()
989            .map_err(|e| Error::io(&self.paths.root, e))?;
990        let full = root.join(candidate);
991        let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
992        if !resolved.starts_with(&root) {
993            return Err(Error::other(format!(
994                "path {rel_path:?} escapes the project root"
995            )));
996        }
997        Ok(resolved)
998    }
999
1000    /// Read a slice of a file with surrounding context lines.
1001    pub fn read_snippet(
1002        &self,
1003        rel_path: &str,
1004        start_line: u32,
1005        end_line: u32,
1006        context: u32,
1007    ) -> Result<Snippet> {
1008        let full = self.resolve_within_root(rel_path)?;
1009        let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
1010        let lines: Vec<&str> = data.lines().collect();
1011        let total = lines.len() as u32;
1012        let to = end_line.saturating_add(context).min(total.max(1));
1013        // Clamp the start into the file as well so an out-of-range request never
1014        // reports a `start_line` past EOF or an inverted (start > end) range.
1015        let from = start_line
1016            .saturating_sub(context)
1017            .max(1)
1018            .min(total.max(1))
1019            .min(to);
1020        let mut body = String::new();
1021        let mut last = from;
1022        for ln in from..=to {
1023            if let Some(text) = lines.get((ln - 1) as usize) {
1024                if !body.is_empty() {
1025                    body.push('\n');
1026                }
1027                body.push_str(text);
1028                last = ln;
1029            }
1030        }
1031        Ok(Snippet {
1032            path: rel_path.to_string(),
1033            start_line: from,
1034            end_line: last,
1035            total_lines: total,
1036            text: body,
1037        })
1038    }
1039
1040    /// Build a token-budgeted context pack for `task`: the symbols (with
1041    /// signatures and code snippets) most relevant to the task, ranked by
1042    /// lexical relevance and call-graph centrality, plus their immediate
1043    /// dependency neighborhood. Designed to hand an agent exactly the code it
1044    /// needs without reading whole files.
1045    pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1046        use crate::context::{self, ContextPack, PackItem};
1047
1048        let terms = context::tokenize(task);
1049
1050        // A candidate symbol with its location and provisional score.
1051        struct Cand {
1052            seg: usize,
1053            sym: usize,
1054            score: f32,
1055            reason: String,
1056        }
1057        let mut cands: Vec<Cand> = Vec::new();
1058        for (si, seg) in self.segments.iter().enumerate() {
1059            for (idx, sym) in seg.syms.iter().enumerate() {
1060                if !seg.is_live(sym.doc_id) {
1061                    continue;
1062                }
1063                let doc = match seg.doc(sym.doc_id) {
1064                    Some(d) => d,
1065                    None => continue,
1066                };
1067                let mut score = context::lexical_score(
1068                    &sym.name,
1069                    &sym.kind,
1070                    sym.signature.as_deref(),
1071                    sym.container.as_deref(),
1072                    &doc.path,
1073                    &terms,
1074                );
1075                if score <= 0.0 {
1076                    continue;
1077                }
1078                // Call-graph centrality, looked up only for the few symbols that
1079                // already cleared the lexical filter (via the call-name index).
1080                let deg = self.call_indegree(&sym.name) as f32;
1081                score += (1.0 + deg).ln() * 1.5;
1082                score += path_score(&doc.path);
1083                cands.push(Cand {
1084                    seg: si,
1085                    sym: idx,
1086                    score,
1087                    reason: "match".to_string(),
1088                });
1089            }
1090        }
1091
1092        cands.sort_by(|a, b| {
1093            b.score
1094                .partial_cmp(&a.score)
1095                .unwrap_or(std::cmp::Ordering::Equal)
1096        });
1097
1098        // Expand the dependency neighborhood of the strongest seeds: include the
1099        // callees of the top matches so the agent sees what they depend on.
1100        let mut seen: HashSet<(String, u32)> = HashSet::new();
1101        for c in &cands {
1102            let seg = &self.segments[c.seg];
1103            let sym = &seg.syms[c.sym];
1104            seen.insert((sym.name.clone(), sym.line_start));
1105        }
1106        let mut extra: Vec<Cand> = Vec::new();
1107        for c in cands.iter().take(8) {
1108            let seg = &self.segments[c.seg];
1109            let sym = &seg.syms[c.sym];
1110            for callee in self.callees(&sym.name, 12, 0) {
1111                for (si2, idx, def) in self.defs_by_name(&callee.callee) {
1112                    let key = (def.name.clone(), def.line_start);
1113                    if !seen.insert(key) {
1114                        continue;
1115                    }
1116                    extra.push(Cand {
1117                        seg: si2,
1118                        sym: idx,
1119                        score: c.score * 0.3,
1120                        reason: format!("callee of {}", sym.name),
1121                    });
1122                }
1123            }
1124        }
1125        cands.extend(extra);
1126        cands.sort_by(|a, b| {
1127            b.score
1128                .partial_cmp(&a.score)
1129                .unwrap_or(std::cmp::Ordering::Equal)
1130        });
1131
1132        // Greedily pack within budget. Lines are read once per file through the
1133        // content cache and split once (cached by content hash), so multiple
1134        // packed symbols from the same file don't re-read or re-split it.
1135        let mut items: Vec<PackItem> = Vec::new();
1136        let mut used: u64 = 0;
1137        let mut truncated = false;
1138        let mut file_lines: std::collections::HashMap<u64, Arc<Vec<String>>> =
1139            std::collections::HashMap::new();
1140        const MAX_ITEM_LINES: u32 = 60;
1141        for c in &cands {
1142            let seg = &self.segments[c.seg];
1143            let sym = &seg.syms[c.sym];
1144            let doc = match seg.doc(sym.doc_id) {
1145                Some(d) => d,
1146                None => continue,
1147            };
1148            let end = sym
1149                .line_end
1150                .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1151            let lines = file_lines
1152                .entry(doc.hash)
1153                .or_insert_with(|| {
1154                    let full = self.paths.root.join(&doc.path);
1155                    let v = match self.content.get_or_read(doc.hash, &full) {
1156                        Some(data) => String::from_utf8_lossy(&data)
1157                            .lines()
1158                            .map(|s| s.to_string())
1159                            .collect(),
1160                        None => Vec::new(),
1161                    };
1162                    Arc::new(v)
1163                })
1164                .clone();
1165            let from = sym.line_start.max(1);
1166            let to = end.min(lines.len() as u32);
1167            let mut code = String::new();
1168            for ln in from..=to {
1169                if let Some(text) = lines.get((ln - 1) as usize) {
1170                    if !code.is_empty() {
1171                        code.push('\n');
1172                    }
1173                    code.push_str(text);
1174                }
1175            }
1176            let chars: u64 =
1177                code.len() as u64 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1178            let cost = context::est_tokens(chars).max(1);
1179            if used + cost > budget_tokens && !items.is_empty() {
1180                truncated = true;
1181                continue;
1182            }
1183            used += cost;
1184            items.push(PackItem {
1185                path: doc.path.clone(),
1186                lang: doc.lang.clone(),
1187                name: sym.name.clone(),
1188                kind: sym.kind.clone(),
1189                line_start: sym.line_start,
1190                line_end: sym.line_end,
1191                signature: sym.signature.clone(),
1192                snippet_start: from,
1193                code,
1194                reason: c.reason.clone(),
1195                score: c.score,
1196            });
1197            if used >= budget_tokens {
1198                truncated = truncated || items.len() < cands.len();
1199                break;
1200            }
1201        }
1202
1203        ContextPack {
1204            task: task.to_string(),
1205            budget_tokens,
1206            used_tokens: used,
1207            truncated,
1208            items,
1209        }
1210    }
1211
1212    /// Blame a single line: the commit and author that last touched it.
1213    pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1214        // Validate the path stays within the project root.
1215        self.resolve_within_root(rel_path)?;
1216        crate::git::blame(&self.paths.root, rel_path, line)
1217    }
1218
1219    /// The commit history of a symbol: resolve `name` to its definition and list
1220    /// the commits that touched that line range, newest first.
1221    pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1222        // Prefer the highest-ranked (non-test/vendor) definition.
1223        let defs = self.defs_by_name(name);
1224        let best = defs
1225            .iter()
1226            .max_by(|a, b| {
1227                let pa = self.segments[a.0]
1228                    .doc(a.2.doc_id)
1229                    .map(|d| path_score(&d.path))
1230                    .unwrap_or(0.0);
1231                let pb = self.segments[b.0]
1232                    .doc(b.2.doc_id)
1233                    .map(|d| path_score(&d.path))
1234                    .unwrap_or(0.0);
1235                pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1236            })
1237            .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1238        let (si, _, sym) = *best;
1239        let doc = self.segments[si]
1240            .doc(sym.doc_id)
1241            .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1242        let commits = crate::git::line_history(
1243            &self.paths.root,
1244            &doc.path,
1245            sym.line_start,
1246            sym.line_end,
1247            limit,
1248        )
1249        .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1250        Ok(SymbolHistory {
1251            name: name.to_string(),
1252            path: doc.path.clone(),
1253            line_start: sym.line_start,
1254            line_end: sym.line_end,
1255            commits,
1256        })
1257    }
1258
1259    /// Files changed since `rev`, annotated with the symbols defined in each
1260    /// (from the index) so an agent sees the affected API surface at a glance.
1261    pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1262        let changed = crate::git::changed_since(&self.paths.root, rev)?;
1263        let mut out = Vec::with_capacity(changed.len());
1264        for cf in changed {
1265            let mut symbols = Vec::new();
1266            for seg in &self.segments {
1267                for (doc_id, doc) in seg.docs.iter().enumerate() {
1268                    if doc.path == cf.path && seg.is_live(doc_id as u32) {
1269                        for s in seg.doc_syms(doc_id as u32) {
1270                            symbols.push(s.name.clone());
1271                        }
1272                    }
1273                }
1274            }
1275            symbols.sort();
1276            symbols.dedup();
1277            out.push(ChangedSymbols {
1278                path: cf.path,
1279                status: cf.status,
1280                symbols,
1281            });
1282        }
1283        Ok(out)
1284    }
1285
1286    /// Structural (AST) search: match a tree-sitter query or `$NAME`
1287    /// meta-variable pattern across documents of one language. Literal tokens in
1288    /// the pattern prune candidates via the trigram index before parsing.
1289    pub fn structural_search(
1290        &self,
1291        pattern: &str,
1292        lang: &str,
1293        limit: usize,
1294        offset: usize,
1295    ) -> Result<Vec<StructHit>> {
1296        let language = crate::lang::Language::from_id(lang)
1297            .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1298        if language.grammar().is_none() {
1299            return Err(Error::other(format!(
1300                "language {lang} is not parseable for structural search"
1301            )));
1302        }
1303        let compiled = crate::structural::compile(language, pattern)?;
1304
1305        // Prefilter on the most selective literal anchor, if any.
1306        let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1307        let tq = anchor
1308            .as_ref()
1309            .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1310
1311        let mut targets: Vec<(usize, u32)> = Vec::new();
1312        for (si, seg) in self.segments.iter().enumerate() {
1313            let candidates = match &tq {
1314                Some(q) => seg.candidates(q)?,
1315                None => seg.all_live(),
1316            };
1317            for doc_id in candidates.iter() {
1318                if !seg.is_live(doc_id) {
1319                    continue;
1320                }
1321                match seg.doc(doc_id) {
1322                    Some(d) if d.lang == lang => targets.push((si, doc_id)),
1323                    _ => {}
1324                }
1325            }
1326        }
1327
1328        let root = &self.paths.root;
1329        let segments = &self.segments;
1330        let content = &self.content;
1331        let compiled_ref = &compiled;
1332        let hits: Vec<StructHit> = targets
1333            .par_iter()
1334            .flat_map_iter(|&(si, doc_id)| {
1335                let seg = &segments[si];
1336                let doc = match seg.doc(doc_id) {
1337                    Some(d) => d,
1338                    None => return Vec::new().into_iter(),
1339                };
1340                let full = root.join(&doc.path);
1341                let data = match content.get_or_read(doc.hash, &full) {
1342                    Some(d) => d,
1343                    None => return Vec::new().into_iter(),
1344                };
1345                let matches = crate::structural::run(language, compiled_ref, &data);
1346                let line_starts = line_starts(&data);
1347                let out: Vec<StructHit> = matches
1348                    .into_iter()
1349                    .map(|m| {
1350                        let li = (m.line_start.saturating_sub(1)) as usize;
1351                        let text = line_starts
1352                            .get(li)
1353                            .map(|_| snippet(line_slice(&data, &line_starts, li)))
1354                            .unwrap_or_default();
1355                        StructHit {
1356                            path: doc.path.clone(),
1357                            lang: doc.lang.clone(),
1358                            line_start: m.line_start,
1359                            line_end: m.line_end,
1360                            kind: m.kind,
1361                            text,
1362                            captures: m.captures,
1363                        }
1364                    })
1365                    .collect();
1366                out.into_iter()
1367            })
1368            .collect();
1369
1370        let cmp = |a: &StructHit, b: &StructHit| {
1371            a.path
1372                .cmp(&b.path)
1373                .then_with(|| a.line_start.cmp(&b.line_start))
1374        };
1375        let mut hits = hits;
1376        hits.sort_by(cmp);
1377        Ok(paginate(hits, offset, limit))
1378    }
1379
1380    /// Summarize the indexed repository.
1381    pub fn summary(&self) -> RepoSummary {
1382        use std::collections::HashMap;
1383        let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1384        let mut by_dir: HashMap<String, u64> = HashMap::new();
1385        let mut files = 0u64;
1386        let mut bytes = 0u64;
1387        let mut symbols = 0u64;
1388        for seg in &self.segments {
1389            for (doc_id, doc) in seg.docs.iter().enumerate() {
1390                if !seg.is_live(doc_id as u32) {
1391                    continue;
1392                }
1393                files += 1;
1394                bytes += doc.size;
1395                let e = by_lang.entry(doc.lang.clone()).or_default();
1396                e.files += 1;
1397                e.bytes += doc.size;
1398                let dir = doc.path.split('/').next().unwrap_or("").to_string();
1399                *by_dir.entry(dir).or_default() += 1;
1400            }
1401            symbols += seg.syms.iter().filter(|s| seg.is_live(s.doc_id)).count() as u64;
1402        }
1403        let mut languages: Vec<LangStat> = by_lang
1404            .into_iter()
1405            .map(|(lang, mut s)| {
1406                s.lang = lang;
1407                s
1408            })
1409            .collect();
1410        languages.sort_by_key(|s| std::cmp::Reverse(s.files));
1411        let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1412        top_dirs.sort_by_key(|d| std::cmp::Reverse(d.1));
1413        top_dirs.truncate(15);
1414        RepoSummary {
1415            files,
1416            bytes,
1417            symbols,
1418            segments: self.segments.len(),
1419            languages,
1420            top_dirs: top_dirs
1421                .into_iter()
1422                .map(|(name, files)| DirStat { name, files })
1423                .collect(),
1424        }
1425    }
1426}
1427
1428/// A file slice with context, returned by [`Searcher::read_snippet`].
1429///
1430/// The body is a single `text` blob (lines joined by `\n`) rather than an array
1431/// of per-line objects: line N is `start_line + i` for the i-th line, so the
1432/// numbers are implicit and never repeated on the wire. This keeps the payload
1433/// compact for agents while staying exactly reconstructable.
1434#[derive(Debug, Clone, Serialize, Deserialize)]
1435pub struct Snippet {
1436    pub path: String,
1437    pub start_line: u32,
1438    pub end_line: u32,
1439    pub total_lines: u32,
1440    pub text: String,
1441}
1442
1443/// Repository summary returned by [`Searcher::summary`].
1444#[derive(Debug, Clone, Serialize, Deserialize)]
1445pub struct RepoSummary {
1446    pub files: u64,
1447    pub bytes: u64,
1448    pub symbols: u64,
1449    pub segments: usize,
1450    pub languages: Vec<LangStat>,
1451    pub top_dirs: Vec<DirStat>,
1452}
1453
1454#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1455pub struct LangStat {
1456    pub lang: String,
1457    pub files: u64,
1458    pub bytes: u64,
1459}
1460
1461#[derive(Debug, Clone, Serialize, Deserialize)]
1462pub struct DirStat {
1463    pub name: String,
1464    pub files: u64,
1465}
1466
1467/// Read a single candidate file and collect matching lines. Matches are found
1468/// over the whole buffer (so regexes may span lines), mapped to line numbers,
1469/// then ranked so the highest-scored matches survive `max_per_file` truncation.
1470#[allow(clippy::too_many_arguments)] // hot path; threading a struct adds churn without clarity
1471fn verify_doc(
1472    seg: &Segment,
1473    doc_id: u32,
1474    root: &Path,
1475    content: &ContentCache,
1476    matcher: &Matcher,
1477    max_per_file: usize,
1478    whole_word: bool,
1479    exhaustive: bool,
1480) -> Vec<SearchHit> {
1481    let doc = match seg.doc(doc_id) {
1482        Some(d) => d,
1483        None => return Vec::new(),
1484    };
1485    let full = root.join(&doc.path);
1486    let data = match content.get_or_read(doc.hash, &full) {
1487        Some(d) => d,
1488        None => return Vec::new(),
1489    };
1490
1491    // Exhaustive search lifts the pathological-input cap so no match is dropped.
1492    let cap = if exhaustive {
1493        usize::MAX
1494    } else {
1495        PER_FILE_MATCH_CAP
1496    };
1497    let matches = matcher.match_starts(&data, whole_word, cap);
1498    if matches.is_empty() {
1499        return Vec::new();
1500    }
1501
1502    let line_starts = line_starts(&data);
1503    let sym_lines = symbol_lines(seg, doc_id);
1504    let base = path_score(&doc.path);
1505
1506    let mut out = Vec::new();
1507    let mut last_line = 0u32;
1508    for (start, _end) in matches {
1509        let li = line_of(start, &line_starts);
1510        let line_no = li as u32 + 1;
1511        // One hit per line; matches are in ascending offset order.
1512        if line_no == last_line {
1513            continue;
1514        }
1515        last_line = line_no;
1516        let col = (start - line_starts[li]) as u32 + 1;
1517        let line_bytes = line_slice(&data, &line_starts, li);
1518        let mut score = 1.0 + base;
1519        if sym_lines.contains(&line_no) {
1520            score += 3.0;
1521        }
1522        out.push(SearchHit {
1523            path: doc.path.clone(),
1524            lang: doc.lang.clone(),
1525            line: line_no,
1526            column: col,
1527            text: snippet(line_bytes),
1528            score,
1529        });
1530    }
1531
1532    // Keep the highest-scored matches when a file has more than the cap.
1533    // Exhaustive mode keeps every line.
1534    if !exhaustive && out.len() > max_per_file {
1535        out.sort_by(|a, b| {
1536            b.score
1537                .partial_cmp(&a.score)
1538                .unwrap_or(std::cmp::Ordering::Equal)
1539                .then_with(|| a.line.cmp(&b.line))
1540        });
1541        out.truncate(max_per_file);
1542    }
1543    out
1544}
1545
1546/// Byte offsets at which each line begins (index 0 is the start of the file).
1547fn line_starts(data: &[u8]) -> Vec<usize> {
1548    let mut starts = Vec::with_capacity(64);
1549    starts.push(0usize);
1550    for p in memchr::memchr_iter(b'\n', data) {
1551        starts.push(p + 1);
1552    }
1553    starts
1554}
1555
1556/// Zero-based line index containing byte offset `off`.
1557fn line_of(off: usize, starts: &[usize]) -> usize {
1558    // Greatest line start that is <= off.
1559    starts.partition_point(|&s| s <= off).saturating_sub(1)
1560}
1561
1562/// The bytes of line `li` (without the trailing newline).
1563fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1564    let begin = starts[li];
1565    let end = if li + 1 < starts.len() {
1566        starts[li + 1].saturating_sub(1)
1567    } else {
1568        data.len()
1569    };
1570    &data[begin..end.min(data.len())]
1571}
1572
1573/// Ranking adjustment based on the file path: prefer shallow paths and
1574/// non-generated/non-test files.
1575fn path_score(path: &str) -> f32 {
1576    let mut s = 0.0f32;
1577    let depth = path.matches('/').count() as f32;
1578    s -= depth * 0.05;
1579    let lower = path.to_ascii_lowercase();
1580    if lower.contains("test")
1581        || lower.contains("/tests/")
1582        || lower.contains("__tests__")
1583        || lower.contains(".test.")
1584        || lower.contains(".spec.")
1585    {
1586        s -= 1.0;
1587    }
1588    if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
1589        s -= 1.5;
1590    }
1591    s
1592}
1593
1594fn symbol_lines(seg: &Segment, doc_id: u32) -> HashSet<u32> {
1595    seg.doc_syms(doc_id).map(|s| s.line_start).collect()
1596}
1597
1598fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1599    if exact {
1600        return if lower == needle { Some(100.0) } else { None };
1601    }
1602    if lower == needle {
1603        Some(100.0)
1604    } else if lower.starts_with(needle) {
1605        Some(70.0)
1606    } else if acronym(name) == needle {
1607        // e.g. "lc" matches loadConfig / load_config.
1608        Some(60.0)
1609    } else if lower.contains(needle) {
1610        Some(50.0)
1611    } else if is_subsequence(needle, lower) {
1612        Some(30.0)
1613    } else {
1614        None
1615    }
1616}
1617
1618/// Split an identifier into lowercase tokens on camelCase and snake/kebab.
1619fn split_identifier(s: &str) -> Vec<String> {
1620    let mut tokens = Vec::new();
1621    let mut cur = String::new();
1622    let mut prev_lower = false;
1623    for ch in s.chars() {
1624        if ch == '_' || ch == '-' || ch == ' ' {
1625            if !cur.is_empty() {
1626                tokens.push(std::mem::take(&mut cur));
1627            }
1628            prev_lower = false;
1629            continue;
1630        }
1631        if ch.is_uppercase() && prev_lower && !cur.is_empty() {
1632            tokens.push(std::mem::take(&mut cur));
1633        }
1634        cur.extend(ch.to_lowercase());
1635        prev_lower = ch.is_lowercase() || ch.is_numeric();
1636    }
1637    if !cur.is_empty() {
1638        tokens.push(cur);
1639    }
1640    tokens
1641}
1642
1643/// First letter of each identifier token, lowercased.
1644fn acronym(s: &str) -> String {
1645    split_identifier(s)
1646        .iter()
1647        .filter_map(|t| t.chars().next())
1648        .collect()
1649}
1650
1651/// Rank `items` best-first and apply offset/limit. Uses a partial selection so
1652/// we only fully sort the `offset + limit` items we actually return.
1653fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
1654where
1655    F: Fn(&T, &T) -> std::cmp::Ordering,
1656{
1657    let need = offset.saturating_add(limit);
1658    if need == 0 {
1659        return Vec::new();
1660    }
1661    if need < items.len() {
1662        items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
1663        items.truncate(need);
1664    }
1665    items.sort_by(|a, b| cmp(a, b));
1666    if offset >= items.len() {
1667        return Vec::new();
1668    }
1669    items.drain(0..offset);
1670    items.truncate(limit);
1671    items
1672}
1673
1674/// Index-free fallback search: walk the working tree and scan every file with
1675/// the matcher, with no trigram prefilter. Used when the index is missing or
1676/// errors, so `search` still returns grep-equivalent results instead of failing.
1677/// Honors the same `lang`/`path` filters, `exhaustive` mode, and ordering as the
1678/// indexed path. Slower (reads every candidate file) but correct and complete.
1679pub fn grep_walk(paths: &Paths, config: &Config, query: &SearchQuery) -> Result<Vec<SearchHit>> {
1680    if query.pattern.is_empty() {
1681        return Ok(Vec::new());
1682    }
1683    let matcher = Matcher::build(query)?;
1684    let walked = crate::walk::walk(paths, config)?;
1685    let path_filter = query.path.as_deref();
1686    let lang_filter = query.lang.as_deref();
1687    let max_per_file = query.max_per_file;
1688    let whole_word = query.whole_word;
1689    let exhaustive = query.exhaustive;
1690    let index_binary = config.index_binary;
1691    let cap = if exhaustive {
1692        usize::MAX
1693    } else {
1694        PER_FILE_MATCH_CAP
1695    };
1696
1697    let mut hits: Vec<SearchHit> = walked
1698        .entries
1699        .par_iter()
1700        .flat_map_iter(|e| {
1701            if path_filter.is_some_and(|pf| !e.rel.contains(pf)) {
1702                return Vec::new().into_iter();
1703            }
1704            let ext = e
1705                .path
1706                .extension()
1707                .and_then(|x| x.to_str())
1708                .unwrap_or("")
1709                .to_ascii_lowercase();
1710            let lang_id = Language::from_extension(&ext).id().to_string();
1711            if lang_filter.is_some_and(|lf| lang_id != lf) {
1712                return Vec::new().into_iter();
1713            }
1714            let data = match std::fs::read(&e.path) {
1715                Ok(d) => d,
1716                Err(_) => return Vec::new().into_iter(),
1717            };
1718            if !index_binary && memchr::memchr(0, &data).is_some() {
1719                return Vec::new().into_iter();
1720            }
1721            let matches = matcher.match_starts(&data, whole_word, cap);
1722            if matches.is_empty() {
1723                return Vec::new().into_iter();
1724            }
1725            let starts = line_starts(&data);
1726            let base = path_score(&e.rel);
1727            let mut out = Vec::new();
1728            let mut last_line = 0u32;
1729            for (start, _end) in matches {
1730                let li = line_of(start, &starts);
1731                let line_no = li as u32 + 1;
1732                if line_no == last_line {
1733                    continue;
1734                }
1735                last_line = line_no;
1736                let col = (start - starts[li]) as u32 + 1;
1737                out.push(SearchHit {
1738                    path: e.rel.clone(),
1739                    lang: lang_id.clone(),
1740                    line: line_no,
1741                    column: col,
1742                    text: snippet(line_slice(&data, &starts, li)),
1743                    score: 1.0 + base,
1744                });
1745            }
1746            if !exhaustive && out.len() > max_per_file {
1747                out.sort_by(|a, b| {
1748                    b.score
1749                        .partial_cmp(&a.score)
1750                        .unwrap_or(std::cmp::Ordering::Equal)
1751                        .then_with(|| a.line.cmp(&b.line))
1752                });
1753                out.truncate(max_per_file);
1754            }
1755            out.into_iter()
1756        })
1757        .collect();
1758
1759    if exhaustive {
1760        hits.sort_by(|a, b| {
1761            a.path
1762                .cmp(&b.path)
1763                .then_with(|| a.line.cmp(&b.line))
1764                .then_with(|| a.column.cmp(&b.column))
1765        });
1766        return Ok(hits);
1767    }
1768    let cmp = |a: &SearchHit, b: &SearchHit| {
1769        b.score
1770            .partial_cmp(&a.score)
1771            .unwrap_or(std::cmp::Ordering::Equal)
1772            .then_with(|| a.path.cmp(&b.path))
1773            .then_with(|| a.line.cmp(&b.line))
1774    };
1775    Ok(rank_paginate(hits, cmp, query.offset, query.limit))
1776}
1777
1778/// Number of leading path components shared by two relative paths.
1779fn shared_prefix_len(a: &str, b: &str) -> usize {
1780    a.split('/')
1781        .zip(b.split('/'))
1782        .take_while(|(x, y)| x == y)
1783        .count()
1784}
1785
1786/// Apply offset/limit to an already-ordered vector.
1787fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
1788    if offset >= items.len() {
1789        return Vec::new();
1790    }
1791    items.drain(0..offset);
1792    items.truncate(limit);
1793    items
1794}
1795
1796fn is_subsequence(needle: &str, haystack: &str) -> bool {
1797    if needle.is_empty() {
1798        return true;
1799    }
1800    let mut chars = needle.chars();
1801    let mut cur = chars.next();
1802    for h in haystack.chars() {
1803        if let Some(c) = cur {
1804            if c == h {
1805                cur = chars.next();
1806            }
1807        } else {
1808            break;
1809        }
1810    }
1811    cur.is_none()
1812}
1813
1814/// Trim and bound a matched line for display.
1815fn snippet(line: &[u8]) -> String {
1816    let s = String::from_utf8_lossy(line);
1817    let trimmed = s.trim_end();
1818    const MAX: usize = 320;
1819    if trimmed.len() > MAX {
1820        let mut end = MAX;
1821        while !trimmed.is_char_boundary(end) {
1822            end -= 1;
1823        }
1824        format!("{}…", &trimmed[..end])
1825    } else {
1826        trimmed.to_string()
1827    }
1828}