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(
224 ctx: &RefactoringContext,
225 symbol_name: &str,
226 def_file: &str,
227) -> References {
228 let Some(ref idx) = ctx.index else {
229 return References {
230 callers: vec![],
231 importers: vec![],
232 };
233 };
234
235 let callers = idx
236 .find_callers(symbol_name, def_file)
237 .await
238 .unwrap_or_default()
239 .into_iter()
240 .map(|(file, caller, line, access)| CallerRef {
241 file,
242 caller,
243 line,
244 access,
245 })
246 .collect();
247
248 let importers = idx
249 .find_symbol_importers(symbol_name)
250 .await
251 .unwrap_or_default()
252 .into_iter()
253 .map(|(file, name, alias, line)| ImportRef {
254 file,
255 name,
256 alias,
257 line,
258 })
259 .collect();
260
261 References { callers, importers }
262}
263
264pub async fn check_conflicts(
268 ctx: &RefactoringContext,
269 def_file: &Path,
270 def_content: &str,
271 new_name: &str,
272 importers: &[ImportRef],
273) -> Vec<String> {
274 let mut conflicts = vec![];
275
276 if ctx
278 .editor
279 .find_symbol(def_file, def_content, new_name, false)
280 .is_some()
281 {
282 let rel = def_file
283 .strip_prefix(&ctx.root)
284 .unwrap_or(def_file)
285 .to_string_lossy();
286 conflicts.push(format!("{}: symbol '{}' already exists", rel, new_name));
287 }
288
289 if !importers.is_empty()
291 && let Some(ref idx) = ctx.index
292 {
293 for imp in importers {
294 if idx
295 .has_import_named(&imp.file, new_name)
296 .await
297 .unwrap_or(false)
298 {
299 conflicts.push(format!("{}: already imports '{}'", imp.file, new_name));
300 }
301 }
302 }
303
304 conflicts
305}
306
307pub fn plan_rename_in_file(
314 ctx: &RefactoringContext,
315 file: &Path,
316 content: &str,
317 lines: &[usize],
318 old_name: &str,
319 new_name: &str,
320) -> Option<PlannedEdit> {
321 let mut current = content.to_string();
322 let mut changed = false;
323
324 for &line_no in lines {
325 if let Some(new_content) = ctx
326 .editor
327 .rename_identifier_in_line(¤t, line_no, old_name, new_name)
328 {
329 current = new_content;
330 changed = true;
331 }
332 }
333
334 if changed {
335 Some(PlannedEdit {
336 file: file.to_path_buf(),
337 original: content.to_string(),
338 new_content: current,
339 description: format!("{} -> {}", old_name, new_name),
340 })
341 } else {
342 None
343 }
344}
345
346pub fn plan_delete_symbol(
348 ctx: &RefactoringContext,
349 file: &Path,
350 content: &str,
351 loc: &SymbolLocation,
352) -> PlannedEdit {
353 let new_content = ctx.editor.delete_symbol(content, loc);
354 PlannedEdit {
355 file: file.to_path_buf(),
356 original: content.to_string(),
357 new_content,
358 description: format!("delete {}", loc.name),
359 }
360}
361
362pub fn plan_insert(
364 ctx: &RefactoringContext,
365 file: &Path,
366 content: &str,
367 loc: &SymbolLocation,
368 position: InsertPosition,
369 code: &str,
370) -> PlannedEdit {
371 let new_content = match position {
372 InsertPosition::Before => ctx.editor.insert_before(content, loc, code),
373 InsertPosition::After => ctx.editor.insert_after(content, loc, code),
374 };
375 let pos_str = match position {
376 InsertPosition::Before => "before",
377 InsertPosition::After => "after",
378 };
379 PlannedEdit {
380 file: file.to_path_buf(),
381 original: content.to_string(),
382 new_content,
383 description: format!("insert {} {}", pos_str, loc.name),
384 }
385}
386
387pub fn plan_replace_symbol(
389 ctx: &RefactoringContext,
390 file: &Path,
391 content: &str,
392 loc: &SymbolLocation,
393 new_code: &str,
394) -> PlannedEdit {
395 let new_content = ctx.editor.replace_symbol(content, loc, new_code);
396 PlannedEdit {
397 file: file.to_path_buf(),
398 original: content.to_string(),
399 new_content,
400 description: format!("replace {}", loc.name),
401 }
402}
403
404pub enum InsertPosition {
406 Before,
407 After,
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use normalize_edit::Editor;
414
415 fn make_ctx(root: &Path) -> RefactoringContext {
416 RefactoringContext {
417 root: root.to_path_buf(),
418 editor: Editor::new(),
419 index: None,
420 loader: normalize_languages::GrammarLoader::new(),
421 }
422 }
423
424 #[test]
425 fn plan_rename_single_line() {
426 let dir = tempfile::tempdir().unwrap();
427 let ctx = make_ctx(dir.path());
428 let file = dir.path().join("test.rs");
429 let content = "fn old_func() {}\nfn other() { old_func(); }\n";
430
431 let edit = plan_rename_in_file(&ctx, &file, content, &[1], "old_func", "new_func");
432 assert!(edit.is_some());
433 let edit = edit.unwrap();
434 assert!(edit.new_content.contains("new_func"));
435 assert!(edit.new_content.contains("old_func")); }
437
438 #[test]
439 fn plan_rename_multiple_lines() {
440 let dir = tempfile::tempdir().unwrap();
441 let ctx = make_ctx(dir.path());
442 let file = dir.path().join("test.rs");
443 let content = "fn old_func() {}\nfn other() { old_func(); }\n";
444
445 let edit = plan_rename_in_file(&ctx, &file, content, &[1, 2], "old_func", "new_func");
446 assert!(edit.is_some());
447 let edit = edit.unwrap();
448 assert!(!edit.new_content.contains("old_func"));
449 }
450
451 #[test]
452 fn plan_rename_no_match_returns_none() {
453 let dir = tempfile::tempdir().unwrap();
454 let ctx = make_ctx(dir.path());
455 let file = dir.path().join("test.rs");
456 let content = "fn something() {}\n";
457
458 let edit = plan_rename_in_file(&ctx, &file, content, &[1], "nonexistent", "new_name");
459 assert!(edit.is_none());
460 }
461
462 #[test]
463 fn locate_symbol_found() {
464 let dir = tempfile::tempdir().unwrap();
465 let ctx = make_ctx(dir.path());
466 let file = dir.path().join("test.rs");
467 std::fs::write(&file, "fn my_func() {}\n").unwrap();
468
469 let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "my_func");
470 assert!(loc.is_some());
471 assert_eq!(loc.unwrap().name, "my_func");
472 }
473
474 fn grammar_available(name: &str) -> bool {
478 normalize_languages::parsers::parser_for(name).is_some()
479 }
480
481 #[test]
482 fn decoration_python_decorator_and_comment() {
483 if !grammar_available("python") {
484 eprintln!("skipping: python grammar not available");
485 return;
486 }
487 let content = "\
488import x
489
490# Leading comment line 1.
491# Leading comment line 2.
492@decorator
493@other_decorator
494def my_func():
495 pass
496";
497 let dir = tempfile::tempdir().unwrap();
498 let file = dir.path().join("test.py");
499 let editor = normalize_edit::Editor::new();
500 std::fs::write(&file, content).unwrap();
501 let loc = editor
502 .find_symbol(&file, content, "my_func", false)
503 .expect("locate");
504 let (start, warning) = decoration_extended_start(&file, content, &loc);
505 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
506 let slice = &content[start..];
507 assert!(
508 slice.starts_with("# Leading comment line 1.\n"),
509 "expected leading comments + decorators included; got: {:?}",
510 slice
511 );
512 assert!(slice.contains("@decorator\n"));
513 assert!(slice.contains("@other_decorator\n"));
514 }
515
516 #[test]
517 fn decoration_python_no_decoration_returns_original() {
518 if !grammar_available("python") {
519 eprintln!("skipping: python grammar not available");
520 return;
521 }
522 let content = "def alone():\n pass\n";
523 let dir = tempfile::tempdir().unwrap();
524 let file = dir.path().join("test.py");
525 std::fs::write(&file, content).unwrap();
526 let editor = normalize_edit::Editor::new();
527 let loc = editor
528 .find_symbol(&file, content, "alone", false)
529 .expect("locate");
530 let (start, warning) = decoration_extended_start(&file, content, &loc);
531 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
532 assert_eq!(start, loc.start_byte);
533 }
534
535 #[test]
536 fn decoration_javascript_decorator() {
537 if !grammar_available("javascript") {
538 eprintln!("skipping: javascript grammar not available");
539 return;
540 }
541 let content = "\
542// Leading comment.
543class Wrapper {
544 @log
545 myMethod() {}
546}
547";
548 let dir = tempfile::tempdir().unwrap();
549 let file = dir.path().join("test.js");
550 std::fs::write(&file, content).unwrap();
551 let editor = normalize_edit::Editor::new();
552 let loc = editor
553 .find_symbol(&file, content, "myMethod", false)
554 .expect("locate");
555 let (start, warning) = decoration_extended_start(&file, content, &loc);
556 assert!(warning.is_none(), "unexpected warning: {:?}", warning);
557 let slice = &content[start..];
559 assert!(
560 slice.trim_start().starts_with("@log"),
561 "expected @log decorator included; got: {:?}",
562 slice
563 );
564 }
565
566 #[test]
567 fn decoration_unsupported_language_falls_back() {
568 let content = "anything here";
570 let file = std::path::PathBuf::from("test.unknown_ext_xyz");
571 let loc = SymbolLocation {
572 name: "x".to_string(),
573 kind: "function".to_string(),
574 start_byte: 5,
575 end_byte: 10,
576 start_line: 1,
577 end_line: 1,
578 indent: String::new(),
579 };
580 let (start, warning) = decoration_extended_start(&file, content, &loc);
581 assert_eq!(start, 5);
582 assert!(
583 warning.is_some(),
584 "expected a warning for unsupported language"
585 );
586 assert!(
587 warning.unwrap().contains("unknown_ext_xyz"),
588 "warning should mention the extension"
589 );
590 }
591
592 #[test]
593 fn locate_symbol_not_found() {
594 let dir = tempfile::tempdir().unwrap();
595 let ctx = make_ctx(dir.path());
596 let file = dir.path().join("test.rs");
597 std::fs::write(&file, "fn my_func() {}\n").unwrap();
598
599 let loc = locate_symbol(&ctx, &file, "fn my_func() {}\n", "nonexistent");
600 assert!(loc.is_none());
601 }
602}