Skip to main content

codelens_engine/
rename.rs

1use crate::project::{ProjectRoot, collect_files};
2use crate::symbols::{SymbolInfo, get_symbols_overview};
3use anyhow::{Result, bail};
4use regex::Regex;
5use serde::Serialize;
6use std::collections::HashMap;
7use std::fs;
8use std::sync::LazyLock;
9
10static IDENTIFIER_RE: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").unwrap());
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RenameScope {
16    File,
17    Project,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct RenameEdit {
22    pub file_path: String,
23    pub line: usize,
24    pub column: usize,
25    pub old_text: String,
26    pub new_text: String,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct RenameResult {
31    pub success: bool,
32    pub message: String,
33    pub modified_files: usize,
34    pub total_replacements: usize,
35    pub edits: Vec<RenameEdit>,
36}
37
38/// Rename a symbol across one file or the entire project.
39///
40/// - `file_path`: the file containing the symbol declaration
41/// - `symbol_name`: current name of the symbol
42/// - `new_name`: desired new name
43/// - `name_path`: optional qualified name path (e.g. "Service/run")
44/// - `scope`: File (declaration scope only) or Project (all references)
45/// - `dry_run`: if true, returns edits without modifying files
46pub fn rename_symbol(
47    project: &ProjectRoot,
48    file_path: &str,
49    symbol_name: &str,
50    new_name: &str,
51    name_path: Option<&str>,
52    scope: RenameScope,
53    dry_run: bool,
54) -> Result<RenameResult> {
55    validate_identifier(new_name)?;
56
57    if symbol_name == new_name {
58        return Ok(RenameResult {
59            success: true,
60            message: "Symbol name unchanged".to_string(),
61            modified_files: 0,
62            total_replacements: 0,
63            edits: vec![],
64        });
65    }
66
67    let edits = match scope {
68        RenameScope::File => {
69            collect_file_scope_edits(project, file_path, symbol_name, new_name, name_path)?
70        }
71        RenameScope::Project => {
72            collect_project_scope_edits(project, file_path, symbol_name, new_name, name_path)?
73        }
74    };
75
76    let modified_files = edits
77        .iter()
78        .map(|e| &e.file_path)
79        .collect::<std::collections::HashSet<_>>()
80        .len();
81    let total_replacements = edits.len();
82
83    if !dry_run {
84        apply_edits(project, &edits)?;
85    }
86
87    Ok(RenameResult {
88        success: true,
89        message: format!(
90            "{} {} replacement(s) in {} file(s)",
91            if dry_run { "Would make" } else { "Made" },
92            total_replacements,
93            modified_files
94        ),
95        modified_files,
96        total_replacements,
97        edits,
98    })
99}
100
101fn validate_identifier(name: &str) -> Result<()> {
102    if !IDENTIFIER_RE.is_match(name) {
103        bail!("invalid identifier: '{name}' — must match [a-zA-Z_][a-zA-Z0-9_]*");
104    }
105    Ok(())
106}
107
108/// FILE scope: only rename within the declaration's body range.
109fn collect_file_scope_edits(
110    project: &ProjectRoot,
111    file_path: &str,
112    symbol_name: &str,
113    new_name: &str,
114    name_path: Option<&str>,
115) -> Result<Vec<RenameEdit>> {
116    let resolved = project.resolve(file_path)?;
117    let source = fs::read_to_string(&resolved)?;
118    let lines: Vec<&str> = source.lines().collect();
119
120    // Find symbol to get its line range
121    let (start_line, end_line) =
122        find_symbol_line_range(project, file_path, symbol_name, name_path)?;
123
124    let word_re = Regex::new(&format!(r"\b{}\b", regex::escape(symbol_name)))?;
125    let mut edits = Vec::new();
126
127    for (line_idx, line) in lines
128        .iter()
129        .enumerate()
130        .take(end_line.min(lines.len()))
131        .skip(start_line.saturating_sub(1))
132    {
133        for mat in word_re.find_iter(line) {
134            edits.push(RenameEdit {
135                file_path: file_path.to_string(),
136                line: line_idx + 1,
137                column: mat.start() + 1,
138                old_text: symbol_name.to_string(),
139                new_text: new_name.to_string(),
140            });
141        }
142    }
143
144    Ok(edits)
145}
146
147/// PROJECT scope: rename in declaration file + all referencing files.
148fn collect_project_scope_edits(
149    project: &ProjectRoot,
150    file_path: &str,
151    symbol_name: &str,
152    new_name: &str,
153    name_path: Option<&str>,
154) -> Result<Vec<RenameEdit>> {
155    // Step 1: Find ALL word-boundary matches across project (handles multiple per line)
156    let all_matches = find_all_word_matches(project, symbol_name)?;
157
158    // Step 2: Get files that have their own declaration of the same name (shadowing)
159    let shadow_files =
160        find_shadowing_files(project, file_path, symbol_name, name_path, &all_matches)?;
161
162    // Step 3: Build edits, skipping files with shadowed declarations
163    let mut edits = Vec::new();
164    for (match_file, line, column) in &all_matches {
165        if match_file != file_path && shadow_files.contains(match_file) {
166            continue;
167        }
168        edits.push(RenameEdit {
169            file_path: match_file.clone(),
170            line: *line,
171            column: *column,
172            old_text: symbol_name.to_string(),
173            new_text: new_name.to_string(),
174        });
175    }
176
177    Ok(edits)
178}
179
180/// Find ALL word-boundary matches of `symbol_name` across the project.
181/// Unlike search_for_pattern, this returns multiple matches per line via find_iter.
182pub fn find_all_word_matches(
183    project: &ProjectRoot,
184    symbol_name: &str,
185) -> Result<Vec<(String, usize, usize)>> {
186    let candidate_files = collect_candidate_files(project)?;
187
188    if candidate_files.is_empty() {
189        return Ok(Vec::new());
190    }
191
192    // Fast path: use indexed file list only when it fully covers the current
193    // project. Partial or empty DBs must not suppress project-wide rename hits.
194    let db_path = crate::db::index_db_path(project.as_path());
195    if db_path.exists()
196        && let Ok(db) = crate::db::IndexDb::open(&db_path)
197        && let Ok(indexed_files) = db.all_file_paths()
198        && indexed_files.len() >= candidate_files.len()
199    {
200        let indexed_set: std::collections::HashSet<&str> =
201            indexed_files.iter().map(String::as_str).collect();
202        if candidate_files
203            .iter()
204            .all(|path| indexed_set.contains(path.as_str()))
205        {
206            return find_word_matches_in_files(project, symbol_name, &indexed_files);
207        }
208    }
209
210    find_word_matches_in_files(project, symbol_name, &candidate_files)
211}
212
213fn collect_candidate_files(project: &ProjectRoot) -> Result<Vec<String>> {
214    Ok(collect_files(project.as_path(), |path| {
215        crate::lang_config::language_for_path(path).is_some()
216    })?
217    .into_iter()
218    .map(|path| project.to_relative(path))
219    .collect())
220}
221
222/// Fast path: scan only indexed files (from DB).
223/// Filters out matches inside comments and string literals using tree-sitter.
224fn find_word_matches_in_files(
225    project: &ProjectRoot,
226    symbol_name: &str,
227    files: &[String],
228) -> Result<Vec<(String, usize, usize)>> {
229    let word_re = Regex::new(&format!(r"\b{}\b", regex::escape(symbol_name)))?;
230    let mut results = Vec::new();
231    let mut non_code_cache: HashMap<std::path::PathBuf, Vec<(usize, usize)>> = HashMap::new();
232    for rel in files {
233        let abs = project.as_path().join(rel);
234        let content = match fs::read_to_string(&abs) {
235            Ok(c) => c,
236            Err(_) => continue,
237        };
238        // Build non-code byte ranges with per-file cache
239        let non_code = non_code_cache
240            .entry(abs.clone())
241            .or_insert_with(|| build_non_code_ranges(&abs, content.as_bytes()));
242
243        let mut byte_offset = 0usize;
244        for (line_idx, raw_line) in content.split_inclusive('\n').enumerate() {
245            let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
246            let line = line.strip_suffix('\r').unwrap_or(line);
247            for mat in word_re.find_iter(line) {
248                let abs_start = byte_offset + mat.start();
249                if !is_in_ranges(non_code, abs_start) {
250                    results.push((rel.clone(), line_idx + 1, mat.start() + 1));
251                }
252            }
253            byte_offset += raw_line.len();
254        }
255    }
256    Ok(results)
257}
258
259/// Node kinds that represent comments or string literals across languages.
260const NON_CODE_KINDS: &[&str] = &[
261    "comment",
262    "line_comment",
263    "block_comment",
264    "string",
265    "string_literal",
266    "raw_string_literal",
267    "template_string",
268    "string_content",
269    "interpreted_string_literal",
270    "heredoc_body",
271    "regex_literal",
272];
273
274/// Build byte ranges of non-code nodes (comments + strings) using tree-sitter.
275fn build_non_code_ranges(path: &std::path::Path, source: &[u8]) -> Vec<(usize, usize)> {
276    let Some(config) = crate::lang_config::language_for_path(path) else {
277        return Vec::new();
278    };
279    let mut parser = tree_sitter::Parser::new();
280    if parser.set_language(&config.language).is_err() {
281        return Vec::new();
282    }
283    let Some(tree) = parser.parse(source, None) else {
284        return Vec::new();
285    };
286    let mut ranges = Vec::new();
287    collect_non_code_ranges(&tree.root_node(), &mut ranges);
288    ranges
289}
290
291fn collect_non_code_ranges(node: &tree_sitter::Node, ranges: &mut Vec<(usize, usize)>) {
292    if NON_CODE_KINDS.contains(&node.kind()) {
293        ranges.push((node.start_byte(), node.end_byte()));
294        return; // don't recurse into children
295    }
296    let mut cursor = node.walk();
297    for child in node.children(&mut cursor) {
298        collect_non_code_ranges(&child, ranges);
299    }
300}
301
302fn is_in_ranges(ranges: &[(usize, usize)], offset: usize) -> bool {
303    // Binary search: ranges are sorted by start_byte from tree-sitter DFS
304    ranges
305        .binary_search_by(|&(start, end)| {
306            if offset < start {
307                std::cmp::Ordering::Greater
308            } else if offset >= end {
309                std::cmp::Ordering::Less
310            } else {
311                std::cmp::Ordering::Equal
312            }
313        })
314        .is_ok()
315}
316
317/// Find files (other than the declaration file) that declare a symbol with the same name.
318fn find_shadowing_files(
319    project: &ProjectRoot,
320    declaration_file: &str,
321    symbol_name: &str,
322    _name_path: Option<&str>,
323    all_matches: &[(String, usize, usize)],
324) -> Result<std::collections::HashSet<String>> {
325    let mut shadow_files = std::collections::HashSet::new();
326
327    let files_with_matches: Vec<&str> = all_matches
328        .iter()
329        .map(|(f, _, _)| f.as_str())
330        .filter(|f| *f != declaration_file)
331        .collect();
332
333    if files_with_matches.is_empty() {
334        return Ok(shadow_files);
335    }
336
337    // Try DB-based batch lookup first (avoids per-file tree-sitter re-parse)
338    let db_path = crate::db::index_db_path(project.as_path());
339    if let Ok(db) = crate::db::IndexDb::open(&db_path)
340        && let Ok(symbols) = db.symbols_for_files(&files_with_matches)
341        && !symbols.is_empty()
342    {
343        for sym in &symbols {
344            if sym.name == symbol_name && sym.file_path != declaration_file {
345                shadow_files.insert(sym.file_path.clone());
346            }
347        }
348        return Ok(shadow_files);
349    }
350
351    // Fallback: per-file tree-sitter parse
352    for fp in files_with_matches {
353        if let Ok(symbols) = get_symbols_overview(project, fp, 3)
354            && has_declaration(&symbols, symbol_name)
355        {
356            shadow_files.insert(fp.to_owned());
357        }
358    }
359
360    Ok(shadow_files)
361}
362
363fn has_declaration(symbols: &[SymbolInfo], name: &str) -> bool {
364    symbols
365        .iter()
366        .any(|s| s.name == name || has_declaration(&s.children, name))
367}
368
369/// Find the line range of a symbol using tree-sitter.
370fn find_symbol_line_range(
371    project: &ProjectRoot,
372    file_path: &str,
373    symbol_name: &str,
374    name_path: Option<&str>,
375) -> Result<(usize, usize)> {
376    let symbols = get_symbols_overview(project, file_path, 0)?;
377    let flat = flatten_symbol_infos(symbols);
378
379    let candidate = if let Some(np) = name_path {
380        flat.iter().find(|s| s.name_path == np)
381    } else {
382        flat.iter().find(|s| s.name == symbol_name)
383    };
384
385    match candidate {
386        Some(sym) => {
387            // Estimate end line from body or use heuristic
388            let end_line = if let Some(body) = &sym.body {
389                sym.line + body.lines().count()
390            } else {
391                // Read the file to get body via find_symbol_range
392                let (_start_byte, end_byte) =
393                    crate::symbols::find_symbol_range(project, file_path, symbol_name, name_path)?;
394                let resolved = project.resolve(file_path)?;
395                let source = fs::read_to_string(&resolved)?;
396
397                source[..end_byte].lines().count()
398            };
399            Ok((sym.line, end_line))
400        }
401        None => bail!("symbol '{}' not found in {}", symbol_name, file_path),
402    }
403}
404
405fn flatten_symbol_infos(symbols: Vec<SymbolInfo>) -> Vec<SymbolInfo> {
406    let mut flat = Vec::new();
407    for mut s in symbols {
408        let children = std::mem::take(&mut s.children);
409        flat.push(s);
410        flat.extend(flatten_symbol_infos(children));
411    }
412    flat
413}
414
415/// Apply edits to files on disk. Edits are sorted by byte offset descending per
416/// file and applied back-to-front to preserve offsets. `RenameEdit` is also used
417/// by LSP WorkspaceEdit text edits, so this handles insertions and multi-line
418/// replacements when `old_text` spans a range.
419pub fn apply_edits(project: &ProjectRoot, edits: &[RenameEdit]) -> Result<()> {
420    // Group by file
421    let mut by_file: HashMap<String, Vec<&RenameEdit>> = HashMap::new();
422    for edit in edits {
423        by_file
424            .entry(edit.file_path.clone())
425            .or_default()
426            .push(edit);
427    }
428
429    for (file_path, file_edits) in by_file {
430        let resolved = project.resolve(&file_path)?;
431        let mut content = fs::read_to_string(&resolved)?;
432        let mut positioned = Vec::new();
433        for (index, edit) in file_edits.iter().enumerate() {
434            let Some(start) = byte_offset_for_line_column(&content, edit.line, edit.column) else {
435                continue;
436            };
437            let end = start.saturating_add(edit.old_text.len());
438            if end > content.len() || !content.is_char_boundary(end) {
439                continue;
440            }
441            if content
442                .get(start..end)
443                .is_some_and(|text| text == edit.old_text)
444            {
445                positioned.push((start, end, index, *edit));
446            }
447        }
448
449        reject_overlapping_edits(&positioned)?;
450        positioned.sort_by(|a, b| b.0.cmp(&a.0).then(b.2.cmp(&a.2)));
451
452        for (start, end, _, edit) in positioned {
453            content.replace_range(start..end, &edit.new_text);
454        }
455        fs::write(&resolved, &content)?;
456    }
457
458    Ok(())
459}
460
461fn byte_offset_for_line_column(content: &str, line: usize, column: usize) -> Option<usize> {
462    if line == 0 || column == 0 {
463        return None;
464    }
465
466    let mut current_line = 1usize;
467    let mut line_start = 0usize;
468    for (byte_index, ch) in content.char_indices() {
469        if current_line == line {
470            break;
471        }
472        if ch == '\n' {
473            current_line += 1;
474            line_start = byte_index + ch.len_utf8();
475        }
476    }
477    if current_line != line {
478        return None;
479    }
480
481    let line_end = content[line_start..]
482        .find('\n')
483        .map(|offset| line_start + offset)
484        .unwrap_or(content.len());
485    let offset = line_start.checked_add(column.saturating_sub(1))?;
486    if offset > line_end || !content.is_char_boundary(offset) {
487        return None;
488    }
489    Some(offset)
490}
491
492fn reject_overlapping_edits(edits: &[(usize, usize, usize, &RenameEdit)]) -> Result<()> {
493    let mut ranges = edits
494        .iter()
495        .filter(|(start, end, _, _)| start != end)
496        .map(|(start, end, _, _)| (*start, *end))
497        .collect::<Vec<_>>();
498    ranges.sort_unstable();
499    for pair in ranges.windows(2) {
500        if pair[0].1 > pair[1].0 {
501            bail!("overlapping text edits are not supported");
502        }
503    }
504    Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::ProjectRoot;
511    use std::fs;
512
513    fn make_fixture() -> (std::path::PathBuf, ProjectRoot) {
514        let dir = std::env::temp_dir().join(format!(
515            "codelens-rename-fixture-{}",
516            std::time::SystemTime::now()
517                .duration_since(std::time::UNIX_EPOCH)
518                .unwrap()
519                .as_nanos()
520        ));
521        fs::create_dir_all(dir.join("src")).unwrap();
522        fs::write(
523            dir.join("src/service.py"),
524            "class UserService:\n    def get_user(self, user_id):\n        return self.db.find(user_id)\n\n    def delete_user(self, user_id):\n        user = self.get_user(user_id)\n        return self.db.delete(user)\n",
525        )
526        .unwrap();
527        fs::write(
528            dir.join("src/main.py"),
529            "from service import UserService\n\nsvc = UserService()\nresult = svc.get_user(1)\n",
530        )
531        .unwrap();
532        fs::write(
533            dir.join("src/other.py"),
534            "class OtherService:\n    def get_user(self):\n        return None\n",
535        )
536        .unwrap();
537        let project = ProjectRoot::new(&dir).unwrap();
538        (dir, project)
539    }
540
541    #[test]
542    fn validates_identifier() {
543        assert!(validate_identifier("newName").is_ok());
544        assert!(validate_identifier("_private").is_ok());
545        assert!(validate_identifier("123bad").is_err());
546        assert!(validate_identifier("has-dash").is_err());
547        assert!(validate_identifier("").is_err());
548    }
549
550    #[test]
551    fn file_scope_renames_within_symbol_body() {
552        let (_dir, project) = make_fixture();
553        let result = rename_symbol(
554            &project,
555            "src/service.py",
556            "get_user",
557            "fetch_user",
558            Some("UserService/get_user"),
559            RenameScope::File,
560            false,
561        )
562        .unwrap();
563        assert!(result.success);
564        assert!(result.total_replacements >= 1);
565        // Verify the file was modified
566        let content = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
567        assert!(content.contains("fetch_user"));
568        // The call to self.get_user in delete_user should NOT be renamed (outside symbol body)
569        // But it depends on the symbol's line range — get_user is a standalone method
570    }
571
572    #[test]
573    fn project_scope_renames_across_files() {
574        let (_dir, project) = make_fixture();
575        let result = rename_symbol(
576            &project,
577            "src/service.py",
578            "UserService",
579            "AccountService",
580            None,
581            RenameScope::Project,
582            false,
583        )
584        .unwrap();
585        assert!(result.success);
586        assert!(result.modified_files >= 2); // service.py + main.py
587        let main_content = fs::read_to_string(project.resolve("src/main.py").unwrap()).unwrap();
588        assert!(main_content.contains("AccountService"));
589        assert!(!main_content.contains("UserService"));
590    }
591
592    #[test]
593    fn project_scope_falls_back_when_symbol_db_is_empty() {
594        let (dir, project) = make_fixture();
595        let db_dir = dir.join(".codelens/index");
596        fs::create_dir_all(&db_dir).unwrap();
597        let _db = crate::db::IndexDb::open(&db_dir.join("symbols.db")).unwrap();
598
599        let result = rename_symbol(
600            &project,
601            "src/service.py",
602            "UserService",
603            "AccountService",
604            None,
605            RenameScope::Project,
606            true,
607        )
608        .unwrap();
609
610        assert!(result.success);
611        assert!(result.modified_files >= 2);
612        assert!(result.total_replacements >= 3);
613    }
614
615    #[test]
616    fn dry_run_does_not_modify_files() {
617        let (_dir, project) = make_fixture();
618        let original = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
619        let result = rename_symbol(
620            &project,
621            "src/service.py",
622            "UserService",
623            "AccountService",
624            None,
625            RenameScope::Project,
626            true,
627        )
628        .unwrap();
629        assert!(result.success);
630        assert!(!result.edits.is_empty());
631        let after = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
632        assert_eq!(original, after);
633    }
634
635    #[test]
636    fn shadowing_skips_other_declarations() {
637        let (_dir, project) = make_fixture();
638        // other.py has its own get_user — should not be renamed
639        let result = rename_symbol(
640            &project,
641            "src/service.py",
642            "get_user",
643            "fetch_user",
644            Some("UserService/get_user"),
645            RenameScope::Project,
646            true,
647        )
648        .unwrap();
649        // Check no edits target other.py
650        let other_edits: Vec<_> = result
651            .edits
652            .iter()
653            .filter(|e| e.file_path == "src/other.py")
654            .collect();
655        assert!(
656            other_edits.is_empty(),
657            "should skip other.py due to shadowing"
658        );
659    }
660
661    #[test]
662    fn same_name_returns_no_changes() {
663        let (_dir, project) = make_fixture();
664        let result = rename_symbol(
665            &project,
666            "src/service.py",
667            "UserService",
668            "UserService",
669            None,
670            RenameScope::Project,
671            false,
672        )
673        .unwrap();
674        assert!(result.success);
675        assert_eq!(result.total_replacements, 0);
676    }
677
678    #[test]
679    fn column_precise_replacement() {
680        let dir = std::env::temp_dir().join(format!(
681            "codelens-rename-col-{}",
682            std::time::SystemTime::now()
683                .duration_since(std::time::UNIX_EPOCH)
684                .unwrap()
685                .as_nanos()
686        ));
687        fs::create_dir_all(&dir).unwrap();
688        // "foo" appears twice on the same line
689        fs::write(dir.join("test.py"), "x = foo + foo\n").unwrap();
690        let project = ProjectRoot::new(&dir).unwrap();
691        let result = rename_symbol(
692            &project,
693            "test.py",
694            "foo",
695            "bar",
696            None,
697            RenameScope::Project,
698            false,
699        )
700        .unwrap();
701        assert!(result.success);
702        let content = fs::read_to_string(project.resolve("test.py").unwrap()).unwrap();
703        assert_eq!(content.trim(), "x = bar + bar");
704        assert_eq!(result.total_replacements, 2);
705    }
706
707    #[test]
708    fn apply_edits_ignores_invalid_utf8_boundary_column() {
709        let dir = std::env::temp_dir().join(format!(
710            "codelens-rename-boundary-{}",
711            std::time::SystemTime::now()
712                .duration_since(std::time::UNIX_EPOCH)
713                .unwrap()
714                .as_nanos()
715        ));
716        fs::create_dir_all(dir.join("src")).unwrap();
717        fs::write(dir.join("src/unicode.py"), "🙂 old_name()\n").unwrap();
718        let project = ProjectRoot::new_exact(&dir).unwrap();
719        let edits = vec![RenameEdit {
720            file_path: "src/unicode.py".to_owned(),
721            line: 1,
722            column: 2,
723            old_text: "old_name".to_owned(),
724            new_text: "new_name".to_owned(),
725        }];
726
727        let result = std::panic::catch_unwind(|| apply_edits(&project, &edits));
728
729        assert!(result.is_ok(), "invalid byte boundary must not panic");
730        assert!(result.unwrap().is_ok());
731        let updated = fs::read_to_string(dir.join("src/unicode.py")).unwrap();
732        assert_eq!(updated, "🙂 old_name()\n");
733    }
734
735    #[test]
736    fn apply_edits_handles_multiline_lsp_workspace_edit() {
737        let dir = std::env::temp_dir().join(format!(
738            "codelens-rename-multiline-{}",
739            std::time::SystemTime::now()
740                .duration_since(std::time::UNIX_EPOCH)
741                .unwrap()
742                .as_nanos()
743        ));
744        fs::create_dir_all(&dir).unwrap();
745        fs::write(dir.join("sample.ts"), "function main() {\n  old();\n}\n").unwrap();
746        let project = ProjectRoot::new_exact(&dir).unwrap();
747        let edits = vec![RenameEdit {
748            file_path: "sample.ts".to_owned(),
749            line: 1,
750            column: 1,
751            old_text: "function main() {\n  old();\n}".to_owned(),
752            new_text: "function main() {\n  extracted();\n}\nfunction extracted() {}\n".to_owned(),
753        }];
754
755        apply_edits(&project, &edits).expect("multiline edit applies");
756
757        let updated = fs::read_to_string(dir.join("sample.ts")).unwrap();
758        assert!(updated.contains("function extracted()"));
759        assert!(updated.contains("extracted();"));
760    }
761
762    #[test]
763    fn apply_edits_handles_empty_old_text_insertion() {
764        let dir = std::env::temp_dir().join(format!(
765            "codelens-rename-insert-{}",
766            std::time::SystemTime::now()
767                .duration_since(std::time::UNIX_EPOCH)
768                .unwrap()
769                .as_nanos()
770        ));
771        fs::create_dir_all(&dir).unwrap();
772        fs::write(dir.join("sample.ts"), "const value = 1;\n").unwrap();
773        let project = ProjectRoot::new_exact(&dir).unwrap();
774        let edits = vec![RenameEdit {
775            file_path: "sample.ts".to_owned(),
776            line: 2,
777            column: 1,
778            old_text: String::new(),
779            new_text: "console.log(value);\n".to_owned(),
780        }];
781
782        apply_edits(&project, &edits).expect("insert applies");
783
784        let updated = fs::read_to_string(dir.join("sample.ts")).unwrap();
785        assert_eq!(updated, "const value = 1;\nconsole.log(value);\n");
786    }
787
788    #[test]
789    fn apply_edits_ignores_zero_line_or_column() {
790        let dir = std::env::temp_dir().join(format!(
791            "codelens-rename-zero-position-{}",
792            std::time::SystemTime::now()
793                .duration_since(std::time::UNIX_EPOCH)
794                .unwrap()
795                .as_nanos()
796        ));
797        fs::create_dir_all(&dir).unwrap();
798        fs::write(dir.join("sample.py"), "old_name()\n").unwrap();
799        let project = ProjectRoot::new_exact(&dir).unwrap();
800        let edits = vec![
801            RenameEdit {
802                file_path: "sample.py".to_owned(),
803                line: 0,
804                column: 1,
805                old_text: "old_name".to_owned(),
806                new_text: "new_name".to_owned(),
807            },
808            RenameEdit {
809                file_path: "sample.py".to_owned(),
810                line: 1,
811                column: 0,
812                old_text: "old_name".to_owned(),
813                new_text: "new_name".to_owned(),
814            },
815        ];
816
817        apply_edits(&project, &edits).expect("invalid zero positions should be ignored");
818
819        let updated = fs::read_to_string(dir.join("sample.py")).unwrap();
820        assert_eq!(updated, "old_name()\n");
821    }
822
823    #[test]
824    fn find_all_word_matches_skips_crlf_string_literals() {
825        let dir = std::env::temp_dir().join(format!(
826            "codelens-rename-crlf-{}",
827            std::time::SystemTime::now()
828                .duration_since(std::time::UNIX_EPOCH)
829                .unwrap()
830                .as_nanos()
831        ));
832        fs::create_dir_all(dir.join("src")).unwrap();
833        fs::write(
834            dir.join("src/main.py"),
835            "label = \"PatternMatch\"\r\nPatternMatch()\r\n",
836        )
837        .unwrap();
838
839        let project = ProjectRoot::new(&dir).unwrap();
840        let matches = find_all_word_matches(&project, "PatternMatch").unwrap();
841
842        assert_eq!(matches, vec![("src/main.py".to_string(), 2, 1)]);
843    }
844}