lean_ctx/core/
symbol_map.rs1use std::collections::HashMap;
2
3use crate::core::tokens::count_tokens;
4
5macro_rules! static_regex {
6 ($pattern:expr_2021) => {{
7 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
8 RE.get_or_init(|| {
9 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
10 })
11 }};
12}
13
14const MIN_IDENT_LENGTH: usize = 6;
15const SHORT_ID_PREFIX: char = 'α';
16
17pub fn substitution_enabled() -> bool {
25 if let Ok(v) = std::env::var("LEAN_CTX_SYMBOL_MAP") {
26 return v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("on");
27 }
28 let cfg = crate::core::config::Config::load();
29 if cfg.symbol_map_auto {
30 return auto_detect_large_project();
31 }
32 false
33}
34
35fn auto_detect_large_project() -> bool {
36 use std::sync::OnceLock;
37 static DETECTED: OnceLock<bool> = OnceLock::new();
38 *DETECTED.get_or_init(|| {
39 let cwd = std::env::current_dir().unwrap_or_default();
40 let source_exts = [
41 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "rb", "cpp", "c", "h", "gd",
42 ];
43 let count = ignore::WalkBuilder::new(&cwd)
44 .hidden(true)
45 .max_depth(Some(6))
46 .git_ignore(true)
47 .require_git(false)
48 .filter_entry(crate::core::walk_filter::keep_entry)
49 .build()
50 .filter_map(std::result::Result::ok)
51 .filter(|e| {
52 e.file_type().is_some_and(|ft| ft.is_file())
53 && crate::core::io_boundary::is_secret_like(
56 e.path().strip_prefix(&cwd).unwrap_or(e.path()),
57 )
58 .is_none()
59 && e.path()
60 .extension()
61 .and_then(|ext| ext.to_str())
62 .is_some_and(|ext| source_exts.contains(&ext))
63 })
64 .take(51)
65 .count();
66 count > 50
67 })
68}
69
70#[derive(Debug, Clone)]
71pub struct SymbolMap {
72 forward: HashMap<String, String>,
73 next_id: usize,
74}
75
76impl Default for SymbolMap {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl SymbolMap {
83 pub fn new() -> Self {
84 Self {
85 forward: HashMap::new(),
86 next_id: 1,
87 }
88 }
89
90 pub fn register(&mut self, identifier: &str) -> Option<String> {
91 if identifier.len() < MIN_IDENT_LENGTH {
92 return None;
93 }
94
95 if let Some(existing) = self.forward.get(identifier) {
96 return Some(existing.clone());
97 }
98
99 let short_id = format!("{SHORT_ID_PREFIX}{}", self.next_id);
100 self.next_id += 1;
101 self.forward
102 .insert(identifier.to_string(), short_id.clone());
103 Some(short_id)
104 }
105
106 pub fn apply(&self, text: &str) -> String {
107 if self.forward.is_empty() {
108 return text.to_string();
109 }
110
111 let mut sorted: Vec<(&String, &String)> = self.forward.iter().collect();
112 sorted.sort_by_key(|x| std::cmp::Reverse(x.0.len()));
113
114 let mut result = text.to_string();
115 for (long, short) in &sorted {
116 result = result.replace(long.as_str(), short.as_str());
117 }
118 result
119 }
120
121 pub fn format_table(&self) -> String {
122 if self.forward.is_empty() {
123 return String::new();
124 }
125
126 let mut entries: Vec<(&String, &String)> = self.forward.iter().collect();
127 entries.sort_by_key(|(_, v)| {
128 v.trim_start_matches(SHORT_ID_PREFIX)
129 .parse::<usize>()
130 .unwrap_or(0)
131 });
132
133 let mut table = String::from("\n§MAP:");
134 for (long, short) in &entries {
135 table.push_str(&format!("\n {short}={long}"));
136 }
137 table
138 }
139
140 pub fn len(&self) -> usize {
141 self.forward.len()
142 }
143
144 pub fn is_empty(&self) -> bool {
145 self.forward.is_empty()
146 }
147}
148
149const MAP_ENTRY_OVERHEAD: usize = 2;
151
152pub fn should_register(identifier: &str, occurrences: usize, next_id: usize) -> bool {
156 if identifier.len() < MIN_IDENT_LENGTH {
157 return false;
158 }
159 let ident_tokens = count_tokens(identifier);
160 let short_id = format!("{SHORT_ID_PREFIX}{next_id}");
161 let short_tokens = count_tokens(&short_id);
162
163 let token_saving_per_use = ident_tokens.saturating_sub(short_tokens);
164 if token_saving_per_use == 0 {
165 return false;
166 }
167
168 let total_savings = occurrences * token_saving_per_use;
169 let entry_cost = ident_tokens + short_tokens + MAP_ENTRY_OVERHEAD;
170
171 total_savings > entry_cost
172}
173
174pub fn extract_identifiers(content: &str, exts: &[&str]) -> Vec<String> {
175 let ident_re = static_regex!(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b");
176
177 let mut seen = HashMap::new();
178 for mat in ident_re.find_iter(content) {
179 let word = mat.as_str();
180 if word.len() >= MIN_IDENT_LENGTH && !is_keyword(word, exts) {
181 *seen.entry(word.to_string()).or_insert(0usize) += 1;
182 }
183 }
184
185 let mut next_id = 1usize;
186 let mut idents: Vec<(String, usize)> = seen
187 .into_iter()
188 .filter(|(ident, count)| {
189 let pass = should_register(ident, *count, next_id);
190 if pass {
191 next_id += 1;
192 }
193 pass
194 })
195 .collect();
196
197 idents.sort_by(|a, b| {
198 let savings_a = a.0.len() * a.1;
199 let savings_b = b.0.len() * b.1;
200 savings_b.cmp(&savings_a)
201 });
202
203 idents.into_iter().map(|(s, _)| s).collect()
204}
205
206fn is_keyword(word: &str, exts: &[&str]) -> bool {
210 exts.iter().any(|&ext| match ext {
211 "rs" => matches!(
212 word,
213 "continue" | "default" | "return" | "struct" | "unsafe" | "where"
214 ),
215 "ts" | "tsx" | "js" | "jsx" => matches!(
216 word,
217 "constructor" | "arguments" | "undefined" | "prototype" | "instanceof"
218 ),
219 "py" => matches!(word, "continue" | "lambda" | "return" | "import" | "class"),
220 _ => false,
221 })
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_should_register_short_ident_rejected() {
230 assert!(!should_register("foo", 100, 1));
231 assert!(!should_register("bar", 50, 1));
232 assert!(!should_register("x", 1000, 1));
233 }
234
235 #[test]
236 fn test_should_register_roi_positive() {
237 assert!(should_register(
239 "authenticate_user_credentials_handler",
240 5,
241 1
242 ));
243 }
244
245 #[test]
246 fn test_should_register_roi_negative_single_use() {
247 assert!(!should_register(
249 "authenticate_user_credentials_handler",
250 1,
251 1
252 ));
253 }
254
255 #[test]
256 fn test_should_register_roi_scales_with_frequency() {
257 let ident = "configuration_manager_instance";
258 let passes_at_low = should_register(ident, 2, 1);
260 let passes_at_high = should_register(ident, 10, 1);
261 assert!(passes_at_high || !passes_at_low);
263 }
264
265 #[test]
266 fn test_extract_identifiers_roi_filtering() {
267 let long = "authenticate_user_credentials_handler";
269 let content = format!("{long} {long} {long} {long} {long} short");
270 let result = extract_identifiers(&content, &["rs"]);
271 assert!(result.contains(&long.to_string()));
272 assert!(!result.contains(&"short".to_string()));
273 }
274
275 #[test]
276 fn test_register_returns_existing() {
277 let mut map = SymbolMap::new();
278 let first = map.register("validateToken");
279 let second = map.register("validateToken");
280 assert_eq!(first, second);
281 }
282
283 #[test]
284 fn test_apply_replaces_identifiers() {
285 let mut map = SymbolMap::new();
286 map.register("validateToken");
287 let result = map.apply("call validateToken here");
288 assert!(result.contains("α1"));
289 assert!(!result.contains("validateToken"));
290 }
291
292 #[test]
293 fn test_format_table_output() {
294 let mut map = SymbolMap::new();
295 map.register("validateToken");
296 let table = map.format_table();
297 assert!(table.contains("§MAP:"));
298 assert!(table.contains("α1=validateToken"));
299 }
300}