Skip to main content

lean_ctx/core/
compressor.rs

1use similar::{ChangeTag, TextDiff};
2
3macro_rules! static_regex {
4    ($pattern:expr_2021) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12/// Removes ANSI escape codes from a string, returning clean text.
13pub fn strip_ansi(s: &str) -> String {
14    if !s.contains('\x1b') {
15        return s.to_string();
16    }
17    let mut result = String::with_capacity(s.len());
18    let mut in_escape = false;
19    for c in s.chars() {
20        if c == '\x1b' {
21            in_escape = true;
22            continue;
23        }
24        if in_escape {
25            if c.is_ascii_alphabetic() {
26                in_escape = false;
27            }
28            continue;
29        }
30        result.push(c);
31    }
32    result
33}
34
35/// Returns the ratio of ANSI escape characters to total string length.
36pub fn ansi_density(s: &str) -> f64 {
37    if s.is_empty() {
38        return 0.0;
39    }
40    let escape_bytes = s.chars().filter(|&c| c == '\x1b').count();
41    escape_bytes as f64 / s.len() as f64
42}
43
44/// Strips comments, blank lines, and normalizes indentation for maximum token savings.
45pub fn aggressive_compress(content: &str, ext: Option<&str>) -> String {
46    // Structured data (JSON/JSONL) carries no comments and barely compresses via
47    // the line-based path below (~0% measured). Strip insignificant whitespace
48    // losslessly instead — key order, numbers, and string contents are preserved.
49    // Markdown-family files opt into the lossy structural compactor by
50    // extension alone; a plain `.txt` must additionally show a real ATX
51    // heading — hyphen lists or wrapped prose are not enough to treat a text
52    // file as a structured document (#655).
53    let markdown_family = matches!(ext, Some("md" | "markdown" | "mdown"));
54    let structured_txt =
55        matches!(ext, Some("txt")) && crate::core::markdown_compact::has_markdown_headings(content);
56    if (markdown_family || structured_txt)
57        && let Some(compacted) = crate::core::markdown_compact::compact_markdown(content)
58    {
59        return compacted;
60    }
61    if let Some(compacted) = crate::core::structured_compact::compact_structured(content, ext) {
62        return compacted;
63    }
64
65    // Tabular data (CSV/TSV, #982): a redundant table with constant columns
66    // compacts losslessly through the columnar crusher far better than the
67    // line-based path below. Fires only when it at least halves the input;
68    // otherwise fall through. The exact bytes stay recoverable via a full re-read.
69    if let Some(delim) = tabular_delimiter(ext)
70        && let Some(crushed) = crate::core::tabular_crush::crush_text_if_beneficial(content, delim)
71    {
72        return crushed;
73    }
74
75    // YAML (#985): a verbose document compacts losslessly to compact JSON through
76    // the shared crusher — formatting dropped, redundant `items`/`list` arrays
77    // factored — far better than the line-based path below. Fires only when it
78    // clears the reduction gate; the exact bytes stay recoverable via a full
79    // re-read.
80    if is_yaml_ext(ext)
81        && let Some(crushed) = crate::core::yaml_crush::crush_text_if_beneficial(content)
82    {
83        return crushed;
84    }
85
86    let mut result: Vec<String> = Vec::new();
87    let is_python = matches!(ext, Some("py"));
88    let is_html = matches!(ext, Some("html" | "htm" | "xml" | "svg"));
89    let is_sql = matches!(ext, Some("sql"));
90    let is_shell = matches!(ext, Some("sh" | "bash" | "zsh" | "fish"));
91
92    let mut in_block_comment = false;
93
94    for line in content.lines() {
95        let trimmed = line.trim();
96
97        if trimmed.is_empty() {
98            continue;
99        }
100
101        if in_block_comment {
102            if trimmed.contains("*/") || (is_html && trimmed.contains("-->")) {
103                in_block_comment = false;
104            }
105            continue;
106        }
107
108        if trimmed.starts_with("/*") || (is_html && trimmed.starts_with("<!--")) {
109            if !(trimmed.contains("*/") || trimmed.contains("-->")) {
110                in_block_comment = true;
111            }
112            continue;
113        }
114
115        if trimmed.starts_with("//") && !trimmed.starts_with("///") {
116            continue;
117        }
118        if trimmed.starts_with('*') || trimmed.starts_with("*/") {
119            continue;
120        }
121        if is_python && trimmed.starts_with('#') {
122            continue;
123        }
124        if is_sql && trimmed.starts_with("--") {
125            continue;
126        }
127        if is_shell && trimmed.starts_with('#') && !trimmed.starts_with("#!") {
128            continue;
129        }
130        if !is_python && trimmed.starts_with('#') && trimmed.contains('[') {
131            continue;
132        }
133
134        if trimmed == "}" || trimmed == "};" || trimmed == ");" || trimmed == "});" {
135            if let Some(last) = result.last() {
136                let last_trimmed = last.trim();
137                if matches!(last_trimmed, "}" | "};" | ");" | "});") {
138                    if let Some(last_mut) = result.last_mut() {
139                        last_mut.push_str(trimmed);
140                    }
141                    continue;
142                }
143            }
144            result.push(trimmed.to_string());
145            continue;
146        }
147
148        let normalized = normalize_indentation(line);
149        result.push(normalized);
150    }
151
152    result.join("\n")
153}
154
155/// Lightweight post-processing cleanup: collapses consecutive closing braces,
156/// removes whitespace-only lines, and limits consecutive blank lines to 1.
157pub fn lightweight_cleanup(content: &str) -> String {
158    let lines: Vec<&str> = content.lines().collect();
159    let total = lines.len();
160
161    let mut result: Vec<String> = Vec::new();
162    let mut blank_count = 0u32;
163    let mut brace_run: Vec<&str> = Vec::new();
164
165    let flush_brace_run = |run: &mut Vec<&str>, out: &mut Vec<String>| {
166        if total <= 200 || run.len() <= 5 {
167            for l in run.iter() {
168                out.push(l.to_string());
169            }
170        } else {
171            out.push(run[0].to_string());
172            out.push(run[1].to_string());
173            out.push(format!("[{} brace-only lines collapsed]", run.len() - 2));
174        }
175        run.clear();
176    };
177
178    for line in &lines {
179        let trimmed = line.trim();
180
181        if trimmed.is_empty() {
182            flush_brace_run(&mut brace_run, &mut result);
183            blank_count += 1;
184            if blank_count <= 1 {
185                result.push(String::new());
186            }
187            continue;
188        }
189        blank_count = 0;
190
191        if matches!(trimmed, "}" | "};" | ");" | "});" | ")") {
192            brace_run.push(trimmed);
193            continue;
194        }
195
196        flush_brace_run(&mut brace_run, &mut result);
197        result.push(line.to_string());
198    }
199    flush_brace_run(&mut brace_run, &mut result);
200
201    result.join("\n")
202}
203
204/// Safeguard: prevents compression from inflating output or destroying small outputs.
205/// For small outputs (<2000 tokens), rejects extreme compression (>95% reduction)
206/// that likely lost important content. For large outputs, trusts the pattern.
207pub fn safeguard_ratio(original: &str, compressed: &str) -> String {
208    let orig_tokens = super::tokens::count_tokens(original);
209    let comp_tokens = super::tokens::count_tokens(compressed);
210
211    if orig_tokens == 0 {
212        return compressed.to_string();
213    }
214
215    if comp_tokens > orig_tokens {
216        return original.to_string();
217    }
218
219    let ratio = comp_tokens as f64 / orig_tokens as f64;
220    if ratio < 0.05 && orig_tokens < 2000 {
221        original.to_string()
222    } else {
223        compressed.to_string()
224    }
225}
226
227/// Delimiter for a delimited-table extension, or `None` for non-tabular files.
228pub(crate) fn tabular_delimiter(ext: Option<&str>) -> Option<char> {
229    match ext {
230        Some("csv") => Some(','),
231        Some("tsv" | "tab") => Some('\t'),
232        _ => None,
233    }
234}
235
236/// True for a YAML file extension (`.yaml` / `.yml`).
237pub(crate) fn is_yaml_ext(ext: Option<&str>) -> bool {
238    matches!(ext, Some("yaml" | "yml"))
239}
240
241fn normalize_indentation(line: &str) -> String {
242    let content = line.trim_start();
243    let leading = line.len() - content.len();
244    let has_tabs = line.starts_with('\t');
245    let reduced = if has_tabs { leading } else { leading / 2 };
246    format!("{}{}", " ".repeat(reduced), content)
247}
248
249/// Produces a compact unified diff between old and new content with line numbers.
250pub fn diff_content(old_content: &str, new_content: &str) -> String {
251    if old_content == new_content {
252        return "(no changes)".to_string();
253    }
254
255    let diff = TextDiff::from_lines(old_content, new_content);
256    let mut changes = Vec::new();
257    let mut additions = 0usize;
258    let mut deletions = 0usize;
259
260    for change in diff.iter_all_changes() {
261        let line_no = change.new_index().or(change.old_index()).map(|i| i + 1);
262        let text = change.value().trim_end_matches('\n');
263        match change.tag() {
264            ChangeTag::Insert => {
265                additions += 1;
266                if let Some(n) = line_no {
267                    changes.push(format!("+{n}: {text}"));
268                }
269            }
270            ChangeTag::Delete => {
271                deletions += 1;
272                if let Some(n) = line_no {
273                    changes.push(format!("-{n}: {text}"));
274                }
275            }
276            ChangeTag::Equal => {}
277        }
278    }
279
280    if changes.is_empty() {
281        return "(no changes)".to_string();
282    }
283
284    changes.push(format!("\ndiff +{additions}/-{deletions} lines"));
285    changes.join("\n")
286}
287
288/// Deduplicates repeated lines, strips boilerplate, and normalizes timestamps/hashes.
289pub fn verbatim_compact(text: &str) -> String {
290    let mut lines: Vec<String> = Vec::new();
291    let mut blank_count = 0u32;
292    let mut prev_line: Option<String> = None;
293    let mut repeat_count = 0u32;
294
295    for line in text.lines() {
296        let trimmed = line.trim();
297
298        if trimmed.is_empty() {
299            blank_count += 1;
300            if blank_count <= 1 {
301                flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
302                lines.push(String::new());
303            }
304            continue;
305        }
306        blank_count = 0;
307
308        if is_boilerplate_line(trimmed) {
309            continue;
310        }
311
312        let normalized = normalize_whitespace(trimmed);
313        let stripped = strip_timestamps_hashes(&normalized);
314
315        if let Some(ref prev) = prev_line
316            && *prev == stripped
317        {
318            repeat_count += 1;
319            continue;
320        }
321
322        flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
323        prev_line = Some(stripped.clone());
324        repeat_count = 1;
325        lines.push(stripped);
326    }
327
328    flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
329    lines.join("\n")
330}
331
332/// Compresses content using the active task intent to preserve task-relevant sections.
333pub fn task_aware_compress(
334    content: &str,
335    ext: Option<&str>,
336    intent: &super::intent_engine::StructuredIntent,
337) -> String {
338    use super::intent_engine::{IntentScope, TaskType};
339
340    let budget_ratio = match intent.scope {
341        IntentScope::SingleFile => 0.7,
342        IntentScope::MultiFile => 0.5,
343        IntentScope::CrossModule => 0.35,
344        IntentScope::ProjectWide => 0.25,
345    };
346
347    match intent.task_type {
348        TaskType::FixBug | TaskType::Debug => {
349            let filtered = super::task_relevance::information_bottleneck_filter_typed(
350                content,
351                &intent.keywords,
352                budget_ratio,
353                Some(intent.task_type),
354                &[],
355            );
356            safeguard_ratio(content, &filtered)
357        }
358        TaskType::Refactor | TaskType::Review => {
359            let cleaned = lightweight_cleanup(content);
360            let filtered = super::task_relevance::information_bottleneck_filter_typed(
361                &cleaned,
362                &intent.keywords,
363                budget_ratio.max(0.5),
364                Some(intent.task_type),
365                &[],
366            );
367            safeguard_ratio(content, &filtered)
368        }
369        TaskType::Generate | TaskType::Test => {
370            let compressed = aggressive_compress(content, ext);
371            safeguard_ratio(content, &compressed)
372        }
373        TaskType::Explore | TaskType::Config | TaskType::Deploy => {
374            let cleaned = lightweight_cleanup(content);
375            safeguard_ratio(content, &cleaned)
376        }
377    }
378}
379
380fn flush_repeats(lines: &mut [String], prev_line: &mut Option<String>, count: &mut u32) {
381    if *count > 1
382        && let &mut Some(ref prev) = prev_line
383    {
384        let last_idx = lines.len().saturating_sub(1);
385        if last_idx < lines.len() {
386            lines[last_idx] = format!("[{count}x] {prev}");
387        }
388    }
389    *count = 0;
390    *prev_line = None;
391}
392
393fn normalize_whitespace(line: &str) -> String {
394    let mut result = String::with_capacity(line.len());
395    let mut prev_space = false;
396    for ch in line.chars() {
397        if ch == ' ' || ch == '\t' {
398            if !prev_space {
399                result.push(' ');
400                prev_space = true;
401            }
402        } else {
403            result.push(ch);
404            prev_space = false;
405        }
406    }
407    result
408}
409
410fn strip_timestamps_hashes(line: &str) -> String {
411    let ts_re =
412        static_regex!(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?");
413    let hash_re = static_regex!(r"\b[0-9a-f]{32,64}\b");
414
415    let s = ts_re.replace_all(line, "[TS]");
416    let s = hash_re.replace_all(&s, "[HASH]");
417    s.into_owned()
418}
419
420fn is_boilerplate_line(trimmed: &str) -> bool {
421    let lower = trimmed.to_lowercase();
422    if lower.starts_with("copyright")
423        || lower.starts_with("licensed under")
424        || lower.starts_with("license:")
425        || lower.starts_with("all rights reserved")
426    {
427        return true;
428    }
429    if lower.starts_with("generated by") || lower.starts_with("auto-generated") {
430        return true;
431    }
432    if trimmed.len() >= 4 {
433        let chars: Vec<char> = trimmed.chars().collect();
434        let first = chars[0];
435        if matches!(first, '=' | '-' | '*' | '─' | '━') {
436            let same = chars.iter().filter(|c| **c == first).count();
437            if same as f64 / chars.len() as f64 > 0.8 {
438                return true;
439            }
440        }
441    }
442    false
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_diff_insertion() {
451        let old = "line1\nline2\nline3";
452        let new = "line1\nline2\nnew_line\nline3";
453        let result = diff_content(old, new);
454        assert!(result.contains('+'), "should show additions");
455        assert!(result.contains("new_line"));
456    }
457
458    #[test]
459    fn test_diff_deletion() {
460        let old = "line1\nline2\nline3";
461        let new = "line1\nline3";
462        let result = diff_content(old, new);
463        assert!(result.contains('-'), "should show deletions");
464        assert!(result.contains("line2"));
465    }
466
467    #[test]
468    fn test_diff_no_changes() {
469        let content = "same\ncontent";
470        assert_eq!(diff_content(content, content), "(no changes)");
471    }
472
473    #[test]
474    fn test_lightweight_cleanup_collapses_braces() {
475        let mut lines: Vec<String> = (0..210).map(|i| format!("line {i}")).collect();
476        lines.extend(
477            ["}", "}", "}", "}", "}", "}", "}", "}"]
478                .iter()
479                .map(std::string::ToString::to_string),
480        );
481        lines.push("fn next() {}".to_string());
482        let input = lines.join("\n");
483        let result = lightweight_cleanup(&input);
484        assert!(
485            result.contains("[6 brace-only lines collapsed]"),
486            "should collapse long brace runs in large files"
487        );
488        assert!(result.contains("fn next()"));
489    }
490
491    #[test]
492    fn test_lightweight_cleanup_blank_lines() {
493        let input = "line1\n\n\n\n\nline2";
494        let result = lightweight_cleanup(input);
495        let blank_runs = result.split("line1").nth(1).unwrap();
496        let blanks = blank_runs.matches('\n').count();
497        assert!(blanks <= 2, "should collapse multiple blank lines");
498    }
499
500    #[test]
501    fn test_safeguard_ratio_prevents_over_compression_on_small_output() {
502        let original = "a ".repeat(100); // ~100 tokens, < 2000
503        let too_compressed = "a";
504        let result = safeguard_ratio(&original, too_compressed);
505        assert_eq!(
506            result, original,
507            "should return original when ratio < 0.05 and output is small"
508        );
509    }
510
511    #[test]
512    fn test_safeguard_ratio_allows_strong_compression_on_large_output() {
513        let original = "line content here\n".repeat(1000); // ~4000 tokens, > 2000
514        let compressed = "summary: 1000 lines";
515        let result = safeguard_ratio(&original, compressed);
516        assert_eq!(
517            result, compressed,
518            "should allow strong compression for large outputs"
519        );
520    }
521
522    #[test]
523    fn test_aggressive_strips_comments() {
524        let code = "fn main() {\n    // a comment\n    let x = 1;\n}";
525        let result = aggressive_compress(code, Some("rs"));
526        assert!(!result.contains("// a comment"));
527        assert!(result.contains("let x = 1"));
528    }
529
530    #[test]
531    fn test_aggressive_python_comments() {
532        let code = "def main():\n    # comment\n    x = 1";
533        let result = aggressive_compress(code, Some("py"));
534        assert!(!result.contains("# comment"));
535        assert!(result.contains("x = 1"));
536    }
537
538    #[test]
539    fn test_aggressive_preserves_doc_comments() {
540        let code = "/// Doc comment\nfn main() {}";
541        let result = aggressive_compress(code, Some("rs"));
542        assert!(result.contains("/// Doc comment"));
543    }
544
545    #[test]
546    fn test_aggressive_block_comment() {
547        let code = "/* start\n * middle\n */ end\nfn main() {}";
548        let result = aggressive_compress(code, Some("rs"));
549        assert!(!result.contains("start"));
550        assert!(!result.contains("middle"));
551        assert!(result.contains("fn main()"));
552    }
553
554    #[test]
555    fn test_strip_ansi_removes_escape_codes() {
556        let input = "\x1b[31mERROR\x1b[0m: something failed";
557        let result = strip_ansi(input);
558        assert_eq!(result, "ERROR: something failed");
559        assert!(!result.contains('\x1b'));
560    }
561
562    #[test]
563    fn test_strip_ansi_passthrough_clean_text() {
564        let input = "clean text without escapes";
565        let result = strip_ansi(input);
566        assert_eq!(result, input);
567    }
568
569    #[test]
570    fn test_ansi_density_zero_for_clean() {
571        assert_eq!(ansi_density("hello world"), 0.0);
572    }
573
574    #[test]
575    fn test_ansi_density_nonzero_for_colored() {
576        let input = "\x1b[31mred\x1b[0m";
577        assert!(ansi_density(input) > 0.0);
578    }
579}