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    ///
379    /// Built on first use rather than at open: it owns a copy of every live
380    /// document's path, so constructing it costs an allocation per file, and
381    /// only three query paths need it. Content search, symbol lookup and
382    /// context packs never touch it.
383    by_path: std::sync::OnceLock<HashMap<String, (usize, u32)>>,
384    /// Shared so a reloaded searcher (daemon hot-swap) keeps its warm,
385    /// content-addressed file cache.
386    content: Arc<ContentCache>,
387}
388
389impl Searcher {
390    /// Open the index described by `meta`.
391    pub fn open(paths: &Paths) -> Result<Searcher> {
392        Self::open_inner(paths, None)
393    }
394
395    /// Open the index, reusing as much of `prev` as possible: segments whose
396    /// id is unchanged share their parsed tables and lookup maps (only the
397    /// live bitmap is re-read), and the verification content cache carries
398    /// over warm. Sound because segment ids are never reused, so an id always
399    /// names the same immutable content. This is what makes the daemon's
400    /// per-save searcher hot-swap cheap on large repositories.
401    pub fn open_reusing(paths: &Paths, prev: &Searcher) -> Result<Searcher> {
402        Self::open_inner(paths, Some(prev))
403    }
404
405    fn open_inner(paths: &Paths, prev: Option<&Searcher>) -> Result<Searcher> {
406        if !paths.exists() {
407            return Err(Error::IndexMissing(paths.base.clone()));
408        }
409        let meta = Meta::load(&paths.meta_file())?;
410        // Open segments concurrently: each is an independent set of files whose
411        // integrity checks dominate the work. `collect` into `Result<Vec<_>>`
412        // keeps manifest order, which the doc-id bookkeeping depends on.
413        let mut segments: Vec<Segment> = meta
414            .segments
415            .par_iter()
416            .map(|&seg_id| {
417                match prev.and_then(|p| p.segments.iter().find(|s| s.id == seg_id)) {
418                    // An id always names the same immutable content, so a
419                    // reusable segment only re-reads its live bitmap.
420                    Some(seg) => seg.reopen(paths),
421                    None => Segment::open(paths, seg_id),
422                }
423            })
424            .collect::<Result<_>>()?;
425        // Honor deletes that are published in the manifest but not yet applied
426        // to the on-disk live bitmaps (the atomic-tombstone window).
427        for pt in &meta.pending_tombstones {
428            if let Some(seg) = segments.iter_mut().find(|s| s.id == pt.segment_id) {
429                seg.subtract_live(&pt.doc_ids);
430            }
431        }
432        let content = match prev {
433            Some(p) => p.content.clone(),
434            None => Arc::new(ContentCache::new(CONTENT_CACHE_BYTES)),
435        };
436        Ok(Searcher {
437            paths: paths.clone(),
438            segments,
439            by_path: std::sync::OnceLock::new(),
440            content,
441        })
442    }
443
444    /// The live path -> (segment, doc) lookup, built on first use.
445    fn by_path(&self) -> &HashMap<String, (usize, u32)> {
446        self.by_path
447            .get_or_init(|| build_path_index(&self.segments))
448    }
449
450    /// Run a content search.
451    pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchHit>> {
452        if query.pattern.is_empty() {
453            return Ok(Vec::new());
454        }
455        let matcher = Matcher::build(query)?;
456        let tq: TrigramQuery = if query.regex {
457            trigram::regex_trigrams(&query.pattern, query.case_insensitive)
458        } else if query.case_insensitive {
459            // Fold ASCII case into per-position trigram clauses so we still prune
460            // candidates instead of scanning the whole repository.
461            TrigramQuery::from_literal_ci(query.pattern.as_bytes())
462        } else {
463            TrigramQuery::from_literal(query.pattern.as_bytes())
464        };
465
466        let path_filter = query.path.as_deref();
467        let lang_filter = query.lang.as_deref();
468
469        // Gather candidate (segment, doc) pairs after cheap metadata filters.
470        let mut targets: Vec<(usize, u32, f32)> = Vec::new();
471        for (si, seg) in self.segments.iter().enumerate() {
472            let candidates = seg.candidates(&tq)?;
473            for doc_id in candidates.iter() {
474                if !seg.is_live(doc_id) {
475                    continue;
476                }
477                let doc = match seg.doc(doc_id) {
478                    Some(d) => d,
479                    None => continue,
480                };
481                if let Some(lf) = lang_filter {
482                    if doc.lang != lf {
483                        continue;
484                    }
485                }
486                if let Some(pf) = path_filter {
487                    if !doc.path.contains(pf) {
488                        continue;
489                    }
490                }
491                targets.push((si, doc_id, 0.0));
492            }
493        }
494
495        // Verify candidates in parallel: each reads its file (cache/page-cache
496        // backed) and scans the buffer with the real matcher.
497        let root = &self.paths.root;
498        let segments = &self.segments;
499        let content: &ContentCache = &self.content;
500        let max_per_file = query.max_per_file;
501        let whole_word = query.whole_word;
502        let exhaustive = query.exhaustive;
503        let verify = |&(si, doc_id, _): &(usize, u32, f32)| {
504            verify_doc(
505                &segments[si],
506                doc_id,
507                root,
508                content,
509                &matcher,
510                max_per_file,
511                whole_word,
512                exhaustive,
513            )
514            .into_iter()
515        };
516
517        if query.exhaustive {
518            // Grep-equivalent: every match, deterministic (path, line, column)
519            // order, no ranking and no offset/limit truncation.
520            let mut hits: Vec<SearchHit> = targets.par_iter().flat_map_iter(verify).collect();
521            hits.sort_by(|a, b| {
522                a.path
523                    .cmp(&b.path)
524                    .then_with(|| a.line.cmp(&b.line))
525                    .then_with(|| a.column.cmp(&b.column))
526            });
527            return Ok(hits);
528        }
529
530        let need = query.offset.saturating_add(query.limit);
531        if need == 0 {
532            return Ok(Vec::new());
533        }
534
535        // Ranked mode: verify in descending max-possible-score order and stop
536        // once `need` collected hits *strictly* outrank everything still
537        // unverified — no unverified doc can then reach the returned page,
538        // including via tie-breaks. A hit's score is its base path score plus
539        // at most 4.0 (the 1.0 match constant + the 3.0 symbol-line bonus).
540        // When one batch covers every candidate, early termination can never
541        // fire, so skip the per-candidate scoring and sort entirely.
542        let chunk = need.saturating_mul(4).clamp(256, 4096);
543        let mut hits: Vec<SearchHit>;
544        if targets.len() <= chunk {
545            hits = targets.par_iter().flat_map_iter(verify).collect();
546        } else {
547            for t in &mut targets {
548                t.2 = self.segments[t.0].doc_path_score(t.1);
549            }
550            targets.sort_unstable_by(|a, b| {
551                b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)
552            });
553            hits = Vec::new();
554            let mut start = 0usize;
555            while start < targets.len() {
556                let end = (start + chunk).min(targets.len());
557                let mut batch: Vec<SearchHit> = targets[start..end]
558                    .par_iter()
559                    .flat_map_iter(verify)
560                    .collect();
561                hits.append(&mut batch);
562                start = end;
563                if start < targets.len() {
564                    let remaining_max = targets[start].2 + 4.0;
565                    let outranking = hits.iter().filter(|h| h.score > remaining_max).count();
566                    if outranking >= need {
567                        break;
568                    }
569                }
570            }
571        }
572
573        let cmp = |a: &SearchHit, b: &SearchHit| {
574            b.score
575                .partial_cmp(&a.score)
576                .unwrap_or(std::cmp::Ordering::Equal)
577                .then_with(|| a.path.cmp(&b.path))
578                .then_with(|| a.line.cmp(&b.line))
579        };
580        Ok(rank_paginate(hits, cmp, query.offset, query.limit))
581    }
582
583    /// Look up symbols by name. Exact queries go through the per-segment name
584    /// index (O(results)); fuzzy queries scan, since prefix/substring/
585    /// subsequence matching has no exact key.
586    pub fn symbols(&self, query: &SymbolQuery) -> Result<Vec<SymbolHit>> {
587        let needle = query.name.to_ascii_lowercase();
588        let mut hits: Vec<SymbolHit> = Vec::new();
589        // Score a row that already passed the name match: decode it (rows are
590        // decoded only for matches), then apply the liveness/kind filters.
591        let mut consider = |seg: &Segment, i: u32, score: f32| {
592            let sym = match seg.sym(i) {
593                Some(s) => s,
594                None => return,
595            };
596            if !seg.is_live(sym.doc_id) {
597                return;
598            }
599            if let Some(k) = &query.kind {
600                if &sym.kind != k {
601                    return;
602                }
603            }
604            let doc = match seg.doc(sym.doc_id) {
605                Some(d) => d,
606                None => return,
607            };
608            hits.push(SymbolHit {
609                path: doc.path.clone(),
610                lang: doc.lang.clone(),
611                name: sym.name,
612                kind: sym.kind,
613                line_start: sym.line_start,
614                line_end: sym.line_end,
615                container: sym.container,
616                signature: sym.signature,
617                score: score + seg.doc_path_score(sym.doc_id),
618            });
619        };
620        for seg in &self.segments {
621            if query.exact {
622                let rows: Vec<u32> = seg.syms_by_lower(&needle).collect();
623                for i in rows {
624                    let score =
625                        match match_symbol(seg.sym_name(i), seg.sym_name_lower(i), &needle, true) {
626                            Some(s) => s,
627                            None => continue,
628                        };
629                    consider(seg, i, score);
630                }
631            } else {
632                // Fuzzy scan: prefix/substring/subsequence matching has no exact
633                // key, so every symbol's name has to be looked at. Walk the
634                // packed name columns in parallel — the per-row test only reads
635                // the mmap, and rayon's `collect` preserves sequence order, so
636                // equal-scoring rows keep the deterministic order the ranking
637                // tie-breaks rely on. Only matching rows are ever decoded.
638                let matches: Vec<(u32, f32)> = (0..seg.sym_count() as u32)
639                    .into_par_iter()
640                    .filter_map(|i| {
641                        match_symbol(seg.sym_name(i), seg.sym_name_lower(i), &needle, false)
642                            .map(|s| (i, s))
643                    })
644                    .collect();
645                for (i, score) in matches {
646                    consider(seg, i, score);
647                }
648            }
649        }
650        let cmp = |a: &SymbolHit, b: &SymbolHit| {
651            b.score
652                .partial_cmp(&a.score)
653                .unwrap_or(std::cmp::Ordering::Equal)
654                .then_with(|| a.name.len().cmp(&b.name.len()))
655                .then_with(|| a.path.cmp(&b.path))
656        };
657        Ok(rank_paginate(hits, cmp, query.offset, query.limit))
658    }
659
660    /// Return the symbol outline for a single file (by relative path).
661    pub fn outline(&self, rel_path: &str) -> Result<Vec<SymbolHit>> {
662        let mut out = Vec::new();
663        if let Some(&(si, doc_id)) = self.by_path().get(rel_path) {
664            let seg = &self.segments[si];
665            if let Some(doc) = seg.doc(doc_id) {
666                for sym in seg.doc_syms(doc_id) {
667                    out.push(SymbolHit {
668                        path: doc.path.clone(),
669                        lang: doc.lang.clone(),
670                        name: sym.name.clone(),
671                        kind: sym.kind.clone(),
672                        line_start: sym.line_start,
673                        line_end: sym.line_end,
674                        container: sym.container.clone(),
675                        signature: sym.signature.clone(),
676                        score: 1.0,
677                    });
678                }
679            }
680        }
681        out.sort_by_key(|s| s.line_start);
682        Ok(out)
683    }
684
685    /// Find references to an identifier (whole-word occurrences across the repo).
686    pub fn references(&self, name: &str, limit: usize, offset: usize) -> Result<Vec<SearchHit>> {
687        self.search(&SearchQuery {
688            pattern: name.to_string(),
689            whole_word: true,
690            limit,
691            offset,
692            ..Default::default()
693        })
694    }
695
696    /// All live symbol definitions whose name matches `name` exactly
697    /// (case-sensitive), as `(segment index, symbol index, symbol)` tuples.
698    /// O(results) via the per-segment name index — this is the inner loop of
699    /// `blast_radius` and `context_pack`, so it must not scan.
700    fn defs_by_name(&self, name: &str) -> Vec<(usize, usize, crate::segment::SymbolEntry)> {
701        let lower = name.to_ascii_lowercase();
702        let mut out = Vec::new();
703        for (si, seg) in self.segments.iter().enumerate() {
704            for idx in seg.syms_by_lower(&lower) {
705                // Cheap exact-case check on the name column before decoding.
706                if seg.sym_name(idx) != name {
707                    continue;
708                }
709                if let Some(sym) = seg.sym(idx) {
710                    if seg.is_live(sym.doc_id) {
711                        out.push((si, idx as usize, sym));
712                    }
713                }
714            }
715        }
716        out
717    }
718
719    /// Number of live call sites targeting `name` (call-graph in-degree),
720    /// computed via the per-segment callee-name index (no full ref scan).
721    ///
722    /// Counts through borrowed row views: the callee name is already known, so
723    /// decoding an owned `RefEntry` per site would allocate a `String` only to
724    /// drop it. `context_pack` calls this once per surviving candidate.
725    fn call_indegree(&self, name: &str) -> u32 {
726        let mut n = 0u32;
727        for seg in &self.segments {
728            for r in seg.ref_views_named(name) {
729                if r.kind == RefKind::Call && seg.is_live(r.doc_id) {
730                    n += 1;
731                }
732            }
733        }
734        n
735    }
736
737    /// Resolved references to `name`: its definitions, call sites, and imports,
738    /// drawn from the structural reference index (not text matching). Ranked
739    /// definitions first, then calls, then imports.
740    pub fn references_resolved(&self, name: &str, limit: usize, offset: usize) -> Vec<RefHit> {
741        let lower = name.to_ascii_lowercase();
742        let mut hits: Vec<RefHit> = Vec::new();
743        for seg in &self.segments {
744            // Definitions and references are both looked up through the
745            // per-segment name indexes (O(results), no table scans).
746            let def_rows: Vec<u32> = seg.syms_by_lower(&lower).collect();
747            for i in def_rows {
748                if seg.sym_name(i) != name {
749                    continue;
750                }
751                let sym = match seg.sym(i) {
752                    Some(s) => s,
753                    None => continue,
754                };
755                if seg.is_live(sym.doc_id) {
756                    if let Some(doc) = seg.doc(sym.doc_id) {
757                        hits.push(RefHit {
758                            path: doc.path.clone(),
759                            lang: doc.lang.clone(),
760                            name: sym.name,
761                            kind: "definition".to_string(),
762                            line: sym.line_start,
763                            column: 1,
764                            container: sym.container,
765                        });
766                    }
767                }
768            }
769            // As in `callers`: references to one name cluster into a few files,
770            // so resolve each document's symbol spans once.
771            let mut ranges: HashMap<u32, DocSymbolRanges> = HashMap::new();
772            let mut container_names: HashMap<u32, Option<String>> = HashMap::new();
773            for r in seg.ref_views_named(name) {
774                if seg.is_live(r.doc_id) {
775                    if let Some(doc) = seg.doc(r.doc_id) {
776                        let row = ranges
777                            .entry(r.doc_id)
778                            .or_insert_with(|| DocSymbolRanges::load(seg, r.doc_id))
779                            .enclosing_row(r.line);
780                        let container = match row {
781                            Some(i) => container_names
782                                .entry(i)
783                                .or_insert_with(|| seg.sym(i).map(|s| s.name))
784                                .clone(),
785                            None => None,
786                        };
787                        hits.push(RefHit {
788                            path: doc.path.clone(),
789                            lang: doc.lang.clone(),
790                            name: r.name.to_string(),
791                            kind: r.kind.as_str().to_string(),
792                            line: r.line,
793                            column: r.column,
794                            container,
795                        });
796                    }
797                }
798            }
799        }
800        let rank = |k: &str| match k {
801            "definition" => 0,
802            "call" => 1,
803            _ => 2,
804        };
805        hits.sort_by(|a, b| {
806            rank(&a.kind)
807                .cmp(&rank(&b.kind))
808                .then_with(|| a.path.cmp(&b.path))
809                .then_with(|| a.line.cmp(&b.line))
810        });
811        paginate(hits, offset, limit)
812    }
813
814    /// Call sites *inside* `name`'s body: what `name` calls. Built by locating
815    /// the definition(s) of `name` and collecting "call" refs within range.
816    pub fn callees(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
817        let mut out: Vec<CallSite> = Vec::new();
818        let mut seen: HashSet<(String, String, u32, u32)> = HashSet::new();
819        for (si, _, sym) in self.defs_by_name(name) {
820            let seg = &self.segments[si];
821            let doc = match seg.doc(sym.doc_id) {
822                Some(d) => d,
823                None => continue,
824            };
825            for r in seg.doc_ref_views(sym.doc_id) {
826                if r.kind == RefKind::Call && r.line >= sym.line_start && r.line <= sym.line_end {
827                    let key = (doc.path.clone(), r.name.to_string(), r.line, r.column);
828                    if !seen.insert(key) {
829                        continue;
830                    }
831                    out.push(CallSite {
832                        caller: Some(name.to_string()),
833                        callee: r.name.to_string(),
834                        path: doc.path.clone(),
835                        lang: doc.lang.clone(),
836                        line: r.line,
837                        column: r.column,
838                    });
839                }
840            }
841        }
842        out.sort_by(|a, b| {
843            a.callee
844                .cmp(&b.callee)
845                .then_with(|| a.path.cmp(&b.path))
846                .then_with(|| a.line.cmp(&b.line))
847        });
848        paginate(out, offset, limit)
849    }
850
851    /// Call sites that target `name`: who calls it. Each is attributed to its
852    /// enclosing caller symbol when one can be determined.
853    pub fn callers(&self, name: &str, limit: usize, offset: usize) -> Vec<CallSite> {
854        let mut out: Vec<CallSite> = Vec::new();
855        for seg in &self.segments {
856            // A hot callee's call sites cluster into a few files, so cache each
857            // document's symbol spans and each resolved caller name rather than
858            // re-deriving them per site.
859            let mut ranges: HashMap<u32, DocSymbolRanges> = HashMap::new();
860            let mut caller_names: HashMap<u32, Option<String>> = HashMap::new();
861            // O(results) via the prebuilt callee-name index instead of a full
862            // scan of every ref — this is the inner loop of `blast_radius`.
863            for r in seg.ref_views_named(name) {
864                if r.kind != RefKind::Call || !seg.is_live(r.doc_id) {
865                    continue;
866                }
867                let doc = match seg.doc(r.doc_id) {
868                    Some(d) => d,
869                    None => continue,
870                };
871                let row = ranges
872                    .entry(r.doc_id)
873                    .or_insert_with(|| DocSymbolRanges::load(seg, r.doc_id))
874                    .enclosing_row(r.line);
875                let caller = match row {
876                    Some(i) => caller_names
877                        .entry(i)
878                        .or_insert_with(|| seg.sym(i).map(|s| s.name))
879                        .clone(),
880                    None => None,
881                };
882                out.push(CallSite {
883                    caller,
884                    callee: name.to_string(),
885                    path: doc.path.clone(),
886                    lang: doc.lang.clone(),
887                    line: r.line,
888                    column: r.column,
889                });
890            }
891        }
892        out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
893        paginate(out, offset, limit)
894    }
895
896    /// Blast radius: the symbols transitively affected if `name` changes, found
897    /// by walking the reverse call graph (callers, then their callers, ...) up
898    /// to `depth` hops. Distance 0 is `name`'s own definition(s).
899    ///
900    /// Resolution is by name, so results are an approximation that can include
901    /// unrelated same-named symbols; it is a guide, not a proof.
902    pub fn blast_radius(&self, name: &str, depth: u32, limit: usize) -> Vec<ImpactNode> {
903        let mut out: Vec<ImpactNode> = Vec::new();
904        let mut visited: HashSet<String> = HashSet::new();
905        visited.insert(name.to_string());
906
907        // Distance 0: the target's own definitions.
908        for (si, _, sym) in self.defs_by_name(name) {
909            if let Some(doc) = self.segments[si].doc(sym.doc_id) {
910                out.push(ImpactNode {
911                    name: sym.name.clone(),
912                    kind: sym.kind.clone(),
913                    path: doc.path.clone(),
914                    lang: doc.lang.clone(),
915                    line_start: sym.line_start,
916                    line_end: sym.line_end,
917                    distance: 0,
918                });
919            }
920        }
921
922        let mut frontier: Vec<String> = vec![name.to_string()];
923        'expand: for dist in 1..=depth {
924            let mut next: Vec<String> = Vec::new();
925            for target in &frontier {
926                for site in self.callers(target, usize::MAX, 0) {
927                    let caller = match site.caller {
928                        Some(c) => c,
929                        None => continue,
930                    };
931                    if !visited.insert(caller.clone()) {
932                        continue;
933                    }
934                    for (si, _, sym) in self.defs_by_name(&caller) {
935                        if let Some(doc) = self.segments[si].doc(sym.doc_id) {
936                            out.push(ImpactNode {
937                                name: sym.name.clone(),
938                                kind: sym.kind.clone(),
939                                path: doc.path.clone(),
940                                lang: doc.lang.clone(),
941                                line_start: sym.line_start,
942                                line_end: sym.line_end,
943                                distance: dist,
944                            });
945                        }
946                    }
947                    next.push(caller);
948                }
949                // Stop expanding entirely once the limit is reached; deeper
950                // levels could only produce nodes that get truncated anyway.
951                if out.len() >= limit {
952                    break 'expand;
953                }
954            }
955            if next.is_empty() {
956                break;
957            }
958            frontier = next;
959        }
960        out.truncate(limit);
961        out
962    }
963
964    /// Typed go-to-definition: resolve the identifier at `rel_path:line:col` to
965    /// its most likely definition(s), combining scope/usage context with the
966    /// global symbol table. Returns candidates ranked by confidence; the unique
967    /// best is flagged `resolved`. Falls back to whole-word text hits (marked
968    /// unresolved) when the name has no indexed definition.
969    pub fn definition(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<DefHit>> {
970        let full = self.resolve_within_root(rel_path)?;
971        let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
972        let ext = Path::new(rel_path)
973            .extension()
974            .and_then(|e| e.to_str())
975            .unwrap_or("");
976        let lang = crate::lang::Language::from_extension(ext);
977
978        let ident = match crate::resolve::identifier_at(lang, &source, line, col) {
979            Some(i) => i,
980            None => {
981                return Err(Error::other(format!(
982                    "no identifier at {rel_path}:{line}:{col}"
983                )))
984            }
985        };
986
987        // Imports referenced by the use-file: a name imported here is likely
988        // defined elsewhere, which lets us prefer cross-file definitions.
989        let imported_here = self.imported_names(rel_path);
990
991        let mut cands: Vec<DefHit> = Vec::new();
992        for (si, _, sym) in self.defs_by_name(&ident.name) {
993            let seg = &self.segments[si];
994            let doc = match seg.doc(sym.doc_id) {
995                Some(d) => d,
996                None => continue,
997            };
998            let mut score = 10.0f32 + seg.doc_path_score(sym.doc_id);
999            let same_file = doc.path == rel_path;
1000            if same_file {
1001                score += 40.0;
1002            }
1003            score += 2.0 * shared_prefix_len(rel_path, &doc.path) as f32;
1004            // Usage-context preference.
1005            let method_like = matches!(sym.kind.as_str(), "method" | "field" | "property");
1006            if ident.is_member && method_like {
1007                score += 25.0;
1008            } else if !ident.is_member && !method_like {
1009                score += 8.0;
1010            }
1011            if ident.is_call
1012                && matches!(
1013                    sym.kind.as_str(),
1014                    "function" | "method" | "macro" | "constructor"
1015                )
1016            {
1017                score += 6.0;
1018            }
1019            if ident.is_type
1020                && matches!(
1021                    sym.kind.as_str(),
1022                    "struct" | "class" | "interface" | "enum" | "type" | "trait" | "record"
1023                )
1024            {
1025                score += 12.0;
1026            }
1027            // If the name is imported into the use-file, a cross-file definition
1028            // is the likely target.
1029            if imported_here.contains(&ident.name) && !same_file {
1030                score += 15.0;
1031            }
1032            cands.push(DefHit {
1033                path: doc.path.clone(),
1034                lang: doc.lang.clone(),
1035                name: sym.name.clone(),
1036                kind: sym.kind.clone(),
1037                line_start: sym.line_start,
1038                line_end: sym.line_end,
1039                container: sym.container.clone(),
1040                signature: sym.signature.clone(),
1041                score,
1042                resolved: false,
1043            });
1044        }
1045
1046        if cands.is_empty() {
1047            // Fallback: whole-word text occurrences, marked unresolved.
1048            let hits = self.references(&ident.name, 50, 0)?;
1049            return Ok(hits
1050                .into_iter()
1051                .map(|h| DefHit {
1052                    path: h.path,
1053                    lang: h.lang,
1054                    name: ident.name.clone(),
1055                    kind: "text".to_string(),
1056                    line_start: h.line,
1057                    line_end: h.line,
1058                    container: None,
1059                    signature: Some(h.text),
1060                    score: h.score,
1061                    resolved: false,
1062                })
1063                .collect());
1064        }
1065
1066        cands.sort_by(|a, b| {
1067            b.score
1068                .partial_cmp(&a.score)
1069                .unwrap_or(std::cmp::Ordering::Equal)
1070                .then_with(|| a.path.cmp(&b.path))
1071                .then_with(|| a.line_start.cmp(&b.line_start))
1072        });
1073        // Mark the unique best as resolved when it clears the runner-up.
1074        let unique_top =
1075            cands.len() == 1 || (cands.len() >= 2 && cands[0].score - cands[1].score >= 12.0);
1076        if unique_top {
1077            cands[0].resolved = true;
1078        }
1079        Ok(cands)
1080    }
1081
1082    /// Resolved references for the identifier at `rel_path:line:col`: its
1083    /// definitions, call sites, and imports across the repo.
1084    pub fn references_of(&self, rel_path: &str, line: u32, col: u32) -> Result<Vec<RefHit>> {
1085        let full = self.resolve_within_root(rel_path)?;
1086        let source = std::fs::read(&full).map_err(|e| Error::io(&full, e))?;
1087        let ext = Path::new(rel_path)
1088            .extension()
1089            .and_then(|e| e.to_str())
1090            .unwrap_or("");
1091        let lang = crate::lang::Language::from_extension(ext);
1092        let ident = crate::resolve::identifier_at(lang, &source, line, col)
1093            .ok_or_else(|| Error::other(format!("no identifier at {rel_path}:{line}:{col}")))?;
1094        Ok(self.references_resolved(&ident.name, usize::MAX, 0))
1095    }
1096
1097    /// The set of names imported into `rel_path` (from the reference index).
1098    fn imported_names(&self, rel_path: &str) -> HashSet<String> {
1099        let mut out = HashSet::new();
1100        if let Some(&(si, doc_id)) = self.by_path().get(rel_path) {
1101            for r in self.segments[si].doc_ref_views(doc_id) {
1102                if r.kind == RefKind::Import {
1103                    out.insert(r.name.to_string());
1104                }
1105            }
1106        }
1107        out
1108    }
1109
1110    /// Resolve a caller-supplied path against the project root, rejecting
1111    /// anything that would escape it: absolute paths (which would make
1112    /// `root.join(..)` discard the root entirely), `..` traversal, and symlinks
1113    /// that resolve outside the tree. Returns the absolute path to read.
1114    fn resolve_within_root(&self, rel_path: &str) -> Result<PathBuf> {
1115        let candidate = Path::new(rel_path);
1116        if candidate.is_absolute() {
1117            return Err(Error::other(format!(
1118                "path {rel_path:?} must be relative to the project root"
1119            )));
1120        }
1121        // Reject parent/prefix components before touching the filesystem.
1122        if candidate
1123            .components()
1124            .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
1125        {
1126            return Err(Error::other(format!(
1127                "path {rel_path:?} escapes the project root"
1128            )));
1129        }
1130        // Canonicalize both sides so symlinks can't redirect the read outside
1131        // the root, then require the resolved path to stay under it.
1132        let root = self
1133            .paths
1134            .root
1135            .canonicalize()
1136            .map_err(|e| Error::io(&self.paths.root, e))?;
1137        let full = root.join(candidate);
1138        let resolved = full.canonicalize().map_err(|e| Error::io(&full, e))?;
1139        if !resolved.starts_with(&root) {
1140            return Err(Error::other(format!(
1141                "path {rel_path:?} escapes the project root"
1142            )));
1143        }
1144        Ok(resolved)
1145    }
1146
1147    /// Read a slice of a file with surrounding context lines.
1148    pub fn read_snippet(
1149        &self,
1150        rel_path: &str,
1151        start_line: u32,
1152        end_line: u32,
1153        context: u32,
1154    ) -> Result<Snippet> {
1155        let full = self.resolve_within_root(rel_path)?;
1156        let data = std::fs::read_to_string(&full).map_err(|e| Error::io(&full, e))?;
1157        let lines: Vec<&str> = data.lines().collect();
1158        let total = lines.len() as u32;
1159        let to = end_line.saturating_add(context).min(total.max(1));
1160        // Clamp the start into the file as well so an out-of-range request never
1161        // reports a `start_line` past EOF or an inverted (start > end) range.
1162        let from = start_line
1163            .saturating_sub(context)
1164            .max(1)
1165            .min(total.max(1))
1166            .min(to);
1167        let mut body = String::new();
1168        let mut last = from;
1169        for ln in from..=to {
1170            if let Some(text) = lines.get((ln - 1) as usize) {
1171                if !body.is_empty() {
1172                    body.push('\n');
1173                }
1174                body.push_str(text);
1175                last = ln;
1176            }
1177        }
1178        Ok(Snippet {
1179            path: rel_path.to_string(),
1180            start_line: from,
1181            end_line: last,
1182            total_lines: total,
1183            text: body,
1184        })
1185    }
1186
1187    /// Build a token-budgeted context pack for `task`: the symbols (with
1188    /// signatures and code snippets) most relevant to the task, ranked by
1189    /// lexical relevance and call-graph centrality, plus their immediate
1190    /// dependency neighborhood. Designed to hand an agent exactly the code it
1191    /// needs without reading whole files.
1192    pub fn context_pack(&self, task: &str, budget_tokens: u64) -> crate::context::ContextPack {
1193        use crate::context::{self, ContextPack, PackItem};
1194
1195        let terms = context::tokenize(task);
1196
1197        // A candidate symbol (decoded once) with its location and score.
1198        struct Cand {
1199            seg: usize,
1200            sym: crate::segment::SymbolEntry,
1201            score: f32,
1202            reason: String,
1203        }
1204        // Scan every live document's symbols, in parallel across documents.
1205        //
1206        // This sweep touches every symbol in the repository and keeps only the
1207        // handful that score above zero, so the per-row work has to be as close
1208        // to free as possible. Two things make it so:
1209        //
1210        //  * rows are *borrowed* from the mmap (`doc_sym_views`) instead of
1211        //    decoded into owned `SymbolEntry`s — only survivors are
1212        //    materialized, via `to_entry`;
1213        //  * the per-document part of the score (the path term bonus and
1214        //    `path_score`) is computed once per file rather than once per
1215        //    symbol, and each worker reuses one `ScoreScratch` for the
1216        //    lowercase/tokenize buffers.
1217        //
1218        // A document's path matching a task term lifts *every* symbol in that
1219        // file above zero, so the name column alone can't prune the scan; the
1220        // cheap-per-row scoring above is what makes the full sweep affordable.
1221        // Live (segment, doc) pairs, materialized so the parallel scan runs over
1222        // an indexed iterator: rayon then preserves input order in the collected
1223        // output, keeping equal-scoring candidates in a deterministic sequence.
1224        let mut doc_targets: Vec<(usize, u32)> = Vec::new();
1225        for (si, seg) in self.segments.iter().enumerate() {
1226            for doc_id in 0..seg.docs.len() as u32 {
1227                if seg.is_live(doc_id) {
1228                    doc_targets.push((si, doc_id));
1229                }
1230            }
1231        }
1232
1233        let mut cands: Vec<Cand> = doc_targets
1234            .par_iter()
1235            .flat_map_iter(|&(si, doc_id)| {
1236                let seg = &self.segments[si];
1237                let mut out: Vec<Cand> = Vec::new();
1238                let doc = match seg.doc(doc_id) {
1239                    Some(d) => d,
1240                    None => return out.into_iter(),
1241                };
1242                let mut scratch = context::ScoreScratch::default();
1243                let path_bonus = context::path_term_bonus(&doc.path, &terms, &mut scratch);
1244                for view in seg.doc_sym_views(doc_id) {
1245                    let score = context::lexical_score_with(
1246                        view.name,
1247                        view.kind,
1248                        view.signature,
1249                        view.container,
1250                        path_bonus,
1251                        &terms,
1252                        &mut scratch,
1253                    );
1254                    if score <= 0.0 {
1255                        continue;
1256                    }
1257                    out.push(Cand {
1258                        seg: si,
1259                        sym: view.to_entry(),
1260                        score,
1261                        reason: "match".to_string(),
1262                    });
1263                }
1264                out.into_iter()
1265            })
1266            .collect();
1267
1268        // Call-graph centrality and the path preference, applied only to the
1269        // symbols that already cleared the lexical filter. Kept in this order
1270        // (centrality, then path) so the accumulated float score is bit-identical
1271        // to evaluating it inline, and the ranking below cannot shift.
1272        //
1273        // A task whose terms appear in a directory name lifts every symbol in
1274        // those files above zero, so this can run over thousands of candidates,
1275        // each doing an independent name-index lookup — worth spreading out.
1276        cands.par_iter_mut().for_each(|c| {
1277            let deg = self.call_indegree(&c.sym.name) as f32;
1278            c.score += (1.0 + deg).ln() * 1.5;
1279            c.score += self.segments[c.seg].doc_path_score(c.sym.doc_id);
1280        });
1281
1282        cands.sort_by(|a, b| {
1283            b.score
1284                .partial_cmp(&a.score)
1285                .unwrap_or(std::cmp::Ordering::Equal)
1286        });
1287
1288        // Expand the dependency neighborhood of the strongest seeds: include the
1289        // callees of the top matches so the agent sees what they depend on.
1290        let mut seen: HashSet<(String, u32)> = HashSet::new();
1291        for c in &cands {
1292            seen.insert((c.sym.name.clone(), c.sym.line_start));
1293        }
1294        let mut extra: Vec<Cand> = Vec::new();
1295        for c in cands.iter().take(8) {
1296            for callee in self.callees(&c.sym.name, 12, 0) {
1297                for (si2, _, def) in self.defs_by_name(&callee.callee) {
1298                    let key = (def.name.clone(), def.line_start);
1299                    if !seen.insert(key) {
1300                        continue;
1301                    }
1302                    extra.push(Cand {
1303                        seg: si2,
1304                        sym: def,
1305                        score: c.score * 0.3,
1306                        reason: format!("callee of {}", c.sym.name),
1307                    });
1308                }
1309            }
1310        }
1311        cands.extend(extra);
1312        cands.sort_by(|a, b| {
1313            b.score
1314                .partial_cmp(&a.score)
1315                .unwrap_or(std::cmp::Ordering::Equal)
1316        });
1317
1318        // Greedily pack within budget. Lines are read once per file through the
1319        // content cache and split once (cached by content hash), so multiple
1320        // packed symbols from the same file don't re-read or re-split it.
1321        //
1322        // Sizing a candidate requires its file, and a candidate that does not
1323        // fit is skipped rather than ending the loop (a smaller one further down
1324        // may still fit), so in the worst case every candidate's file is
1325        // touched: on a task whose terms appear in directory names that measured
1326        // 1,477 files and 55 MB read to fill an 8,000-token budget, and it was
1327        // 90% of the whole operation. So walk the candidates in chunks and warm
1328        // each chunk's files concurrently before packing it. The greedy pass
1329        // still sees candidates in exactly the same order, and stopping early
1330        // wastes at most one chunk of prefetching.
1331        let mut items: Vec<PackItem> = Vec::new();
1332        let mut used: u64 = 0;
1333        let mut truncated = false;
1334        let mut file_lines: HashMap<u64, Arc<FileLines>> = HashMap::new();
1335        const MAX_ITEM_LINES: u32 = 60;
1336        /// Candidates whose files are warmed per round. Large enough to keep the
1337        /// prefetch wide, small enough that an early stop wastes little.
1338        const PACK_PREFETCH_CHUNK: usize = 256;
1339        'packing: for chunk in cands.chunks(PACK_PREFETCH_CHUNK) {
1340            let mut wanted: Vec<(u64, &str)> = Vec::new();
1341            let mut seen_hash: HashSet<u64> = HashSet::new();
1342            for c in chunk {
1343                if let Some(doc) = self.segments[c.seg].doc(c.sym.doc_id) {
1344                    if !file_lines.contains_key(&doc.hash) && seen_hash.insert(doc.hash) {
1345                        wanted.push((doc.hash, doc.path.as_str()));
1346                    }
1347                }
1348            }
1349            let loaded: Vec<(u64, Arc<FileLines>)> = wanted
1350                .par_iter()
1351                .map(|&(hash, path)| {
1352                    let full = self.paths.root.join(path);
1353                    let data = self.content.get_or_read(hash, &full);
1354                    (
1355                        hash,
1356                        Arc::new(FileLines::new(data.as_deref().unwrap_or(&[]))),
1357                    )
1358                })
1359                .collect();
1360            file_lines.extend(loaded);
1361
1362            for c in chunk {
1363                let seg = &self.segments[c.seg];
1364                let sym = &c.sym;
1365                let doc = match seg.doc(sym.doc_id) {
1366                    Some(d) => d,
1367                    None => continue,
1368                };
1369                let end = sym
1370                    .line_end
1371                    .min(sym.line_start.saturating_add(MAX_ITEM_LINES));
1372                // Warmed by the prefetch above; the fallback keeps this correct if a
1373                // candidate's document went missing between the two passes.
1374                let lines = match file_lines.get(&doc.hash) {
1375                    Some(l) => l.clone(),
1376                    None => {
1377                        let full = self.paths.root.join(&doc.path);
1378                        let data = self.content.get_or_read(doc.hash, &full);
1379                        let l = Arc::new(FileLines::new(data.as_deref().unwrap_or(&[])));
1380                        file_lines.insert(doc.hash, l.clone());
1381                        l
1382                    }
1383                };
1384                let from = sym.line_start.max(1);
1385                let to = end.min(lines.len() as u32);
1386                // Size the snippet before building it. Once the budget is nearly
1387                // full most candidates no longer fit, and this loop keeps scanning
1388                // them (a later, smaller one may still fit), so assembling a
1389                // `String` per rejected candidate was pure waste. Summing the line
1390                // lengths gives exactly `code.len()`: a separator is added only
1391                // when something has already been written, so `code_len > 0` stands
1392                // in for `!code.is_empty()` — which matters when a leading line is
1393                // itself empty.
1394                let mut code_len = 0usize;
1395                for ln in from..=to {
1396                    if let Some(text) = lines.get((ln - 1) as usize) {
1397                        if code_len > 0 {
1398                            code_len += 1;
1399                        }
1400                        code_len += text.len();
1401                    }
1402                }
1403                let chars: u64 =
1404                    code_len as u64 + sym.signature.as_ref().map(|s| s.len() as u64).unwrap_or(0);
1405                let cost = context::est_tokens(chars).max(1);
1406                if used + cost > budget_tokens && !items.is_empty() {
1407                    truncated = true;
1408                    continue;
1409                }
1410                let mut code = String::with_capacity(code_len);
1411                for ln in from..=to {
1412                    if let Some(text) = lines.get((ln - 1) as usize) {
1413                        if !code.is_empty() {
1414                            code.push('\n');
1415                        }
1416                        code.push_str(text);
1417                    }
1418                }
1419                debug_assert_eq!(code.len(), code_len, "snippet cost estimate must be exact");
1420                used += cost;
1421                items.push(PackItem {
1422                    path: doc.path.clone(),
1423                    lang: doc.lang.clone(),
1424                    name: sym.name.clone(),
1425                    kind: sym.kind.clone(),
1426                    line_start: sym.line_start,
1427                    line_end: sym.line_end,
1428                    signature: sym.signature.clone(),
1429                    snippet_start: from,
1430                    code,
1431                    reason: c.reason.clone(),
1432                    score: c.score,
1433                });
1434                if used >= budget_tokens {
1435                    truncated = truncated || items.len() < cands.len();
1436                    break 'packing;
1437                }
1438            }
1439        }
1440
1441        ContextPack {
1442            task: task.to_string(),
1443            budget_tokens,
1444            used_tokens: used,
1445            truncated,
1446            items,
1447        }
1448    }
1449
1450    /// Blame a single line: the commit and author that last touched it.
1451    pub fn blame(&self, rel_path: &str, line: u32) -> Result<crate::git::BlameLine> {
1452        // Validate the path stays within the project root.
1453        self.resolve_within_root(rel_path)?;
1454        crate::git::blame(&self.paths.root, rel_path, line)
1455    }
1456
1457    /// The commit history of a symbol: resolve `name` to its definition and list
1458    /// the commits that touched that line range, newest first.
1459    pub fn symbol_history(&self, name: &str, limit: usize) -> Result<SymbolHistory> {
1460        // Prefer the highest-ranked (non-test/vendor) definition.
1461        let defs = self.defs_by_name(name);
1462        let best = defs
1463            .iter()
1464            .max_by(|a, b| {
1465                let pa = self.segments[a.0].doc_path_score(a.2.doc_id);
1466                let pb = self.segments[b.0].doc_path_score(b.2.doc_id);
1467                pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
1468            })
1469            .ok_or_else(|| Error::other(format!("no definition found for {name:?}")))?;
1470        let (si, _, sym) = best;
1471        let si = *si;
1472        let doc = self.segments[si]
1473            .doc(sym.doc_id)
1474            .ok_or_else(|| Error::other("definition document missing".to_string()))?;
1475        let commits = crate::git::line_history(
1476            &self.paths.root,
1477            &doc.path,
1478            sym.line_start,
1479            sym.line_end,
1480            limit,
1481        )
1482        .or_else(|_| crate::git::file_history(&self.paths.root, &doc.path, limit))?;
1483        Ok(SymbolHistory {
1484            name: name.to_string(),
1485            path: doc.path.clone(),
1486            line_start: sym.line_start,
1487            line_end: sym.line_end,
1488            commits,
1489        })
1490    }
1491
1492    /// Files changed since `rev`, annotated with the symbols defined in each
1493    /// (from the index) so an agent sees the affected API surface at a glance.
1494    pub fn changed_since(&self, rev: &str) -> Result<Vec<ChangedSymbols>> {
1495        let changed = crate::git::changed_since(&self.paths.root, rev)?;
1496        let mut out = Vec::with_capacity(changed.len());
1497        for cf in changed {
1498            let mut symbols = Vec::new();
1499            if let Some(&(si, doc_id)) = self.by_path().get(&cf.path) {
1500                for s in self.segments[si].doc_syms(doc_id) {
1501                    symbols.push(s.name.clone());
1502                }
1503            }
1504            symbols.sort();
1505            symbols.dedup();
1506            out.push(ChangedSymbols {
1507                path: cf.path,
1508                status: cf.status,
1509                symbols,
1510            });
1511        }
1512        Ok(out)
1513    }
1514
1515    /// Structural (AST) search: match a tree-sitter query or `$NAME`
1516    /// meta-variable pattern across documents of one language. Literal tokens in
1517    /// the pattern prune candidates via the trigram index before parsing.
1518    pub fn structural_search(
1519        &self,
1520        pattern: &str,
1521        lang: &str,
1522        limit: usize,
1523        offset: usize,
1524    ) -> Result<Vec<StructHit>> {
1525        let language = crate::lang::Language::from_id(lang)
1526            .ok_or_else(|| Error::other(format!("unknown language id: {lang:?}")))?;
1527        if language.grammar().is_none() {
1528            return Err(Error::other(format!(
1529                "language {lang} is not parseable for structural search"
1530            )));
1531        }
1532        let compiled = crate::structural::compile(language, pattern)?;
1533
1534        // Prefilter on the most selective literal anchor, if any.
1535        let anchor = compiled.anchors.iter().max_by_key(|a| a.len()).cloned();
1536        let tq = anchor
1537            .as_ref()
1538            .map(|a| TrigramQuery::from_literal(a.as_bytes()));
1539
1540        let mut targets: Vec<(usize, u32)> = Vec::new();
1541        for (si, seg) in self.segments.iter().enumerate() {
1542            let candidates = match &tq {
1543                Some(q) => seg.candidates(q)?,
1544                None => seg.all_live(),
1545            };
1546            for doc_id in candidates.iter() {
1547                if !seg.is_live(doc_id) {
1548                    continue;
1549                }
1550                match seg.doc(doc_id) {
1551                    Some(d) if d.lang == lang => targets.push((si, doc_id)),
1552                    _ => {}
1553                }
1554            }
1555        }
1556
1557        let root = &self.paths.root;
1558        let segments = &self.segments;
1559        let content = &self.content;
1560        let compiled_ref = &compiled;
1561        let hits: Vec<StructHit> = targets
1562            .par_iter()
1563            .flat_map_iter(|&(si, doc_id)| {
1564                let seg = &segments[si];
1565                let doc = match seg.doc(doc_id) {
1566                    Some(d) => d,
1567                    None => return Vec::new().into_iter(),
1568                };
1569                let full = root.join(&doc.path);
1570                let data = match content.get_or_read(doc.hash, &full) {
1571                    Some(d) => d,
1572                    None => return Vec::new().into_iter(),
1573                };
1574                let matches = crate::structural::run(language, compiled_ref, &data);
1575                let line_starts = line_starts(&data);
1576                let out: Vec<StructHit> = matches
1577                    .into_iter()
1578                    .map(|m| {
1579                        let li = (m.line_start.saturating_sub(1)) as usize;
1580                        let text = line_starts
1581                            .get(li)
1582                            .map(|_| snippet(line_slice(&data, &line_starts, li)))
1583                            .unwrap_or_default();
1584                        StructHit {
1585                            path: doc.path.clone(),
1586                            lang: doc.lang.clone(),
1587                            line_start: m.line_start,
1588                            line_end: m.line_end,
1589                            kind: m.kind,
1590                            text,
1591                            captures: m.captures,
1592                        }
1593                    })
1594                    .collect();
1595                out.into_iter()
1596            })
1597            .collect();
1598
1599        let cmp = |a: &StructHit, b: &StructHit| {
1600            a.path
1601                .cmp(&b.path)
1602                .then_with(|| a.line_start.cmp(&b.line_start))
1603        };
1604        let mut hits = hits;
1605        hits.sort_by(cmp);
1606        Ok(paginate(hits, offset, limit))
1607    }
1608
1609    /// Summarize the indexed repository.
1610    pub fn summary(&self) -> RepoSummary {
1611        use std::collections::HashMap;
1612        let mut by_lang: HashMap<String, LangStat> = HashMap::new();
1613        let mut by_dir: HashMap<String, u64> = HashMap::new();
1614        let mut files = 0u64;
1615        let mut bytes = 0u64;
1616        let mut symbols = 0u64;
1617        for seg in &self.segments {
1618            for (doc_id, doc) in seg.docs.iter().enumerate() {
1619                if !seg.is_live(doc_id as u32) {
1620                    continue;
1621                }
1622                files += 1;
1623                bytes += doc.size;
1624                let e = by_lang.entry(doc.lang.clone()).or_default();
1625                e.files += 1;
1626                e.bytes += doc.size;
1627                let dir = doc.path.split('/').next().unwrap_or("").to_string();
1628                *by_dir.entry(dir).or_default() += 1;
1629                // Symbol counts come from the doc CSR — no row is decoded.
1630                symbols += u64::from(seg.doc_sym_count(doc_id as u32));
1631            }
1632        }
1633        let mut languages: Vec<LangStat> = by_lang
1634            .into_iter()
1635            .map(|(lang, mut s)| {
1636                s.lang = lang;
1637                s
1638            })
1639            .collect();
1640        // Break count ties by name. Both lists are collected from a `HashMap`,
1641        // whose iteration order varies per process, and a stable sort preserved
1642        // it — so `summary` returned a different ordering on every run, and
1643        // because `top_dirs` is truncated, a different *set* of directories too.
1644        languages.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.lang.cmp(&b.lang)));
1645        let mut top_dirs: Vec<(String, u64)> = by_dir.into_iter().collect();
1646        top_dirs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1647        top_dirs.truncate(15);
1648        RepoSummary {
1649            files,
1650            bytes,
1651            symbols,
1652            segments: self.segments.len(),
1653            languages,
1654            top_dirs: top_dirs
1655                .into_iter()
1656                .map(|(name, files)| DirStat { name, files })
1657                .collect(),
1658        }
1659    }
1660}
1661
1662/// A file slice with context, returned by [`Searcher::read_snippet`].
1663///
1664/// The body is a single `text` blob (lines joined by `\n`) rather than an array
1665/// of per-line objects: line N is `start_line + i` for the i-th line, so the
1666/// numbers are implicit and never repeated on the wire. This keeps the payload
1667/// compact for agents while staying exactly reconstructable.
1668#[derive(Debug, Clone, Serialize, Deserialize)]
1669pub struct Snippet {
1670    pub path: String,
1671    pub start_line: u32,
1672    pub end_line: u32,
1673    pub total_lines: u32,
1674    pub text: String,
1675}
1676
1677/// Repository summary returned by [`Searcher::summary`].
1678#[derive(Debug, Clone, Serialize, Deserialize)]
1679pub struct RepoSummary {
1680    pub files: u64,
1681    pub bytes: u64,
1682    pub symbols: u64,
1683    pub segments: usize,
1684    pub languages: Vec<LangStat>,
1685    pub top_dirs: Vec<DirStat>,
1686}
1687
1688#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1689pub struct LangStat {
1690    pub lang: String,
1691    pub files: u64,
1692    pub bytes: u64,
1693}
1694
1695#[derive(Debug, Clone, Serialize, Deserialize)]
1696pub struct DirStat {
1697    pub name: String,
1698    pub files: u64,
1699}
1700
1701/// Read a single candidate file and collect matching lines. Matches are found
1702/// over the whole buffer (so regexes may span lines), mapped to line numbers,
1703/// then ranked so the highest-scored matches survive `max_per_file` truncation.
1704#[allow(clippy::too_many_arguments)] // hot path; threading a struct adds churn without clarity
1705fn verify_doc(
1706    seg: &Segment,
1707    doc_id: u32,
1708    root: &Path,
1709    content: &ContentCache,
1710    matcher: &Matcher,
1711    max_per_file: usize,
1712    whole_word: bool,
1713    exhaustive: bool,
1714) -> Vec<SearchHit> {
1715    let doc = match seg.doc(doc_id) {
1716        Some(d) => d,
1717        None => return Vec::new(),
1718    };
1719    let full = root.join(&doc.path);
1720    let data = match content.get_or_read(doc.hash, &full) {
1721        Some(d) => d,
1722        None => return Vec::new(),
1723    };
1724
1725    // Exhaustive search lifts the pathological-input cap so no match is dropped.
1726    let cap = if exhaustive {
1727        usize::MAX
1728    } else {
1729        PER_FILE_MATCH_CAP
1730    };
1731    let matches = matcher.match_starts(&data, whole_word, cap);
1732    if matches.is_empty() {
1733        return Vec::new();
1734    }
1735
1736    let line_starts = line_starts(&data);
1737    let sym_lines = symbol_lines(seg, doc_id);
1738    let base = seg.doc_path_score(doc_id);
1739
1740    let mut out = Vec::new();
1741    let mut last_line = 0u32;
1742    for (start, _end) in matches {
1743        let li = line_of(start, &line_starts);
1744        let line_no = li as u32 + 1;
1745        // One hit per line; matches are in ascending offset order.
1746        if line_no == last_line {
1747            continue;
1748        }
1749        last_line = line_no;
1750        let col = (start - line_starts[li]) as u32 + 1;
1751        let line_bytes = line_slice(&data, &line_starts, li);
1752        let mut score = 1.0 + base;
1753        if sym_lines.binary_search(&line_no).is_ok() {
1754            score += 3.0;
1755        }
1756        out.push(SearchHit {
1757            path: doc.path.clone(),
1758            lang: doc.lang.clone(),
1759            line: line_no,
1760            column: col,
1761            text: snippet(line_bytes),
1762            score,
1763        });
1764    }
1765
1766    // Keep the highest-scored matches when a file has more than the cap.
1767    // Exhaustive mode keeps every line.
1768    if !exhaustive && out.len() > max_per_file {
1769        out.sort_by(|a, b| {
1770            b.score
1771                .partial_cmp(&a.score)
1772                .unwrap_or(std::cmp::Ordering::Equal)
1773                .then_with(|| a.line.cmp(&b.line))
1774        });
1775        out.truncate(max_per_file);
1776    }
1777    out
1778}
1779
1780/// A file's text together with the byte span of each of its lines, so snippets
1781/// can be sliced out without allocating a `String` per line.
1782///
1783/// Context packing needs the lines of every candidate file, and a candidate that
1784/// does not fit the remaining budget is skipped — but the file still had to be
1785/// split to find that out. Splitting into `Vec<String>` cost one allocation per
1786/// line of every file touched (thousands per file on a large source tree); this
1787/// costs one copy of the file plus one span table.
1788struct FileLines {
1789    text: String,
1790    /// `(start, end)` byte offsets within `text`, excluding line terminators.
1791    spans: Vec<(usize, usize)>,
1792}
1793
1794impl FileLines {
1795    fn new(data: &[u8]) -> FileLines {
1796        // Source files are almost always valid UTF-8, and validating is the bulk
1797        // of the conversion for a multi-MB candidate set. `simdutf8` checks that
1798        // vectorized; lossy conversion of already-valid input yields the same
1799        // bytes, so the fast path is exactly equivalent.
1800        let text = match simdutf8::basic::from_utf8(data) {
1801            Ok(s) => s.to_owned(),
1802            Err(_) => String::from_utf8_lossy(data).into_owned(),
1803        };
1804        // Derive the spans from `str::lines` itself rather than reimplementing
1805        // its rules (`\n` vs `\r\n`, optional final terminator): each yielded
1806        // slice points into `text`, so its offset is exact by construction.
1807        //
1808        // Offsets are `usize`, not `u32`: `max_file_size` is configurable
1809        // (`GREPLM_MAX_FILE_SIZE`) and files are re-read at query time, so a
1810        // 4 GiB+ file is reachable, and truncating an offset would produce an
1811        // inverted range and panic on the slice.
1812        let base = text.as_ptr() as usize;
1813        let spans = text
1814            .lines()
1815            .map(|line| {
1816                let start = line.as_ptr() as usize - base;
1817                (start, start + line.len())
1818            })
1819            .collect();
1820        FileLines { text, spans }
1821    }
1822
1823    fn len(&self) -> usize {
1824        self.spans.len()
1825    }
1826
1827    fn get(&self, i: usize) -> Option<&str> {
1828        self.spans.get(i).map(|&(s, e)| &self.text[s..e])
1829    }
1830}
1831
1832/// The symbol line spans of one document, in storage order, so that many
1833/// positions can be resolved against the same document without re-reading its
1834/// line column each time.
1835///
1836/// A hot callee has thousands of call sites concentrated in a few files, and
1837/// attributing each one to its enclosing caller is the inner loop of `callers`
1838/// and therefore of `blast_radius`. Reading the document's spans once turns that
1839/// from O(sites x symbols-per-document) into O(symbols-per-document + sites).
1840struct DocSymbolRanges {
1841    /// `(line_start, line_end, row id)`, in storage order.
1842    rows: Vec<(u32, u32, u32)>,
1843}
1844
1845impl DocSymbolRanges {
1846    fn load(seg: &Segment, doc_id: u32) -> DocSymbolRanges {
1847        let rows = seg
1848            .doc_sym_rows(doc_id)
1849            .filter_map(|i| seg.sym_view(i).map(|v| (v.line_start, v.line_end, i)))
1850            .collect();
1851        DocSymbolRanges { rows }
1852    }
1853
1854    /// Row id of the innermost symbol whose range contains `line`.
1855    ///
1856    /// Among equally tight ranges the earliest row in storage order wins, which
1857    /// is the tie-break the original per-call scan had.
1858    fn enclosing_row(&self, line: u32) -> Option<u32> {
1859        let mut best: Option<(u32, u32)> = None; // (row id, span)
1860        for &(start, end, i) in &self.rows {
1861            if start <= line && line <= end {
1862                let span = end - start;
1863                match best {
1864                    Some((_, best_span)) if best_span <= span => {}
1865                    _ => best = Some((i, span)),
1866                }
1867            }
1868        }
1869        best.map(|(i, _)| i)
1870    }
1871}
1872
1873/// Build the path -> (segment index, doc id) lookup over live documents.
1874/// A path is live in exactly one segment (changed files tombstone the old
1875/// copy), so the map is unambiguous.
1876fn build_path_index(segments: &[Segment]) -> HashMap<String, (usize, u32)> {
1877    let mut map = HashMap::new();
1878    for (si, seg) in segments.iter().enumerate() {
1879        for (doc_id, doc) in seg.docs.iter().enumerate() {
1880            let doc_id = doc_id as u32;
1881            if seg.is_live(doc_id) {
1882                map.insert(doc.path.clone(), (si, doc_id));
1883            }
1884        }
1885    }
1886    map
1887}
1888
1889/// Byte offsets at which each line begins (index 0 is the start of the file).
1890fn line_starts(data: &[u8]) -> Vec<usize> {
1891    let mut starts = Vec::with_capacity(64);
1892    starts.push(0usize);
1893    for p in memchr::memchr_iter(b'\n', data) {
1894        starts.push(p + 1);
1895    }
1896    starts
1897}
1898
1899/// Zero-based line index containing byte offset `off`.
1900fn line_of(off: usize, starts: &[usize]) -> usize {
1901    // Greatest line start that is <= off.
1902    starts.partition_point(|&s| s <= off).saturating_sub(1)
1903}
1904
1905/// The bytes of line `li` (without the trailing newline).
1906fn line_slice<'a>(data: &'a [u8], starts: &[usize], li: usize) -> &'a [u8] {
1907    let begin = starts[li];
1908    let end = if li + 1 < starts.len() {
1909        starts[li + 1].saturating_sub(1)
1910    } else {
1911        data.len()
1912    };
1913    &data[begin..end.min(data.len())]
1914}
1915
1916/// ASCII-case-insensitive substring test. `needle` must already be ASCII
1917/// lowercase. Avoids materializing a lowercased copy of `hay`, which is what
1918/// made [`path_score`] too expensive to call per hit.
1919///
1920/// Naive offset scan: both strings are short (a repo-relative path and a
1921/// handful of literals), so this beats any setup cost.
1922fn contains_ascii_ci(hay: &str, needle: &[u8]) -> bool {
1923    let h = hay.as_bytes();
1924    if needle.len() > h.len() {
1925        return false;
1926    }
1927    (0..=h.len() - needle.len()).any(|i| {
1928        h[i..i + needle.len()]
1929            .iter()
1930            .zip(needle)
1931            .all(|(a, b)| a.to_ascii_lowercase() == *b)
1932    })
1933}
1934
1935/// Ranking adjustment based on the file path: prefer shallow paths and
1936/// non-generated/non-test files.
1937///
1938/// Called once per hit, per ranked candidate and per scored symbol, so it must
1939/// not allocate — it used to build a lowercased copy of the path.
1940///
1941/// The test-path check needs just two probes, not five: `/tests/`, `__tests__`
1942/// and `.test.` all contain `test`, so they can only match when `test` does.
1943pub(crate) fn path_score(path: &str) -> f32 {
1944    let mut s = 0.0f32;
1945    let depth = memchr::memchr_iter(b'/', path.as_bytes()).count() as f32;
1946    s -= depth * 0.05;
1947    if contains_ascii_ci(path, b"test") || contains_ascii_ci(path, b".spec.") {
1948        s -= 1.0;
1949    }
1950    if contains_ascii_ci(path, b"/vendor/")
1951        || contains_ascii_ci(path, b"/generated/")
1952        || contains_ascii_ci(path, b".min.")
1953    {
1954        s -= 1.5;
1955    }
1956    s
1957}
1958
1959/// Sorted, deduplicated set of lines on which `doc_id` defines a symbol, used
1960/// to bonus-score matches that land on a definition.
1961///
1962/// Runs for every file that produced a match, so it reads the line column
1963/// through borrowed views rather than materializing each symbol's strings. A
1964/// sorted `Vec` beats a `HashSet` here: the counts are small and it is one
1965/// allocation instead of a table plus hashing per row.
1966fn symbol_lines(seg: &Segment, doc_id: u32) -> Vec<u32> {
1967    let mut lines: Vec<u32> = seg.doc_sym_views(doc_id).map(|s| s.line_start).collect();
1968    lines.sort_unstable();
1969    lines.dedup();
1970    lines
1971}
1972
1973fn match_symbol(name: &str, lower: &str, needle: &str, exact: bool) -> Option<f32> {
1974    if exact {
1975        return if lower == needle { Some(100.0) } else { None };
1976    }
1977    if lower == needle {
1978        Some(100.0)
1979    } else if lower.starts_with(needle) {
1980        Some(70.0)
1981    } else if acronym_eq(name, needle) {
1982        // e.g. "lc" matches loadConfig / load_config.
1983        Some(60.0)
1984    } else if lower.contains(needle) {
1985        Some(50.0)
1986    } else if is_subsequence(needle, lower) {
1987        Some(30.0)
1988    } else {
1989        None
1990    }
1991}
1992
1993/// True when `needle` equals the acronym of `name` — the lowercased first
1994/// letter of each identifier token, split on camelCase and snake/kebab (so "lc"
1995/// matches `loadConfig` and `load_config`).
1996///
1997/// Streams the comparison instead of building the acronym. The fuzzy symbol
1998/// scan reaches this branch for nearly every symbol in the repository (only
1999/// exact and prefix matches short-circuit before it), so the old version's
2000/// `Vec<String>` of tokens per symbol dominated that path.
2001fn acronym_eq(name: &str, needle: &str) -> bool {
2002    let mut want = needle.chars();
2003    let mut prev_lower = false;
2004    // Whether we are inside a token; mirrors the tokenizer's `!cur.is_empty()`.
2005    let mut open = false;
2006    for ch in name.chars() {
2007        if ch == '_' || ch == '-' || ch == ' ' {
2008            open = false;
2009            prev_lower = false;
2010            continue;
2011        }
2012        if ch.is_uppercase() && prev_lower && open {
2013            open = false;
2014        }
2015        if !open {
2016            open = true;
2017            // This character starts a token, so the first `char` of its
2018            // lowercased form is the acronym letter this token contributes.
2019            let c = match ch.to_lowercase().next() {
2020                Some(c) => c,
2021                None => continue,
2022            };
2023            if want.next() != Some(c) {
2024                return false;
2025            }
2026        }
2027        prev_lower = ch.is_lowercase() || ch.is_numeric();
2028    }
2029    // Every token consumed exactly one needle character, and none are left.
2030    want.next().is_none()
2031}
2032
2033/// Rank `items` best-first and apply offset/limit. Uses a partial selection so
2034/// we only fully sort the `offset + limit` items we actually return.
2035fn rank_paginate<T, F>(mut items: Vec<T>, cmp: F, offset: usize, limit: usize) -> Vec<T>
2036where
2037    F: Fn(&T, &T) -> std::cmp::Ordering,
2038{
2039    let need = offset.saturating_add(limit);
2040    if need == 0 {
2041        return Vec::new();
2042    }
2043    if need < items.len() {
2044        items.select_nth_unstable_by(need - 1, |a, b| cmp(a, b));
2045        items.truncate(need);
2046    }
2047    items.sort_by(|a, b| cmp(a, b));
2048    if offset >= items.len() {
2049        return Vec::new();
2050    }
2051    items.drain(0..offset);
2052    items.truncate(limit);
2053    items
2054}
2055
2056/// Index-free fallback search: walk the working tree and scan every file with
2057/// the matcher, with no trigram prefilter. Used when the index is missing or
2058/// errors, so `search` still returns grep-equivalent results instead of failing.
2059/// Honors the same `lang`/`path` filters, `exhaustive` mode, and ordering as the
2060/// indexed path. Slower (reads every candidate file) but correct and complete.
2061pub fn grep_walk(paths: &Paths, config: &Config, query: &SearchQuery) -> Result<Vec<SearchHit>> {
2062    if query.pattern.is_empty() {
2063        return Ok(Vec::new());
2064    }
2065    let matcher = Matcher::build(query)?;
2066    let walked = crate::walk::walk(paths, config)?;
2067    let path_filter = query.path.as_deref();
2068    let lang_filter = query.lang.as_deref();
2069    let max_per_file = query.max_per_file;
2070    let whole_word = query.whole_word;
2071    let exhaustive = query.exhaustive;
2072    let index_binary = config.index_binary;
2073    let cap = if exhaustive {
2074        usize::MAX
2075    } else {
2076        PER_FILE_MATCH_CAP
2077    };
2078
2079    let mut hits: Vec<SearchHit> = walked
2080        .entries
2081        .par_iter()
2082        .flat_map_iter(|e| {
2083            if path_filter.is_some_and(|pf| !e.rel.contains(pf)) {
2084                return Vec::new().into_iter();
2085            }
2086            let ext = e
2087                .path
2088                .extension()
2089                .and_then(|x| x.to_str())
2090                .unwrap_or("")
2091                .to_ascii_lowercase();
2092            let lang_id = Language::from_extension(&ext).id().to_string();
2093            if lang_filter.is_some_and(|lf| lang_id != lf) {
2094                return Vec::new().into_iter();
2095            }
2096            let data = match std::fs::read(&e.path) {
2097                Ok(d) => d,
2098                Err(_) => return Vec::new().into_iter(),
2099            };
2100            if !index_binary && memchr::memchr(0, &data).is_some() {
2101                return Vec::new().into_iter();
2102            }
2103            let matches = matcher.match_starts(&data, whole_word, cap);
2104            if matches.is_empty() {
2105                return Vec::new().into_iter();
2106            }
2107            let starts = line_starts(&data);
2108            let base = path_score(&e.rel);
2109            let mut out = Vec::new();
2110            let mut last_line = 0u32;
2111            for (start, _end) in matches {
2112                let li = line_of(start, &starts);
2113                let line_no = li as u32 + 1;
2114                if line_no == last_line {
2115                    continue;
2116                }
2117                last_line = line_no;
2118                let col = (start - starts[li]) as u32 + 1;
2119                out.push(SearchHit {
2120                    path: e.rel.clone(),
2121                    lang: lang_id.clone(),
2122                    line: line_no,
2123                    column: col,
2124                    text: snippet(line_slice(&data, &starts, li)),
2125                    score: 1.0 + base,
2126                });
2127            }
2128            if !exhaustive && out.len() > max_per_file {
2129                out.sort_by(|a, b| {
2130                    b.score
2131                        .partial_cmp(&a.score)
2132                        .unwrap_or(std::cmp::Ordering::Equal)
2133                        .then_with(|| a.line.cmp(&b.line))
2134                });
2135                out.truncate(max_per_file);
2136            }
2137            out.into_iter()
2138        })
2139        .collect();
2140
2141    if exhaustive {
2142        hits.sort_by(|a, b| {
2143            a.path
2144                .cmp(&b.path)
2145                .then_with(|| a.line.cmp(&b.line))
2146                .then_with(|| a.column.cmp(&b.column))
2147        });
2148        return Ok(hits);
2149    }
2150    let cmp = |a: &SearchHit, b: &SearchHit| {
2151        b.score
2152            .partial_cmp(&a.score)
2153            .unwrap_or(std::cmp::Ordering::Equal)
2154            .then_with(|| a.path.cmp(&b.path))
2155            .then_with(|| a.line.cmp(&b.line))
2156    };
2157    Ok(rank_paginate(hits, cmp, query.offset, query.limit))
2158}
2159
2160/// Number of leading path components shared by two relative paths.
2161fn shared_prefix_len(a: &str, b: &str) -> usize {
2162    a.split('/')
2163        .zip(b.split('/'))
2164        .take_while(|(x, y)| x == y)
2165        .count()
2166}
2167
2168/// Apply offset/limit to an already-ordered vector.
2169fn paginate<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Vec<T> {
2170    if offset >= items.len() {
2171        return Vec::new();
2172    }
2173    items.drain(0..offset);
2174    items.truncate(limit);
2175    items
2176}
2177
2178fn is_subsequence(needle: &str, haystack: &str) -> bool {
2179    if needle.is_empty() {
2180        return true;
2181    }
2182    let mut chars = needle.chars();
2183    let mut cur = chars.next();
2184    for h in haystack.chars() {
2185        if let Some(c) = cur {
2186            if c == h {
2187                cur = chars.next();
2188            }
2189        } else {
2190            break;
2191        }
2192    }
2193    cur.is_none()
2194}
2195
2196/// Trim and bound a matched line for display.
2197fn snippet(line: &[u8]) -> String {
2198    let s = String::from_utf8_lossy(line);
2199    let trimmed = s.trim_end();
2200    const MAX: usize = 320;
2201    if trimmed.len() > MAX {
2202        let mut end = MAX;
2203        while !trimmed.is_char_boundary(end) {
2204            end -= 1;
2205        }
2206        format!("{}…", &trimmed[..end])
2207    } else {
2208        trimmed.to_string()
2209    }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214    use super::*;
2215
2216    /// The allocating acronym the fuzzy matcher used before `acronym_eq`, kept
2217    /// as the reference definition of the behavior.
2218    fn reference_acronym(s: &str) -> String {
2219        crate::context::split_identifier(s)
2220            .iter()
2221            .filter_map(|t| t.chars().next())
2222            .collect()
2223    }
2224
2225    /// Identifiers covering every tokenizer branch, plus the non-ASCII cases
2226    /// where `to_lowercase` can expand a single `char`.
2227    const NAMES: &[&str] = &[
2228        "",
2229        "x",
2230        "flush",
2231        "loadConfig",
2232        "load_config",
2233        "LoadConfig",
2234        "HTTPServer",
2235        "parseHTTP2Frame",
2236        "v2Handler",
2237        "vfs_read",
2238        "__init_waitqueue_head",
2239        "trailing__",
2240        "a__b",
2241        "kebab-case-name",
2242        "with space",
2243        "snake_And_Camel",
2244        "ALLCAPS",
2245        "ÄÖÜ_grüß",
2246        "İstanbul",
2247        "page_cache_sync_readahead",
2248    ];
2249
2250    /// `acronym_eq` must agree with the allocating implementation for every
2251    /// name, both on its own acronym and on near-miss needles.
2252    #[test]
2253    fn acronym_eq_matches_reference() {
2254        for name in NAMES {
2255            let want = reference_acronym(name);
2256            assert!(
2257                acronym_eq(name, &want),
2258                "{name:?} should match its own acronym {want:?}"
2259            );
2260            // Perturbations that must not match.
2261            let mut wrong = vec![format!("{want}z"), format!("z{want}")];
2262            if !want.is_empty() {
2263                wrong.push(want[..want.len() - 1].to_string());
2264                wrong.push(want.to_uppercase());
2265            }
2266            for w in wrong {
2267                if w == want {
2268                    continue;
2269                }
2270                assert_eq!(
2271                    acronym_eq(name, &w),
2272                    reference_acronym(name) == w,
2273                    "{name:?} vs needle {w:?}"
2274                );
2275            }
2276        }
2277    }
2278
2279    /// Cross-check against every other name's acronym, so a needle that happens
2280    /// to collide is treated identically by both implementations.
2281    #[test]
2282    fn acronym_eq_agrees_on_all_pairs() {
2283        for name in NAMES {
2284            for other in NAMES {
2285                let needle = reference_acronym(other);
2286                assert_eq!(
2287                    acronym_eq(name, &needle),
2288                    reference_acronym(name) == needle,
2289                    "name {name:?} vs needle {needle:?}"
2290                );
2291            }
2292        }
2293    }
2294
2295    /// The original `path_score`: one lowercased copy, five test-path probes,
2296    /// three vendor probes. Kept as the reference definition of the scoring.
2297    fn reference_path_score(path: &str) -> f32 {
2298        let mut s = 0.0f32;
2299        let depth = path.matches('/').count() as f32;
2300        s -= depth * 0.05;
2301        let lower = path.to_ascii_lowercase();
2302        if lower.contains("test")
2303            || lower.contains("/tests/")
2304            || lower.contains("__tests__")
2305            || lower.contains(".test.")
2306            || lower.contains(".spec.")
2307        {
2308            s -= 1.0;
2309        }
2310        if lower.contains("/vendor/") || lower.contains("/generated/") || lower.contains(".min.") {
2311            s -= 1.5;
2312        }
2313        s
2314    }
2315
2316    /// The allocation-free rewrite must score every path exactly as before,
2317    /// including the collapsed test-path disjunction and ASCII case folding.
2318    #[test]
2319    fn path_score_matches_reference() {
2320        let paths = [
2321            "",
2322            "a.c",
2323            "fs/read_write.c",
2324            "a/b/c/d/e/f/g.rs",
2325            "src/tests/mod.rs",
2326            "src/TESTS/mod.rs",
2327            "Test.java",
2328            "TEST.java",
2329            "foo/__tests__/bar.js",
2330            "foo/bar.test.ts",
2331            "foo/bar.spec.ts",
2332            "foo/bar.SPEC.ts",
2333            "protest/attestation.c",
2334            "third_party/vendor/lib.go",
2335            "third_party/VENDOR/lib.go",
2336            "out/generated/api.rs",
2337            "web/app.min.js",
2338            "web/app.MIN.js",
2339            "vendor/nested/test/.spec.x",
2340            "no_slashes_or_markers",
2341            "spec.rs",
2342            ".spec.",
2343            "tes",
2344            "t",
2345        ];
2346        for p in paths {
2347            assert_eq!(
2348                path_score(p),
2349                reference_path_score(p),
2350                "path_score mismatch for {p:?}"
2351            );
2352        }
2353    }
2354
2355    /// `contains_ascii_ci` must agree with lowercase-then-`contains`.
2356    #[test]
2357    fn contains_ascii_ci_matches_std() {
2358        let hays = [
2359            "", "a", "Test", "tEsT", "xxtestxx", "TES", "/Vendor/", ".MIN.", "aaa",
2360        ];
2361        for h in hays {
2362            for n in [
2363                &b"test"[..],
2364                b".spec.",
2365                b"/vendor/",
2366                b"/generated/",
2367                b".min.",
2368                b"a",
2369            ] {
2370                let needle = std::str::from_utf8(n).unwrap();
2371                assert_eq!(
2372                    contains_ascii_ci(h, n),
2373                    h.to_ascii_lowercase().contains(needle),
2374                    "{h:?} contains {needle:?}"
2375                );
2376            }
2377        }
2378    }
2379
2380    /// `FileLines` must be indistinguishable from the
2381    /// `String::from_utf8_lossy(..).lines().map(to_string).collect()` it
2382    /// replaced — including CRLF, blank lines, a missing final terminator, and
2383    /// invalid UTF-8 (which lossy conversion turns into U+FFFD, changing byte
2384    /// lengths and therefore the packing cost).
2385    #[test]
2386    fn file_lines_match_str_lines() {
2387        let cases: &[&[u8]] = &[
2388            b"",
2389            b"\n",
2390            b"a",
2391            b"a\n",
2392            b"a\nb",
2393            b"a\nb\n",
2394            b"\na",
2395            b"a\n\nb\n",
2396            b"a\r\nb\r\n",
2397            b"a\r\nb",
2398            b"a\r",
2399            b"\r\n\r\n",
2400            b"no terminator at all",
2401            b"trailing blank lines\n\n\n",
2402            b"tabs\tand  spaces\n  indented\n",
2403            &[0xC3, 0x28, b'\n', b'o', b'k'],        // invalid UTF-8
2404            &[b'a', b'\n', 0xFF, 0xFE, b'\n', b'z'], // invalid UTF-8
2405            "héllo\nwörld\n".as_bytes(),             // multibyte
2406        ];
2407        for data in cases {
2408            let want: Vec<String> = String::from_utf8_lossy(data)
2409                .lines()
2410                .map(|s| s.to_string())
2411                .collect();
2412            let got = FileLines::new(data);
2413            assert_eq!(got.len(), want.len(), "line count for {data:?}");
2414            for (i, line) in want.iter().enumerate() {
2415                assert_eq!(got.get(i), Some(line.as_str()), "line {i} of {data:?}");
2416            }
2417            assert_eq!(got.get(want.len()), None, "past-the-end for {data:?}");
2418        }
2419    }
2420
2421    /// The enclosing-symbol rule: innermost (tightest) range wins, and among
2422    /// equally tight ranges the earliest row in storage order wins. Both
2423    /// `callers` and `references_resolved` attribute references with this, so
2424    /// its tie-break decides user-visible output.
2425    #[test]
2426    fn enclosing_row_picks_innermost_then_earliest() {
2427        // Row 0 spans the whole file, row 1 is a method inside it, rows 2 and 3
2428        // are equally tight and overlapping, row 4 is a single line.
2429        let r = DocSymbolRanges {
2430            rows: vec![
2431                (1, 100, 0),
2432                (10, 20, 1),
2433                (30, 40, 2),
2434                (30, 40, 3),
2435                (50, 50, 4),
2436            ],
2437        };
2438        assert_eq!(
2439            r.enclosing_row(5),
2440            Some(0),
2441            "only the outer range contains it"
2442        );
2443        assert_eq!(
2444            r.enclosing_row(15),
2445            Some(1),
2446            "innermost wins over the outer"
2447        );
2448        assert_eq!(r.enclosing_row(35), Some(2), "earliest of two equal spans");
2449        assert_eq!(
2450            r.enclosing_row(50),
2451            Some(4),
2452            "single-line range is tightest"
2453        );
2454        assert_eq!(r.enclosing_row(200), None, "outside every range");
2455        assert_eq!(DocSymbolRanges { rows: vec![] }.enclosing_row(1), None);
2456        // Boundaries are inclusive on both ends.
2457        assert_eq!(r.enclosing_row(10), Some(1));
2458        assert_eq!(r.enclosing_row(20), Some(1));
2459        assert_eq!(r.enclosing_row(9), Some(0));
2460    }
2461
2462    /// The documented behavior of the acronym branch.
2463    #[test]
2464    fn acronym_matches_camel_and_snake() {
2465        assert!(acronym_eq("loadConfig", "lc"));
2466        assert!(acronym_eq("load_config", "lc"));
2467        assert!(acronym_eq("vfs_read", "vr"));
2468        assert!(!acronym_eq("loadConfig", "l"));
2469        assert!(!acronym_eq("loadConfig", "lcx"));
2470    }
2471}