1use std::path::Path;
14
15use serde::Serialize;
16use thiserror::Error;
17use tree_sitter::{Node, Parser};
18
19use kedge_core::HarnessError;
20
21#[derive(Debug, Error)]
22pub enum CompactError {
23 #[error("failed to load Tree-sitter grammar: {0}")]
24 Language(#[from] tree_sitter::LanguageError),
25 #[error("parser produced no tree")]
26 ParseFailed,
27 #[error("unsupported language for `.{0}` (supported: rs, py, js, ts, go)")]
28 UnsupportedExtension(String),
29}
30
31impl From<CompactError> for HarnessError {
32 fn from(e: CompactError) -> Self {
33 HarnessError::backend("compact", e)
34 }
35}
36
37pub type Result<T> = std::result::Result<T, CompactError>;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Language {
42 Rust,
43 Python,
44 JavaScript,
45 TypeScript,
46 Go,
47}
48
49impl Language {
50 pub fn name(self) -> &'static str {
51 match self {
52 Language::Rust => "rust",
53 Language::Python => "python",
54 Language::JavaScript => "javascript",
55 Language::TypeScript => "typescript",
56 Language::Go => "go",
57 }
58 }
59
60 pub fn from_extension(ext: &str) -> Option<Self> {
62 Some(match ext.to_ascii_lowercase().as_str() {
63 "rs" => Language::Rust,
64 "py" | "pyi" => Language::Python,
65 "js" | "jsx" | "mjs" | "cjs" => Language::JavaScript,
66 "ts" | "mts" | "cts" => Language::TypeScript,
67 "go" => Language::Go,
68 _ => return None,
69 })
70 }
71
72 pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
74 path.as_ref()
75 .extension()
76 .and_then(|e| e.to_str())
77 .and_then(Self::from_extension)
78 }
79
80 fn grammar(self) -> tree_sitter::Language {
81 match self {
82 Language::Rust => tree_sitter_rust::LANGUAGE.into(),
83 Language::Python => tree_sitter_python::LANGUAGE.into(),
84 Language::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
85 Language::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
86 Language::Go => tree_sitter_go::LANGUAGE.into(),
87 }
88 }
89
90 fn function_kinds(self) -> &'static [&'static str] {
92 match self {
93 Language::Rust => &["function_item"],
94 Language::Python => &["function_definition"],
95 Language::JavaScript | Language::TypeScript => &[
96 "function_declaration",
97 "generator_function_declaration",
98 "method_definition",
99 "function_expression",
100 ],
101 Language::Go => &["function_declaration", "method_declaration"],
102 }
103 }
104
105 fn body_placeholder(self, lines: usize) -> String {
107 match self {
108 Language::Python => format!("... # {lines} lines elided"),
110 _ => format!("{{ /* {lines} lines elided */ }}"),
111 }
112 }
113}
114
115pub fn estimate_tokens(text: &str) -> usize {
121 const CHARS_PER_TOKEN: f64 = 3.7;
122 (text.chars().count() as f64 / CHARS_PER_TOKEN).ceil() as usize
123}
124
125#[derive(Debug, Clone, Serialize)]
127pub struct CompactResult {
128 pub text: String,
129 pub original_tokens: usize,
130 pub compacted_tokens: usize,
131 pub elided_bodies: usize,
133}
134
135impl CompactResult {
136 pub fn savings(&self) -> f64 {
138 if self.original_tokens == 0 {
139 return 0.0;
140 }
141 1.0 - (self.compacted_tokens as f64 / self.original_tokens as f64)
142 }
143}
144
145pub struct Compactor {
147 parser: Parser,
148 language: Language,
149}
150
151impl Compactor {
152 pub fn new(language: Language) -> Result<Self> {
154 let mut parser = Parser::new();
155 parser.set_language(&language.grammar())?;
156 Ok(Compactor { parser, language })
157 }
158
159 pub fn rust() -> Result<Self> {
161 Self::new(Language::Rust)
162 }
163
164 pub fn for_path(path: impl AsRef<Path>) -> Result<Self> {
166 let path = path.as_ref();
167 let lang = Language::from_path(path).ok_or_else(|| {
168 CompactError::UnsupportedExtension(
169 path.extension()
170 .and_then(|e| e.to_str())
171 .unwrap_or("")
172 .to_string(),
173 )
174 })?;
175 Self::new(lang)
176 }
177
178 pub fn language(&self) -> Language {
179 self.language
180 }
181
182 #[tracing::instrument(
185 name = "compact.outline",
186 skip_all,
187 fields(
188 language = ?self.language,
189 files_compacted = 1,
190 tokens_saved = tracing::field::Empty,
191 elided_bodies = tracing::field::Empty,
192 )
193 )]
194 pub fn outline(&mut self, source: &str) -> Result<CompactResult> {
195 let tree = self
196 .parser
197 .parse(source, None)
198 .ok_or(CompactError::ParseFailed)?;
199 let mut edits: Vec<(usize, usize, String)> = Vec::new();
200 collect_body_elisions(tree.root_node(), source, self.language, &mut edits, 0);
201 edits.sort_by_key(|e| e.0);
202
203 let mut out = String::with_capacity(source.len());
204 let mut pos = 0;
205 for (start, end, replacement) in &edits {
206 if *start < pos {
207 continue; }
209 out.push_str(&source[pos..*start]);
210 out.push_str(replacement);
211 pos = *end;
212 }
213 out.push_str(&source[pos..]);
214
215 let result = CompactResult {
216 original_tokens: estimate_tokens(source),
217 compacted_tokens: estimate_tokens(&out),
218 elided_bodies: edits.len(),
219 text: out,
220 };
221 let span = tracing::Span::current();
222 span.record(
223 "tokens_saved",
224 result
225 .original_tokens
226 .saturating_sub(result.compacted_tokens) as u64,
227 );
228 span.record("elided_bodies", result.elided_bodies as u64);
229 Ok(result)
230 }
231
232 pub fn compact_to_budget(&mut self, source: &str, max_tokens: usize) -> Result<CompactResult> {
236 let original = estimate_tokens(source);
237 if original <= max_tokens {
238 return Ok(CompactResult {
239 text: source.to_string(),
240 original_tokens: original,
241 compacted_tokens: original,
242 elided_bodies: 0,
243 });
244 }
245 self.outline(source)
246 }
247
248 #[tracing::instrument(
262 name = "compact.expand",
263 skip_all,
264 fields(language = ?self.language, symbol = %symbol, matches = tracing::field::Empty)
265 )]
266 pub fn expand(&mut self, source: &str, symbol: &str) -> Result<Vec<String>> {
267 let tree = self
268 .parser
269 .parse(source, None)
270 .ok_or(CompactError::ParseFailed)?;
271 let mut hits = Vec::new();
272 collect_named(
273 tree.root_node(),
274 source,
275 self.language,
276 symbol,
277 &mut hits,
278 0,
279 );
280 tracing::Span::current().record("matches", hits.len() as u64);
281 Ok(hits)
282 }
283}
284
285const MAX_AST_DEPTH: u32 = 400;
291
292fn collect_named(
296 node: Node,
297 source: &str,
298 lang: Language,
299 symbol: &str,
300 out: &mut Vec<String>,
301 depth: u32,
302) {
303 if depth > MAX_AST_DEPTH {
304 return;
305 }
306 let kinds = lang.function_kinds();
307 let mut cursor = node.walk();
308 for child in node.children(&mut cursor) {
309 if kinds.contains(&child.kind()) {
310 let name = child
311 .child_by_field_name("name")
312 .and_then(|n| n.utf8_text(source.as_bytes()).ok());
313 if name == Some(symbol) {
314 if let Ok(text) = child.utf8_text(source.as_bytes()) {
315 out.push(text.to_string());
316 }
317 }
318 } else {
321 collect_named(child, source, lang, symbol, out, depth + 1);
322 }
323 }
324}
325
326fn collect_body_elisions(
329 node: Node,
330 source: &str,
331 lang: Language,
332 edits: &mut Vec<(usize, usize, String)>,
333 depth: u32,
334) {
335 if depth > MAX_AST_DEPTH {
336 return;
337 }
338 let kinds = lang.function_kinds();
339 let mut cursor = node.walk();
340 for child in node.children(&mut cursor) {
341 if kinds.contains(&child.kind()) {
342 if let Some(body) = child.child_by_field_name("body") {
343 let span = &source[body.start_byte()..body.end_byte()];
344 let lines = span.lines().count().max(1);
345 let replacement = lang.body_placeholder(lines);
346 if replacement.len() < span.len() {
348 edits.push((body.start_byte(), body.end_byte(), replacement));
349 }
350 }
351 } else {
353 collect_body_elisions(child, source, lang, edits, depth + 1);
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 const RUST: &str = r#"
364/// A widget.
365pub struct Widget { pub id: u32 }
366impl Widget {
367 /// Build one.
368 pub fn new(id: u32) -> Self {
369 let secret = compute_expensive_thing(id);
370 Widget { id: secret }
371 }
372}
373pub fn top_level(a: i32, b: i32) -> i32 {
374 let mut acc = 0;
375 for i in a..b { acc += i * i; }
376 acc
377}
378"#;
379
380 #[test]
381 fn estimate_is_monotonic() {
382 assert!(estimate_tokens("short") < estimate_tokens("a considerably longer string here"));
383 assert_eq!(estimate_tokens(""), 0);
384 }
385
386 #[test]
387 fn rust_outline_keeps_signatures_and_elides_bodies() {
388 let mut c = Compactor::rust().unwrap();
389 let r = c.outline(RUST).unwrap();
390 assert!(r.text.contains("pub struct Widget"));
391 assert!(r.text.contains("pub fn new(id: u32) -> Self"));
392 assert!(r.text.contains("pub fn top_level(a: i32, b: i32) -> i32"));
393 assert!(r.text.contains("/// Build one."));
394 assert!(!r.text.contains("compute_expensive_thing"));
395 assert!(!r.text.contains("acc += i * i"));
396 assert_eq!(r.elided_bodies, 2);
397 assert!(r.compacted_tokens < r.original_tokens);
398 }
399
400 #[test]
401 fn python_outline_keeps_defs_and_uses_ellipsis() {
402 let src = "def compute(a, b):\n total = 0\n for i in range(a, b):\n total += i * i\n return total\n\nclass Widget:\n def build(self, n):\n secret = expensive(n)\n return secret\n";
403 let mut c = Compactor::new(Language::Python).unwrap();
404 let r = c.outline(src).unwrap();
405 assert!(r.text.contains("def compute(a, b):"));
406 assert!(r.text.contains("def build(self, n):"));
407 assert!(r.text.contains("class Widget:"));
408 assert!(r.text.contains("..."));
409 assert!(!r.text.contains("total += i * i"));
410 assert!(!r.text.contains("expensive(n)"));
411 assert_eq!(r.elided_bodies, 2);
412 }
413
414 #[test]
415 fn javascript_outline_elides_function_and_method_bodies() {
416 let src = "function add(a, b) {\n const s = a + b;\n return s;\n}\nclass C {\n method(x) {\n const y = heavyThing(x);\n return y;\n }\n}\n";
417 let mut c = Compactor::new(Language::JavaScript).unwrap();
418 let r = c.outline(src).unwrap();
419 assert!(r.text.contains("function add(a, b)"));
420 assert!(r.text.contains("method(x)"));
421 assert!(!r.text.contains("heavyThing"));
422 assert_eq!(r.elided_bodies, 2);
423 }
424
425 #[test]
426 fn go_outline_elides_func_and_method_bodies() {
427 let src = "package main\nfunc Add(a, b int) int {\n\ts := a + b\n\treturn s\n}\nfunc (w Widget) Build(n int) int {\n\tsecret := expensive(n)\n\treturn secret\n}\n";
428 let mut c = Compactor::new(Language::Go).unwrap();
429 let r = c.outline(src).unwrap();
430 assert!(r.text.contains("func Add(a, b int) int"));
431 assert!(r.text.contains("func (w Widget) Build(n int) int"));
432 assert!(!r.text.contains("expensive(n)"));
433 assert_eq!(r.elided_bodies, 2);
434 }
435
436 #[test]
437 fn language_detection_by_extension() {
438 assert_eq!(Language::from_extension("rs"), Some(Language::Rust));
439 assert_eq!(Language::from_extension("PY"), Some(Language::Python));
440 assert_eq!(Language::from_extension("tsx"), None); assert_eq!(Language::from_extension("ts"), Some(Language::TypeScript));
442 assert_eq!(Language::from_extension("go"), Some(Language::Go));
443 assert_eq!(Language::from_extension("txt"), None);
444 assert_eq!(Language::from_path("src/main.rs"), Some(Language::Rust));
445 }
446
447 #[test]
448 fn for_path_rejects_unknown_extensions() {
449 assert!(matches!(
450 Compactor::for_path("data.txt"),
451 Err(CompactError::UnsupportedExtension(_))
452 ));
453 }
454
455 #[test]
456 fn compaction_never_grows_the_source() {
457 let mut c = Compactor::rust().unwrap();
458 let tiny = "fn a() { 1 }\nfn b() -> i32 { 2 }\n";
459 let r = c.outline(tiny).unwrap();
460 assert!(r.compacted_tokens <= r.original_tokens);
461 assert_eq!(r.elided_bodies, 0);
462 }
463
464 #[test]
467 fn expand_returns_the_full_body_a_skeleton_elided() {
468 let mut c = Compactor::rust().unwrap();
469 let hits = c.expand(RUST, "new").unwrap();
472 assert_eq!(hits.len(), 1);
473 assert!(hits[0].contains("compute_expensive_thing(id)"));
474 assert!(hits[0].contains("pub fn new"));
475 }
476
477 #[test]
478 fn expand_finds_top_level_functions_too() {
479 let mut c = Compactor::rust().unwrap();
480 let hits = c.expand(RUST, "top_level").unwrap();
481 assert_eq!(hits.len(), 1);
482 assert!(hits[0].contains("acc += i * i"));
483 }
484
485 #[test]
486 fn expand_returns_every_match_since_names_are_not_unique() {
487 let mut c = Compactor::rust().unwrap();
488 let src = "impl A { fn make() -> u8 { 1 } }\nimpl B { fn make() -> u8 { 2 } }\n";
489 let hits = c.expand(src, "make").unwrap();
490 assert_eq!(hits.len(), 2, "both `make` methods should come back");
491 }
492
493 #[test]
494 fn expand_of_an_unknown_symbol_is_empty_not_an_error() {
495 let mut c = Compactor::rust().unwrap();
496 assert!(c.expand(RUST, "does_not_exist").unwrap().is_empty());
497 }
498
499 #[test]
500 fn expand_works_across_languages() {
501 let mut py = Compactor::new(Language::Python).unwrap();
502 let hits = py
503 .expand("def greet(name):\n return f'hi {name}'\n", "greet")
504 .unwrap();
505 assert_eq!(hits.len(), 1);
506 assert!(hits[0].contains("return f'hi {name}'"));
507 }
508}