1use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
18pub struct CodeContext {
19 pub line: String,
21 pub before: Vec<String>,
23 pub after: Vec<String>,
25 pub line_number: u32,
27 pub column: Option<u16>,
29}
30
31impl CodeContext {
32 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 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 output.push_str(&format!("> {:>4}: {}\n", self.line_number, self.line));
45
46 if highlight_column {
48 if let Some(col) = self.column {
49 let padding = 8 + col as usize; output.push_str(&format!("{}^\n", " ".repeat(padding)));
51 }
52 }
53
54 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 pub fn line_content(&self) -> &str {
66 &self.line
67 }
68}
69
70pub struct FileCache {
79 cache: HashMap<PathBuf, Vec<String>>,
81 bytes_cached: usize,
83 max_bytes: usize,
85 project_root: PathBuf,
87}
88
89impl FileCache {
90 pub fn new(project_root: impl AsRef<Path>) -> Self {
92 Self::with_capacity(project_root, 16 * 1024 * 1024)
93 }
94
95 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 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 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 let content = fs::read_to_string(&resolved).ok()?;
121 let bytes = content.len();
122
123 while self.bytes_cached + bytes > self.max_bytes && !self.cache.is_empty() {
125 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 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 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 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 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 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 let target_line = lines[idx].clone();
191
192 let before_start = idx.saturating_sub(before as usize);
194 let before_lines: Vec<String> = lines[before_start..idx].to_vec();
195
196 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 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 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 if brace_depth <= 0 && l.contains('{') {
235 start = i;
236 break;
237 }
238
239 if idx - i > max_lines as usize / 2 {
241 start = i;
242 break;
243 }
244 }
245
246 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 if brace_depth <= 0 && l.contains('}') {
257 end = i;
258 break;
259 }
260
261 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 pub fn file_exists(&self, path: &Path) -> bool {
273 let resolved = self.resolve_path(path);
274 resolved.exists()
275 }
276
277 pub fn line_count(&mut self, path: &Path) -> Option<usize> {
279 self.ensure_loaded(path).map(|lines| lines.len())
280 }
281
282 pub fn clear(&mut self) {
284 self.cache.clear();
285 self.bytes_cached = 0;
286 }
287
288 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#[derive(Debug, Clone)]
300pub struct CacheStats {
301 pub files_cached: usize,
302 pub bytes_cached: usize,
303 pub max_bytes: usize,
304}
305
306pub 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 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 pub fn before(mut self, lines: u32) -> Self {
333 self.before_lines = lines;
334 self
335 }
336
337 pub fn after(mut self, lines: u32) -> Self {
339 self.after_lines = lines;
340 self
341 }
342
343 pub fn context(mut self, lines: u32) -> Self {
345 self.before_lines = lines;
346 self.after_lines = lines;
347 self
348 }
349
350 pub fn trim(mut self) -> Self {
352 self.trim_whitespace = true;
353 self
354 }
355
356 pub fn max_length(mut self, max: usize) -> Self {
358 self.max_line_length = Some(max);
359 self
360 }
361
362 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 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#[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 let mut cache = FileCache::with_capacity(dir.path(), 1500);
494
495 cache.get_line(&path1, 1);
497 assert_eq!(cache.stats().files_cached, 1);
498
499 cache.get_line(&path2, 1);
501 assert_eq!(cache.stats().files_cached, 1);
502 }
503}