ck_tui/
chunks.rs

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
41/// Calculate the global depth for each chunk across the entire file
42pub 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(); // (start, end, depth)
45
46    // Sort chunks by start line, then by end line (descending) for consistent ordering
47    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        // Remove chunks from stack that have ended before this chunk starts
55        // Use > instead of >= so chunks ending at the same line don't affect depth
56        stack.retain(|(_, stack_end, _)| *stack_end > start);
57
58        // Current depth is the stack size
59        let depth = stack.len();
60        depth_map.insert((start, end), depth);
61
62        // Add current chunk to stack
63        stack.push((start, end, depth));
64    }
65
66    depth_map
67}
68
69/// Calculate the maximum nesting depth across all chunks
70pub 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 // +1 because depth is 0-indexed
73}
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    // Filter out text chunks for depth calculation - they're not structural elements
91    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    // Collect text chunks separately (imports, comments, etc.)
103    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    // Calculate global depth for structural chunks only
114    let depth_map = calculate_chunk_depths(&structural_chunks);
115    let max_depth = calculate_max_depth(&structural_chunks);
116
117    // Track chunks by their assigned depth
118    let mut depth_slots: Vec<Option<&IndexedChunkMeta>> = vec![None; max_depth];
119    let mut start_map: BTreeMap<usize, Vec<&IndexedChunkMeta>> = BTreeMap::new();
120
121    // Always show all structural chunks in the visible range (like --dump-chunks)
122    // The chunk_meta parameter is only used for highlighting/coloring the matched chunk
123    let source_chunks: Vec<&IndexedChunkMeta> = structural_chunks
124        .iter()
125        .filter(|meta| {
126            // Include chunks that end just before the visible window (for closing brackets)
127            meta.span.line_end >= first_line.saturating_sub(1) && meta.span.line_start <= last_line
128        })
129        .collect();
130
131    // Pre-populate chunks that start before the visible range
132    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    // Build start map for chunks starting within the visible range
143    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    // Sort chunks at each start line by length (longest first)
153    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        // Remove chunks that have ended before this line
162        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        // Add chunks starting at this line
171        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        // Add label for matched chunk at its start line
182        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            // Create a more bar-like header design with better spacing
204            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        // Handle files with no chunks
212        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        // Check if this line is covered by a text chunk (import, comment, etc.)
249        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        // Build column characters for all depth levels (fixed width)
260        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 line is ONLY in text chunk (no structural chunks), show with bracket indicator
290        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                // Single-line text chunk
293                '·'
294            } else if line_num == text_meta.span.line_start {
295                // Start of multi-line text chunk
296                '┌'
297            } else if line_num == text_meta.span.line_end {
298                // End of multi-line text chunk
299                '└'
300            } else {
301                // Middle of multi-line text chunk
302                '│'
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        // Remove chunks that end at this line
325        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    // Only show this message in single-chunk mode (not full file mode)
335    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
344/// Convert ChunkDisplayLine to plain text string
345pub 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            // Render bracket columns
359            for col in columns {
360                output.push(col.ch);
361            }
362
363            // Add spacing
364            output.push(' ');
365
366            // Add line number with fixed width (at least 4 chars)
367            output.push_str(&format!("{:4} | ", line_num));
368
369            // Add line text
370            output.push_str(text);
371
372            output
373        }
374        ChunkDisplayLine::Message(msg) => msg.clone(),
375    }
376}
377
378/// Convert ck_chunk::Chunk to IndexedChunkMeta format
379pub 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
401/// Shared function to perform live chunking on a file (used by both --dump-chunks and TUI)
402pub 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    // Use model-aware chunking (same approach as --dump-chunks)
415    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    // Convert chunks to IndexedChunkMeta format
420    let chunk_metas = convert_chunks_to_meta(chunks);
421
422    Ok((lines, chunk_metas))
423}