1use crate::core::signatures::{Signature, extract_signatures};
7use crate::core::tokens::count_tokens;
8
9const MAX_CHUNKS: usize = 9;
11const DEFAULT_CHUNKS: usize = 7;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ChunkKind {
17 Function,
19 Type,
21 Test,
23 Config,
25 Comment,
27 Import,
29 Block,
31}
32
33#[derive(Debug, Clone)]
35pub struct SemanticChunk {
36 pub content: String,
38 pub kind: ChunkKind,
40 pub complexity: f64,
42 pub line_range: (usize, usize),
44 pub token_count: usize,
46}
47
48#[derive(Debug, Clone, Copy)]
49struct Boundary {
50 start: usize,
51 end: usize,
52 kind: ChunkKind,
53}
54
55pub(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
279pub(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
306pub(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}