Skip to main content

greplm_core/
search.rs

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