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
38pub 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
108fn 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 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
147fn 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 let all_matches = find_all_word_matches(project, symbol_name)?;
157
158 let shadow_files =
160 find_shadowing_files(project, file_path, symbol_name, name_path, &all_matches)?;
161
162 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
180pub 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 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
222fn 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 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
259const 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
274fn 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; }
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 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
317fn 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 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 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
369fn 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 let end_line = if let Some(body) = &sym.body {
389 sym.line + body.lines().count()
390 } else {
391 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
415pub fn apply_edits(project: &ProjectRoot, edits: &[RenameEdit]) -> Result<()> {
420 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 let content = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
567 assert!(content.contains("fetch_user"));
568 }
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); 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 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 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 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}