Skip to main content

codelens_engine/file_ops/
mod.rs

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
11// Re-export reader functions
12pub use reader::{find_files, list_dir, read_file, search_for_pattern, search_for_pattern_smart};
13
14// Re-export writer functions
15pub 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/// Pattern match enriched with enclosing symbol context (Smart Excerpt).
54#[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
90// --- Helper structs and functions (pub(super) for use within file_ops) ---
91
92pub(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 // heuristic: assume ~10 lines per symbol
139    }
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
187// --- Public functions that stay in mod.rs ---
188
189/// Find references to a symbol via text-based search (no LSP required).
190/// Optionally exclude the declaration file and filter out shadowing files.
191pub 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
249/// Extract the word (identifier) at a given line/column position in a file.
250pub 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); // "def greet" + "print(greet(...))"
480        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}