1use crate::lang::Lang;
24use anyhow::Result;
25use tree_sitter::{Node, Parser};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum RenameTarget {
37 Callable,
39 Signal,
42 Type,
44 Value,
46 #[default]
48 Unresolved,
49}
50
51impl RenameTarget {
52 pub fn from_indexed_kind(kind: &str) -> Self {
59 match kind {
60 "function" | "method" => Self::Callable,
61 "signal" => Self::Signal,
62 "struct" | "enum" | "enum_class" | "trait" | "class" | "data_class"
63 | "sealed_class" | "interface" | "type_alias" | "union" | "object"
64 | "companion_object" | "impl" | "record" | "delegate" => Self::Type,
65 "const" | "static" | "variable" | "property" | "enum_member" => Self::Value,
66 _ => Self::Unresolved,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct IdentifierOccurrence {
74 pub start_byte: usize,
75 pub end_byte: usize,
76 pub expands_shorthand_key: bool,
81}
82
83pub fn identifier_node_kinds(lang: Lang) -> &'static [&'static str] {
89 match lang {
90 #[cfg(feature = "lang-rust")]
94 Lang::Rust => &[
95 "identifier",
96 "type_identifier",
97 "field_identifier",
98 "shorthand_field_identifier",
99 ],
100 #[cfg(feature = "lang-python")]
101 Lang::Python => &["identifier"],
102 #[cfg(feature = "lang-typescript")]
103 Lang::TypeScript | Lang::Tsx => &[
104 "identifier",
105 "type_identifier",
106 "property_identifier",
107 "shorthand_property_identifier",
108 "shorthand_property_identifier_pattern",
109 ],
110 #[cfg(feature = "lang-javascript")]
111 Lang::JavaScript | Lang::Jsx => &[
112 "identifier",
113 "property_identifier",
114 "shorthand_property_identifier",
115 "shorthand_property_identifier_pattern",
116 ],
117 #[cfg(feature = "lang-kotlin")]
118 Lang::Kotlin => &["identifier"],
119 #[cfg(feature = "lang-zig")]
120 Lang::Zig => &["identifier"],
121 #[cfg(feature = "lang-bash")]
126 Lang::Bash => &["word", "variable_name"],
127 #[cfg(feature = "lang-go")]
132 Lang::Go => &["identifier", "type_identifier", "field_identifier"],
133 #[cfg(feature = "lang-csharp")]
134 Lang::CSharp => &["identifier"],
135 #[cfg(feature = "lang-gdscript")]
138 Lang::GdScript => &["identifier", "name"],
139 #[cfg(feature = "lang-markdown")]
141 Lang::Markdown => &[],
142 }
143}
144
145fn occurrence_is_renamable(lang: Lang, node: Node) -> bool {
155 match lang {
156 #[cfg(feature = "lang-bash")]
157 Lang::Bash => {
158 if node.kind() != "word" {
159 return true;
162 }
163 node.parent().is_some_and(|parent| {
164 matches!(parent.kind(), "function_definition" | "command_name")
165 })
166 }
167 _ => {
168 let _ = node;
169 true
170 }
171 }
172}
173
174#[allow(unused_variables)]
181fn occurrence_matches_target(
182 lang: Lang,
183 node: Node,
184 source: &[u8],
185 target: RenameTarget,
186) -> bool {
187 if target == RenameTarget::Unresolved {
188 return true;
189 }
190 match lang {
191 #[cfg(feature = "lang-rust")]
192 Lang::Rust => rust_occurrence_matches_target(node, target),
193 #[cfg(feature = "lang-python")]
194 Lang::Python => python_occurrence_matches_target(node, source, target),
195 #[cfg(feature = "lang-gdscript")]
196 Lang::GdScript => gdscript_occurrence_matches_target(node, target),
197 #[cfg(feature = "lang-typescript")]
198 Lang::TypeScript | Lang::Tsx => js_like_occurrence_matches_target(node, target),
199 #[cfg(feature = "lang-javascript")]
200 Lang::JavaScript | Lang::Jsx => js_like_occurrence_matches_target(node, target),
201 #[cfg(feature = "lang-kotlin")]
202 Lang::Kotlin => kotlin_occurrence_matches_target(node, source, target),
203 #[cfg(feature = "lang-zig")]
204 Lang::Zig => zig_occurrence_matches_target(node, source, target),
205 #[cfg(feature = "lang-go")]
206 Lang::Go => go_occurrence_matches_target(node, source, target),
207 #[cfg(feature = "lang-csharp")]
208 Lang::CSharp => csharp_occurrence_matches_target(node, target),
209 _ => {
210 let _ = node;
211 true
212 }
213 }
214}
215
216#[cfg(feature = "lang-csharp")]
217fn csharp_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
218 let Some(parent) = node.parent() else {
219 return true;
220 };
221
222 let declaration_target = match parent.kind() {
223 "method_declaration" | "local_function_statement" => Some(RenameTarget::Callable),
224 "class_declaration"
225 | "struct_declaration"
226 | "interface_declaration"
227 | "enum_declaration"
228 | "record_declaration"
229 | "delegate_declaration" => Some(RenameTarget::Type),
230 "property_declaration" | "enum_member_declaration" => Some(RenameTarget::Value),
231 _ => None,
232 };
233 if let Some(declaration_target) = declaration_target {
234 return target == declaration_target;
235 }
236
237 if parent.kind() == "invocation_expression"
238 && parent
239 .child_by_field_name("function")
240 .is_some_and(|function| function.id() == node.id())
241 {
242 return target == RenameTarget::Callable;
243 }
244
245 if parent.kind() == "member_access_expression"
246 && parent
247 .child_by_field_name("name")
248 .is_some_and(|name| name.id() == node.id())
249 {
250 let is_call = parent.parent().is_some_and(|call| {
251 call.kind() == "invocation_expression"
252 && call
253 .child_by_field_name("function")
254 .is_some_and(|function| function.id() == parent.id())
255 });
256 return match target {
257 RenameTarget::Callable => is_call,
258 RenameTarget::Value => !is_call,
259 RenameTarget::Type | RenameTarget::Signal | RenameTarget::Unresolved => true,
260 };
261 }
262
263 true
264}
265
266#[cfg(feature = "lang-go")]
275fn go_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
276 let Some(parent) = node.parent() else {
277 return true;
278 };
279 match parent.kind() {
280 "field_declaration" => !parent
284 .children_by_field_name("name", &mut parent.walk())
285 .any(|name| name.id() == node.id()),
286 "selector_expression" => {
287 if parent
288 .child_by_field_name("field")
289 .is_none_or(|field| field.id() != node.id())
290 {
291 return true;
292 }
293 if go_receiver_is_imported_package(parent, source) {
294 return true;
295 }
296 target == RenameTarget::Callable
297 && parent.parent().is_some_and(|call| {
298 call.kind() == "call_expression"
299 && call
300 .child_by_field_name("function")
301 .is_some_and(|function| function.id() == parent.id())
302 })
303 }
304 _ => true,
305 }
306}
307
308#[cfg(feature = "lang-go")]
316fn go_receiver_is_imported_package(selector: Node, source: &[u8]) -> bool {
317 let Some(mut operand) = selector.child_by_field_name("operand") else {
318 return false;
319 };
320 while operand.kind() == "selector_expression" {
321 let Some(inner) = operand.child_by_field_name("operand") else {
322 return false;
323 };
324 operand = inner;
325 }
326 if operand.kind() != "identifier" && operand.kind() != "package_identifier" {
327 return false;
328 }
329 let Ok(name) = operand.utf8_text(source) else {
330 return false;
331 };
332 go_file_imports_package(selector, name, source)
333}
334
335#[cfg(feature = "lang-go")]
336fn go_file_imports_package(node: Node, name: &str, source: &[u8]) -> bool {
337 let mut root = node;
338 while let Some(parent) = root.parent() {
339 root = parent;
340 }
341 let mut found = false;
342 go_walk_import_specs(root, source, &mut |bound| {
343 if bound == name {
344 found = true;
345 }
346 });
347 found
348}
349
350#[cfg(feature = "lang-go")]
352fn go_walk_import_specs(node: Node, source: &[u8], visit: &mut impl FnMut(&str)) {
353 if node.kind() == "import_spec" {
354 if let Some(alias) = node.child_by_field_name("name")
355 && let Ok(text) = alias.utf8_text(source)
356 {
357 visit(text);
358 return;
359 }
360 if let Some(path) = node.child_by_field_name("path")
361 && let Ok(text) = path.utf8_text(source)
362 {
363 let trimmed = text.trim_matches('"');
364 if let Some(last) = trimmed.rsplit('/').next() {
365 visit(last);
366 }
367 }
368 return;
369 }
370 let mut cursor = node.walk();
371 for child in node.children(&mut cursor) {
372 go_walk_import_specs(child, source, visit);
373 }
374}
375
376#[cfg(feature = "lang-python")]
384fn python_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
385 let Some(attribute) = node.parent().filter(|parent| parent.kind() == "attribute") else {
386 return true;
387 };
388 if attribute
389 .child_by_field_name("attribute")
390 .is_none_or(|name| name.id() != node.id())
391 {
392 return true;
393 }
394 if python_receiver_is_imported_module(attribute, source) {
395 return true;
396 }
397
398 target == RenameTarget::Callable
399 && attribute.parent().is_some_and(|call| {
400 call.kind() == "call"
401 && call
402 .child_by_field_name("function")
403 .is_some_and(|function| function.id() == attribute.id())
404 })
405}
406
407#[cfg(feature = "lang-python")]
418fn python_receiver_is_imported_module(attribute: Node, source: &[u8]) -> bool {
419 let Some(mut object) = attribute.child_by_field_name("object") else {
420 return false;
421 };
422 while object.kind() == "attribute" {
423 let Some(inner) = object.child_by_field_name("object") else {
424 return false;
425 };
426 object = inner;
427 }
428 if object.kind() != "identifier" {
429 return false;
430 }
431 let Ok(name) = object.utf8_text(source) else {
432 return false;
433 };
434 python_file_imports_module(attribute, name, source)
435}
436
437#[cfg(feature = "lang-python")]
439fn python_file_imports_module(node: Node, name: &str, source: &[u8]) -> bool {
440 let mut root = node;
441 while let Some(parent) = root.parent() {
442 root = parent;
443 }
444 let mut cursor = root.walk();
445 let mut descend = true;
446 loop {
447 if descend {
448 let current = cursor.node();
449 if current.kind() == "import_statement"
450 && python_import_binds(current, name, source)
451 {
452 return true;
453 }
454 if cursor.goto_first_child() {
455 continue;
456 }
457 }
458 if cursor.goto_next_sibling() {
459 descend = true;
460 continue;
461 }
462 if !cursor.goto_parent() {
463 return false;
464 }
465 descend = false;
466 }
467}
468
469#[cfg(feature = "lang-python")]
473fn python_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
474 let mut cursor = import.walk();
475 import.named_children(&mut cursor).any(|clause| {
476 let bound = match clause.kind() {
477 "aliased_import" => clause.child_by_field_name("alias"),
478 "dotted_name" => clause.named_child(0),
479 _ => None,
480 };
481 bound.is_some_and(|bound| bound.utf8_text(source).is_ok_and(|text| text == name))
482 })
483}
484
485#[cfg(feature = "lang-kotlin")]
493fn kotlin_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
494 let Some(navigation) = node
495 .parent()
496 .filter(|parent| parent.kind() == "navigation_expression")
497 else {
498 return true;
499 };
500 if node.prev_named_sibling().is_none() {
501 return true;
502 }
503 if kotlin_receiver_is_namespace(navigation, source) {
504 return true;
505 }
506
507 target == RenameTarget::Callable
508 && navigation.parent().is_some_and(|call| {
509 call.kind() == "call_expression"
510 && call
511 .named_child(0)
512 .is_some_and(|function| function.id() == navigation.id())
513 })
514}
515
516#[cfg(feature = "lang-kotlin")]
519fn kotlin_receiver_is_namespace(navigation: Node, source: &[u8]) -> bool {
520 let mut receiver = navigation;
521 while receiver.kind() == "navigation_expression" {
522 let Some(inner) = receiver.named_child(0) else {
523 return false;
524 };
525 receiver = inner;
526 }
527 if receiver.kind() != "identifier" {
528 return false;
529 }
530 let Ok(name) = receiver.utf8_text(source) else {
531 return false;
532 };
533 kotlin_file_declares_type(navigation, name, source)
534 || kotlin_file_imports_name(navigation, name, source)
535}
536
537#[cfg(feature = "lang-kotlin")]
540fn kotlin_file_declares_type(node: Node, name: &str, source: &[u8]) -> bool {
541 let mut root = node;
542 while let Some(parent) = root.parent() {
543 root = parent;
544 }
545 let mut cursor = root.walk();
546 let mut descend = true;
547 loop {
548 if descend {
549 let current = cursor.node();
550 if matches!(
551 current.kind(),
552 "class_declaration" | "object_declaration" | "interface_declaration"
553 ) && current
554 .child_by_field_name("name")
555 .and_then(|declared| declared.utf8_text(source).ok())
556 == Some(name)
557 {
558 return true;
559 }
560 if cursor.goto_first_child() {
561 continue;
562 }
563 }
564 if cursor.goto_next_sibling() {
565 descend = true;
566 continue;
567 }
568 if !cursor.goto_parent() {
569 return false;
570 }
571 descend = false;
572 }
573}
574
575#[cfg(feature = "lang-kotlin")]
581fn kotlin_file_imports_name(node: Node, name: &str, source: &[u8]) -> bool {
582 let mut root = node;
583 while let Some(parent) = root.parent() {
584 root = parent;
585 }
586 let mut cursor = root.walk();
587 let mut descend = true;
588 loop {
589 if descend {
590 let current = cursor.node();
591 if current.kind() == "import" && kotlin_import_binds(current, name, source) {
592 return true;
593 }
594 if cursor.goto_first_child() {
595 continue;
596 }
597 }
598 if cursor.goto_next_sibling() {
599 descend = true;
600 continue;
601 }
602 if !cursor.goto_parent() {
603 return false;
604 }
605 descend = false;
606 }
607}
608
609#[cfg(feature = "lang-kotlin")]
610fn kotlin_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
611 let mut cursor = import.walk();
612 let children = import.named_children(&mut cursor).collect::<Vec<_>>();
613 if let Some(alias) = children
614 .get(1)
615 .filter(|child| child.kind() == "identifier")
616 {
617 return alias
618 .utf8_text(source)
619 .is_ok_and(|bound_name| bound_name == name);
620 }
621 children
622 .first()
623 .filter(|path| matches!(path.kind(), "identifier" | "qualified_identifier"))
624 .and_then(|path| path.utf8_text(source).ok())
625 .and_then(|path| path.rsplit('.').next())
626 .is_some_and(|bound_name| bound_name == name)
627}
628
629#[cfg(feature = "lang-zig")]
643fn zig_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
644 let Some(parent) = node.parent() else {
645 return true;
646 };
647 match parent.kind() {
648 "container_field" => parent
652 .child_by_field_name("name")
653 .is_none_or(|name| name.id() != node.id()),
654 "field_expression" => {
655 if parent
656 .child_by_field_name("member")
657 .is_none_or(|member| member.id() != node.id())
658 {
659 return true;
660 }
661 if zig_receiver_is_namespace(parent, source) {
662 return true;
663 }
664 target == RenameTarget::Callable
665 && parent.parent().is_some_and(|call| {
666 call.kind() == "call_expression"
667 && call
668 .child_by_field_name("function")
669 .is_some_and(|function| function.id() == parent.id())
670 })
671 }
672 _ => true,
673 }
674}
675
676#[cfg(feature = "lang-zig")]
690fn zig_receiver_is_namespace(field_expression: Node, source: &[u8]) -> bool {
691 let Some(mut object) = field_expression.child_by_field_name("object") else {
692 return false;
693 };
694 while object.kind() == "field_expression" {
695 let Some(inner) = object.child_by_field_name("object") else {
696 return false;
697 };
698 object = inner;
699 }
700 match object.kind() {
701 "builtin_function" => zig_is_import_builtin(object, source),
702 "identifier" => object
703 .utf8_text(source)
704 .is_ok_and(|name| zig_file_binds_namespace(field_expression, name, source)),
705 _ => false,
706 }
707}
708
709#[cfg(feature = "lang-zig")]
711fn zig_is_import_builtin(builtin: Node, source: &[u8]) -> bool {
712 let mut cursor = builtin.walk();
713 builtin.named_children(&mut cursor).any(|child| {
714 child.kind() == "builtin_identifier"
715 && child.utf8_text(source).is_ok_and(|text| text == "@import")
716 })
717}
718
719#[cfg(feature = "lang-zig")]
727fn zig_file_binds_namespace(node: Node, name: &str, source: &[u8]) -> bool {
728 let mut root = node;
729 while let Some(parent) = root.parent() {
730 root = parent;
731 }
732 let mut cursor = root.walk();
733 let mut descend = true;
734 loop {
735 if descend {
736 let current = cursor.node();
737 if current.kind() == "variable_declaration"
738 && zig_declaration_binds_namespace(current, name, source)
739 {
740 return true;
741 }
742 if cursor.goto_first_child() {
743 continue;
744 }
745 }
746 if cursor.goto_next_sibling() {
747 descend = true;
748 continue;
749 }
750 if !cursor.goto_parent() {
751 return false;
752 }
753 descend = false;
754 }
755}
756
757#[cfg(feature = "lang-zig")]
759fn zig_declaration_binds_namespace(declaration: Node, name: &str, source: &[u8]) -> bool {
760 let mut cursor = declaration.walk();
761 let children: Vec<Node> = declaration.named_children(&mut cursor).collect();
762 let binds_name = children.iter().any(|child| {
763 child.kind() == "identifier" && child.utf8_text(source).is_ok_and(|text| text == name)
764 });
765 if !binds_name {
766 return false;
767 }
768 children.iter().any(|child| match child.kind() {
769 "builtin_function" => zig_is_import_builtin(*child, source),
770 "struct_declaration" | "enum_declaration" | "union_declaration"
773 | "opaque_declaration" => true,
774 _ => false,
775 })
776}
777
778#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
785fn js_like_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
786 match node.kind() {
787 "property_identifier" => false,
788 "type_identifier" => target == RenameTarget::Type,
789 _ => true,
790 }
791}
792
793#[allow(unused_variables)]
801fn occurrence_expands_shorthand_key(lang: Lang, node: Node, target: RenameTarget) -> bool {
802 if target == RenameTarget::Unresolved {
803 return false;
804 }
805 match lang {
806 #[cfg(feature = "lang-typescript")]
807 Lang::TypeScript | Lang::Tsx => js_like_shorthand_key(node),
808 #[cfg(feature = "lang-javascript")]
809 Lang::JavaScript | Lang::Jsx => js_like_shorthand_key(node),
810 _ => false,
811 }
812}
813
814#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
822fn js_like_shorthand_key(node: Node) -> bool {
823 node.kind() == "shorthand_property_identifier"
824 && node.parent().is_some_and(|parent| parent.kind() == "object")
825}
826
827#[cfg(feature = "lang-rust")]
832fn rust_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
833 let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
834 match node.kind() {
835 "field_identifier" => {
836 target == RenameTarget::Callable && parent_kind == "field_expression" && {
841 node.parent()
842 .and_then(|field_expression| {
843 let call = field_expression.parent()?;
844 (call.kind() == "call_expression"
845 && call.child_by_field_name("function")?.id() == field_expression.id())
846 .then_some(())
847 })
848 .is_some()
849 }
850 }
851 "shorthand_field_identifier" => target == RenameTarget::Value,
852 "identifier" if parent_kind == "shorthand_field_initializer" => {
857 matches!(target, RenameTarget::Value)
858 }
859 "type_identifier" => target == RenameTarget::Type,
860 _ => true,
861 }
862}
863
864#[cfg(feature = "lang-gdscript")]
869fn gdscript_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
870 let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
871 match node.kind() {
872 "name" => {
873 let declares: &[&str] = match target {
874 RenameTarget::Callable => &["function_definition"],
875 RenameTarget::Signal => &["signal_statement"],
876 RenameTarget::Type => &["class_definition", "class_name_statement", "enum_definition"],
877 RenameTarget::Value => &[
878 "variable_statement",
879 "const_statement",
880 "export_variable_statement",
881 "onready_variable_statement",
882 ],
883 RenameTarget::Unresolved => return true,
884 };
885 declares.contains(&parent_kind)
886 }
887 "identifier" if parent_kind == "parameters" => false,
890 _ => true,
891 }
892}
893
894pub fn identifier_occurrences(
900 lang: Lang,
901 source: &[u8],
902 name: &str,
903) -> Result<Vec<IdentifierOccurrence>> {
904 identifier_occurrences_for(lang, source, name, RenameTarget::Unresolved)
905}
906
907pub fn identifier_occurrences_for(
909 lang: Lang,
910 source: &[u8],
911 name: &str,
912 target: RenameTarget,
913) -> Result<Vec<IdentifierOccurrence>> {
914 let kinds = identifier_node_kinds(lang);
915 if kinds.is_empty() || name.is_empty() {
916 return Ok(Vec::new());
917 }
918
919 let ts_lang = lang.tree_sitter_language();
920 let mut parser = Parser::new();
921 parser.set_language(&ts_lang)?;
922 let tree = parser
923 .parse(source, None)
924 .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
925
926 let mut occurrences = Vec::new();
927 let mut shadowing_declaration_line: Option<usize> = None;
930 let mut saw_ambiguous_reference = false;
932 let mut cursor = tree.walk();
933 let mut descend = true;
934 loop {
935 if descend {
936 let node = cursor.node();
937 if kinds.contains(&node.kind())
938 && node.utf8_text(source).is_ok_and(|it| it == name)
939 && occurrence_is_renamable(lang, node)
940 {
941 if occurrence_matches_target(lang, node, source, target) {
942 occurrences.push(IdentifierOccurrence {
943 start_byte: node.start_byte(),
944 end_byte: node.end_byte(),
945 expands_shorthand_key: occurrence_expands_shorthand_key(
946 lang, node, target,
947 ),
948 });
949 saw_ambiguous_reference |= occurrence_is_ambiguous_reference(lang, node, target);
950 } else if shadowing_declaration_line.is_none()
951 && occurrence_shadows_target(lang, node, target)
952 {
953 shadowing_declaration_line = Some(node.start_position().row + 1);
954 }
955 }
956 if cursor.goto_first_child() {
957 continue;
958 }
959 }
960 if cursor.goto_next_sibling() {
961 descend = true;
962 continue;
963 }
964 if !cursor.goto_parent() {
965 break;
966 }
967 descend = false;
968 }
969
970 occurrences.sort_by_key(|occurrence| (occurrence.start_byte, occurrence.end_byte));
974 occurrences.dedup();
975
976 if let Some(line) = shadowing_declaration_line
983 && saw_ambiguous_reference
984 {
985 anyhow::bail!(
986 "rename_symbol refuses {name:?}: a same-named declaration on line {line} shadows it, and a bare reference cannot say which one it belongs to"
987 );
988 }
989 Ok(occurrences)
990}
991
992fn occurrence_shadows_target(lang: Lang, node: Node, target: RenameTarget) -> bool {
999 match lang {
1000 #[cfg(feature = "lang-gdscript")]
1001 Lang::GdScript => {
1002 if target != RenameTarget::Callable {
1003 return false;
1004 }
1005 let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
1006 match node.kind() {
1007 "name" => matches!(
1008 parent_kind,
1009 "variable_statement"
1010 | "const_statement"
1011 | "export_variable_statement"
1012 | "onready_variable_statement"
1013 ),
1014 "identifier" => parent_kind == "parameters",
1015 _ => false,
1016 }
1017 }
1018 _ => {
1019 let _ = (node, target);
1020 false
1021 }
1022 }
1023}
1024
1025fn occurrence_is_ambiguous_reference(lang: Lang, node: Node, target: RenameTarget) -> bool {
1030 match lang {
1031 #[cfg(feature = "lang-gdscript")]
1032 Lang::GdScript => {
1033 if target != RenameTarget::Callable || node.kind() != "identifier" {
1034 return false;
1035 }
1036 let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
1037 !matches!(parent_kind, "call" | "attribute_call" | "base_call")
1038 }
1039 _ => {
1040 let _ = (node, target);
1041 false
1042 }
1043 }
1044}
1045
1046pub fn replace_occurrences(
1049 source: &str,
1050 occurrences: &[IdentifierOccurrence],
1051 replacement: &str,
1052) -> (String, usize) {
1053 let mut out = String::with_capacity(source.len());
1054 let mut last = 0usize;
1055 let mut replaced = 0usize;
1056 for occurrence in occurrences {
1057 if occurrence.start_byte < last {
1058 continue;
1060 }
1061 out.push_str(&source[last..occurrence.start_byte]);
1062 if occurrence.expands_shorthand_key {
1063 out.push_str(&source[occurrence.start_byte..occurrence.end_byte]);
1066 out.push_str(": ");
1067 }
1068 out.push_str(replacement);
1069 last = occurrence.end_byte;
1070 replaced += 1;
1071 }
1072 out.push_str(&source[last..]);
1073 (out, replaced)
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[cfg(feature = "lang-csharp")]
1081 #[test]
1082 fn csharp_callable_rename_skips_properties_strings_and_comments() {
1083 let source = r#"class Counter {
1084 static int WidgetCount() => 3;
1085 static int Caller() => WidgetCount();
1086 // WidgetCount stays prose.
1087 static string Label = "WidgetCount";
1088}
1089class Data {
1090 public int WidgetCount { get; set; }
1091 public int Read() => this.WidgetCount;
1092}
1093"#;
1094 let found = identifier_occurrences_for(
1095 Lang::CSharp,
1096 source.as_bytes(),
1097 "WidgetCount",
1098 RenameTarget::Callable,
1099 )
1100 .unwrap();
1101 let (out, replaced) = replace_occurrences(source, &found, "GadgetCount");
1102
1103 assert_eq!(replaced, 2, "got {found:?}\n{out}");
1104 assert!(out.contains("static int GadgetCount()"), "{out}");
1105 assert!(out.contains("=> GadgetCount();"), "{out}");
1106 assert!(out.contains("int WidgetCount { get; set; }"), "{out}");
1107 assert!(out.contains("this.WidgetCount"), "{out}");
1108 assert!(out.contains("// WidgetCount stays prose."), "{out}");
1109 assert!(out.contains("\"WidgetCount\""), "{out}");
1110 }
1111
1112 #[cfg(feature = "lang-rust")]
1113 const RUST_SOURCE: &str = r#"/// doc widget_count
1114fn widget_count() -> usize { 3 }
1115
1116fn describe() -> String {
1117 // widget_count comment
1118 let label = "widget_count";
1119 format!("{label}: {}", widget_count())
1120}
1121"#;
1122
1123 #[cfg(feature = "lang-rust")]
1124 #[test]
1125 fn rust_skips_strings_and_comments_but_reaches_macro_arguments() {
1126 let found =
1127 identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
1128 assert_eq!(
1131 found.len(),
1132 2,
1133 "expected the definition and the macro-argument call, got {found:?}"
1134 );
1135 for occurrence in &found {
1136 let before = &RUST_SOURCE[..occurrence.start_byte];
1137 assert!(
1138 !before.ends_with("/// doc ") && !before.ends_with("// "),
1139 "occurrence at {} is inside a comment",
1140 occurrence.start_byte
1141 );
1142 assert!(
1143 !before.ends_with('"'),
1144 "occurrence at {} is inside a string literal",
1145 occurrence.start_byte
1146 );
1147 }
1148 }
1149
1150 #[cfg(feature = "lang-rust")]
1151 #[test]
1152 fn replacing_rust_occurrences_leaves_prose_and_data_alone() {
1153 let found =
1154 identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
1155 let (out, replaced) = replace_occurrences(RUST_SOURCE, &found, "gadget_count");
1156 assert_eq!(replaced, 2);
1157 assert!(out.contains("fn gadget_count()"), "definition not renamed");
1158 assert!(
1159 out.contains("gadget_count())"),
1160 "macro-argument call not renamed"
1161 );
1162 assert!(
1163 out.contains("/// doc widget_count"),
1164 "doc comment was renamed"
1165 );
1166 assert!(
1167 out.contains("// widget_count comment"),
1168 "line comment was renamed"
1169 );
1170 assert!(
1171 out.contains("\"widget_count\""),
1172 "string literal was renamed"
1173 );
1174 }
1175
1176 #[cfg(feature = "lang-python")]
1177 #[test]
1178 fn python_skips_strings_and_comments() {
1179 let source = "def widget_count():\n # widget_count comment\n return \"widget_count\"\n\nwidget_count()\n";
1180 let found = identifier_occurrences(Lang::Python, source.as_bytes(), "widget_count").unwrap();
1181 assert_eq!(found.len(), 2, "got {found:?}");
1182 let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1183 assert_eq!(replaced, 2);
1184 assert!(out.contains("def gadget_count()"));
1185 assert!(out.contains("gadget_count()\n"));
1186 assert!(out.contains("# widget_count comment"));
1187 assert!(out.contains("\"widget_count\""));
1188 }
1189
1190 #[cfg(feature = "lang-python")]
1191 #[test]
1192 fn python_callable_narrowing_keeps_method_calls_but_skips_attribute_reads() {
1193 let source = "def widget_count():\n return 1\n\nclass Panel:\n def widget_count(self):\n return 2\n\nread = panel.widget_count\ncalled = panel.widget_count()\ndirect = widget_count()\n";
1194 let found = identifier_occurrences_for(
1195 Lang::Python,
1196 source.as_bytes(),
1197 "widget_count",
1198 RenameTarget::Callable,
1199 )
1200 .unwrap();
1201 let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1202
1203 assert_eq!(replaced, 4, "got {found:?}\n{out}");
1204 assert!(out.contains("def gadget_count():"));
1205 assert!(out.contains("def gadget_count(self):"));
1206 assert!(out.contains("called = panel.gadget_count()"));
1207 assert!(out.contains("direct = gadget_count()"));
1208 assert!(out.contains("read = panel.widget_count\n"));
1209 }
1210
1211 #[cfg(feature = "lang-python")]
1215 #[test]
1216 fn python_narrowing_keeps_imported_module_attributes_including_bare_reads() {
1217 let source = "import mod\nimport pkg.deep as aliased\n\ndef widget_count():\n return 1\n\nread = panel.widget_count\nmodule_read = mod.widget_count\nmodule_call = mod.widget_count()\naliased_read = aliased.widget_count\n";
1218 let found = identifier_occurrences_for(
1219 Lang::Python,
1220 source.as_bytes(),
1221 "widget_count",
1222 RenameTarget::Callable,
1223 )
1224 .unwrap();
1225 let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1226
1227 assert_eq!(replaced, 4, "got {found:?}\n{out}");
1228 assert!(out.contains("def gadget_count():"), "{out}");
1229 assert!(
1230 out.contains("module_read = mod.gadget_count\n"),
1231 "an imported-module read was dropped:\n{out}"
1232 );
1233 assert!(out.contains("module_call = mod.gadget_count()"), "{out}");
1234 assert!(
1235 out.contains("aliased_read = aliased.gadget_count"),
1236 "an aliased-import read was dropped:\n{out}"
1237 );
1238 assert!(
1239 out.contains("read = panel.widget_count\n"),
1240 "an instance attribute read was renamed:\n{out}"
1241 );
1242 }
1243
1244 #[cfg(feature = "lang-kotlin")]
1245 #[test]
1246 fn kotlin_callable_narrowing_keeps_method_calls_but_skips_navigation_reads() {
1247 let source = "fun widgetCount(): Int = 1\n\nclass Panel {\n fun widgetCount(): Int = 2\n}\n\nval read = panel.widgetCount\nval called = panel.widgetCount()\nval direct = widgetCount()\n";
1248 let found = identifier_occurrences_for(
1249 Lang::Kotlin,
1250 source.as_bytes(),
1251 "widgetCount",
1252 RenameTarget::Callable,
1253 )
1254 .unwrap();
1255 let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1256
1257 assert_eq!(replaced, 4, "got {found:?}\n{out}");
1258 assert!(out.contains("fun gadgetCount(): Int = 1"));
1259 assert!(out.contains("fun gadgetCount(): Int = 2"));
1260 assert!(out.contains("val called = panel.gadgetCount()"));
1261 assert!(out.contains("val direct = gadgetCount()"));
1262 assert!(out.contains("val read = panel.widgetCount\n"));
1263 }
1264
1265 #[cfg(feature = "lang-kotlin")]
1269 #[test]
1270 fn kotlin_narrowing_keeps_members_of_types_declared_in_the_file() {
1271 let source = "class Panel {\n companion object {\n fun widgetCount(): Int = 2\n }\n}\n\nobject Registry {\n fun widgetCount(): Int = 3\n}\n\nval fromClass = Panel.widgetCount\nval fromObject = Registry.widgetCount()\nval fromValue = panel.widgetCount\n";
1272 let found = identifier_occurrences_for(
1273 Lang::Kotlin,
1274 source.as_bytes(),
1275 "widgetCount",
1276 RenameTarget::Callable,
1277 )
1278 .unwrap();
1279 let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1280
1281 assert_eq!(replaced, 4, "got {found:?}\n{out}");
1282 assert!(out.contains("fun gadgetCount(): Int = 2"), "{out}");
1283 assert!(out.contains("fun gadgetCount(): Int = 3"), "{out}");
1284 assert!(
1285 out.contains("val fromClass = Panel.gadgetCount\n"),
1286 "a companion member read was dropped:\n{out}"
1287 );
1288 assert!(
1289 out.contains("val fromObject = Registry.gadgetCount()"),
1290 "an object member call was dropped:\n{out}"
1291 );
1292 assert!(
1293 out.contains("val fromValue = panel.widgetCount\n"),
1294 "a value's member read was renamed:\n{out}"
1295 );
1296 }
1297
1298 #[cfg(feature = "lang-kotlin")]
1302 #[test]
1303 fn kotlin_narrowing_keeps_members_of_imported_names() {
1304 let source = "import widgets.Panel\n\
1305import widgets.Registry as ExternalRegistry\n\
1306\n\
1307val fromClass = Panel.widgetCount\n\
1308val fromAlias = ExternalRegistry.widgetCount()\n\
1309val fromValue = panel.widgetCount\n";
1310 let found = identifier_occurrences_for(
1311 Lang::Kotlin,
1312 source.as_bytes(),
1313 "widgetCount",
1314 RenameTarget::Callable,
1315 )
1316 .unwrap();
1317 let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1318
1319 assert_eq!(replaced, 2, "got {found:?}\n{out}");
1320 assert!(
1321 out.contains("val fromClass = Panel.gadgetCount\n"),
1322 "{out}"
1323 );
1324 assert!(
1325 out.contains("val fromAlias = ExternalRegistry.gadgetCount()\n"),
1326 "{out}"
1327 );
1328 assert!(out.contains("val fromValue = panel.widgetCount\n"), "{out}");
1329 }
1330
1331 #[cfg(feature = "lang-typescript")]
1332 #[test]
1333 fn typescript_skips_strings_and_comments() {
1334 let source = "// widgetCount comment\nfunction widgetCount(): number { return 1; }\nconst label = \"widgetCount\";\nwidgetCount();\n";
1335 let found =
1336 identifier_occurrences(Lang::TypeScript, source.as_bytes(), "widgetCount").unwrap();
1337 assert_eq!(found.len(), 2, "got {found:?}");
1338 let (out, _) = replace_occurrences(source, &found, "gadgetCount");
1339 assert!(out.contains("function gadgetCount()"));
1340 assert!(out.contains("// widgetCount comment"));
1341 assert!(out.contains("\"widgetCount\""));
1342 }
1343
1344 #[cfg(feature = "lang-bash")]
1345 const BASH_SOURCE: &str = r#"widget_count() {
1346 echo widget_count
1347 local label="widget_count"
1348 # widget_count comment
1349 echo "$widget_count"
1350}
1351widget_count
1352"#;
1353
1354 #[cfg(feature = "lang-bash")]
1355 #[test]
1356 fn bash_renames_names_but_not_arguments_prose_or_data() {
1357 let found =
1358 identifier_occurrences(Lang::Bash, BASH_SOURCE.as_bytes(), "widget_count").unwrap();
1359 assert_eq!(found.len(), 3, "got {found:?}");
1362 let (out, replaced) = replace_occurrences(BASH_SOURCE, &found, "gadget_count");
1363 assert_eq!(replaced, 3);
1364 assert!(out.contains("gadget_count() {"), "definition not renamed");
1365 assert!(
1366 out.contains("echo \"$gadget_count\""),
1367 "expansion not renamed"
1368 );
1369 assert!(
1370 out.contains("}\ngadget_count\n"),
1371 "bare call not renamed:\n{out}"
1372 );
1373 assert!(
1374 out.contains("echo widget_count\n"),
1375 "an unquoted argument was renamed, which rewrites data:\n{out}"
1376 );
1377 assert!(out.contains("label=\"widget_count\""), "string was renamed");
1378 assert!(
1379 out.contains("# widget_count comment"),
1380 "comment was renamed"
1381 );
1382 }
1383
1384 #[cfg(feature = "lang-zig")]
1385 const ZIG_MEMBER_SOURCE: &str = "const m = @import(\"m.zig\");\n\npub fn widget_count() u32 { return 3; }\n\nconst Panel = struct {\n widget_count: u32 = 0,\n\n pub fn describe(self: Panel) u32 { return self.widget_count; }\n};\n\npub fn caller(p: Panel) u32 {\n return widget_count() + p.widget_count + m.widget_count() + m.widget_count + Panel.widget_count;\n}\n";
1386
1387 #[cfg(feature = "lang-zig")]
1392 #[test]
1393 fn zig_callable_narrowing_keeps_namespace_members_but_skips_field_reads() {
1394 let found = identifier_occurrences_for(
1395 Lang::Zig,
1396 ZIG_MEMBER_SOURCE.as_bytes(),
1397 "widget_count",
1398 RenameTarget::Callable,
1399 )
1400 .unwrap();
1401 let (out, replaced) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
1402
1403 assert_eq!(replaced, 5, "got {found:?}\n{out}");
1404 assert!(out.contains("pub fn gadget_count() u32"), "{out}");
1405 assert!(out.contains("return gadget_count() +"), "{out}");
1406 assert!(out.contains("m.gadget_count()"), "import call dropped:\n{out}");
1407 assert!(
1408 out.contains("m.gadget_count +"),
1409 "import read dropped, which breaks every cross-file reference:\n{out}"
1410 );
1411 assert!(
1412 out.contains("Panel.gadget_count;"),
1413 "container-type member dropped:\n{out}"
1414 );
1415 assert!(
1416 out.contains(" widget_count: u32 = 0,"),
1417 "a struct field declaration was renamed:\n{out}"
1418 );
1419 assert!(
1420 out.contains("p.widget_count +"),
1421 "a field read off a value was renamed:\n{out}"
1422 );
1423 assert!(
1424 out.contains("return self.widget_count;"),
1425 "a field read off self was renamed:\n{out}"
1426 );
1427 }
1428
1429 #[cfg(feature = "lang-zig")]
1435 #[test]
1436 fn zig_value_narrowing_keeps_namespace_members_and_drops_struct_fields() {
1437 let found = identifier_occurrences_for(
1438 Lang::Zig,
1439 ZIG_MEMBER_SOURCE.as_bytes(),
1440 "widget_count",
1441 RenameTarget::Value,
1442 )
1443 .unwrap();
1444 let (out, _) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
1445
1446 assert!(
1447 out.contains("m.gadget_count +"),
1448 "an import-qualified const read was dropped:\n{out}"
1449 );
1450 assert!(
1451 out.contains("Panel.gadget_count;"),
1452 "a container-type const read was dropped:\n{out}"
1453 );
1454 assert!(
1455 out.contains("p.widget_count +"),
1456 "a struct field read was renamed by a const rename:\n{out}"
1457 );
1458 assert!(
1459 out.contains(" widget_count: u32 = 0,"),
1460 "the field declaration is not an indexed symbol and must not move:\n{out}"
1461 );
1462 }
1463
1464 #[cfg(feature = "lang-zig")]
1465 #[test]
1466 fn zig_skips_strings_and_comments() {
1467 let source = "// widget_count comment\npub fn widget_count() u32 {\n const label = \"widget_count\";\n _ = label;\n return 3;\n}\npub fn caller() u32 { return widget_count(); }\n";
1468 let found = identifier_occurrences(Lang::Zig, source.as_bytes(), "widget_count").unwrap();
1469 assert_eq!(found.len(), 2, "got {found:?}");
1470 let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1471 assert_eq!(replaced, 2);
1472 assert!(out.contains("pub fn gadget_count()"), "definition not renamed");
1473 assert!(out.contains("return gadget_count();"), "call not renamed");
1474 assert!(
1475 out.contains("// widget_count comment"),
1476 "comment was renamed"
1477 );
1478 assert!(out.contains("\"widget_count\""), "string was renamed");
1479 }
1480
1481 #[cfg(feature = "lang-gdscript")]
1482 #[test]
1483 fn gdscript_renames_declaration_and_reference_but_not_prose() {
1484 let source = "# widget_count comment\nfunc widget_count():\n\tvar label = \"widget_count\"\n\treturn label\n\nfunc caller():\n\treturn widget_count()\n";
1485 let found =
1486 identifier_occurrences(Lang::GdScript, source.as_bytes(), "widget_count").unwrap();
1487 assert_eq!(found.len(), 2, "got {found:?}");
1490 let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1491 assert_eq!(replaced, 2);
1492 assert!(out.contains("func gadget_count():"), "definition not renamed");
1493 assert!(out.contains("return gadget_count()"), "call not renamed");
1494 assert!(
1495 out.contains("# widget_count comment"),
1496 "comment was renamed"
1497 );
1498 assert!(out.contains("\"widget_count\""), "string was renamed");
1499 }
1500
1501 #[cfg(feature = "lang-rust")]
1502 const RUST_FIELD_SOURCE: &str = r#"struct Meter { count: usize }
1503fn count() -> usize { 3 }
1504impl Meter {
1505 fn read(&self) -> usize { self.count }
1506 fn count(&self) -> usize { self.count }
1507}
1508fn use_it(m: &Meter) -> usize { m.count() + m.count + count() }
1509fn build() -> Meter { Meter { count: 1 } }
1510"#;
1511
1512 #[cfg(feature = "lang-rust")]
1513 #[test]
1514 fn renaming_a_rust_function_leaves_an_identically_named_field_alone() {
1515 let found =
1516 identifier_occurrences_for(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count", RenameTarget::Callable)
1517 .unwrap();
1518 let (out, _) = replace_occurrences(RUST_FIELD_SOURCE, &found, "tally");
1519 assert!(out.contains("fn tally() -> usize"), "free fn:\n{out}");
1521 assert!(out.contains("fn tally(&self)"), "inherent method:\n{out}");
1522 assert!(out.contains("m.tally()"), "method call:\n{out}");
1523 assert!(out.contains("+ tally()"), "free call:\n{out}");
1524 assert!(
1526 out.contains("struct Meter { count: usize }"),
1527 "field declaration was renamed:\n{out}"
1528 );
1529 assert!(
1530 out.contains("{ self.count }"),
1531 "field read was renamed:\n{out}"
1532 );
1533 assert!(
1534 out.contains("m.count +"),
1535 "field read was renamed:\n{out}"
1536 );
1537 assert!(
1538 out.contains("Meter { count: 1 }"),
1539 "struct literal field was renamed:\n{out}"
1540 );
1541 }
1542
1543 #[cfg(feature = "lang-rust")]
1544 #[test]
1545 fn an_unresolved_rust_target_keeps_the_pre_narrowing_behaviour() {
1546 let narrowed = identifier_occurrences_for(
1549 Lang::Rust,
1550 RUST_FIELD_SOURCE.as_bytes(),
1551 "count",
1552 RenameTarget::Callable,
1553 )
1554 .unwrap();
1555 let wide = identifier_occurrences(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count").unwrap();
1556 assert!(
1557 wide.len() > narrowed.len(),
1558 "narrowing dropped nothing: {} vs {}",
1559 wide.len(),
1560 narrowed.len()
1561 );
1562 }
1563
1564 #[cfg(feature = "lang-rust")]
1565 #[test]
1566 fn a_field_access_inside_a_macro_is_still_renamed() {
1567 let source = "struct Meter { count: usize }\nfn count() -> usize { 3 }\nfn f(m: &Meter) -> String { format!(\"{}\", m.count) }\n";
1574 let found =
1575 identifier_occurrences_for(Lang::Rust, source.as_bytes(), "count", RenameTarget::Callable)
1576 .unwrap();
1577 let (out, _) = replace_occurrences(source, &found, "tally");
1578 assert!(out.contains("m.tally)"), "expected the known over-rename:\n{out}");
1579 assert!(
1580 out.contains("struct Meter { count: usize }"),
1581 "the field declaration is outside the macro and must survive:\n{out}"
1582 );
1583 }
1584
1585 #[cfg(feature = "lang-gdscript")]
1586 #[test]
1587 fn renaming_a_gdscript_func_leaves_an_identically_named_var_declaration_alone() {
1588 let source = "func count():\n\tvar count = 1\n\treturn 2\n\nfunc caller():\n\treturn count()\n";
1591 let found =
1592 identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
1593 .unwrap();
1594 let (out, _) = replace_occurrences(source, &found, "tally");
1595 assert!(out.contains("func tally():"), "declaration:\n{out}");
1596 assert!(out.contains("return tally()"), "call:\n{out}");
1597 assert!(
1598 out.contains("var count = 1"),
1599 "the local var declaration was renamed:\n{out}"
1600 );
1601 }
1602
1603 #[cfg(feature = "lang-gdscript")]
1604 #[test]
1605 fn a_gdscript_local_that_shadows_the_target_and_is_read_refuses() {
1606 let source = "func count():\n\tvar count = 1\n\treturn count\n\nfunc caller():\n\treturn count()\n";
1610 let err = identifier_occurrences_for(
1611 Lang::GdScript,
1612 source.as_bytes(),
1613 "count",
1614 RenameTarget::Callable,
1615 )
1616 .unwrap_err();
1617 let message = format!("{err:#}");
1618 assert!(message.contains("shadows it"), "{message}");
1619 assert!(message.contains("line 2"), "{message}");
1620 }
1621
1622 #[cfg(feature = "lang-gdscript")]
1623 #[test]
1624 fn a_gdscript_callee_is_never_ambiguous() {
1625 let source = "func count():\n\treturn 1\n\nfunc caller():\n\treturn count() + count()\n";
1628 let found = identifier_occurrences_for(
1629 Lang::GdScript,
1630 source.as_bytes(),
1631 "count",
1632 RenameTarget::Callable,
1633 )
1634 .unwrap();
1635 assert_eq!(found.len(), 3, "got {found:?}");
1636 }
1637
1638 #[cfg(feature = "lang-gdscript")]
1639 #[test]
1640 fn renaming_a_gdscript_var_leaves_the_function_declaration_alone() {
1641 let source = "var count = 1\nfunc count():\n\treturn count\n";
1643 let found =
1644 identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Value)
1645 .unwrap();
1646 let (out, _) = replace_occurrences(source, &found, "tally");
1647 assert!(out.contains("var tally = 1"), "var declaration:\n{out}");
1648 assert!(
1649 out.contains("func count():"),
1650 "the function declaration was renamed:\n{out}"
1651 );
1652 }
1653
1654 #[cfg(feature = "lang-gdscript")]
1655 #[test]
1656 fn a_gdscript_parameter_is_a_binding_not_a_reference() {
1657 let shadowed = "func caller(count):\n\treturn count\n";
1660 let err = identifier_occurrences_for(
1661 Lang::GdScript,
1662 shadowed.as_bytes(),
1663 "count",
1664 RenameTarget::Callable,
1665 )
1666 .unwrap_err();
1667 assert!(format!("{err:#}").contains("shadows it"), "{err:#}");
1668
1669 let source = "func caller(count):\n\treturn 1\n";
1672 let found =
1673 identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
1674 .unwrap();
1675 let (out, _) = replace_occurrences(source, &found, "tally");
1676 assert!(
1677 out.contains("func caller(count):"),
1678 "a parameter declaration was renamed:\n{out}"
1679 );
1680 }
1681
1682 #[cfg(feature = "lang-typescript")]
1683 const TS_PROPERTY_SOURCE: &str = r#"function beta(v: number) { return v; }
1684const keyed = { beta: 1 };
1685const shorthand = { beta };
1686class K { beta() { return 2; } }
1687const k = new K();
1688const read = k.beta() + keyed.beta + beta(3);
1689export { beta };
1690"#;
1691
1692 #[cfg(feature = "lang-typescript")]
1693 #[test]
1694 fn renaming_a_typescript_function_leaves_properties_alone() {
1695 let found = identifier_occurrences_for(
1696 Lang::TypeScript,
1697 TS_PROPERTY_SOURCE.as_bytes(),
1698 "beta",
1699 RenameTarget::Callable,
1700 )
1701 .unwrap();
1702 let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
1703 assert!(out.contains("function gamma(v: number)"), "declaration:
1705{out}");
1706 assert!(out.contains("+ gamma(3)"), "call:
1707{out}");
1708 assert!(out.contains("export { gamma };"), "export:
1709{out}");
1710 assert!(out.contains("{ beta: 1 }"), "object key was renamed:
1712{out}");
1713 assert!(
1714 out.contains("class K { beta()"),
1715 "class method was renamed:
1716{out}"
1717 );
1718 assert!(out.contains("k.beta()"), "member call was renamed:
1719{out}");
1720 assert!(out.contains("keyed.beta"), "member read was renamed:
1721{out}");
1722 }
1723
1724 #[cfg(feature = "lang-typescript")]
1725 #[test]
1726 fn a_javascript_object_shorthand_is_expanded_rather_than_overwritten() {
1727 let found = identifier_occurrences_for(
1731 Lang::TypeScript,
1732 TS_PROPERTY_SOURCE.as_bytes(),
1733 "beta",
1734 RenameTarget::Callable,
1735 )
1736 .unwrap();
1737 let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
1738 assert!(
1739 out.contains("const shorthand = { beta: gamma };"),
1740 "shorthand was not expanded:
1741{out}"
1742 );
1743 }
1744
1745 #[cfg(feature = "lang-typescript")]
1746 #[test]
1747 fn a_destructuring_pattern_is_renamed_in_place_not_expanded() {
1748 let source = "import * as mod from './mod';
1752const { beta } = mod;
1753beta();
1754";
1755 let found =
1756 identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "beta", RenameTarget::Callable)
1757 .unwrap();
1758 let (out, _) = replace_occurrences(source, &found, "gamma");
1759 assert!(out.contains("const { gamma } = mod;"), "{out}");
1760 assert!(!out.contains("beta: gamma"), "pattern was expanded:
1761{out}");
1762 }
1763
1764 #[cfg(feature = "lang-typescript")]
1765 #[test]
1766 fn a_typescript_type_rename_keeps_type_identifiers_and_drops_properties() {
1767 let source = "type Beta = number;
1768const o = { Beta: 1 };
1769const v: Beta = 1;
1770export type { Beta };
1771";
1772 let callable = identifier_occurrences_for(
1773 Lang::TypeScript,
1774 source.as_bytes(),
1775 "Beta",
1776 RenameTarget::Callable,
1777 )
1778 .unwrap();
1779 let typed =
1780 identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "Beta", RenameTarget::Type)
1781 .unwrap();
1782 assert!(
1783 typed.len() > callable.len(),
1784 "a type rename must reach type_identifier positions a callable rename does not: {typed:?} vs {callable:?}"
1785 );
1786 let (out, _) = replace_occurrences(source, &typed, "Gamma");
1787 assert!(out.contains("type Gamma = number;"), "{out}");
1788 assert!(out.contains("const v: Gamma = 1;"), "{out}");
1789 assert!(out.contains("{ Beta: 1 }"), "object key was renamed:
1790{out}");
1791 }
1792
1793 #[test]
1794 fn indexed_symbol_kinds_map_onto_what_a_grammar_can_check() {
1795 assert_eq!(RenameTarget::from_indexed_kind("function"), RenameTarget::Callable);
1796 assert_eq!(RenameTarget::from_indexed_kind("signal"), RenameTarget::Signal);
1797 assert_eq!(RenameTarget::from_indexed_kind("struct"), RenameTarget::Type);
1798 assert_eq!(RenameTarget::from_indexed_kind("class"), RenameTarget::Type);
1799 assert_eq!(RenameTarget::from_indexed_kind("variable"), RenameTarget::Value);
1800 assert_eq!(RenameTarget::from_indexed_kind("const"), RenameTarget::Value);
1801 assert_eq!(RenameTarget::from_indexed_kind("heading"), RenameTarget::Unresolved);
1803 assert_eq!(RenameTarget::from_indexed_kind(""), RenameTarget::Unresolved);
1804 assert_eq!(RenameTarget::default(), RenameTarget::Unresolved);
1805 }
1806
1807 #[test]
1808 fn a_name_that_only_appears_in_prose_has_no_occurrences() {
1809 #[cfg(feature = "lang-rust")]
1810 {
1811 let source = "// widget_count\nfn other() {}\n";
1812 let found =
1813 identifier_occurrences(Lang::Rust, source.as_bytes(), "widget_count").unwrap();
1814 assert!(found.is_empty(), "got {found:?}");
1815 }
1816 }
1817
1818 #[cfg(feature = "lang-markdown")]
1819 #[test]
1820 fn markdown_has_no_identifier_kinds() {
1821 assert!(identifier_node_kinds(Lang::Markdown).is_empty());
1822 assert!(
1823 identifier_occurrences(Lang::Markdown, b"# widget_count\n", "widget_count")
1824 .unwrap()
1825 .is_empty()
1826 );
1827 }
1828
1829 #[test]
1830 fn every_indexed_language_declares_its_identifier_kinds() {
1831 for lang in Lang::all() {
1834 let kinds = identifier_node_kinds(lang);
1835 if lang.name() == "markdown" {
1836 continue;
1837 }
1838 assert!(
1839 !kinds.is_empty(),
1840 "{} declares no identifier node kinds",
1841 lang.name()
1842 );
1843 let ts_lang = lang.tree_sitter_language();
1844 for kind in kinds {
1845 assert!(
1846 ts_lang.id_for_node_kind(kind, true) != 0,
1847 "{} declares node kind {kind:?}, which its grammar does not have",
1848 lang.name()
1849 );
1850 }
1851 }
1852 }
1853}