Skip to main content

veles_core/
scope.rs

1//! Scope-label heuristics for chunks.
2//!
3//! Given the index's tree-sitter symbol table and a chunk, produce a
4//! short human-readable label answering "what does this chunk show?".
5//!
6//! Used by formatters (CLI, MCP) to enrich result headers — an agent or
7//! human reading the line `crates/foo/bar.rs:46-95  [score=0.025]` gets
8//! a much faster answer to "is this relevant?" when the line ends with
9//! ``defines `Manifest``` or ``in `fn handle_search```.
10//!
11//! Two entry points:
12//!
13//! - [`chunk_scope_label`] — one-shot label lookup. Iterates the full
14//!   symbol slice; fine for one or two labels per call (CLI format,
15//!   MCP refs handler).
16//! - [`ScopeIndex`] — pre-builds a `file_path → symbol indices` map so
17//!   each subsequent label call is `O(symbols_in_file)` instead of
18//!   `O(total_symbols)`. The right choice for the TUI render loop,
19//!   which resolves ~30 labels per redraw.
20
21use ahash::AHashMap;
22
23use crate::symbols::Symbol;
24use crate::types::Chunk;
25
26/// Pick a short scope label for a chunk so a reader can route on the
27/// result header without reading the body.
28///
29/// Two-tier heuristic:
30/// 1. If any symbols *start* inside the chunk, the chunk is showing
31///    those definitions — return ``defines `name` `` (or
32///    ``defines `name` (+N more) `` when several definitions appear).
33/// 2. Else find the most specific symbol whose range strictly contains
34///    `chunk.start_line` (the chunk is mid-body) — return ``in `name` ``.
35///
36/// Returns `None` for chunks that neither define nor live inside any
37/// tree-sitter-recognised symbol (typical for module-level prelude
38/// before the first definition, or files in unsupported languages).
39pub fn chunk_scope_label(symbols: &[Symbol], chunk: &Chunk) -> Option<String> {
40    let same_file = || symbols.iter().filter(|s| s.file_path == chunk.file_path);
41
42    let defined: Vec<&Symbol> = same_file()
43        .filter(|s| s.start_line >= chunk.start_line && s.start_line <= chunk.end_line)
44        .collect();
45    if let Some(first) = defined.first() {
46        return Some(if defined.len() == 1 {
47            format!("defines `{}`", first.name)
48        } else {
49            format!("defines `{}` (+{} more)", first.name, defined.len() - 1)
50        });
51    }
52
53    same_file()
54        .filter(|s| s.start_line < chunk.start_line && chunk.start_line <= s.end_line)
55        .min_by_key(|s| s.end_line.saturating_sub(s.start_line))
56        .map(|s| format!("in `{}`", s.name))
57}
58
59/// Pre-indexed `file_path → symbol indices` map for fast repeated
60/// scope-label lookups (the TUI render loop).
61///
62/// Built once from an immutable symbol slice; each `label()` call then
63/// scans only the symbols of the chunk's file rather than the entire
64/// index. For a 200K-symbol repo this turns each redraw's O(N × rows)
65/// scan into O(symbols_per_file × rows) — typically a handful per row.
66///
67/// Stores `u32` indices into the original symbol slice, so the slice
68/// the caller passes to `label()` must be the same one that was used
69/// to build the index (length and order preserved). Pass the
70/// `VelesIndex::symbols()` slice and it just works.
71#[derive(Debug, Default)]
72pub struct ScopeIndex {
73    by_file: AHashMap<String, Vec<u32>>,
74}
75
76impl ScopeIndex {
77    /// Build the lookup map. O(symbols).
78    pub fn new(symbols: &[Symbol]) -> Self {
79        let mut by_file: AHashMap<String, Vec<u32>> = AHashMap::new();
80        for (i, s) in symbols.iter().enumerate() {
81            by_file
82                .entry(s.file_path.clone())
83                .or_default()
84                .push(i as u32);
85        }
86        Self { by_file }
87    }
88
89    /// Same semantics as [`chunk_scope_label`] but O(symbols_in_file).
90    pub fn label(&self, symbols: &[Symbol], chunk: &Chunk) -> Option<String> {
91        let indices = self.by_file.get(chunk.file_path.as_str())?;
92
93        // Tier 1: definitions whose start line falls inside the chunk.
94        let mut first_defined: Option<&Symbol> = None;
95        let mut defined_count: usize = 0;
96        for &i in indices {
97            let s = symbols.get(i as usize)?;
98            if s.start_line >= chunk.start_line && s.start_line <= chunk.end_line {
99                if first_defined.is_none() {
100                    first_defined = Some(s);
101                }
102                defined_count += 1;
103            }
104        }
105        if let Some(first) = first_defined {
106            return Some(if defined_count == 1 {
107                format!("defines `{}`", first.name)
108            } else {
109                format!("defines `{}` (+{} more)", first.name, defined_count - 1)
110            });
111        }
112
113        // Tier 2: innermost enclosing symbol whose range strictly contains chunk.start_line.
114        let mut best: Option<&Symbol> = None;
115        let mut best_span: usize = usize::MAX;
116        for &i in indices {
117            let s = symbols.get(i as usize)?;
118            if s.start_line < chunk.start_line && chunk.start_line <= s.end_line {
119                let span = s.end_line.saturating_sub(s.start_line);
120                if span < best_span {
121                    best_span = span;
122                    best = Some(s);
123                }
124            }
125        }
126        best.map(|s| format!("in `{}`", s.name))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::symbols::SymbolKind;
134
135    fn sym(name: &str, kind: SymbolKind, file: &str, start: usize, end: usize) -> Symbol {
136        Symbol {
137            name: name.to_string(),
138            kind,
139            file_path: file.to_string(),
140            start_line: start,
141            end_line: end,
142            language: "rust".to_string(),
143        }
144    }
145
146    fn chunk(file: &str, start: usize, end: usize) -> Chunk {
147        Chunk {
148            content: String::new(),
149            file_path: file.to_string(),
150            start_line: start,
151            end_line: end,
152            language: Some("rust".to_string()),
153        }
154    }
155
156    #[test]
157    fn defines_one_symbol() {
158        let symbols = vec![sym("foo", SymbolKind::Function, "a.rs", 5, 8)];
159        let label = chunk_scope_label(&symbols, &chunk("a.rs", 1, 50));
160        assert_eq!(label.as_deref(), Some("defines `foo`"));
161    }
162
163    #[test]
164    fn defines_with_more() {
165        let symbols = vec![
166            sym("foo", SymbolKind::Function, "a.rs", 5, 8),
167            sym("bar", SymbolKind::Function, "a.rs", 10, 12),
168            sym("baz", SymbolKind::Struct, "a.rs", 14, 20),
169        ];
170        let label = chunk_scope_label(&symbols, &chunk("a.rs", 1, 50));
171        assert_eq!(label.as_deref(), Some("defines `foo` (+2 more)"));
172    }
173
174    #[test]
175    fn picks_innermost_enclosing_when_no_def_inside() {
176        // Outer fn covers 1-100, inner method covers 30-60 inside it.
177        // A chunk starting at line 40 should be tagged with the inner one.
178        let symbols = vec![
179            sym("outer", SymbolKind::Function, "a.rs", 1, 100),
180            sym("inner", SymbolKind::Function, "a.rs", 30, 60),
181        ];
182        let label = chunk_scope_label(&symbols, &chunk("a.rs", 40, 50));
183        assert_eq!(label.as_deref(), Some("in `inner`"));
184    }
185
186    #[test]
187    fn other_files_ignored() {
188        let symbols = vec![sym("foo", SymbolKind::Function, "b.rs", 5, 8)];
189        let label = chunk_scope_label(&symbols, &chunk("a.rs", 1, 50));
190        assert_eq!(label, None);
191    }
192
193    #[test]
194    fn no_match_returns_none() {
195        let symbols = vec![sym("foo", SymbolKind::Function, "a.rs", 100, 110)];
196        let label = chunk_scope_label(&symbols, &chunk("a.rs", 1, 50));
197        assert_eq!(label, None);
198    }
199
200    #[test]
201    fn scope_index_matches_one_shot() {
202        // The ScopeIndex must return the same labels as chunk_scope_label
203        // for every chunk — it's a strict optimisation, not a behaviour change.
204        let symbols = vec![
205            sym("outer", SymbolKind::Function, "a.rs", 1, 100),
206            sym("inner", SymbolKind::Function, "a.rs", 30, 60),
207            sym("other", SymbolKind::Function, "b.rs", 5, 8),
208        ];
209        let idx = ScopeIndex::new(&symbols);
210        let chunks = [
211            chunk("a.rs", 1, 50),    // defines outer
212            chunk("a.rs", 40, 50),   // in inner
213            chunk("b.rs", 1, 10),    // defines other
214            chunk("a.rs", 200, 250), // nothing
215            chunk("nonexistent.rs", 1, 5),
216        ];
217        for c in &chunks {
218            let one_shot = chunk_scope_label(&symbols, c);
219            let indexed = idx.label(&symbols, c);
220            assert_eq!(
221                one_shot, indexed,
222                "ScopeIndex diverged from chunk_scope_label for chunk {c:?}"
223            );
224        }
225    }
226}