lean_ctx_compress/
compressor.rs1use 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
16pub 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
25pub fn aggressive_compress(content: &str, ext: Option<&str>) -> String {
27 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 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 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
136pub 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
185pub 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
208pub 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
217pub 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
230pub 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
269pub 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); 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); 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}