1use ck_core::{Language, Span};
2use std::cmp::Reverse;
3use std::collections::{BTreeMap, HashMap};
4use std::path::Path;
5
6#[derive(Clone)]
7#[allow(dead_code)]
8pub struct IndexedChunkMeta {
9 pub span: Span,
10 pub chunk_type: Option<String>,
11 pub breadcrumb: Option<String>,
12 pub ancestry: Vec<String>,
13 pub estimated_tokens: Option<usize>,
14 pub byte_length: Option<usize>,
15 pub leading_trivia: Option<Vec<String>>,
16 pub trailing_trivia: Option<Vec<String>>,
17}
18
19#[derive(Clone)]
20pub struct ChunkColumnChar {
21 pub ch: char,
22 pub is_match: bool,
23}
24
25pub enum ChunkDisplayLine {
26 Label {
27 prefix: usize,
28 text: String,
29 },
30 Content {
31 columns: Vec<ChunkColumnChar>,
32 line_num: usize,
33 text: String,
34 is_match_line: bool,
35 in_matched_chunk: bool,
36 has_any_chunk: bool,
37 },
38 Message(String),
39}
40
41pub fn calculate_chunk_depths(all_chunks: &[IndexedChunkMeta]) -> HashMap<(usize, usize), usize> {
43 let mut depth_map: HashMap<(usize, usize), usize> = HashMap::new();
44 let mut stack: Vec<(usize, usize, usize)> = Vec::new(); let mut sorted_chunks: Vec<_> = all_chunks.iter().collect();
48 sorted_chunks.sort_by_key(|meta| (meta.span.line_start, Reverse(meta.span.line_end)));
49
50 for meta in sorted_chunks {
51 let start = meta.span.line_start;
52 let end = meta.span.line_end;
53
54 stack.retain(|(_, stack_end, _)| *stack_end > start);
57
58 let depth = stack.len();
60 depth_map.insert((start, end), depth);
61
62 stack.push((start, end, depth));
64 }
65
66 depth_map
67}
68
69pub fn calculate_max_depth(all_chunks: &[IndexedChunkMeta]) -> usize {
71 let depth_map = calculate_chunk_depths(all_chunks);
72 depth_map.values().copied().max().unwrap_or(0) + 1 }
74
75#[allow(clippy::too_many_arguments)]
76pub fn collect_chunk_display_lines(
77 lines: &[String],
78 context_start: usize,
79 context_end: usize,
80 match_line: usize,
81 chunk_meta: Option<&IndexedChunkMeta>,
82 all_chunks: &[IndexedChunkMeta],
83 full_file_mode: bool,
84) -> Vec<ChunkDisplayLine> {
85 let mut rows = Vec::new();
86
87 let first_line = context_start + 1;
88 let last_line = context_end;
89
90 let structural_chunks: Vec<_> = all_chunks
92 .iter()
93 .filter(|meta| {
94 meta.chunk_type
95 .as_deref()
96 .map(|t| t != "text")
97 .unwrap_or(true)
98 })
99 .cloned()
100 .collect();
101
102 let text_chunks: Vec<_> = all_chunks
104 .iter()
105 .filter(|meta| {
106 meta.chunk_type
107 .as_deref()
108 .map(|t| t == "text")
109 .unwrap_or(false)
110 })
111 .collect();
112
113 let depth_map = calculate_chunk_depths(&structural_chunks);
115 let max_depth = calculate_max_depth(&structural_chunks);
116
117 let mut depth_slots: Vec<Option<&IndexedChunkMeta>> = vec![None; max_depth];
119 let mut start_map: BTreeMap<usize, Vec<&IndexedChunkMeta>> = BTreeMap::new();
120
121 let source_chunks: Vec<&IndexedChunkMeta> = structural_chunks
124 .iter()
125 .filter(|meta| {
126 meta.span.line_end >= first_line.saturating_sub(1) && meta.span.line_start <= last_line
128 })
129 .collect();
130
131 for meta in &structural_chunks {
133 if meta.span.line_start < first_line
134 && meta.span.line_end >= first_line
135 && let Some(&depth) = depth_map.get(&(meta.span.line_start, meta.span.line_end))
136 && depth < max_depth
137 {
138 depth_slots[depth] = Some(meta);
139 }
140 }
141
142 for meta in source_chunks {
144 if meta.span.line_start >= first_line {
145 start_map
146 .entry(meta.span.line_start)
147 .or_default()
148 .push(meta);
149 }
150 }
151
152 for starts in start_map.values_mut() {
154 starts.sort_by_key(|meta| Reverse(meta.span.line_end.saturating_sub(meta.span.line_start)));
155 }
156
157 for (idx, line_text) in lines[context_start..context_end].iter().enumerate() {
158 let line_num = context_start + idx + 1;
159 let is_match_line = line_num == match_line;
160
161 for slot in depth_slots.iter_mut() {
163 if let Some(meta) = slot
164 && meta.span.line_end < line_num
165 {
166 *slot = None;
167 }
168 }
169
170 if let Some(starting) = start_map.remove(&line_num) {
172 for meta in starting {
173 if let Some(&depth) = depth_map.get(&(meta.span.line_start, meta.span.line_end))
174 && depth < max_depth
175 {
176 depth_slots[depth] = Some(meta);
177 }
178 }
179 }
180
181 if let Some(meta) = chunk_meta
183 && line_num == meta.span.line_start
184 {
185 let chunk_kind = meta.chunk_type.as_deref().unwrap_or("chunk");
186 let breadcrumb_text = meta
187 .breadcrumb
188 .as_deref()
189 .filter(|crumb| !crumb.is_empty())
190 .map(|crumb| format!(" ({})", crumb))
191 .unwrap_or_else(|| {
192 if !meta.ancestry.is_empty() {
193 format!(" ({})", meta.ancestry.join("::"))
194 } else {
195 String::new()
196 }
197 });
198 let token_hint = meta
199 .estimated_tokens
200 .map(|tokens| format!(" • {} tokens", tokens))
201 .unwrap_or_default();
202
203 let bar_text = format!("{} {}{}", chunk_kind, breadcrumb_text, token_hint);
205 rows.push(ChunkDisplayLine::Label {
206 prefix: max_depth,
207 text: bar_text,
208 });
209 }
210
211 if all_chunks.is_empty() {
213 let is_boundary = line_text.trim_start().starts_with("fn ")
214 || line_text.trim_start().starts_with("func ")
215 || line_text.trim_start().starts_with("def ")
216 || line_text.trim_start().starts_with("class ")
217 || line_text.trim_start().starts_with("impl ")
218 || line_text.trim_start().starts_with("struct ")
219 || line_text.trim_start().starts_with("enum ");
220
221 let columns_chars = if is_boundary {
222 vec![
223 ChunkColumnChar {
224 ch: '┣',
225 is_match: false,
226 },
227 ChunkColumnChar {
228 ch: '━',
229 is_match: false,
230 },
231 ]
232 } else {
233 Vec::new()
234 };
235
236 rows.push(ChunkDisplayLine::Content {
237 columns: columns_chars,
238 line_num,
239 text: line_text.clone(),
240 is_match_line,
241 in_matched_chunk: false,
242 has_any_chunk: is_boundary,
243 });
244
245 continue;
246 }
247
248 let text_chunk_here = text_chunks
250 .iter()
251 .find(|meta| line_num >= meta.span.line_start && line_num <= meta.span.line_end);
252
253 let has_any_structural = depth_slots.iter().any(|slot| slot.is_some());
254 let has_any_chunk = has_any_structural || text_chunk_here.is_some();
255 let in_matched_chunk = chunk_meta
256 .map(|meta| line_num >= meta.span.line_start && line_num <= meta.span.line_end)
257 .unwrap_or(false);
258
259 let mut column_chars: Vec<ChunkColumnChar> = depth_slots
261 .iter()
262 .map(|slot| {
263 if let Some(meta) = slot {
264 let span = &meta.span;
265 let ch = if span.line_start == span.line_end {
266 '─'
267 } else if line_num == span.line_start {
268 '┌'
269 } else if line_num == span.line_end {
270 '└'
271 } else {
272 '│'
273 };
274 let is_match = chunk_meta
275 .map(|m| {
276 m.span.line_start == span.line_start && m.span.line_end == span.line_end
277 })
278 .unwrap_or(false);
279 ChunkColumnChar { ch, is_match }
280 } else {
281 ChunkColumnChar {
282 ch: ' ',
283 is_match: false,
284 }
285 }
286 })
287 .collect();
288
289 if !has_any_structural && let Some(text_meta) = text_chunk_here {
291 let ch = if text_meta.span.line_start == text_meta.span.line_end {
292 '·'
294 } else if line_num == text_meta.span.line_start {
295 '┌'
297 } else if line_num == text_meta.span.line_end {
298 '└'
300 } else {
301 '│'
303 };
304
305 if column_chars.is_empty() {
306 column_chars.push(ChunkColumnChar {
307 ch,
308 is_match: false,
309 });
310 } else {
311 column_chars[0].ch = ch;
312 }
313 }
314
315 rows.push(ChunkDisplayLine::Content {
316 columns: column_chars,
317 line_num,
318 text: line_text.clone(),
319 is_match_line,
320 in_matched_chunk,
321 has_any_chunk,
322 });
323
324 for slot in depth_slots.iter_mut() {
326 if let Some(meta) = slot
327 && meta.span.line_end == line_num
328 {
329 *slot = None;
330 }
331 }
332 }
333
334 if !full_file_mode && chunk_meta.is_none() && !all_chunks.is_empty() {
336 rows.push(ChunkDisplayLine::Message(
337 "Chunk metadata available but no matching chunk found for this line.".to_string(),
338 ));
339 }
340
341 rows
342}
343
344pub fn chunk_display_line_to_string(line: &ChunkDisplayLine) -> String {
346 match line {
347 ChunkDisplayLine::Label { prefix, text } => {
348 format!("{}{}", " ".repeat(*prefix), text)
349 }
350 ChunkDisplayLine::Content {
351 columns,
352 line_num,
353 text,
354 ..
355 } => {
356 let mut output = String::new();
357
358 for col in columns {
360 output.push(col.ch);
361 }
362
363 output.push(' ');
365
366 output.push_str(&format!("{:4} | ", line_num));
368
369 output.push_str(text);
371
372 output
373 }
374 ChunkDisplayLine::Message(msg) => msg.clone(),
375 }
376}
377
378pub fn convert_chunks_to_meta(chunks: Vec<ck_chunk::Chunk>) -> Vec<IndexedChunkMeta> {
380 chunks
381 .iter()
382 .map(|chunk| IndexedChunkMeta {
383 span: chunk.span.clone(),
384 chunk_type: Some(match chunk.chunk_type {
385 ck_chunk::ChunkType::Function => "function".to_string(),
386 ck_chunk::ChunkType::Class => "class".to_string(),
387 ck_chunk::ChunkType::Method => "method".to_string(),
388 ck_chunk::ChunkType::Module => "module".to_string(),
389 ck_chunk::ChunkType::Text => "text".to_string(),
390 }),
391 breadcrumb: chunk.metadata.breadcrumb.clone(),
392 ancestry: chunk.metadata.ancestry.clone(),
393 byte_length: Some(chunk.metadata.byte_length),
394 estimated_tokens: Some(chunk.metadata.estimated_tokens),
395 leading_trivia: Some(chunk.metadata.leading_trivia.clone()),
396 trailing_trivia: Some(chunk.metadata.trailing_trivia.clone()),
397 })
398 .collect()
399}
400
401pub fn chunk_file_live(file_path: &Path) -> Result<(Vec<String>, Vec<IndexedChunkMeta>), String> {
403 use std::fs;
404
405 if !file_path.exists() {
406 return Err(format!("File does not exist: {}", file_path.display()));
407 }
408
409 let detected_lang = Language::from_path(file_path);
410 let content = fs::read_to_string(file_path)
411 .map_err(|err| format!("Could not read {}: {}", file_path.display(), err))?;
412 let lines: Vec<String> = content.lines().map(String::from).collect();
413
414 let default_model = "nomic-embed-text-v1.5";
416 let chunks = ck_chunk::chunk_text_with_model(&content, detected_lang, Some(default_model))
417 .map_err(|err| format!("Failed to chunk file: {}", err))?;
418
419 let chunk_metas = convert_chunks_to_meta(chunks);
421
422 Ok((lines, chunk_metas))
423}