1use std::collections::HashSet;
7use std::path::Path;
8
9use normalize_edit::SymbolLocation;
10use normalize_languages::parsers::{grammar_loader, parse_with_grammar};
11use normalize_languages::satisfies_predicates;
12use normalize_languages::support_for_path;
13use tree_sitter::StreamingIterator as _;
14
15use crate::{CallerRef, ImportRef, PlannedEdit, RefactoringContext, References};
16
17pub fn locate_symbol(
21 ctx: &RefactoringContext,
22 file: &Path,
23 content: &str,
24 name: &str,
25) -> Option<SymbolLocation> {
26 ctx.editor.find_symbol(file, content, name, false)
27}
28
29const DECORATION_KINDS: &[&str] = &[
36 "attribute_item", "inner_attribute_item", "meta_item", "attribute", "attribute_list", "decorator", "decorator_list", "annotation", "marker_annotation", "modifiers", "pragma", "preproc_call", ];
49
50fn is_decoration_kind(kind: &str) -> bool {
51 kind.contains("comment") || DECORATION_KINDS.contains(&kind)
52}
53
54const DECORATION_WRAPPER_KINDS: &[&str] = &[
60 "decorated_definition", "export_statement", "export_default_declaration", "ambient_declaration", ];
65
66fn is_decoration_wrapper_kind(kind: &str) -> bool {
67 DECORATION_WRAPPER_KINDS.contains(&kind)
68}
69
70pub fn decoration_extended_start(
81 file: &Path,
82 content: &str,
83 loc: &SymbolLocation,
84 ) -> (usize, Option<String>) {
86 let fallback = loc.start_byte;
87 let Some(support) = support_for_path(file) else {
88 let ext = file
89 .extension()
90 .and_then(|e| e.to_str())
91 .unwrap_or("<unknown>");
92 return (
93 fallback,
94 Some(format!(
95 "No language support for {ext}: doc comments and attributes will not be included with the moved symbol"
96 )),
97 );
98 };
99 let grammar = support.grammar_name();
100 let Some(tree) = parse_with_grammar(grammar, content) else {
101 return (
102 fallback,
103 Some(format!(
104 "Grammar for {grammar} not loaded: doc comments and attributes will not be included. Install grammars with `normalize grammars install`."
105 )),
106 );
107 };
108
109 let root = tree.root_node();
110 let sym_start = loc.start_byte.min(content.len());
118 let Some(mut node) = root.descendant_for_byte_range(sym_start, sym_start) else {
119 return (fallback, None);
120 };
121
122 while let Some(parent) = node.parent() {
127 if parent.start_byte() == node.start_byte() && parent.id() != root.id() {
128 node = parent;
129 } else {
130 break;
131 }
132 }
133
134 let loader = grammar_loader();
137 let decoration_ids: Option<HashSet<usize>> = loader.get_decorations(grammar).and_then(|q| {
138 let compiled = loader.get_compiled_query(grammar, "decorations", &q)?;
139 let mut qcursor = tree_sitter::QueryCursor::new();
140 let mut matches = qcursor.matches(&compiled, root, content.as_bytes());
141 let mut ids = HashSet::new();
142 let source_bytes = content.as_bytes();
143 while let Some(m) = matches.next() {
144 if !satisfies_predicates(&compiled, m, source_bytes) {
145 continue;
146 }
147 for capture in m.captures {
148 ids.insert(capture.node.id());
149 }
150 }
151 Some(ids)
152 });
153
154 let is_decoration = |n: tree_sitter::Node<'_>| -> bool {
155 if let Some(ref ids) = decoration_ids {
156 ids.contains(&n.id())
157 } else {
158 is_decoration_kind(n.kind())
159 }
160 };
161
162 let initial_start = node.start_byte();
170 let mut earliest_start = initial_start;
171 let mut cursor = node;
172 loop {
173 while let Some(prev) = cursor.prev_named_sibling() {
174 if !is_decoration(prev) {
175 return finalize(content, earliest_start, initial_start, fallback);
177 }
178 let gap = &content.as_bytes()[prev.end_byte()..earliest_start];
181 if !gap.iter().all(|b| b.is_ascii_whitespace()) {
182 return finalize(content, earliest_start, initial_start, fallback);
183 }
184 earliest_start = prev.start_byte();
185 cursor = prev;
186 }
187 let Some(parent) = cursor.parent() else { break };
190 if parent.id() == root.id() || !is_decoration_wrapper_kind(parent.kind()) {
191 break;
192 }
193 cursor = parent;
196 }
197 finalize(content, earliest_start, initial_start, fallback)
198}
199
200fn finalize(
201 content: &str,
202 earliest_start: usize,
203 initial_start: usize,
204 fallback: usize,
205 ) -> (usize, Option<String>) {
207 if earliest_start == initial_start {
208 return (fallback, None);
209 }
210 let snapped = content[..earliest_start]
214 .rfind('\n')
215 .map(|i| i + 1)
216 .unwrap_or(0);
217 (snapped, None)
218}
219
220pub async fn find_references(
228 ctx: &RefactoringContext,
229 symbol_name: &str,
230 def_file: &str,
231) -> References {
232 let Some(ref idx) = ctx.index else {
233 return References {
234 callers: vec![],
235 importers: vec![],
236 };
237 };
238
239 let confidence: &'static str = {
243 let def_path = ctx.root.join(def_file);
244 if support_for_path(&def_path)
245 .and_then(|lang| lang.module_resolver())
246 .is_some()
247 {
248 "resolved"
249 } else {
250 "heuristic"
251 }
252 };
253
254 let callers = idx
255 .find_callers(symbol_name, def_file)
256 .await
257 .unwrap_or_default()
258 .into_iter()
259 .map(|(file, caller, line, access)| CallerRef {
260 file,
261 caller,
262 line,
263 access,
264 confidence,
265 })
266 .collect();
267
268 let importers = idx
269 .find_symbol_importers(symbol_name)
270 .await
271 .unwrap_or_default()
272 .into_iter()
273 .map(|(file, name, alias, line)| ImportRef {
274 file,
275 name,
276 alias,
277 line,
278 confidence,
279 })
280 .collect();
281
282 References { callers, importers }
283}
284
285pub async fn check_conflicts(
289 ctx: &RefactoringContext,
290 def_file: &Path,
291 def_content: &str,
292 new_name: &str,
293 importers: &[ImportRef],
294) -> Vec<String> {
295 let mut conflicts = vec![];
296
297 if ctx
299 .editor
300 .find_symbol(def_file, def_content, new_name, false)
301 .is_some()
302 {
303 let rel = def_file
304 .strip_prefix(&ctx.root)
305 .unwrap_or(def_file)
306 .to_string_lossy();
307 conflicts.push(format!("{}: symbol '{}' already exists", rel, new_name));
308 }
309
310 if !importers.is_empty()
312 && let Some(ref idx) = ctx.index
313 {
314 for imp in importers {
315 if idx
316 .has_import_named(&imp.file, new_name)
317 .await
318 .unwrap_or(false)
319 {
320 conflicts.push(format!("{}: already imports '{}'", imp.file, new_name));
321 }
322 }
323 }
324
325 conflicts
326}
327
328pub fn plan_rename_in_file(
335 ctx: &RefactoringContext,
336 file: &Path,
337 content: &str,
338 lines: &[usize],
339 old_name: &str,
340 new_name: &str,
341) -> Option<PlannedEdit> {
342 let mut current = content.to_string();
343 let mut changed = false;
344
345 for &line_no in lines {
346 if let Some(new_content) = ctx
347 .editor
348 .rename_identifier_in_line(¤t, line_no, old_name, new_name)
349 {
350 current = new_content;
351 changed = true;
352 }
353 }
354
355 if changed {
356 Some(PlannedEdit {
357 file: file.to_path_buf(),
358 original: content.to_string(),
359 new_content: current,
360 description: format!("{} -> {}", old_name, new_name),
361 })
362 } else {
363 None
364 }
365}
366
367pub fn plan_delete_symbol(
369 ctx: &RefactoringContext,
370 file: &Path,
371 content: &str,
372 loc: &SymbolLocation,
373) -> PlannedEdit {
374 let new_content = ctx.editor.delete_symbol(content, loc);
375 PlannedEdit {
376 file: file.to_path_buf(),
377 original: content.to_string(),
378 new_content,
379 description: format!("delete {}", loc.name),
380 }
381}
382
383pub fn plan_insert(
385 ctx: &RefactoringContext,
386 file: &Path,
387 content: &str,
388 loc: &SymbolLocation,
389 position: InsertPosition,
390 code: &str,
391) -> PlannedEdit {
392 let new_content = match position {
393 InsertPosition::Before => ctx.editor.insert_before(content, loc, code),
394 InsertPosition::After => ctx.editor.insert_after(content, loc, code),
395 };
396 let pos_str = match position {
397 InsertPosition::Before => "before",
398 InsertPosition::After => "after",
399 };
400 PlannedEdit {
401 file: file.to_path_buf(),
402 original: content.to_string(),
403 new_content,
404 description: format!("insert {} {}", pos_str, loc.name),
405 }
406}
407
408pub fn plan_replace_symbol(
410 ctx: &RefactoringContext,
411 file: &Path,
412 content: &str,
413 loc: &SymbolLocation,
414 new_code: &str,
415) -> PlannedEdit {
416 let new_content = ctx.editor.replace_symbol(content, loc, new_code);
417 PlannedEdit {
418 file: file.to_path_buf(),
419 original: content.to_string(),
420 new_content,
421 description: format!("replace {}", loc.name),
422 }
423}
424
425pub enum InsertPosition {
427 Before,
428 After,
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use normalize_edit::Editor;
435
436 fn make_ctx(root: &Path) -> RefactoringContext {
437 RefactoringContext {
438 root: root.to_path_buf(),
439 editor: Editor::new(),
440 index: None,
441 loader: normalize_languages::GrammarLoader::new(),
442 }
443 }
444
445 #[test]
446 fn plan_rename_single_line() {
447 let dir = tempfile::tempdir().unwrap();
448 let ctx = make_ctx(dir.path());
449 let file = dir.path().join("test.rs");
450 let content = "fn old_func() {}\nfn other() { old_func(); }\n";
451
452 let edit = plan_rename_in_file(&ctx, &file, content, &[1], "old_func", "new_func");
453 assert!(edit.is_some());
454 let edit = edit.unwrap();
455 assert!(edit.new_content.contains("new_func"));
456 assert!(edit.new_content.contains("old_func")); }
458
459 #[test]
460 fn plan_rename_multiple_lines() {
461 let dir = tempfile::tempdir().unwrap();
462 let ctx = make_ctx(dir.path());
463 let file = dir.path().join("test.rs");
464 let content = "fn old_func() {}\nfn other() { old_func(); }\n";
465
466 let edit = plan_rename_in_file(&ctx, &file, content, &[1, 2], "old_func", "new_func");
467 assert!(edit.is_some());
468 let edit = edit.unwrap();
469 assert!(!edit.new_content.contains("old_func"));
470 }
471
472 #[test]
473 fn plan_rename_no_match_returns_none() {
474 let dir = tempfile::tempdir().unwrap();
475 let ctx = make_ctx(dir.path());
476 let file = dir.path().join("test.rs");
477 let content = "fn something() {}\n";
478
479 let edit = plan_rename_in_file(&ctx, &file, content, &[1], "nonexistent", "new_name");
480 assert!(edit.is_none());
481 }
482
483 #[test]
484 fn locate_symbol_found() {
485 let dir = tempfile::tempdir().unwrap();
486 let ctx = make_ctx(dir.path());
487 let file = dir.path().join("test.rs");
488 std::fs::write(&file, "fn my_func() {}\n").unwrap();
489
490 let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "my_func");
491 assert!(loc.is_some());
492 assert_eq!(loc.unwrap().name, "my_func");
493 }
494
495 fn grammar_available(name: &str) -> bool {
499 normalize_languages::parsers::parser_for(name).is_some()
500 }
501
502 #[test]
503 fn decoration_python_decorator_and_comment() {
504 if !grammar_available("python") {
505 eprintln!("skipping: python grammar not available");
506 return;
507 }
508 let content = "\
509import x
510
511# Leading comment line 1.
512# Leading comment line 2.
513@decorator
514@other_decorator
515def my_func():
516 pass
517";
518 let dir = tempfile::tempdir().unwrap();
519 let file = dir.path().join("test.py");
520 let editor = normalize_edit::Editor::new();
521 std::fs::write(&file, content).unwrap();
522 let loc = editor
523 .find_symbol(&file, content, "my_func", false)
524 .expect("locate");
525 let (start, warning) = decoration_extended_start(&file, content, &loc);
526 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
527 let slice = &content[start..];
528 assert!(
529 slice.starts_with("# Leading comment line 1.\n"),
530 "expected leading comments + decorators included; got: {:?}",
531 slice
532 );
533 assert!(slice.contains("@decorator\n"));
534 assert!(slice.contains("@other_decorator\n"));
535 }
536
537 #[test]
538 fn decoration_python_no_decoration_returns_original() {
539 if !grammar_available("python") {
540 eprintln!("skipping: python grammar not available");
541 return;
542 }
543 let content = "def alone():\n pass\n";
544 let dir = tempfile::tempdir().unwrap();
545 let file = dir.path().join("test.py");
546 std::fs::write(&file, content).unwrap();
547 let editor = normalize_edit::Editor::new();
548 let loc = editor
549 .find_symbol(&file, content, "alone", false)
550 .expect("locate");
551 let (start, warning) = decoration_extended_start(&file, content, &loc);
552 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
553 assert_eq!(start, loc.start_byte);
554 }
555
556 #[test]
557 fn decoration_javascript_decorator() {
558 if !grammar_available("javascript") {
559 eprintln!("skipping: javascript grammar not available");
560 return;
561 }
562 let content = "\
563// Leading comment.
564class Wrapper {
565 @log
566 myMethod() {}
567}
568";
569 let dir = tempfile::tempdir().unwrap();
570 let file = dir.path().join("test.js");
571 std::fs::write(&file, content).unwrap();
572 let editor = normalize_edit::Editor::new();
573 let loc = editor
574 .find_symbol(&file, content, "myMethod", false)
575 .expect("locate");
576 let (start, warning) = decoration_extended_start(&file, content, &loc);
577 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
578 let slice = &content[start..];
580 assert!(
581 slice.trim_start().starts_with("@log"),
582 "expected @log decorator included; got: {:?}",
583 slice
584 );
585 }
586
587 #[test]
588 fn decoration_unsupported_language_falls_back() {
589 let content = "anything here";
591 let file = std::path::PathBuf::from("test.unknown_ext_xyz");
592 let loc = SymbolLocation {
593 name: "x".to_string(),
594 kind: "function".to_string(),
595 start_byte: 5,
596 end_byte: 10,
597 start_line: 1,
598 end_line: 1,
599 indent: String::new(),
600 };
601 let (start, warning) = decoration_extended_start(&file, content, &loc);
602 assert_eq!(start, 5);
603 assert!(
604 warning.is_some(),
605 "expected a warning for unsupported language"
606 );
607 assert!(
608 warning.unwrap().contains("unknown_ext_xyz"),
609 "warning should mention the extension"
610 );
611 }
612
613 #[test]
614 fn locate_symbol_not_found() {
615 let dir = tempfile::tempdir().unwrap();
616 let ctx = make_ctx(dir.path());
617 let file = dir.path().join("test.rs");
618 std::fs::write(&file, "fn my_func() {}\n").unwrap();
619
620 let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "nonexistent");
621 assert!(loc.is_none());
622 }
623}