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, line) in content.lines().enumerate() {
245 for mat in word_re.find_iter(line) {
246 let abs_start = byte_offset + mat.start();
247 if !is_in_ranges(non_code, abs_start) {
248 results.push((rel.clone(), line_idx + 1, mat.start() + 1));
249 }
250 }
251 byte_offset += line.len() + 1; }
253 }
254 Ok(results)
255}
256
257const NON_CODE_KINDS: &[&str] = &[
259 "comment",
260 "line_comment",
261 "block_comment",
262 "string",
263 "string_literal",
264 "raw_string_literal",
265 "template_string",
266 "string_content",
267 "interpreted_string_literal",
268 "heredoc_body",
269 "regex_literal",
270];
271
272fn build_non_code_ranges(path: &std::path::Path, source: &[u8]) -> Vec<(usize, usize)> {
274 let Some(config) = crate::lang_config::language_for_path(path) else {
275 return Vec::new();
276 };
277 let mut parser = tree_sitter::Parser::new();
278 if parser.set_language(&config.language).is_err() {
279 return Vec::new();
280 }
281 let Some(tree) = parser.parse(source, None) else {
282 return Vec::new();
283 };
284 let mut ranges = Vec::new();
285 collect_non_code_ranges(&tree.root_node(), &mut ranges);
286 ranges
287}
288
289fn collect_non_code_ranges(node: &tree_sitter::Node, ranges: &mut Vec<(usize, usize)>) {
290 if NON_CODE_KINDS.contains(&node.kind()) {
291 ranges.push((node.start_byte(), node.end_byte()));
292 return; }
294 let mut cursor = node.walk();
295 for child in node.children(&mut cursor) {
296 collect_non_code_ranges(&child, ranges);
297 }
298}
299
300fn is_in_ranges(ranges: &[(usize, usize)], offset: usize) -> bool {
301 ranges
303 .binary_search_by(|&(start, end)| {
304 if offset < start {
305 std::cmp::Ordering::Greater
306 } else if offset >= end {
307 std::cmp::Ordering::Less
308 } else {
309 std::cmp::Ordering::Equal
310 }
311 })
312 .is_ok()
313}
314
315fn find_shadowing_files(
317 project: &ProjectRoot,
318 declaration_file: &str,
319 symbol_name: &str,
320 _name_path: Option<&str>,
321 all_matches: &[(String, usize, usize)],
322) -> Result<std::collections::HashSet<String>> {
323 let mut shadow_files = std::collections::HashSet::new();
324
325 let files_with_matches: Vec<&str> = all_matches
326 .iter()
327 .map(|(f, _, _)| f.as_str())
328 .filter(|f| *f != declaration_file)
329 .collect();
330
331 if files_with_matches.is_empty() {
332 return Ok(shadow_files);
333 }
334
335 let db_path = crate::db::index_db_path(project.as_path());
337 if let Ok(db) = crate::db::IndexDb::open(&db_path)
338 && let Ok(symbols) = db.symbols_for_files(&files_with_matches)
339 && !symbols.is_empty()
340 {
341 for sym in &symbols {
342 if sym.name == symbol_name && sym.file_path != declaration_file {
343 shadow_files.insert(sym.file_path.clone());
344 }
345 }
346 return Ok(shadow_files);
347 }
348
349 for fp in files_with_matches {
351 if let Ok(symbols) = get_symbols_overview(project, fp, 3)
352 && has_declaration(&symbols, symbol_name)
353 {
354 shadow_files.insert(fp.to_owned());
355 }
356 }
357
358 Ok(shadow_files)
359}
360
361fn has_declaration(symbols: &[SymbolInfo], name: &str) -> bool {
362 symbols
363 .iter()
364 .any(|s| s.name == name || has_declaration(&s.children, name))
365}
366
367fn find_symbol_line_range(
369 project: &ProjectRoot,
370 file_path: &str,
371 symbol_name: &str,
372 name_path: Option<&str>,
373) -> Result<(usize, usize)> {
374 let symbols = get_symbols_overview(project, file_path, 0)?;
375 let flat = flatten_symbol_infos(symbols);
376
377 let candidate = if let Some(np) = name_path {
378 flat.iter().find(|s| s.name_path == np)
379 } else {
380 flat.iter().find(|s| s.name == symbol_name)
381 };
382
383 match candidate {
384 Some(sym) => {
385 let end_line = if let Some(body) = &sym.body {
387 sym.line + body.lines().count()
388 } else {
389 let (_start_byte, end_byte) =
391 crate::symbols::find_symbol_range(project, file_path, symbol_name, name_path)?;
392 let resolved = project.resolve(file_path)?;
393 let source = fs::read_to_string(&resolved)?;
394
395 source[..end_byte].lines().count()
396 };
397 Ok((sym.line, end_line))
398 }
399 None => bail!("symbol '{}' not found in {}", symbol_name, file_path),
400 }
401}
402
403fn flatten_symbol_infos(symbols: Vec<SymbolInfo>) -> Vec<SymbolInfo> {
404 let mut flat = Vec::new();
405 for mut s in symbols {
406 let children = std::mem::take(&mut s.children);
407 flat.push(s);
408 flat.extend(flatten_symbol_infos(children));
409 }
410 flat
411}
412
413pub fn apply_edits(project: &ProjectRoot, edits: &[RenameEdit]) -> Result<()> {
416 let mut by_file: HashMap<String, Vec<&RenameEdit>> = HashMap::new();
418 for edit in edits {
419 by_file
420 .entry(edit.file_path.clone())
421 .or_default()
422 .push(edit);
423 }
424
425 for (file_path, mut file_edits) in by_file {
426 let resolved = project.resolve(&file_path)?;
427 let content = fs::read_to_string(&resolved)?;
428 let mut lines: Vec<String> = content.lines().map(String::from).collect();
429
430 file_edits.sort_by(|a, b| b.line.cmp(&a.line).then(b.column.cmp(&a.column)));
432
433 for edit in &file_edits {
434 let line_idx = edit.line - 1;
435 if line_idx >= lines.len() {
436 continue;
437 }
438 let line = &mut lines[line_idx];
439 let col_idx = edit.column - 1;
440 let old_len = edit.old_text.len();
441 if col_idx + old_len <= line.len() && line[col_idx..col_idx + old_len] == edit.old_text
442 {
443 line.replace_range(col_idx..col_idx + old_len, &edit.new_text);
444 }
445 }
446
447 let mut result = lines.join("\n");
448 if content.ends_with('\n') {
449 result.push('\n');
450 }
451 fs::write(&resolved, &result)?;
452 }
453
454 Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::ProjectRoot;
461 use std::fs;
462
463 fn make_fixture() -> (std::path::PathBuf, ProjectRoot) {
464 let dir = std::env::temp_dir().join(format!(
465 "codelens-rename-fixture-{}",
466 std::time::SystemTime::now()
467 .duration_since(std::time::UNIX_EPOCH)
468 .unwrap()
469 .as_nanos()
470 ));
471 fs::create_dir_all(dir.join("src")).unwrap();
472 fs::write(
473 dir.join("src/service.py"),
474 "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",
475 )
476 .unwrap();
477 fs::write(
478 dir.join("src/main.py"),
479 "from service import UserService\n\nsvc = UserService()\nresult = svc.get_user(1)\n",
480 )
481 .unwrap();
482 fs::write(
483 dir.join("src/other.py"),
484 "class OtherService:\n def get_user(self):\n return None\n",
485 )
486 .unwrap();
487 let project = ProjectRoot::new(&dir).unwrap();
488 (dir, project)
489 }
490
491 #[test]
492 fn validates_identifier() {
493 assert!(validate_identifier("newName").is_ok());
494 assert!(validate_identifier("_private").is_ok());
495 assert!(validate_identifier("123bad").is_err());
496 assert!(validate_identifier("has-dash").is_err());
497 assert!(validate_identifier("").is_err());
498 }
499
500 #[test]
501 fn file_scope_renames_within_symbol_body() {
502 let (_dir, project) = make_fixture();
503 let result = rename_symbol(
504 &project,
505 "src/service.py",
506 "get_user",
507 "fetch_user",
508 Some("UserService/get_user"),
509 RenameScope::File,
510 false,
511 )
512 .unwrap();
513 assert!(result.success);
514 assert!(result.total_replacements >= 1);
515 let content = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
517 assert!(content.contains("fetch_user"));
518 }
521
522 #[test]
523 fn project_scope_renames_across_files() {
524 let (_dir, project) = make_fixture();
525 let result = rename_symbol(
526 &project,
527 "src/service.py",
528 "UserService",
529 "AccountService",
530 None,
531 RenameScope::Project,
532 false,
533 )
534 .unwrap();
535 assert!(result.success);
536 assert!(result.modified_files >= 2); let main_content = fs::read_to_string(project.resolve("src/main.py").unwrap()).unwrap();
538 assert!(main_content.contains("AccountService"));
539 assert!(!main_content.contains("UserService"));
540 }
541
542 #[test]
543 fn project_scope_falls_back_when_symbol_db_is_empty() {
544 let (dir, project) = make_fixture();
545 let db_dir = dir.join(".codelens/index");
546 fs::create_dir_all(&db_dir).unwrap();
547 let _db = crate::db::IndexDb::open(&db_dir.join("symbols.db")).unwrap();
548
549 let result = rename_symbol(
550 &project,
551 "src/service.py",
552 "UserService",
553 "AccountService",
554 None,
555 RenameScope::Project,
556 true,
557 )
558 .unwrap();
559
560 assert!(result.success);
561 assert!(result.modified_files >= 2);
562 assert!(result.total_replacements >= 3);
563 }
564
565 #[test]
566 fn dry_run_does_not_modify_files() {
567 let (_dir, project) = make_fixture();
568 let original = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
569 let result = rename_symbol(
570 &project,
571 "src/service.py",
572 "UserService",
573 "AccountService",
574 None,
575 RenameScope::Project,
576 true,
577 )
578 .unwrap();
579 assert!(result.success);
580 assert!(!result.edits.is_empty());
581 let after = fs::read_to_string(project.resolve("src/service.py").unwrap()).unwrap();
582 assert_eq!(original, after);
583 }
584
585 #[test]
586 fn shadowing_skips_other_declarations() {
587 let (_dir, project) = make_fixture();
588 let result = rename_symbol(
590 &project,
591 "src/service.py",
592 "get_user",
593 "fetch_user",
594 Some("UserService/get_user"),
595 RenameScope::Project,
596 true,
597 )
598 .unwrap();
599 let other_edits: Vec<_> = result
601 .edits
602 .iter()
603 .filter(|e| e.file_path == "src/other.py")
604 .collect();
605 assert!(
606 other_edits.is_empty(),
607 "should skip other.py due to shadowing"
608 );
609 }
610
611 #[test]
612 fn same_name_returns_no_changes() {
613 let (_dir, project) = make_fixture();
614 let result = rename_symbol(
615 &project,
616 "src/service.py",
617 "UserService",
618 "UserService",
619 None,
620 RenameScope::Project,
621 false,
622 )
623 .unwrap();
624 assert!(result.success);
625 assert_eq!(result.total_replacements, 0);
626 }
627
628 #[test]
629 fn column_precise_replacement() {
630 let dir = std::env::temp_dir().join(format!(
631 "codelens-rename-col-{}",
632 std::time::SystemTime::now()
633 .duration_since(std::time::UNIX_EPOCH)
634 .unwrap()
635 .as_nanos()
636 ));
637 fs::create_dir_all(&dir).unwrap();
638 fs::write(dir.join("test.py"), "x = foo + foo\n").unwrap();
640 let project = ProjectRoot::new(&dir).unwrap();
641 let result = rename_symbol(
642 &project,
643 "test.py",
644 "foo",
645 "bar",
646 None,
647 RenameScope::Project,
648 false,
649 )
650 .unwrap();
651 assert!(result.success);
652 let content = fs::read_to_string(project.resolve("test.py").unwrap()).unwrap();
653 assert_eq!(content.trim(), "x = bar + bar");
654 assert_eq!(result.total_replacements, 2);
655 }
656}