Skip to main content

lean_ctx/core/cognitive/
chunker.rs

1//! Semantic source chunking under Miller's Law cognitive budget constraints.
2//!
3//! Splits files at tree-sitter signature boundaries, scores chunks by semantic
4//! role and complexity, and renders a bounded subset for LLM context injection.
5
6use crate::core::signatures::{Signature, extract_signatures};
7use crate::core::tokens::count_tokens;
8
9/// Maximum chunks to return (Miller's Law upper bound).
10const MAX_CHUNKS: usize = 9;
11/// Default chunks to return.
12const DEFAULT_CHUNKS: usize = 7;
13
14/// Semantic role of a source-code chunk.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ChunkKind {
17    /// A function or method implementation.
18    Function,
19    /// A type, trait, class, or implementation declaration.
20    Type,
21    /// A test function or test block.
22    Test,
23    /// Configuration content.
24    Config,
25    /// Comment-only content.
26    Comment,
27    /// A leading import group.
28    Import,
29    /// Source between recognized semantic boundaries.
30    Block,
31}
32
33/// A bounded, scored section of source code.
34#[derive(Debug, Clone)]
35pub struct SemanticChunk {
36    /// Source text contained in this chunk.
37    pub content: String,
38    /// Semantic role used for budget prioritization.
39    pub kind: ChunkKind,
40    /// Heuristic complexity derived from size, nesting, and branch count.
41    pub complexity: f64,
42    /// Inclusive, one-based source line range.
43    pub line_range: (usize, usize),
44    /// Model-correct token count for `content`.
45    pub token_count: usize,
46}
47
48#[derive(Debug, Clone, Copy)]
49struct Boundary {
50    start: usize,
51    end: usize,
52    kind: ChunkKind,
53}
54
55/// Detect semantic chunks in source code using tree-sitter signature boundaries.
56pub(crate) fn detect_chunks(content: &str, file_ext: &str) -> Vec<SemanticChunk> {
57    if content.trim().is_empty() {
58        return Vec::new();
59    }
60
61    let lines: Vec<&str> = content.lines().collect();
62    let mut boundaries = signature_boundaries(&extract_signatures(content, file_ext), &lines);
63    if let Some(imports) = leading_import_boundary(&lines) {
64        boundaries.push(imports);
65    }
66    boundaries.sort_by_key(|boundary| (boundary.start, boundary.end));
67
68    let mut chunks = Vec::new();
69    let mut cursor = 1;
70    for (position, boundary) in boundaries.iter().enumerate() {
71        if boundary.start > cursor {
72            push_chunk(
73                &mut chunks,
74                &lines,
75                cursor,
76                boundary.start - 1,
77                None,
78                file_ext,
79            );
80        }
81        if boundary.end < cursor {
82            continue;
83        }
84        let next_start = boundaries
85            .get(position + 1)
86            .map_or(lines.len() + 1, |next| next.start);
87        let end = boundary.end.min(next_start.saturating_sub(1));
88        let start = boundary.start.max(cursor);
89        push_chunk(
90            &mut chunks,
91            &lines,
92            start,
93            end,
94            Some(boundary.kind),
95            file_ext,
96        );
97        cursor = end.saturating_add(1);
98    }
99    if cursor <= lines.len() {
100        push_chunk(&mut chunks, &lines, cursor, lines.len(), None, file_ext);
101    }
102    chunks
103}
104
105fn signature_boundaries(signatures: &[Signature], lines: &[&str]) -> Vec<Boundary> {
106    let mut boundaries: Vec<Boundary> = signatures
107        .iter()
108        .filter_map(|signature| {
109            let mut start = signature.start_line?;
110            let end = signature.end_line?.min(lines.len());
111            if start == 0 || start > end {
112                return None;
113            }
114            start = include_rust_attributes(start, lines);
115            let text = lines[start - 1..end].join("\n");
116            Some(Boundary {
117                start,
118                end,
119                kind: signature_kind(signature, &text),
120            })
121        })
122        .collect();
123    boundaries.sort_by_key(|boundary| (boundary.start, boundary.end));
124    boundaries.dedup_by_key(|boundary| (boundary.start, boundary.end));
125    boundaries
126}
127
128fn include_rust_attributes(mut start: usize, lines: &[&str]) -> usize {
129    while start > 1 {
130        let previous = lines[start - 2].trim();
131        if previous.starts_with("#[") || previous.starts_with("///") {
132            start -= 1;
133        } else {
134            break;
135        }
136    }
137    start
138}
139
140fn signature_kind(signature: &Signature, text: &str) -> ChunkKind {
141    if signature.name.starts_with("test_")
142        || text.contains("#[test]")
143        || text.contains("#[tokio::test]")
144    {
145        ChunkKind::Test
146    } else if matches!(signature.kind, "fn" | "method" | "constructor") {
147        ChunkKind::Function
148    } else if matches!(
149        signature.kind,
150        "struct" | "enum" | "trait" | "type" | "class" | "interface" | "impl" | "record"
151    ) {
152        ChunkKind::Type
153    } else {
154        ChunkKind::Block
155    }
156}
157
158fn leading_import_boundary(lines: &[&str]) -> Option<Boundary> {
159    let start = lines.iter().position(|line| is_import_start(line))?;
160    if lines[..start]
161        .iter()
162        .any(|line| !line.trim().is_empty() && !is_comment_line(line))
163    {
164        return None;
165    }
166
167    let mut end = start;
168    let mut delimiter_depth = 0_isize;
169    let mut continued = false;
170    for (index, line) in lines.iter().enumerate().skip(start) {
171        let trimmed = line.trim();
172        if is_import_start(line) || delimiter_depth > 0 || continued || trimmed.is_empty() {
173            end = index;
174            delimiter_depth += delimiter_delta(trimmed);
175            delimiter_depth = delimiter_depth.max(0);
176            continued = trimmed.ends_with('\\');
177        } else {
178            break;
179        }
180    }
181    Some(Boundary {
182        start: start + 1,
183        end: end + 1,
184        kind: ChunkKind::Import,
185    })
186}
187
188fn delimiter_delta(line: &str) -> isize {
189    line.chars().fold(0, |depth, character| match character {
190        '{' | '(' | '[' => depth + 1,
191        '}' | ')' | ']' => depth - 1,
192        _ => depth,
193    })
194}
195
196fn is_import_start(line: &str) -> bool {
197    let trimmed = line.trim_start();
198    trimmed.starts_with("use ")
199        || trimmed.starts_with("pub use ")
200        || trimmed.starts_with("extern crate ")
201        || trimmed.starts_with("import ")
202        || trimmed.starts_with("from ")
203        || trimmed.starts_with("#include ")
204}
205
206fn is_comment_line(line: &str) -> bool {
207    let trimmed = line.trim();
208    trimmed.starts_with("//")
209        || trimmed.starts_with('#')
210        || trimmed.starts_with("/*")
211        || trimmed.starts_with('*')
212        || trimmed.ends_with("*/")
213}
214
215fn push_chunk(
216    chunks: &mut Vec<SemanticChunk>,
217    lines: &[&str],
218    start: usize,
219    end: usize,
220    explicit_kind: Option<ChunkKind>,
221    file_ext: &str,
222) {
223    if start == 0 || end < start || start > lines.len() {
224        return;
225    }
226    let content = lines[start - 1..end.min(lines.len())].join("\n");
227    if content.trim().is_empty() {
228        return;
229    }
230    let kind = explicit_kind.unwrap_or_else(|| gap_kind(&content, file_ext));
231    let complexity = chunk_complexity(&content);
232    let token_count = count_tokens(&content);
233    chunks.push(SemanticChunk {
234        content,
235        kind,
236        complexity,
237        line_range: (start, end.min(lines.len())),
238        token_count,
239    });
240}
241
242fn gap_kind(content: &str, file_ext: &str) -> ChunkKind {
243    if content
244        .lines()
245        .all(|line| line.trim().is_empty() || is_comment_line(line))
246    {
247        ChunkKind::Comment
248    } else if matches!(file_ext, "json" | "toml" | "yaml" | "yml" | "ini") {
249        ChunkKind::Config
250    } else {
251        ChunkKind::Block
252    }
253}
254
255fn chunk_complexity(content: &str) -> f64 {
256    let line_count = content
257        .lines()
258        .filter(|line| !line.trim().is_empty())
259        .count();
260    let mut depth = 0_usize;
261    let mut max_depth = 0_usize;
262    for character in content.chars() {
263        match character {
264            '{' | '(' | '[' => {
265                depth += 1;
266                max_depth = max_depth.max(depth);
267            }
268            '}' | ')' | ']' => depth = depth.saturating_sub(1),
269            _ => {}
270        }
271    }
272    let branches = [" if ", " else ", " match ", " for ", " while ", "&&", "||"]
273        .iter()
274        .map(|needle| content.matches(needle).count())
275        .sum::<usize>();
276    1.0 + (line_count as f64).ln_1p() + max_depth as f64 * 0.5 + branches as f64
277}
278
279/// Select top-K chunks within the cognitive budget (default 7, max 9).
280pub(crate) fn budget_select(chunks: &[SemanticChunk], max_chunks: Option<usize>) -> Vec<usize> {
281    let limit = max_chunks.unwrap_or(DEFAULT_CHUNKS).min(MAX_CHUNKS);
282    let mut ranked: Vec<usize> = (0..chunks.len()).collect();
283    ranked.sort_by(|left, right| {
284        let left_score = kind_priority(chunks[*left].kind) as f64 * chunks[*left].complexity;
285        let right_score = kind_priority(chunks[*right].kind) as f64 * chunks[*right].complexity;
286        right_score
287            .total_cmp(&left_score)
288            .then_with(|| chunks[*left].line_range.cmp(&chunks[*right].line_range))
289    });
290    ranked.truncate(limit);
291    ranked.sort_by_key(|index| chunks[*index].line_range);
292    ranked
293}
294
295fn kind_priority(kind: ChunkKind) -> u8 {
296    match kind {
297        ChunkKind::Test => 5,
298        ChunkKind::Function => 4,
299        ChunkKind::Type => 3,
300        ChunkKind::Import | ChunkKind::Config => 2,
301        ChunkKind::Block => 1,
302        ChunkKind::Comment => 0,
303    }
304}
305
306/// Render selected chunks as output text with inter-chunk markers.
307pub(crate) fn render_budget_output(
308    chunks: &[SemanticChunk],
309    selected: &[usize],
310    file_path: &str,
311) -> String {
312    let mut ordered: Vec<&SemanticChunk> = selected
313        .iter()
314        .filter_map(|index| chunks.get(*index))
315        .collect();
316    ordered.sort_by_key(|chunk| chunk.line_range);
317    let total_tokens = ordered.iter().map(|chunk| chunk.token_count).sum::<usize>();
318    let mut output = format!("// {file_path}\n");
319    let mut previous_end = None;
320    for chunk in &ordered {
321        if let Some(end) = previous_end {
322            let omitted = chunk.line_range.0.saturating_sub(end + 1);
323            if omitted > 0 {
324                output.push_str(&format!("// ... {omitted} lines omitted\n"));
325            }
326        }
327        output.push_str(&format!(
328            "ยง {} {} (L{}-L{})\n{}\n",
329            kind_label(chunk.kind),
330            chunk_name(chunk),
331            chunk.line_range.0,
332            chunk.line_range.1,
333            chunk.content
334        ));
335        previous_end = Some(chunk.line_range.1);
336    }
337    output.push_str(&format!(
338        "{}/{} chunks shown ({} tokens)",
339        ordered.len(),
340        chunks.len(),
341        total_tokens
342    ));
343    output
344}
345
346fn kind_label(kind: ChunkKind) -> &'static str {
347    match kind {
348        ChunkKind::Function => "function",
349        ChunkKind::Type => "type",
350        ChunkKind::Test => "test",
351        ChunkKind::Config => "config",
352        ChunkKind::Comment => "comment",
353        ChunkKind::Import => "import",
354        ChunkKind::Block => "block",
355    }
356}
357
358fn chunk_name(chunk: &SemanticChunk) -> &str {
359    if matches!(chunk.kind, ChunkKind::Import) {
360        return "imports";
361    }
362    let words: Vec<&str> = chunk.content.split_whitespace().collect();
363    let keyword = match chunk.kind {
364        ChunkKind::Function | ChunkKind::Test => ["fn", "def", "function"].as_slice(),
365        ChunkKind::Type => [
366            "struct",
367            "enum",
368            "trait",
369            "type",
370            "class",
371            "interface",
372            "impl",
373        ]
374        .as_slice(),
375        _ => return kind_label(chunk.kind),
376    };
377    words
378        .windows(2)
379        .find(|pair| keyword.contains(&pair[0]))
380        .map_or(kind_label(chunk.kind), |pair| {
381            pair[1].trim_matches(|character: char| !character.is_alphanumeric() && character != '_')
382        })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::{ChunkKind, SemanticChunk, budget_select, detect_chunks, render_budget_output};
388
389    fn chunk(kind: ChunkKind, line: usize, complexity: f64) -> SemanticChunk {
390        SemanticChunk {
391            content: format!("line {line}"),
392            kind,
393            complexity,
394            line_range: (line, line),
395            token_count: 2,
396        }
397    }
398
399    #[test]
400    fn detect_chunks_finds_functions_in_rust() {
401        let source = "fn one() {}\n\nfn two() { if true {} }\n\nfn three() {}\n";
402        let chunks = detect_chunks(source, "rs");
403        assert_eq!(
404            chunks
405                .iter()
406                .filter(|chunk| chunk.kind == ChunkKind::Function)
407                .count(),
408            3
409        );
410    }
411
412    #[test]
413    fn detect_chunks_finds_imports() {
414        let source = "use std::fmt;\nuse std::path::Path;\n\nfn main() {}\n";
415        let chunks = detect_chunks(source, "rs");
416        assert_eq!(chunks[0].kind, ChunkKind::Import);
417        assert!(chunks[0].content.contains("std::path::Path"));
418    }
419
420    #[test]
421    fn budget_select_limits_to_seven() {
422        let chunks: Vec<_> = (1..=15)
423            .map(|line| chunk(ChunkKind::Function, line, line as f64))
424            .collect();
425        assert_eq!(budget_select(&chunks, None).len(), 7);
426    }
427
428    #[test]
429    fn budget_select_prefers_tests_over_comments() {
430        let mut chunks: Vec<_> = (1..=9)
431            .map(|line| chunk(ChunkKind::Comment, line, 100.0))
432            .collect();
433        chunks.push(chunk(ChunkKind::Test, 10, 1.0));
434        assert!(budget_select(&chunks, Some(1)).contains(&9));
435    }
436
437    #[test]
438    fn render_output_preserves_source_order() {
439        let chunks = vec![
440            chunk(ChunkKind::Function, 10, 1.0),
441            chunk(ChunkKind::Test, 20, 1.0),
442        ];
443        let output = render_budget_output(&chunks, &[1, 0], "src/lib.rs");
444        assert!(
445            output.find("line 10").expect("first chunk")
446                < output.find("line 20").expect("second chunk")
447        );
448    }
449
450    #[test]
451    fn empty_file_returns_empty_chunks() {
452        assert!(detect_chunks(" \n\t", "rs").is_empty());
453    }
454}