1use async_trait::async_trait;
46use boundgraph_core::{SchemaId, SlicerId};
47use serde_json::json;
48use tree_sitter::{Node, Parser};
49
50use crate::log::LogSlicer;
51use crate::slicer::{Raw, Slice, SliceError, Slicer};
52
53pub const AST_SCHEMA: &str = "boundgraph.code.v1";
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63#[non_exhaustive]
64pub enum Language {
65 Rust,
67 Python,
69 JavaScript,
71}
72
73impl Language {
74 pub const SUPPORTED: &'static [Self] = &[Self::Rust, Self::Python, Self::JavaScript];
76
77 pub fn of_hint(hint: &str) -> Option<Self> {
79 let lower = hint.to_ascii_lowercase();
80 let extension = lower.rsplit('.').next()?;
81 match extension {
82 "rs" => Some(Self::Rust),
83 "py" | "pyi" => Some(Self::Python),
84 "js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
85 _ => None,
86 }
87 }
88
89 pub fn name(self) -> &'static str {
91 match self {
92 Self::Rust => "rust",
93 Self::Python => "python",
94 Self::JavaScript => "javascript",
95 }
96 }
97
98 fn grammar(self) -> tree_sitter::Language {
99 match self {
100 Self::Rust => tree_sitter_rust::LANGUAGE.into(),
101 Self::Python => tree_sitter_python::LANGUAGE.into(),
102 Self::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
103 }
104 }
105
106 fn declarations(self) -> &'static [&'static str] {
108 match self {
109 Self::Rust => &[
110 "use_declaration",
111 "mod_item",
112 "function_item",
113 "struct_item",
114 "enum_item",
115 "trait_item",
116 "impl_item",
117 "type_item",
118 "const_item",
119 "static_item",
120 "macro_definition",
121 ],
122 Self::Python => &[
123 "import_statement",
124 "import_from_statement",
125 "function_definition",
126 "class_definition",
127 "decorated_definition",
128 ],
129 Self::JavaScript => &[
130 "import_statement",
131 "export_statement",
132 "function_declaration",
133 "generator_function_declaration",
134 "class_declaration",
135 "method_definition",
136 "lexical_declaration",
137 ],
138 }
139 }
140
141 fn elision(self, lines: usize) -> String {
147 match self {
148 Self::Rust | Self::JavaScript => format!("{{ /* … {lines} lines … */ }}"),
149 Self::Python => format!("... # … {lines} lines …"),
150 }
151 }
152
153 fn is_body(self, kind: &str) -> bool {
155 match self {
156 Self::Rust => kind == "block",
157 Self::Python => kind == "block",
158 Self::JavaScript => kind == "statement_block" || kind == "class_body",
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Item {
166 pub line: usize,
168 pub text: String,
170 pub kind: String,
172 relevance: usize,
174}
175
176#[derive(Debug, Clone, Default)]
202pub struct AstSlicer {
203 relevant: Vec<String>,
204 fallback: LogSlicer,
205}
206
207impl AstSlicer {
208 pub fn new() -> Self {
210 Self {
211 relevant: Vec::new(),
212 fallback: LogSlicer::new(),
213 }
214 }
215
216 #[must_use]
221 pub fn relevant_to<S: Into<String>>(mut self, terms: impl IntoIterator<Item = S>) -> Self {
222 self.relevant
223 .extend(terms.into_iter().map(|t| t.into().to_ascii_lowercase()));
224 self
225 }
226
227 fn score(&self, text: &str) -> usize {
229 if self.relevant.is_empty() {
230 return 0;
231 }
232 let lower = text.to_ascii_lowercase();
233 self.relevant.iter().filter(|t| lower.contains(*t)).count()
234 }
235
236 fn outline_of(language: Language, node: Node<'_>, source: &str) -> String {
238 let mut out = String::new();
239 let mut at = node.start_byte();
240
241 fn walk(
242 language: Language,
243 node: Node<'_>,
244 source: &str,
245 at: &mut usize,
246 out: &mut String,
247 depth: usize,
248 ) {
249 let mut cursor = node.walk();
250 for child in node.children(&mut cursor) {
251 if language.is_body(child.kind()) {
252 out.push_str(&source[*at..child.start_byte()]);
255 let lines = source[child.start_byte()..child.end_byte()].lines().count();
256 out.push_str(&language.elision(lines));
257 *at = child.end_byte();
258 continue;
259 }
260 if child.child_count() > 0 && depth < 4 {
263 walk(language, child, source, at, out, depth + 1);
264 }
265 }
266 }
267
268 walk(language, node, source, &mut at, &mut out, 0);
269 out.push_str(&source[at..node.end_byte()]);
270 out.trim_end().to_string()
271 }
272
273 fn items(&self, language: Language, source: &str) -> Option<Vec<Item>> {
275 let mut parser = Parser::new();
276 parser.set_language(&language.grammar()).ok()?;
277 let tree = parser.parse(source, None)?;
278 let root = tree.root_node();
279
280 let kinds = language.declarations();
281 let mut items = Vec::new();
282 let mut cursor = root.walk();
283
284 for child in root.children(&mut cursor) {
285 if !kinds.contains(&child.kind()) {
286 continue;
287 }
288 let text = Self::outline_of(language, child, source);
289 if text.trim().is_empty() {
290 continue;
291 }
292 items.push(Item {
293 line: child.start_position().row + 1,
294 relevance: self.score(&text),
295 kind: child.kind().to_string(),
296 text,
297 });
298 }
299
300 if root.has_error() && items.is_empty() {
305 return None;
306 }
307 Some(items)
308 }
309
310 fn payload(language: Language, kept: &[Item], all: &[Item]) -> serde_json::Value {
311 let outline = kept
312 .iter()
313 .map(|i| i.text.as_str())
314 .collect::<Vec<_>>()
315 .join("\n\n");
316
317 let mut payload = json!({
318 "language": language.name(),
319 "outline": outline,
320 "counts": { "items": all.len(), "shown": kept.len() },
321 });
322
323 if kept.len() < all.len() {
324 let shown: std::collections::BTreeSet<usize> = kept.iter().map(|i| i.line).collect();
325 let dropped: Vec<&str> = all
326 .iter()
327 .filter(|i| !shown.contains(&i.line))
328 .map(|i| i.kind.as_str())
329 .collect();
330 payload["dropped"] = json!(dropped.len());
331 payload["dropped_kinds"] = json!(
332 dropped
333 .iter()
334 .collect::<std::collections::BTreeSet<_>>()
335 .into_iter()
336 .collect::<Vec<_>>()
337 );
338 }
339 payload
340 }
341}
342
343#[async_trait]
344impl Slicer for AstSlicer {
345 fn id(&self) -> SlicerId {
346 SlicerId::new("ast", 1)
347 }
348
349 async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
350 let language = raw.hint.as_deref().and_then(Language::of_hint);
351 let source = raw.text();
352
353 let Some(language) = language else {
356 return self.fallback.slice(raw, cap).await;
357 };
358 let Some(items) = self.items(language, &source) else {
359 return self.fallback.slice(raw, cap).await;
360 };
361 if items.is_empty() {
362 return self.fallback.slice(raw, cap).await;
363 }
364
365 let counter = self.counter();
366 let fits = |value: &serde_json::Value| {
367 counter.count(&serde_json::to_string(value).unwrap_or_default()) <= cap
368 };
369
370 let floor = Self::payload(language, &[], &items);
371 if !fits(&floor) {
372 return Err(SliceError::CapTooSmall {
373 cap,
374 minimum: counter.count(&serde_json::to_string(&floor).unwrap_or_default()),
375 });
376 }
377
378 let mut order: Vec<usize> = (0..items.len()).collect();
380 order.sort_by_key(|&i| (std::cmp::Reverse(items[i].relevance), items[i].line));
381
382 let mut chosen: Vec<usize> = Vec::new();
383 for &index in &order {
384 let mut candidate = chosen.clone();
385 candidate.push(index);
386 candidate.sort_by_key(|&i| items[i].line);
389
390 let kept: Vec<Item> = candidate.iter().map(|&i| items[i].clone()).collect();
391 if fits(&Self::payload(language, &kept, &items)) {
392 chosen = candidate;
393 }
394 }
395
396 chosen.sort_by_key(|&i| items[i].line);
397 let kept: Vec<Item> = chosen.iter().map(|&i| items[i].clone()).collect();
398
399 Ok(Slice {
400 schema: SchemaId::new(AST_SCHEMA, 1),
401 payload: Self::payload(language, &kept, &items),
402 lossy: true,
406 })
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::slicer::MemoryStore;
414
415 const RUST: &str = r#"
416use std::collections::BTreeMap;
417
418pub struct Header {
419 pub version: u8,
420 pub length: u32,
421}
422
423pub fn parse_header(input: &[u8]) -> Result<Header, Error> {
424 let mut cursor = 0;
425 let version = input[cursor];
426 cursor += 1;
427 let length = u32::from_be_bytes(input[cursor..cursor + 4].try_into()?);
428 Ok(Header { version, length })
429}
430
431pub fn unrelated_helper(x: u32) -> u32 {
432 let mut total = 0;
433 for i in 0..x {
434 total += i;
435 }
436 total
437}
438
439impl Header {
440 pub fn is_valid(&self) -> bool {
441 self.version > 0
442 }
443}
444"#;
445
446 const PYTHON: &str = r#"
447import os
448from typing import Optional
449
450class Parser:
451 def parse_header(self, data: bytes) -> Optional[dict]:
452 cursor = 0
453 version = data[cursor]
454 cursor += 1
455 return {"version": version}
456
457def unrelated(x):
458 total = 0
459 for i in range(x):
460 total += i
461 return total
462"#;
463
464 const JAVASCRIPT: &str = r#"
465import { readFile } from "fs";
466
467export function parseHeader(input) {
468 let cursor = 0;
469 const version = input[cursor];
470 cursor += 1;
471 return { version };
472}
473
474function unrelated(x) {
475 let total = 0;
476 for (let i = 0; i < x; i++) { total += i; }
477 return total;
478}
479"#;
480
481 fn raw_of(source: &str, hint: &str) -> Raw {
482 Raw::new(source.as_bytes().to_vec()).hinting(hint)
483 }
484
485 async fn outline(source: &str, hint: &str, cap: usize) -> String {
486 let slice = AstSlicer::new()
487 .slice(&raw_of(source, hint), cap)
488 .await
489 .expect("a satisfiable cap");
490 slice.payload["outline"]
491 .as_str()
492 .unwrap_or_default()
493 .to_string()
494 }
495
496 #[tokio::test]
500 async fn rust_signatures_and_types_survive() {
501 let out = outline(RUST, "src/parse.rs", 2_000).await;
502
503 assert!(out.contains("use std::collections::BTreeMap"));
504 assert!(out.contains("pub struct Header"));
505 assert!(out.contains("pub fn parse_header(input: &[u8]) -> Result<Header, Error>"));
506 assert!(out.contains("impl Header"));
507 }
508
509 #[tokio::test]
511 async fn function_bodies_are_replaced_by_a_marker() {
512 let out = outline(RUST, "src/parse.rs", 2_000).await;
513
514 assert!(
515 !out.contains("u32::from_be_bytes"),
516 "the body should be gone"
517 );
518 assert!(out.contains("lines"), "and marked: {out}");
519 assert!(out.contains("pub version: u8"));
521 }
522
523 #[tokio::test]
526 async fn the_outline_is_still_parseable_source() {
527 for (source, hint, language) in [
528 (RUST, "a.rs", Language::Rust),
529 (PYTHON, "a.py", Language::Python),
530 (JAVASCRIPT, "a.js", Language::JavaScript),
531 ] {
532 let out = outline(source, hint, 4_000).await;
533
534 let mut parser = Parser::new();
535 parser.set_language(&language.grammar()).unwrap();
536 let tree = parser.parse(&out, None).expect("the outline parses");
537 assert!(
538 !tree.root_node().has_error(),
539 "{} outline does not parse:\n{out}",
540 language.name()
541 );
542 }
543 }
544
545 #[tokio::test]
546 async fn python_and_javascript_are_outlined_too() {
547 let py = outline(PYTHON, "a.py", 2_000).await;
548 assert!(py.contains("class Parser"));
549 assert!(py.contains("import os"));
550 assert!(!py.contains("cursor = 0"), "the body should be gone: {py}");
551
552 let js = outline(JAVASCRIPT, "a.js", 2_000).await;
553 assert!(js.contains("export function parseHeader"));
554 assert!(
555 !js.contains("const version"),
556 "the body should be gone: {js}"
557 );
558 }
559
560 #[tokio::test]
566 async fn a_tight_cap_keeps_what_the_step_was_asked_about() {
567 let raw = raw_of(RUST, "src/parse.rs");
568
569 let relevant = AstSlicer::new()
570 .relevant_to(["parse_header"])
571 .slice(&raw, 120)
572 .await
573 .unwrap();
574 let out = relevant.payload["outline"].as_str().unwrap();
575
576 assert!(
577 out.contains("parse_header"),
578 "the relevant function must survive: {out}"
579 );
580 assert!(
581 !out.contains("unrelated_helper"),
582 "the irrelevant one should have gone first: {out}"
583 );
584 }
585
586 #[tokio::test]
587 async fn without_terms_the_order_is_positional_and_stable() {
588 let raw = raw_of(RUST, "src/parse.rs");
589 let first = AstSlicer::new().slice(&raw, 200).await.unwrap();
590 for _ in 0..5 {
591 assert_eq!(AstSlicer::new().slice(&raw, 200).await.unwrap(), first);
592 }
593 }
594
595 #[tokio::test]
598 async fn whatever_survives_is_emitted_in_source_order() {
599 let out = AstSlicer::new()
600 .relevant_to(["is_valid"])
601 .slice(&raw_of(RUST, "src/parse.rs"), 4_000)
602 .await
603 .unwrap();
604 let text = out.payload["outline"].as_str().unwrap();
605
606 let use_at = text.find("use std").expect("the import");
607 let impl_at = text.find("impl Header").expect("the impl");
608 assert!(use_at < impl_at, "source order, not relevance order");
609 }
610
611 #[tokio::test]
615 async fn an_unsupported_language_falls_back_to_the_log_slicer() {
616 let raw = raw_of("SELECT * FROM users WHERE id = 1;\n", "query.sql");
617 let slice = AstSlicer::new().slice(&raw, 400).await.unwrap();
618
619 assert_eq!(
620 slice.schema,
621 SchemaId::new(crate::log::LOG_SCHEMA, 1),
622 "it should be a log condensate, and say so"
623 );
624 assert!(slice.payload.get("lines").is_some());
625 }
626
627 #[tokio::test]
629 async fn input_that_is_not_really_source_falls_back_rather_than_failing() {
630 let raw = raw_of(
631 "\u{1}\u{2} not code at all >>>>>> <<<<<< ####\nnor is this line\n",
632 "broken.rs",
633 );
634 let slice = AstSlicer::new().slice(&raw, 400).await.unwrap();
635
636 assert_eq!(slice.schema, SchemaId::new(crate::log::LOG_SCHEMA, 1));
637 }
638
639 #[tokio::test]
643 async fn a_file_with_one_syntax_error_still_produces_an_outline() {
644 let source = "
645pub fn good(x: u32) -> u32 { x + 1 }
646
647pub fn broken(y: u32 -> u32 { y }
648
649pub struct Fine { pub a: u8 }
650";
651 let raw = raw_of(source, "src/lib.rs");
652 let slice = AstSlicer::new().slice(&raw, 2_000).await.unwrap();
653
654 assert_eq!(slice.schema, SchemaId::new(AST_SCHEMA, 1), "not a fallback");
655 let out = slice.payload["outline"].as_str().unwrap();
656 assert!(out.contains("good") || out.contains("Fine"), "{out}");
657 }
658
659 #[tokio::test]
660 async fn a_file_with_no_hint_falls_back() {
661 let slice = AstSlicer::new()
662 .slice(&Raw::new(RUST.as_bytes().to_vec()), 800)
663 .await
664 .unwrap();
665 assert_eq!(slice.schema, SchemaId::new(crate::log::LOG_SCHEMA, 1));
666 }
667
668 #[test]
671 fn the_supported_languages_are_declared_explicitly() {
672 assert_eq!(Language::SUPPORTED.len(), 3);
673 assert_eq!(Language::of_hint("src/main.rs"), Some(Language::Rust));
674 assert_eq!(Language::of_hint("app/views.py"), Some(Language::Python));
675 assert_eq!(
676 Language::of_hint("web/index.jsx"),
677 Some(Language::JavaScript)
678 );
679 assert_eq!(Language::of_hint("Cargo.toml"), None);
680 assert_eq!(Language::of_hint("README"), None);
681 assert_eq!(Language::of_hint("query.sql"), None);
682 }
683
684 #[test]
685 fn every_supported_language_has_a_working_grammar() {
686 for language in Language::SUPPORTED {
687 let mut parser = Parser::new();
688 parser
689 .set_language(&language.grammar())
690 .unwrap_or_else(|e| panic!("{} grammar rejected: {e}", language.name()));
691 }
692 }
693
694 #[test]
695 fn every_elision_marker_is_valid_in_its_own_language() {
696 for language in Language::SUPPORTED {
697 let marker = language.elision(42);
698 assert!(marker.contains("42"), "{}", language.name());
699 }
700 assert!(Language::Python.elision(3).starts_with("..."));
701 assert!(Language::Rust.elision(3).starts_with('{'));
702 }
703
704 #[tokio::test]
707 async fn the_output_never_exceeds_the_cap() {
708 let store = MemoryStore::new();
709 let raw = raw_of(RUST, "src/parse.rs");
710
711 for cap in [120, 200, 400, 800, 2_000, 8_000] {
712 let condensate = AstSlicer::new()
713 .relevant_to(["parse_header"])
714 .condense(&raw, cap, &store)
715 .await
716 .unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
717 assert_eq!(condensate.cap, cap);
718 }
719 }
720
721 #[tokio::test]
723 async fn an_outline_is_always_lossy() {
724 let slice = AstSlicer::new()
725 .slice(&raw_of(RUST, "src/parse.rs"), 8_000)
726 .await
727 .unwrap();
728
729 assert!(slice.lossy);
730 assert_eq!(slice.payload["counts"]["items"], 5);
731 assert_eq!(slice.payload["counts"]["shown"], 5);
732 }
733
734 #[tokio::test]
735 async fn what_was_dropped_is_counted() {
736 let slice = AstSlicer::new()
737 .relevant_to(["parse_header"])
738 .slice(&raw_of(RUST, "src/parse.rs"), 130)
739 .await
740 .unwrap();
741
742 assert!(slice.payload["dropped"].as_u64().unwrap() > 0);
743 assert!(slice.payload["dropped_kinds"].is_array());
744 }
745}