Skip to main content

lean_ctx/core/
symbol_map.rs

1use 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
17/// Whether alpha/§MAP identifier substitution should be applied to tool output.
18///
19/// Activation order:
20/// 1. `LEAN_CTX_SYMBOL_MAP=1` env var → force on
21/// 2. `LEAN_CTX_SYMBOL_MAP=0` env var → force off
22/// 3. `symbol_map_auto = true` in config + project >50 source files → auto-on
23/// 4. Default: off (the abbreviated form hinders editing; opt-in only)
24pub 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                    // Same corpus rule as the persisted index walks
54                    // (GHSA-rxv5-4c3x-g4q6 A).
55                    && 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
149/// MAP entry cost in tokens: "  αN=identifier\n" ≈ short_id_tokens + ident_tokens + 2 (= and newline)
150const MAP_ENTRY_OVERHEAD: usize = 2;
151
152/// ROI-based decision: register only when total savings exceed the MAP entry cost.
153/// savings = occurrences * (tokens(ident) - tokens(short_id))
154/// cost    = tokens(ident) + tokens(short_id) + MAP_ENTRY_OVERHEAD
155pub 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
206/// True when `word` is a language keyword for *any* of `exts`. An empty slice
207/// (no `include` glob, or one without a file extension) matches nothing, so all
208/// identifiers stay eligible for substitution.
209fn 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        // Very long identifier (many BPE tokens) appearing 5 times
238        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        // Long ident but only 1 occurrence — MAP entry cost > savings
248        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        // Should fail at low frequency, pass at high frequency
259        let passes_at_low = should_register(ident, 2, 1);
260        let passes_at_high = should_register(ident, 10, 1);
261        // At some point frequency makes it worthwhile
262        assert!(passes_at_high || !passes_at_low);
263    }
264
265    #[test]
266    fn test_extract_identifiers_roi_filtering() {
267        // Repeat a long identifier enough times that ROI is positive
268        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}