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