Skip to main content

lean_ctx/tools/
ctx_symbol.rs

1use std::path::Path;
2
3use crate::core::graph_index::{self, ProjectIndex, SymbolEntry};
4use crate::core::protocol;
5use crate::core::tokens::count_tokens;
6
7pub fn handle(
8    name: &str,
9    file: Option<&str>,
10    kind: Option<&str>,
11    project_root: &str,
12) -> (String, usize) {
13    let index = graph_index::load_or_build(project_root);
14
15    let matches = find_symbols(&index, name, file, kind);
16
17    if matches.is_empty() {
18        return (
19            format!(
20                "Symbol '{name}' not found in index ({} symbols indexed). \
21                 Try ctx_search(pattern=\"{name}\") for a broader search.",
22                index.symbol_count()
23            ),
24            0,
25        );
26    }
27
28    if matches.len() == 1 {
29        return render_single(matches[0], &index, project_root);
30    }
31
32    if matches.len() <= 5 {
33        return render_multiple(&matches, &index, project_root);
34    }
35
36    let mut out = format!(
37        "{} matches for '{name}'. Narrow with file= or kind=:\n",
38        matches.len()
39    );
40    for m in matches.iter().take(20) {
41        out.push_str(&format!(
42            "  {}::{} ({}:L{}-{})\n",
43            m.file, m.name, m.kind, m.start_line, m.end_line
44        ));
45    }
46    if matches.len() > 20 {
47        out.push_str(&format!("  ... and {} more\n", matches.len() - 20));
48    }
49    (out, 0)
50}
51
52fn find_symbols<'a>(
53    index: &'a ProjectIndex,
54    name: &str,
55    file_filter: Option<&str>,
56    kind_filter: Option<&str>,
57) -> Vec<&'a SymbolEntry> {
58    let name_lower = name.to_lowercase();
59    let mut results: Vec<&SymbolEntry> = index
60        .symbols
61        .values()
62        .filter(|s| {
63            let s_lower = s.name.to_lowercase();
64            let name_match = s_lower == name_lower
65                || s_lower.ends_with(&name_lower)
66                || s_lower.starts_with(&format!("{name_lower}::"))
67                || s_lower.contains(&format!("::{name_lower}"));
68
69            let file_match = file_filter.is_none_or(|f| s.file.contains(f));
70
71            let kind_match = kind_filter.is_none_or(|k| s.kind.to_lowercase() == k.to_lowercase());
72
73            name_match && file_match && kind_match
74        })
75        .collect();
76
77    results.sort_by(|a, b| {
78        let a_exact = a.name.to_lowercase() == name_lower;
79        let b_exact = b.name.to_lowercase() == name_lower;
80        b_exact.cmp(&a_exact).then_with(|| a.file.cmp(&b.file))
81    });
82
83    results
84}
85
86fn render_single(sym: &SymbolEntry, index: &ProjectIndex, project_root: &str) -> (String, usize) {
87    let abs_path = resolve_file_path(&sym.file, project_root);
88
89    let Ok(content) = std::fs::read_to_string(&abs_path) else {
90        return (
91            format!(
92                "Symbol '{}' found at {}:L{}-{} but file unreadable",
93                sym.name, sym.file, sym.start_line, sym.end_line
94            ),
95            0,
96        );
97    };
98
99    let lines: Vec<&str> = content.lines().collect();
100    let start = sym.start_line.saturating_sub(1);
101    let end = sym.end_line.min(lines.len());
102    let snippet: String = lines[start..end]
103        .iter()
104        .enumerate()
105        .map(|(i, line)| format!("{:>4}|{}", start + i + 1, line))
106        .collect::<Vec<_>>()
107        .join("\n");
108
109    let full_tokens = count_tokens(&content);
110    let snippet_tokens = count_tokens(&snippet);
111
112    let vis = if sym.is_exported { "+" } else { "-" };
113    let header = format!(
114        "{}::{} ({} {}, L{}-{})",
115        sym.file, sym.name, vis, sym.kind, sym.start_line, sym.end_line
116    );
117
118    let file_info = index.files.get(&sym.file);
119    let ctx = if let Some(f) = file_info {
120        format!(
121            "File: {} ({} lines, {} tokens)",
122            sym.file, f.line_count, f.token_count
123        )
124    } else {
125        format!("File: {}", sym.file)
126    };
127
128    let savings = protocol::format_savings(full_tokens, snippet_tokens);
129
130    (
131        format!("{header}\n{ctx}\n\n{snippet}\n{savings}"),
132        full_tokens,
133    )
134}
135
136fn render_multiple(
137    symbols: &[&SymbolEntry],
138    index: &ProjectIndex,
139    project_root: &str,
140) -> (String, usize) {
141    let mut out = String::new();
142    let mut total_original = 0usize;
143
144    for (i, sym) in symbols.iter().enumerate() {
145        if i > 0 {
146            out.push_str("\n---\n\n");
147        }
148        let (rendered, orig) = render_single(sym, index, project_root);
149        out.push_str(&rendered);
150        total_original = total_original.max(orig);
151    }
152
153    (out, total_original)
154}
155
156fn resolve_file_path(relative: &str, project_root: &str) -> String {
157    let p = Path::new(relative);
158    if p.is_absolute() && p.exists() {
159        return relative.to_string();
160    }
161    let joined = Path::new(project_root).join(relative);
162    if joined.exists() {
163        return joined.to_string_lossy().to_string();
164    }
165    relative.to_string()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::core::graph_index::{ProjectIndex, SymbolEntry};
172
173    fn test_index() -> ProjectIndex {
174        let mut index = ProjectIndex::new("/tmp/test");
175        index.symbols.insert(
176            "src/main.rs::main".to_string(),
177            SymbolEntry {
178                file: "src/main.rs".to_string(),
179                name: "main".to_string(),
180                kind: "fn".to_string(),
181                start_line: 1,
182                end_line: 10,
183                is_exported: false,
184            },
185        );
186        index.symbols.insert(
187            "src/lib.rs::Config".to_string(),
188            SymbolEntry {
189                file: "src/lib.rs".to_string(),
190                name: "Config".to_string(),
191                kind: "struct".to_string(),
192                start_line: 5,
193                end_line: 20,
194                is_exported: true,
195            },
196        );
197        index.symbols.insert(
198            "src/lib.rs::Config::load".to_string(),
199            SymbolEntry {
200                file: "src/lib.rs".to_string(),
201                name: "Config::load".to_string(),
202                kind: "method".to_string(),
203                start_line: 22,
204                end_line: 35,
205                is_exported: true,
206            },
207        );
208        index
209    }
210
211    #[test]
212    fn find_exact_match() {
213        let index = test_index();
214        let results = find_symbols(&index, "main", None, None);
215        assert_eq!(results.len(), 1);
216        assert_eq!(results[0].name, "main");
217    }
218
219    #[test]
220    fn find_with_kind_filter() {
221        let index = test_index();
222        let results = find_symbols(&index, "Config", None, Some("struct"));
223        assert_eq!(results.len(), 1);
224        assert_eq!(results[0].kind, "struct");
225    }
226
227    #[test]
228    fn find_with_file_filter() {
229        let index = test_index();
230        let results = find_symbols(&index, "Config", Some("lib.rs"), None);
231        assert_eq!(results.len(), 2);
232    }
233
234    #[test]
235    fn no_match_returns_empty() {
236        let index = test_index();
237        let results = find_symbols(&index, "nonexistent", None, None);
238        assert!(results.is_empty());
239    }
240}