Skip to main content

lean_ctx_compress/
compressor.rs

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