Skip to main content

lean_ctx/tools/
ctx_symbol.rs

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