Skip to main content

greplm_core/
search.rs

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