greppy/trace/
context.rs

1//! Code Context Engine
2//!
3//! Provides file caching and code context extraction for trace operations.
4//! This is the foundation for showing actual code instead of `// line X`.
5//!
6//! @module trace/context
7
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12// =============================================================================
13// TYPES
14// =============================================================================
15
16/// Code context around a specific line
17#[derive(Debug, Clone)]
18pub struct CodeContext {
19    /// The target line of code (trimmed)
20    pub line: String,
21    /// Lines before the target (in order)
22    pub before: Vec<String>,
23    /// Lines after the target (in order)
24    pub after: Vec<String>,
25    /// The line number of the target
26    pub line_number: u32,
27    /// Column position (for highlighting)
28    pub column: Option<u16>,
29}
30
31impl CodeContext {
32    /// Get formatted output with line numbers
33    pub fn format(&self, highlight_column: bool) -> String {
34        let mut output = String::new();
35        let start_line = self.line_number.saturating_sub(self.before.len() as u32);
36
37        // Before lines
38        for (i, line) in self.before.iter().enumerate() {
39            let ln = start_line + i as u32;
40            output.push_str(&format!("  {:>4}: {}\n", ln, line));
41        }
42
43        // Target line with marker
44        output.push_str(&format!("> {:>4}: {}\n", self.line_number, self.line));
45
46        // Column indicator if requested
47        if highlight_column {
48            if let Some(col) = self.column {
49                let padding = 8 + col as usize; // "> NNNN: " = 8 chars
50                output.push_str(&format!("{}^\n", " ".repeat(padding)));
51            }
52        }
53
54        // After lines
55        let after_start = self.line_number + 1;
56        for (i, line) in self.after.iter().enumerate() {
57            let ln = after_start + i as u32;
58            output.push_str(&format!("  {:>4}: {}\n", ln, line));
59        }
60
61        output
62    }
63
64    /// Get just the line content (no formatting)
65    pub fn line_content(&self) -> &str {
66        &self.line
67    }
68}
69
70// =============================================================================
71// FILE CACHE
72// =============================================================================
73
74/// LRU cache for file contents
75///
76/// Caches file contents as line vectors for fast line access.
77/// Uses a simple eviction strategy when memory limit is reached.
78pub struct FileCache {
79    /// Cached file contents (path -> lines)
80    cache: HashMap<PathBuf, Vec<String>>,
81    /// Total bytes cached (approximate)
82    bytes_cached: usize,
83    /// Maximum bytes to cache
84    max_bytes: usize,
85    /// Project root for resolving relative paths
86    project_root: PathBuf,
87}
88
89impl FileCache {
90    /// Create a new file cache with default 16MB limit
91    pub fn new(project_root: impl AsRef<Path>) -> Self {
92        Self::with_capacity(project_root, 16 * 1024 * 1024)
93    }
94
95    /// Create with custom memory limit
96    pub fn with_capacity(project_root: impl AsRef<Path>, max_bytes: usize) -> Self {
97        Self {
98            cache: HashMap::new(),
99            bytes_cached: 0,
100            max_bytes,
101            project_root: project_root.as_ref().to_path_buf(),
102        }
103    }
104
105    /// Resolve a path (handles relative paths from index)
106    fn resolve_path(&self, path: &Path) -> PathBuf {
107        if path.is_absolute() {
108            path.to_path_buf()
109        } else {
110            self.project_root.join(path)
111        }
112    }
113
114    /// Load file into cache if not already present
115    fn ensure_loaded(&mut self, path: &Path) -> Option<&Vec<String>> {
116        let resolved = self.resolve_path(path);
117
118        if !self.cache.contains_key(&resolved) {
119            // Try to load the file
120            let content = fs::read_to_string(&resolved).ok()?;
121            let bytes = content.len();
122
123            // Evict if needed
124            while self.bytes_cached + bytes > self.max_bytes && !self.cache.is_empty() {
125                // Simple eviction: remove first entry
126                if let Some(key) = self.cache.keys().next().cloned() {
127                    if let Some(lines) = self.cache.remove(&key) {
128                        self.bytes_cached -= lines.iter().map(|l| l.len()).sum::<usize>();
129                    }
130                }
131            }
132
133            // Parse into lines
134            let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
135            self.bytes_cached += bytes;
136            self.cache.insert(resolved.clone(), lines);
137        }
138
139        self.cache.get(&resolved)
140    }
141
142    /// Get a single line from a file (1-indexed)
143    pub fn get_line(&mut self, path: &Path, line: u32) -> Option<String> {
144        let lines = self.ensure_loaded(path)?;
145        let idx = line.saturating_sub(1) as usize;
146        lines.get(idx).cloned()
147    }
148
149    /// Get multiple lines as a range (1-indexed, inclusive)
150    pub fn get_range(&mut self, path: &Path, start: u32, end: u32) -> Option<Vec<String>> {
151        let lines = self.ensure_loaded(path)?;
152        let start_idx = start.saturating_sub(1) as usize;
153        let end_idx = end.min(lines.len() as u32) as usize;
154
155        if start_idx >= lines.len() {
156            return None;
157        }
158
159        Some(lines[start_idx..end_idx].to_vec())
160    }
161
162    /// Get code context around a line
163    pub fn get_context(
164        &mut self,
165        path: &Path,
166        line: u32,
167        before: u32,
168        after: u32,
169    ) -> Option<CodeContext> {
170        self.get_context_with_column(path, line, None, before, after)
171    }
172
173    /// Get code context with column highlighting
174    pub fn get_context_with_column(
175        &mut self,
176        path: &Path,
177        line: u32,
178        column: Option<u16>,
179        before: u32,
180        after: u32,
181    ) -> Option<CodeContext> {
182        let lines = self.ensure_loaded(path)?;
183        let idx = line.saturating_sub(1) as usize;
184
185        if idx >= lines.len() {
186            return None;
187        }
188
189        // Get the target line
190        let target_line = lines[idx].clone();
191
192        // Get before lines
193        let before_start = idx.saturating_sub(before as usize);
194        let before_lines: Vec<String> = lines[before_start..idx].to_vec();
195
196        // Get after lines
197        let after_end = (idx + 1 + after as usize).min(lines.len());
198        let after_lines: Vec<String> = lines[idx + 1..after_end].to_vec();
199
200        Some(CodeContext {
201            line: target_line,
202            before: before_lines,
203            after: after_lines,
204            line_number: line,
205            column,
206        })
207    }
208
209    /// Get the full function/block containing a line
210    pub fn get_enclosing_block(
211        &mut self,
212        path: &Path,
213        line: u32,
214        max_lines: u32,
215    ) -> Option<Vec<String>> {
216        let lines = self.ensure_loaded(path)?;
217        let idx = line.saturating_sub(1) as usize;
218
219        if idx >= lines.len() {
220            return None;
221        }
222
223        // Simple heuristic: find enclosing braces
224        // Look backwards for function start
225        let mut start = idx;
226        let mut brace_depth = 0;
227
228        for i in (0..=idx).rev() {
229            let l = &lines[i];
230            brace_depth += l.matches('}').count() as i32;
231            brace_depth -= l.matches('{').count() as i32;
232
233            // Found opening brace at same or lower level
234            if brace_depth <= 0 && l.contains('{') {
235                start = i;
236                break;
237            }
238
239            // Don't go too far back
240            if idx - i > max_lines as usize / 2 {
241                start = i;
242                break;
243            }
244        }
245
246        // Look forwards for function end
247        let mut end = idx;
248        brace_depth = 0;
249
250        for i in idx..lines.len() {
251            let l = &lines[i];
252            brace_depth += l.matches('{').count() as i32;
253            brace_depth -= l.matches('}').count() as i32;
254
255            // Found closing brace
256            if brace_depth <= 0 && l.contains('}') {
257                end = i;
258                break;
259            }
260
261            // Don't go too far forward
262            if i - idx > max_lines as usize / 2 {
263                end = i;
264                break;
265            }
266        }
267
268        Some(lines[start..=end.min(lines.len() - 1)].to_vec())
269    }
270
271    /// Check if a file exists and is readable
272    pub fn file_exists(&self, path: &Path) -> bool {
273        let resolved = self.resolve_path(path);
274        resolved.exists()
275    }
276
277    /// Get total lines in a file
278    pub fn line_count(&mut self, path: &Path) -> Option<usize> {
279        self.ensure_loaded(path).map(|lines| lines.len())
280    }
281
282    /// Clear the cache
283    pub fn clear(&mut self) {
284        self.cache.clear();
285        self.bytes_cached = 0;
286    }
287
288    /// Get cache statistics
289    pub fn stats(&self) -> CacheStats {
290        CacheStats {
291            files_cached: self.cache.len(),
292            bytes_cached: self.bytes_cached,
293            max_bytes: self.max_bytes,
294        }
295    }
296}
297
298/// Cache statistics
299#[derive(Debug, Clone)]
300pub struct CacheStats {
301    pub files_cached: usize,
302    pub bytes_cached: usize,
303    pub max_bytes: usize,
304}
305
306// =============================================================================
307// CONTEXT BUILDER
308// =============================================================================
309
310/// Builder for creating code contexts with various options
311pub struct ContextBuilder<'a> {
312    cache: &'a mut FileCache,
313    before_lines: u32,
314    after_lines: u32,
315    trim_whitespace: bool,
316    max_line_length: Option<usize>,
317}
318
319impl<'a> ContextBuilder<'a> {
320    /// Create a new context builder
321    pub fn new(cache: &'a mut FileCache) -> Self {
322        Self {
323            cache,
324            before_lines: 0,
325            after_lines: 0,
326            trim_whitespace: false,
327            max_line_length: None,
328        }
329    }
330
331    /// Set lines of context before the target
332    pub fn before(mut self, lines: u32) -> Self {
333        self.before_lines = lines;
334        self
335    }
336
337    /// Set lines of context after the target
338    pub fn after(mut self, lines: u32) -> Self {
339        self.after_lines = lines;
340        self
341    }
342
343    /// Set context on both sides
344    pub fn context(mut self, lines: u32) -> Self {
345        self.before_lines = lines;
346        self.after_lines = lines;
347        self
348    }
349
350    /// Trim leading/trailing whitespace from lines
351    pub fn trim(mut self) -> Self {
352        self.trim_whitespace = true;
353        self
354    }
355
356    /// Truncate lines longer than max
357    pub fn max_length(mut self, max: usize) -> Self {
358        self.max_line_length = Some(max);
359        self
360    }
361
362    /// Build context for a specific location
363    pub fn build(self, path: &Path, line: u32, column: Option<u16>) -> Option<CodeContext> {
364        let mut ctx = self.cache.get_context_with_column(
365            path,
366            line,
367            column,
368            self.before_lines,
369            self.after_lines,
370        )?;
371
372        // Apply transformations
373        if self.trim_whitespace {
374            ctx.line = ctx.line.trim().to_string();
375            ctx.before = ctx.before.iter().map(|s| s.trim().to_string()).collect();
376            ctx.after = ctx.after.iter().map(|s| s.trim().to_string()).collect();
377        }
378
379        if let Some(max) = self.max_line_length {
380            if ctx.line.len() > max {
381                ctx.line = format!("{}...", &ctx.line[..max]);
382            }
383            ctx.before = ctx
384                .before
385                .iter()
386                .map(|s| {
387                    if s.len() > max {
388                        format!("{}...", &s[..max])
389                    } else {
390                        s.clone()
391                    }
392                })
393                .collect();
394            ctx.after = ctx
395                .after
396                .iter()
397                .map(|s| {
398                    if s.len() > max {
399                        format!("{}...", &s[..max])
400                    } else {
401                        s.clone()
402                    }
403                })
404                .collect();
405        }
406
407        Some(ctx)
408    }
409}
410
411// =============================================================================
412// TESTS
413// =============================================================================
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use std::io::Write;
419    use tempfile::TempDir;
420
421    fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
422        let path = dir.path().join(name);
423        let mut file = fs::File::create(&path).unwrap();
424        file.write_all(content.as_bytes()).unwrap();
425        path
426    }
427
428    #[test]
429    fn test_get_line() {
430        let dir = TempDir::new().unwrap();
431        let path = create_test_file(&dir, "test.rs", "line 1\nline 2\nline 3\nline 4\nline 5\n");
432
433        let mut cache = FileCache::new(dir.path());
434
435        assert_eq!(cache.get_line(&path, 1), Some("line 1".to_string()));
436        assert_eq!(cache.get_line(&path, 3), Some("line 3".to_string()));
437        assert_eq!(cache.get_line(&path, 5), Some("line 5".to_string()));
438        assert_eq!(cache.get_line(&path, 6), None);
439    }
440
441    #[test]
442    fn test_get_context() {
443        let dir = TempDir::new().unwrap();
444        let path = create_test_file(
445            &dir,
446            "test.rs",
447            "fn main() {\n    let x = 1;\n    let y = 2;\n    let z = 3;\n}\n",
448        );
449
450        let mut cache = FileCache::new(dir.path());
451
452        let ctx = cache.get_context(&path, 3, 1, 1).unwrap();
453        assert_eq!(ctx.line, "    let y = 2;");
454        assert_eq!(ctx.before.len(), 1);
455        assert_eq!(ctx.after.len(), 1);
456        assert_eq!(ctx.before[0], "    let x = 1;");
457        assert_eq!(ctx.after[0], "    let z = 3;");
458    }
459
460    #[test]
461    fn test_get_range() {
462        let dir = TempDir::new().unwrap();
463        let path = create_test_file(&dir, "test.rs", "a\nb\nc\nd\ne\n");
464
465        let mut cache = FileCache::new(dir.path());
466
467        let range = cache.get_range(&path, 2, 4).unwrap();
468        assert_eq!(range, vec!["b", "c", "d"]);
469    }
470
471    #[test]
472    fn test_context_format() {
473        let ctx = CodeContext {
474            line: "let x = 42;".to_string(),
475            before: vec!["fn main() {".to_string()],
476            after: vec!["}".to_string()],
477            line_number: 2,
478            column: Some(4),
479        };
480
481        let formatted = ctx.format(true);
482        assert!(formatted.contains("> "));
483        assert!(formatted.contains("let x = 42;"));
484    }
485
486    #[test]
487    fn test_cache_eviction() {
488        let dir = TempDir::new().unwrap();
489        let path1 = create_test_file(&dir, "big1.txt", &"x".repeat(1000));
490        let path2 = create_test_file(&dir, "big2.txt", &"y".repeat(1000));
491
492        // Small cache that can only hold one file
493        let mut cache = FileCache::with_capacity(dir.path(), 1500);
494
495        // Load first file
496        cache.get_line(&path1, 1);
497        assert_eq!(cache.stats().files_cached, 1);
498
499        // Load second file - should evict first
500        cache.get_line(&path2, 1);
501        assert_eq!(cache.stats().files_cached, 1);
502    }
503}