1mod reader;
2mod writer;
3
4use crate::project::ProjectRoot;
5use anyhow::{Context, Result, bail};
6use globset::{Glob, GlobMatcher};
7use serde::Serialize;
8use std::fs;
9use std::path::Path;
10
11pub use reader::{find_files, list_dir, read_file, search_for_pattern, search_for_pattern_smart};
13
14pub use writer::{
16 create_text_file, delete_lines, insert_after_symbol, insert_at_line, insert_before_symbol,
17 replace_content, replace_lines, replace_symbol_body,
18};
19
20#[derive(Debug, Clone, Serialize)]
21pub struct FileReadResult {
22 pub file_path: String,
23 pub total_lines: usize,
24 pub content: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DirectoryEntry {
29 pub name: String,
30 pub entry_type: String,
31 pub path: String,
32 pub size: Option<u64>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct FileMatch {
37 pub path: String,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct PatternMatch {
42 pub file_path: String,
43 pub line: usize,
44 pub column: usize,
45 pub matched_text: String,
46 pub line_content: String,
47 #[serde(skip_serializing_if = "Vec::is_empty")]
48 pub context_before: Vec<String>,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 pub context_after: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct SmartPatternMatch {
56 pub file_path: String,
57 pub line: usize,
58 pub column: usize,
59 pub matched_text: String,
60 pub line_content: String,
61 #[serde(skip_serializing_if = "Vec::is_empty")]
62 pub context_before: Vec<String>,
63 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub context_after: Vec<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub enclosing_symbol: Option<EnclosingSymbol>,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct EnclosingSymbol {
71 pub name: String,
72 pub kind: String,
73 pub name_path: String,
74 pub start_line: usize,
75 pub end_line: usize,
76 pub signature: String,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct TextReference {
81 pub file_path: String,
82 pub line: usize,
83 pub column: usize,
84 pub line_content: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub enclosing_symbol: Option<EnclosingSymbol>,
87 pub is_declaration: bool,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub context_before: Vec<String>,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub context_after: Vec<String>,
97}
98
99#[derive(Debug, Clone)]
104pub struct TextRefsReport {
105 pub references: Vec<TextReference>,
106 pub shadow_files_suppressed: Vec<String>,
107}
108
109pub(super) struct FlatSymbol {
112 pub(super) name: String,
113 pub(super) kind: String,
114 pub(super) name_path: String,
115 pub(super) start_line: usize,
116 pub(super) end_line: usize,
117 pub(super) signature: String,
118}
119
120pub(super) fn flatten_to_ranges(symbols: &[crate::symbols::SymbolInfo]) -> Vec<FlatSymbol> {
121 let mut flat = Vec::new();
122 for s in symbols {
123 let end_line = estimate_end_line(s);
124 if matches!(
125 s.kind,
126 crate::symbols::SymbolKind::Function
127 | crate::symbols::SymbolKind::Method
128 | crate::symbols::SymbolKind::Class
129 | crate::symbols::SymbolKind::Interface
130 | crate::symbols::SymbolKind::Module
131 ) {
132 flat.push(FlatSymbol {
133 name: s.name.clone(),
134 kind: s.kind.as_label().to_owned(),
135 name_path: s.name_path.clone(),
136 start_line: s.line,
137 end_line,
138 signature: s.signature.clone(),
139 });
140 }
141 flat.extend(flatten_to_ranges(&s.children));
142 }
143 flat
144}
145
146fn estimate_end_line(symbol: &crate::symbols::SymbolInfo) -> usize {
147 if symbol.end_line > symbol.line {
155 return symbol.end_line;
156 }
157 if let Some(body) = &symbol.body {
158 symbol.line + body.lines().count()
159 } else if !symbol.children.is_empty() {
160 symbol
161 .children
162 .iter()
163 .map(estimate_end_line)
164 .max()
165 .unwrap_or(symbol.line + 10)
166 } else {
167 symbol.line + 10 }
169}
170
171pub(super) fn find_enclosing_symbol(
172 symbols: &[FlatSymbol],
173 line: usize,
174) -> Option<EnclosingSymbol> {
175 symbols
176 .iter()
177 .filter(|s| s.start_line <= line && line <= s.end_line)
178 .min_by_key(|s| s.end_line - s.start_line)
179 .map(|s| EnclosingSymbol {
180 name: s.name.clone(),
181 kind: s.kind.clone(),
182 name_path: s.name_path.clone(),
183 start_line: s.start_line,
184 end_line: s.end_line,
185 signature: s.signature.clone(),
186 })
187}
188
189pub(super) fn to_directory_entry(project: &ProjectRoot, path: &Path) -> Result<DirectoryEntry> {
190 let metadata = fs::metadata(path)?;
191 Ok(DirectoryEntry {
192 name: path
193 .file_name()
194 .map(|name| name.to_string_lossy().into_owned())
195 .unwrap_or_default(),
196 entry_type: if metadata.is_dir() {
197 "directory".to_owned()
198 } else {
199 "file".to_owned()
200 },
201 path: project.to_relative(path),
202 size: if metadata.is_file() {
203 Some(metadata.len())
204 } else {
205 None
206 },
207 })
208}
209
210pub(super) fn compile_glob(pattern: &str) -> Result<GlobMatcher> {
211 Glob::new(pattern)
212 .with_context(|| format!("invalid glob: {pattern}"))
213 .map(|glob| glob.compile_matcher())
214}
215
216pub fn find_referencing_symbols_via_text(
221 project: &ProjectRoot,
222 symbol_name: &str,
223 declaration_file: Option<&str>,
224 max_results: usize,
225) -> Result<TextRefsReport> {
226 use crate::rename::find_all_word_matches;
227 use crate::symbols::get_symbols_overview;
228
229 let all_matches = find_all_word_matches(project, symbol_name)?;
230
231 let shadow_files =
232 find_shadowing_files_for_refs(project, declaration_file, symbol_name, &all_matches)?;
233
234 let mut symbol_cache: std::collections::HashMap<String, Vec<FlatSymbol>> =
235 std::collections::HashMap::new();
236
237 let mut results = Vec::new();
238 for (file_path, line, column) in &all_matches {
239 if results.len() >= max_results {
240 break;
241 }
242 if let Some(decl) = declaration_file
243 && file_path != decl
244 && shadow_files.contains(file_path)
245 {
246 continue;
247 }
248
249 let (context_before, line_content, context_after) =
250 read_line_window(project, file_path, *line, 2, 2)
251 .unwrap_or_else(|_| (Vec::new(), String::new(), Vec::new()));
252
253 if !symbol_cache.contains_key(file_path)
254 && let Ok(symbols) = get_symbols_overview(project, file_path, 3)
255 {
256 symbol_cache.insert(file_path.clone(), flatten_to_ranges(&symbols));
257 }
258 let enclosing = symbol_cache
259 .get(file_path)
260 .and_then(|symbols| find_enclosing_symbol(symbols, *line));
261
262 let is_declaration = enclosing
263 .as_ref()
264 .map(|e| e.name == symbol_name && e.start_line == *line)
265 .unwrap_or(false);
266
267 results.push(TextReference {
268 file_path: file_path.clone(),
269 line: *line,
270 column: *column,
271 line_content,
272 enclosing_symbol: enclosing,
273 is_declaration,
274 context_before,
275 context_after,
276 });
277 }
278
279 let mut shadow_files_sorted: Vec<String> = shadow_files.into_iter().collect();
280 shadow_files_sorted.sort();
281
282 Ok(TextRefsReport {
283 references: results,
284 shadow_files_suppressed: shadow_files_sorted,
285 })
286}
287
288pub fn extract_word_at_position(
290 project: &ProjectRoot,
291 file_path: &str,
292 line: usize,
293 column: usize,
294) -> Result<String> {
295 let resolved = project.resolve(file_path)?;
296 let content = fs::read_to_string(&resolved)?;
297 let lines: Vec<&str> = content.lines().collect();
298 let line_idx = line.saturating_sub(1);
299 if line_idx >= lines.len() {
300 bail!(
301 "line {} out of range (file has {} lines)",
302 line,
303 lines.len()
304 );
305 }
306 let line_str = lines[line_idx];
307 let col_idx = column.saturating_sub(1);
308 if col_idx >= line_str.len() {
309 bail!(
310 "column {} out of range (line has {} chars)",
311 column,
312 line_str.len()
313 );
314 }
315
316 let bytes = line_str.as_bytes();
317 let mut start = col_idx;
318 while start > 0 && is_ident_char(bytes[start - 1]) {
319 start -= 1;
320 }
321 let mut end = col_idx;
322 while end < bytes.len() && is_ident_char(bytes[end]) {
323 end += 1;
324 }
325 if start == end {
326 bail!("no identifier at {}:{}", line, column);
327 }
328 Ok(line_str[start..end].to_string())
329}
330
331fn is_ident_char(b: u8) -> bool {
332 b.is_ascii_alphanumeric() || b == b'_'
333}
334
335fn read_line_window(
342 project: &ProjectRoot,
343 file_path: &str,
344 line: usize,
345 n_before: usize,
346 n_after: usize,
347) -> Result<(Vec<String>, String, Vec<String>)> {
348 let resolved = project.resolve(file_path)?;
349 let content = fs::read_to_string(&resolved)?;
350 let all_lines: Vec<&str> = content.lines().collect();
351 if line == 0 || line > all_lines.len() {
352 return Err(anyhow::anyhow!("line {} out of range", line));
353 }
354 let idx = line - 1;
355 let before_start = idx.saturating_sub(n_before);
356 let after_end = (idx + 1 + n_after).min(all_lines.len());
357 let before: Vec<String> = all_lines[before_start..idx].iter().map(|s| s.to_string()).collect();
358 let current = all_lines[idx].to_string();
359 let after: Vec<String> = all_lines[idx + 1..after_end].iter().map(|s| s.to_string()).collect();
360 Ok((before, current, after))
361}
362
363fn find_shadowing_files_for_refs(
364 project: &ProjectRoot,
365 declaration_file: Option<&str>,
366 symbol_name: &str,
367 all_matches: &[(String, usize, usize)],
368) -> Result<std::collections::HashSet<String>> {
369 use crate::symbols::get_symbols_overview;
370
371 let mut shadow_files = std::collections::HashSet::new();
372 let files_with_matches: std::collections::HashSet<&String> =
373 all_matches.iter().map(|(f, _, _)| f).collect();
374
375 for fp in files_with_matches {
376 if declaration_file.map(|d| d == fp).unwrap_or(false) {
377 continue;
378 }
379 if let Ok(symbols) = get_symbols_overview(project, fp, 3)
380 && has_declaration_recursive(&symbols, symbol_name)
381 {
382 shadow_files.insert(fp.clone());
383 }
384 }
385 Ok(shadow_files)
386}
387
388fn has_declaration_recursive(symbols: &[crate::symbols::SymbolInfo], name: &str) -> bool {
389 symbols
390 .iter()
391 .any(|s| s.name == name || has_declaration_recursive(&s.children, name))
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{find_files, list_dir, read_file, search_for_pattern};
397 use crate::ProjectRoot;
398 use std::fs;
399
400 #[test]
401 fn reads_partial_file() {
402 let root = fixture_root();
403 let project = ProjectRoot::new(&root).expect("project");
404 let result = read_file(&project, "src/main.py", Some(1), Some(3)).expect("read file");
405 assert_eq!(result.total_lines, 4);
406 assert_eq!(
407 result.content,
408 "def greet(name):\n return f\"Hello {name}\""
409 );
410 }
411
412 #[test]
413 fn lists_nested_dir() {
414 let root = fixture_root();
415 let project = ProjectRoot::new(&root).expect("project");
416 let result = list_dir(&project, ".", true).expect("list dir");
417 assert!(result.iter().any(|entry| entry.path == "src/main.py"));
418 }
419
420 #[test]
421 fn finds_files_by_glob() {
422 let root = fixture_root();
423 let project = ProjectRoot::new(&root).expect("project");
424 let result = find_files(&project, "*.py", Some("src")).expect("find files");
425 assert_eq!(result.len(), 1);
426 assert_eq!(result[0].path, "src/main.py");
427 }
428
429 #[test]
430 fn searches_text_pattern() {
431 let root = fixture_root();
432 let project = ProjectRoot::new(&root).expect("project");
433 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
434 assert_eq!(result.len(), 2);
435 assert_eq!(result[0].file_path, "src/main.py");
436 assert!(result[0].context_before.is_empty());
437 assert!(result[0].context_after.is_empty());
438 }
439
440 #[test]
441 fn search_with_zero_context() {
442 let root = fixture_root();
443 let project = ProjectRoot::new(&root).expect("project");
444 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
445 for m in &result {
446 assert!(m.context_before.is_empty());
447 assert!(m.context_after.is_empty());
448 }
449 }
450
451 #[test]
452 fn search_with_symmetric_context() {
453 let root = fixture_root();
454 let project = ProjectRoot::new(&root).expect("project");
455 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 1, 1).expect("search");
456 assert_eq!(result.len(), 2);
457 assert_eq!(result[0].line, 2);
458 assert_eq!(result[0].context_before.len(), 1);
459 assert_eq!(result[0].context_before[0], "class Service:");
460 assert_eq!(result[0].context_after.len(), 1);
461 assert!(result[0].context_after[0].contains("return"));
462 assert_eq!(result[1].line, 4);
463 assert_eq!(result[1].context_before.len(), 1);
464 assert!(result[1].context_after.is_empty());
465 }
466
467 #[test]
468 fn search_context_at_file_start() {
469 let root = fixture_root();
470 let project = ProjectRoot::new(&root).expect("project");
471 let result = search_for_pattern(&project, "class", Some("*.py"), 10, 3, 1).expect("search");
472 assert_eq!(result.len(), 1);
473 assert_eq!(result[0].line, 1);
474 assert!(result[0].context_before.is_empty());
475 assert_eq!(result[0].context_after.len(), 1);
476 }
477
478 #[test]
479 fn search_context_at_file_end() {
480 let root = fixture_root();
481 let project = ProjectRoot::new(&root).expect("project");
482 let result = search_for_pattern(&project, "print", Some("*.py"), 10, 2, 3).expect("search");
483 assert_eq!(result.len(), 1);
484 assert_eq!(result[0].line, 4);
485 assert_eq!(result[0].context_before.len(), 2);
486 assert!(result[0].context_after.is_empty());
487 }
488
489 #[test]
490 fn search_asymmetric_context() {
491 let root = fixture_root();
492 let project = ProjectRoot::new(&root).expect("project");
493 let result =
494 search_for_pattern(&project, "return", Some("*.py"), 10, 2, 1).expect("search");
495 assert_eq!(result.len(), 1);
496 assert_eq!(result[0].line, 3);
497 assert_eq!(result[0].context_before.len(), 2);
498 assert_eq!(result[0].context_after.len(), 1);
499 }
500
501 #[test]
502 fn search_context_serialization() {
503 let m_empty = super::PatternMatch {
504 file_path: "test.py".to_string(),
505 line: 1,
506 column: 1,
507 matched_text: "foo".to_string(),
508 line_content: "foo bar".to_string(),
509 context_before: vec![],
510 context_after: vec![],
511 };
512 let json_empty = serde_json::to_string(&m_empty).expect("serialize");
513 assert!(!json_empty.contains("context_before"));
514 assert!(!json_empty.contains("context_after"));
515
516 let m_with = super::PatternMatch {
517 file_path: "test.py".to_string(),
518 line: 2,
519 column: 1,
520 matched_text: "foo".to_string(),
521 line_content: "foo bar".to_string(),
522 context_before: vec!["line above".to_string()],
523 context_after: vec!["line below".to_string()],
524 };
525 let json_with = serde_json::to_string(&m_with).expect("serialize");
526 assert!(json_with.contains("context_before"));
527 assert!(json_with.contains("context_after"));
528 }
529
530 #[test]
531 fn text_reference_finds_all_occurrences() {
532 let root = fixture_root();
533 let project = ProjectRoot::new(&root).expect("project");
534 let report = super::find_referencing_symbols_via_text(&project, "greet", None, 100)
535 .expect("text refs");
536 let refs = &report.references;
537 assert_eq!(refs.len(), 2); assert!(refs.iter().all(|r| r.file_path == "src/main.py"));
539 assert!(refs.iter().all(|r| !r.line_content.is_empty()));
540 }
541
542 #[test]
543 fn text_reference_with_declaration_file() {
544 let dir = ref_fixture_root();
545 let project = ProjectRoot::new(&dir).expect("project");
546 let report =
547 super::find_referencing_symbols_via_text(&project, "helper", Some("src/utils.py"), 100)
548 .expect("text refs");
549 assert!(report.references.len() >= 2);
550 }
551
552 #[test]
553 fn text_reference_shadowing_excluded() {
554 let dir = ref_fixture_root();
555 let project = ProjectRoot::new(&dir).expect("project");
556 let report =
557 super::find_referencing_symbols_via_text(&project, "run", Some("src/service.py"), 100)
558 .expect("text refs");
559 assert!(
560 report.references.iter().all(|r| r.file_path != "src/other.py"),
561 "should exclude other.py (has own 'run' declaration)"
562 );
563 }
564
565 #[test]
566 fn text_reference_resolves_rust_impl_method_as_enclosing() {
567 let dir = std::env::temp_dir().join(format!(
575 "codelens-impl-enclosing-{}",
576 std::time::SystemTime::now()
577 .duration_since(std::time::UNIX_EPOCH)
578 .expect("time")
579 .as_nanos()
580 ));
581 fs::create_dir_all(&dir).expect("create dir");
582 fs::write(
583 dir.join("lib.rs"),
584 "pub fn helper() -> usize { 1 }\n\
585 pub struct Widget;\n\
586 impl Widget {\n\
587 \x20 pub fn run(&self) -> usize {\n\
588 \x20 // intentionally long so the 10-line heuristic would miss it\n\
589 \x20 let _a = 1;\n\
590 \x20 let _b = 2;\n\
591 \x20 let _c = 3;\n\
592 \x20 let _d = 4;\n\
593 \x20 let _e = 5;\n\
594 \x20 let _f = 6;\n\
595 \x20 let _g = 7;\n\
596 \x20 let _h = 8;\n\
597 \x20 let _i = 9;\n\
598 \x20 let _j = 10;\n\
599 \x20 helper()\n\
600 \x20 }\n\
601 }\n",
602 )
603 .expect("write rust");
604 let project = ProjectRoot::new(&dir).expect("project");
605 let report = super::find_referencing_symbols_via_text(&project, "helper", None, 100)
606 .expect("text refs");
607 let call_site = report
608 .references
609 .iter()
610 .find(|r| !r.is_declaration)
611 .expect("should find call site reference");
612 let enclosing = call_site
613 .enclosing_symbol
614 .as_ref()
615 .expect("call site inside impl Widget::run must resolve to an enclosing symbol");
616 assert!(
617 enclosing.name_path.contains("run"),
618 "enclosing symbol should be the `run` method; got {enclosing:?}"
619 );
620 }
621
622 #[test]
623 fn extract_word_at_position_works() {
624 let root = fixture_root();
625 let project = ProjectRoot::new(&root).expect("project");
626 let word = super::extract_word_at_position(&project, "src/main.py", 2, 5).expect("word");
627 assert_eq!(word, "greet");
628 let word2 = super::extract_word_at_position(&project, "src/main.py", 2, 11).expect("word");
629 assert_eq!(word2, "name");
630 }
631
632 fn ref_fixture_root() -> std::path::PathBuf {
633 let dir = std::env::temp_dir().join(format!(
634 "codelens-ref-fixture-{}",
635 std::time::SystemTime::now()
636 .duration_since(std::time::UNIX_EPOCH)
637 .expect("time")
638 .as_nanos()
639 ));
640 fs::create_dir_all(dir.join("src")).expect("create src dir");
641 fs::write(dir.join("src/utils.py"), "def helper():\n return True\n")
642 .expect("write utils");
643 fs::write(
644 dir.join("src/main.py"),
645 "from utils import helper\n\nresult = helper()\n",
646 )
647 .expect("write main");
648 fs::write(
649 dir.join("src/service.py"),
650 "class Service:\n def run(self):\n return True\n",
651 )
652 .expect("write service");
653 fs::write(
654 dir.join("src/other.py"),
655 "class Other:\n def run(self):\n return False\n",
656 )
657 .expect("write other");
658 dir
659 }
660
661 fn fixture_root() -> std::path::PathBuf {
662 let dir = std::env::temp_dir().join(format!(
663 "codelens-core-fixture-{}",
664 std::time::SystemTime::now()
665 .duration_since(std::time::UNIX_EPOCH)
666 .expect("time")
667 .as_nanos()
668 ));
669 fs::create_dir_all(dir.join("src")).expect("create src dir");
670 fs::write(
671 dir.join("src/main.py"),
672 "class Service:\ndef greet(name):\n return f\"Hello {name}\"\nprint(greet(\"A\"))\n",
673 )
674 .expect("write fixture");
675 dir
676 }
677
678 #[test]
679 fn text_refs_report_exposes_shadow_suppression_count() {
680 use crate::file_ops::find_referencing_symbols_via_text;
681 use std::fs;
682
683 let dir = tempfile::tempdir().expect("tempdir");
684 let root = dir.path();
685 fs::write(root.join("decl.py"), "class Target:\n pass\n").unwrap();
686 fs::write(
687 root.join("shadow.py"),
688 "class Target:\n pass\n# Target\n",
689 )
690 .unwrap();
691 fs::write(root.join("use.py"), "from decl import Target\nTarget()\n").unwrap();
692
693 let project = crate::ProjectRoot::new(root).expect("project");
694 let report =
695 find_referencing_symbols_via_text(&project, "Target", Some("decl.py"), 50).unwrap();
696
697 assert!(
698 report.shadow_files_suppressed.iter().any(|f| f == "shadow.py"),
699 "shadow.py should be suppressed, got: {:?}",
700 report.shadow_files_suppressed
701 );
702 assert!(
703 report.references.iter().all(|r| r.file_path != "shadow.py"),
704 "no reference should come from the suppressed file"
705 );
706 }
707}