Skip to main content

boundgraph_slice/
ast.rs

1//! Condensing source code: keep the shape, elide the bodies.
2//!
3//! # Why code cannot be truncated
4//!
5//! Cutting a source file at a byte offset destroys exactly what a coding agent
6//! needs. The signatures are spread through the file, the imports are at the
7//! top, and the function the agent was asked about is as likely to be at the
8//! end as anywhere. A model handed the first two hundred lines of a thousand
9//! line file knows less than one handed every signature in it.
10//!
11//! So this slicer parses, keeps the declarations, and replaces each body with a
12//! marker. A four hundred line module becomes forty lines of signatures that
13//! still say what the module *is*.
14//!
15//! # The output is still source
16//!
17//! Every elision marker is valid syntax in the language it replaces — a block
18//! comment in Rust and JavaScript, `...` in Python. The result parses. That is
19//! not decoration: a model reading something that looks like code but does not
20//! parse will reproduce the malformation in its patch, and a downstream syntax
21//! checker will then blame the agent.
22//!
23//! # Relevance, not file order
24//!
25//! When the cap will not hold every signature, the ones that go are chosen by
26//! relevance to what the step was asked to do rather than by position. The
27//! terms come from the caller — the runtime holds the
28//! [`WorkOrder`](boundgraph_core::WorkOrder) and constructs the slicer with terms
29//! drawn from its intent — because the [`Slicer`] contract takes raw output and
30//! a cap and nothing else, and widening it so one slicer could see the work
31//! order would put a model's proposal into every slicer's reach.
32//!
33//! Whatever survives is emitted in source order. Reordering a file to put the
34//! relevant parts first would make the line numbers lie.
35//!
36//! # When it cannot parse
37//!
38//! It degrades to the log slicer rather than failing the step. An unsupported
39//! language, a file that is mostly a syntax error, a template with a foreign
40//! preprocessor — all of them are still *text*, and text sliced by the log
41//! slicer is far more useful than a refused step. The condensate says which
42//! slicer actually produced it, so nothing downstream is misled about what it
43//! is reading.
44
45use 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
53/// The schema an outline condensate declares.
54pub const AST_SCHEMA: &str = "boundgraph.code.v1";
55
56/// A language this slicer can parse.
57///
58/// Declared explicitly rather than sniffed. A slicer that guessed would
59/// occasionally guess wrong and emit an outline of the wrong shape, which is
60/// worse than declining — declining degrades to the log slicer, which is always
61/// correct if unexciting.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63#[non_exhaustive]
64pub enum Language {
65    /// Rust.
66    Rust,
67    /// Python.
68    Python,
69    /// JavaScript, including JSX.
70    JavaScript,
71}
72
73impl Language {
74    /// Every language this build can parse.
75    pub const SUPPORTED: &'static [Self] = &[Self::Rust, Self::Python, Self::JavaScript];
76
77    /// The language a path or hint names, if it is one that is supported.
78    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    /// The name used in the payload.
90    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    /// The node kinds worth keeping: the declarations that make up the shape.
107    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    /// How this language spells "a body was removed here".
142    ///
143    /// Chosen so the result still parses. `...` is a real expression in Python;
144    /// a block comment is valid wherever a statement could be in Rust and
145    /// JavaScript.
146    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    /// Whether a node is a body that should be replaced rather than shown.
154    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/// One declaration kept from a file.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Item {
166    /// Where it starts in the original, counting from one.
167    pub line: usize,
168    /// The declaration with its bodies elided.
169    pub text: String,
170    /// The node kind, for a reader who wants to know what was kept.
171    pub kind: String,
172    /// How relevant it looked to what the step was asked to do.
173    relevance: usize,
174}
175
176/// Condenses source code to an outline.
177///
178/// # Example
179///
180/// ```
181/// use boundgraph_slice::{AstSlicer, MemoryStore, Raw, Slicer};
182///
183/// let source = r#"
184/// pub fn parse_header(input: &[u8]) -> Result<Header, Error> {
185///     let mut cursor = 0;
186///     // ... forty more lines ...
187///     Ok(Header::default())
188/// }
189/// "#;
190///
191/// let raw = Raw::new(source).hinting("src/parse.rs");
192/// let condensate = tokio::runtime::Builder::new_current_thread()
193///     .build()?
194///     .block_on(AstSlicer::new().condense(&raw, 300, &MemoryStore::new()))?;
195///
196/// // The signature survived; the body did not.
197/// let rendered = format!("{:?}", condensate.payload);
198/// assert!(rendered.contains("parse_header"));
199/// # Ok::<(), Box<dyn std::error::Error>>(())
200/// ```
201#[derive(Debug, Clone, Default)]
202pub struct AstSlicer {
203    relevant: Vec<String>,
204    fallback: LogSlicer,
205}
206
207impl AstSlicer {
208    /// A slicer with no relevance terms, keeping declarations in file order.
209    pub fn new() -> Self {
210        Self {
211            relevant: Vec::new(),
212            fallback: LogSlicer::new(),
213        }
214    }
215
216    /// Prefer declarations mentioning any of these terms.
217    ///
218    /// The runtime supplies them from the work order's intent — see the module
219    /// documentation for why they arrive here rather than through the trait.
220    #[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    /// How well a declaration matches the terms.
228    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    /// Render a node with every body inside it replaced by a marker.
237    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                    // Emit everything up to the body, then the marker, and skip
253                    // the body itself — never a partial one.
254                    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                // Descend, so a body nested inside an `impl` is elided too, but
261                // not so far that this becomes quadratic on a deep file.
262                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    /// Extract the declarations of a parsed file.
274    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        // Tree-sitter recovers from errors, so a tree with ERROR nodes is
301        // normal for a file with one typo and useless for a file that is not
302        // really source at all. Extracting nothing from an erroring tree is the
303        // signal that this is the second case.
304        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        // Either the language is not one this build parses, or the file is not
354        // really source. Both are text, and text is what the log slicer is for.
355        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        // Choose by relevance, then by position so ties are stable.
379        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            // Emitted in source order: reordering a file would make the line
387            // numbers lie.
388            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            // Bodies are always elided, so an outline is lossy even when every
403            // declaration survived. Reporting otherwise would tell a downstream
404            // step it has the whole file when it has only the shape.
405            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    // ── structure is kept ───────────────────────────────────────────────────
497
498    /// The criterion: module, type, and function signatures survive.
499    #[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    /// The criterion: bodies are elided with a marker, never cut mid-token.
510    #[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        // The struct's fields are its shape, not a body, so they stay.
520        assert!(out.contains("pub version: u8"));
521    }
522
523    /// The criterion: the result parses. A model shown malformed code will
524    /// reproduce the malformation in its patch.
525    #[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    // ── relevance ───────────────────────────────────────────────────────────
561
562    /// The criterion: elision is driven by relevance, not file order.
563    /// `unrelated_helper` comes *before* `impl Header` in the file, so file
564    /// order alone would keep the wrong one.
565    #[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    /// Reordering a file to put the relevant parts first would make the line
596    /// numbers lie.
597    #[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    // ── degrading ───────────────────────────────────────────────────────────
612
613    /// The criterion: an unsupported language degrades predictably.
614    #[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    /// The criterion: unparseable input degrades rather than failing the step.
628    #[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    /// Tree-sitter recovers from errors, so a file with one typo still yields
640    /// an outline — falling back on any error at all would give up on most
641    /// real files an agent is mid-way through editing.
642    #[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    // ── declared support ────────────────────────────────────────────────────
669
670    #[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    // ── the contract ────────────────────────────────────────────────────────
705
706    #[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    /// An outline has always dropped the bodies, so it is never lossless.
722    #[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}