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}
89
90pub(super) struct FlatSymbol {
93 pub(super) name: String,
94 pub(super) kind: String,
95 pub(super) name_path: String,
96 pub(super) start_line: usize,
97 pub(super) end_line: usize,
98 pub(super) signature: String,
99}
100
101pub(super) fn flatten_to_ranges(symbols: &[crate::symbols::SymbolInfo]) -> Vec<FlatSymbol> {
102 let mut flat = Vec::new();
103 for s in symbols {
104 let end_line = estimate_end_line(s);
105 if matches!(
106 s.kind,
107 crate::symbols::SymbolKind::Function
108 | crate::symbols::SymbolKind::Method
109 | crate::symbols::SymbolKind::Class
110 | crate::symbols::SymbolKind::Interface
111 | crate::symbols::SymbolKind::Module
112 ) {
113 flat.push(FlatSymbol {
114 name: s.name.clone(),
115 kind: s.kind.as_label().to_owned(),
116 name_path: s.name_path.clone(),
117 start_line: s.line,
118 end_line,
119 signature: s.signature.clone(),
120 });
121 }
122 flat.extend(flatten_to_ranges(&s.children));
123 }
124 flat
125}
126
127fn estimate_end_line(symbol: &crate::symbols::SymbolInfo) -> usize {
128 if let Some(body) = &symbol.body {
129 symbol.line + body.lines().count()
130 } else if !symbol.children.is_empty() {
131 symbol
132 .children
133 .iter()
134 .map(estimate_end_line)
135 .max()
136 .unwrap_or(symbol.line + 10)
137 } else {
138 symbol.line + 10 }
140}
141
142pub(super) fn find_enclosing_symbol(
143 symbols: &[FlatSymbol],
144 line: usize,
145) -> Option<EnclosingSymbol> {
146 symbols
147 .iter()
148 .filter(|s| s.start_line <= line && line <= s.end_line)
149 .min_by_key(|s| s.end_line - s.start_line)
150 .map(|s| EnclosingSymbol {
151 name: s.name.clone(),
152 kind: s.kind.clone(),
153 name_path: s.name_path.clone(),
154 start_line: s.start_line,
155 end_line: s.end_line,
156 signature: s.signature.clone(),
157 })
158}
159
160pub(super) fn to_directory_entry(project: &ProjectRoot, path: &Path) -> Result<DirectoryEntry> {
161 let metadata = fs::metadata(path)?;
162 Ok(DirectoryEntry {
163 name: path
164 .file_name()
165 .map(|name| name.to_string_lossy().into_owned())
166 .unwrap_or_default(),
167 entry_type: if metadata.is_dir() {
168 "directory".to_owned()
169 } else {
170 "file".to_owned()
171 },
172 path: project.to_relative(path),
173 size: if metadata.is_file() {
174 Some(metadata.len())
175 } else {
176 None
177 },
178 })
179}
180
181pub(super) fn compile_glob(pattern: &str) -> Result<GlobMatcher> {
182 Glob::new(pattern)
183 .with_context(|| format!("invalid glob: {pattern}"))
184 .map(|glob| glob.compile_matcher())
185}
186
187pub fn find_referencing_symbols_via_text(
192 project: &ProjectRoot,
193 symbol_name: &str,
194 declaration_file: Option<&str>,
195 max_results: usize,
196) -> Result<Vec<TextReference>> {
197 use crate::rename::find_all_word_matches;
198 use crate::symbols::get_symbols_overview;
199
200 let all_matches = find_all_word_matches(project, symbol_name)?;
201
202 let shadow_files =
203 find_shadowing_files_for_refs(project, declaration_file, symbol_name, &all_matches)?;
204
205 let mut symbol_cache: std::collections::HashMap<String, Vec<FlatSymbol>> =
206 std::collections::HashMap::new();
207
208 let mut results = Vec::new();
209 for (file_path, line, column) in &all_matches {
210 if results.len() >= max_results {
211 break;
212 }
213 if let Some(decl) = declaration_file
214 && file_path != decl
215 && shadow_files.contains(file_path)
216 {
217 continue;
218 }
219
220 let line_content = read_line_at(project, file_path, *line).unwrap_or_default();
221
222 if !symbol_cache.contains_key(file_path)
223 && let Ok(symbols) = get_symbols_overview(project, file_path, 3)
224 {
225 symbol_cache.insert(file_path.clone(), flatten_to_ranges(&symbols));
226 }
227 let enclosing = symbol_cache
228 .get(file_path)
229 .and_then(|symbols| find_enclosing_symbol(symbols, *line));
230
231 let is_declaration = enclosing
232 .as_ref()
233 .map(|e| e.name == symbol_name && e.start_line == *line)
234 .unwrap_or(false);
235
236 results.push(TextReference {
237 file_path: file_path.clone(),
238 line: *line,
239 column: *column,
240 line_content,
241 enclosing_symbol: enclosing,
242 is_declaration,
243 });
244 }
245
246 Ok(results)
247}
248
249pub fn extract_word_at_position(
251 project: &ProjectRoot,
252 file_path: &str,
253 line: usize,
254 column: usize,
255) -> Result<String> {
256 let resolved = project.resolve(file_path)?;
257 let content = fs::read_to_string(&resolved)?;
258 let lines: Vec<&str> = content.lines().collect();
259 let line_idx = line.saturating_sub(1);
260 if line_idx >= lines.len() {
261 bail!(
262 "line {} out of range (file has {} lines)",
263 line,
264 lines.len()
265 );
266 }
267 let line_str = lines[line_idx];
268 let col_idx = column.saturating_sub(1);
269 if col_idx >= line_str.len() {
270 bail!(
271 "column {} out of range (line has {} chars)",
272 column,
273 line_str.len()
274 );
275 }
276
277 let bytes = line_str.as_bytes();
278 let mut start = col_idx;
279 while start > 0 && is_ident_char(bytes[start - 1]) {
280 start -= 1;
281 }
282 let mut end = col_idx;
283 while end < bytes.len() && is_ident_char(bytes[end]) {
284 end += 1;
285 }
286 if start == end {
287 bail!("no identifier at {}:{}", line, column);
288 }
289 Ok(line_str[start..end].to_string())
290}
291
292fn is_ident_char(b: u8) -> bool {
293 b.is_ascii_alphanumeric() || b == b'_'
294}
295
296fn read_line_at(project: &ProjectRoot, file_path: &str, line: usize) -> Result<String> {
297 let resolved = project.resolve(file_path)?;
298 let content = fs::read_to_string(&resolved)?;
299 content
300 .lines()
301 .nth(line.saturating_sub(1))
302 .map(|l| l.to_string())
303 .ok_or_else(|| anyhow::anyhow!("line {} out of range", line))
304}
305
306fn find_shadowing_files_for_refs(
307 project: &ProjectRoot,
308 declaration_file: Option<&str>,
309 symbol_name: &str,
310 all_matches: &[(String, usize, usize)],
311) -> Result<std::collections::HashSet<String>> {
312 use crate::symbols::get_symbols_overview;
313
314 let mut shadow_files = std::collections::HashSet::new();
315 let files_with_matches: std::collections::HashSet<&String> =
316 all_matches.iter().map(|(f, _, _)| f).collect();
317
318 for fp in files_with_matches {
319 if declaration_file.map(|d| d == fp).unwrap_or(false) {
320 continue;
321 }
322 if let Ok(symbols) = get_symbols_overview(project, fp, 3)
323 && has_declaration_recursive(&symbols, symbol_name)
324 {
325 shadow_files.insert(fp.clone());
326 }
327 }
328 Ok(shadow_files)
329}
330
331fn has_declaration_recursive(symbols: &[crate::symbols::SymbolInfo], name: &str) -> bool {
332 symbols
333 .iter()
334 .any(|s| s.name == name || has_declaration_recursive(&s.children, name))
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{find_files, list_dir, read_file, search_for_pattern};
340 use crate::ProjectRoot;
341 use std::fs;
342
343 #[test]
344 fn reads_partial_file() {
345 let root = fixture_root();
346 let project = ProjectRoot::new(&root).expect("project");
347 let result = read_file(&project, "src/main.py", Some(1), Some(3)).expect("read file");
348 assert_eq!(result.total_lines, 4);
349 assert_eq!(
350 result.content,
351 "def greet(name):\n return f\"Hello {name}\""
352 );
353 }
354
355 #[test]
356 fn lists_nested_dir() {
357 let root = fixture_root();
358 let project = ProjectRoot::new(&root).expect("project");
359 let result = list_dir(&project, ".", true).expect("list dir");
360 assert!(result.iter().any(|entry| entry.path == "src/main.py"));
361 }
362
363 #[test]
364 fn finds_files_by_glob() {
365 let root = fixture_root();
366 let project = ProjectRoot::new(&root).expect("project");
367 let result = find_files(&project, "*.py", Some("src")).expect("find files");
368 assert_eq!(result.len(), 1);
369 assert_eq!(result[0].path, "src/main.py");
370 }
371
372 #[test]
373 fn searches_text_pattern() {
374 let root = fixture_root();
375 let project = ProjectRoot::new(&root).expect("project");
376 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
377 assert_eq!(result.len(), 2);
378 assert_eq!(result[0].file_path, "src/main.py");
379 assert!(result[0].context_before.is_empty());
380 assert!(result[0].context_after.is_empty());
381 }
382
383 #[test]
384 fn search_with_zero_context() {
385 let root = fixture_root();
386 let project = ProjectRoot::new(&root).expect("project");
387 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 0, 0).expect("search");
388 for m in &result {
389 assert!(m.context_before.is_empty());
390 assert!(m.context_after.is_empty());
391 }
392 }
393
394 #[test]
395 fn search_with_symmetric_context() {
396 let root = fixture_root();
397 let project = ProjectRoot::new(&root).expect("project");
398 let result = search_for_pattern(&project, "greet", Some("*.py"), 10, 1, 1).expect("search");
399 assert_eq!(result.len(), 2);
400 assert_eq!(result[0].line, 2);
401 assert_eq!(result[0].context_before.len(), 1);
402 assert_eq!(result[0].context_before[0], "class Service:");
403 assert_eq!(result[0].context_after.len(), 1);
404 assert!(result[0].context_after[0].contains("return"));
405 assert_eq!(result[1].line, 4);
406 assert_eq!(result[1].context_before.len(), 1);
407 assert!(result[1].context_after.is_empty());
408 }
409
410 #[test]
411 fn search_context_at_file_start() {
412 let root = fixture_root();
413 let project = ProjectRoot::new(&root).expect("project");
414 let result = search_for_pattern(&project, "class", Some("*.py"), 10, 3, 1).expect("search");
415 assert_eq!(result.len(), 1);
416 assert_eq!(result[0].line, 1);
417 assert!(result[0].context_before.is_empty());
418 assert_eq!(result[0].context_after.len(), 1);
419 }
420
421 #[test]
422 fn search_context_at_file_end() {
423 let root = fixture_root();
424 let project = ProjectRoot::new(&root).expect("project");
425 let result = search_for_pattern(&project, "print", Some("*.py"), 10, 2, 3).expect("search");
426 assert_eq!(result.len(), 1);
427 assert_eq!(result[0].line, 4);
428 assert_eq!(result[0].context_before.len(), 2);
429 assert!(result[0].context_after.is_empty());
430 }
431
432 #[test]
433 fn search_asymmetric_context() {
434 let root = fixture_root();
435 let project = ProjectRoot::new(&root).expect("project");
436 let result =
437 search_for_pattern(&project, "return", Some("*.py"), 10, 2, 1).expect("search");
438 assert_eq!(result.len(), 1);
439 assert_eq!(result[0].line, 3);
440 assert_eq!(result[0].context_before.len(), 2);
441 assert_eq!(result[0].context_after.len(), 1);
442 }
443
444 #[test]
445 fn search_context_serialization() {
446 let m_empty = super::PatternMatch {
447 file_path: "test.py".to_string(),
448 line: 1,
449 column: 1,
450 matched_text: "foo".to_string(),
451 line_content: "foo bar".to_string(),
452 context_before: vec![],
453 context_after: vec![],
454 };
455 let json_empty = serde_json::to_string(&m_empty).expect("serialize");
456 assert!(!json_empty.contains("context_before"));
457 assert!(!json_empty.contains("context_after"));
458
459 let m_with = super::PatternMatch {
460 file_path: "test.py".to_string(),
461 line: 2,
462 column: 1,
463 matched_text: "foo".to_string(),
464 line_content: "foo bar".to_string(),
465 context_before: vec!["line above".to_string()],
466 context_after: vec!["line below".to_string()],
467 };
468 let json_with = serde_json::to_string(&m_with).expect("serialize");
469 assert!(json_with.contains("context_before"));
470 assert!(json_with.contains("context_after"));
471 }
472
473 #[test]
474 fn text_reference_finds_all_occurrences() {
475 let root = fixture_root();
476 let project = ProjectRoot::new(&root).expect("project");
477 let refs = super::find_referencing_symbols_via_text(&project, "greet", None, 100)
478 .expect("text refs");
479 assert_eq!(refs.len(), 2); assert!(refs.iter().all(|r| r.file_path == "src/main.py"));
481 assert!(refs.iter().all(|r| !r.line_content.is_empty()));
482 }
483
484 #[test]
485 fn text_reference_with_declaration_file() {
486 let dir = ref_fixture_root();
487 let project = ProjectRoot::new(&dir).expect("project");
488 let refs =
489 super::find_referencing_symbols_via_text(&project, "helper", Some("src/utils.py"), 100)
490 .expect("text refs");
491 assert!(refs.len() >= 2);
492 }
493
494 #[test]
495 fn text_reference_shadowing_excluded() {
496 let dir = ref_fixture_root();
497 let project = ProjectRoot::new(&dir).expect("project");
498 let refs =
499 super::find_referencing_symbols_via_text(&project, "run", Some("src/service.py"), 100)
500 .expect("text refs");
501 assert!(
502 refs.iter().all(|r| r.file_path != "src/other.py"),
503 "should exclude other.py (has own 'run' declaration)"
504 );
505 }
506
507 #[test]
508 fn extract_word_at_position_works() {
509 let root = fixture_root();
510 let project = ProjectRoot::new(&root).expect("project");
511 let word = super::extract_word_at_position(&project, "src/main.py", 2, 5).expect("word");
512 assert_eq!(word, "greet");
513 let word2 = super::extract_word_at_position(&project, "src/main.py", 2, 11).expect("word");
514 assert_eq!(word2, "name");
515 }
516
517 fn ref_fixture_root() -> std::path::PathBuf {
518 let dir = std::env::temp_dir().join(format!(
519 "codelens-ref-fixture-{}",
520 std::time::SystemTime::now()
521 .duration_since(std::time::UNIX_EPOCH)
522 .expect("time")
523 .as_nanos()
524 ));
525 fs::create_dir_all(dir.join("src")).expect("create src dir");
526 fs::write(dir.join("src/utils.py"), "def helper():\n return True\n")
527 .expect("write utils");
528 fs::write(
529 dir.join("src/main.py"),
530 "from utils import helper\n\nresult = helper()\n",
531 )
532 .expect("write main");
533 fs::write(
534 dir.join("src/service.py"),
535 "class Service:\n def run(self):\n return True\n",
536 )
537 .expect("write service");
538 fs::write(
539 dir.join("src/other.py"),
540 "class Other:\n def run(self):\n return False\n",
541 )
542 .expect("write other");
543 dir
544 }
545
546 fn fixture_root() -> std::path::PathBuf {
547 let dir = std::env::temp_dir().join(format!(
548 "codelens-core-fixture-{}",
549 std::time::SystemTime::now()
550 .duration_since(std::time::UNIX_EPOCH)
551 .expect("time")
552 .as_nanos()
553 ));
554 fs::create_dir_all(dir.join("src")).expect("create src dir");
555 fs::write(
556 dir.join("src/main.py"),
557 "class Service:\ndef greet(name):\n return f\"Hello {name}\"\nprint(greet(\"A\"))\n",
558 )
559 .expect("write fixture");
560 dir
561 }
562}