use crate::lang::Lang;
use anyhow::Result;
use tree_sitter::{Node, Parser};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenameTarget {
Callable,
Signal,
Type,
Value,
#[default]
Unresolved,
}
impl RenameTarget {
pub fn from_indexed_kind(kind: &str) -> Self {
match kind {
"function" | "method" => Self::Callable,
"signal" => Self::Signal,
"struct" | "enum" | "enum_class" | "trait" | "class" | "data_class"
| "sealed_class" | "interface" | "type_alias" | "union" | "object"
| "companion_object" | "impl" => Self::Type,
"const" | "static" | "variable" => Self::Value,
_ => Self::Unresolved,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IdentifierOccurrence {
pub start_byte: usize,
pub end_byte: usize,
pub expands_shorthand_key: bool,
}
pub fn identifier_node_kinds(lang: Lang) -> &'static [&'static str] {
match lang {
#[cfg(feature = "lang-rust")]
Lang::Rust => &[
"identifier",
"type_identifier",
"field_identifier",
"shorthand_field_identifier",
],
#[cfg(feature = "lang-python")]
Lang::Python => &["identifier"],
#[cfg(feature = "lang-typescript")]
Lang::TypeScript | Lang::Tsx => &[
"identifier",
"type_identifier",
"property_identifier",
"shorthand_property_identifier",
"shorthand_property_identifier_pattern",
],
#[cfg(feature = "lang-javascript")]
Lang::JavaScript | Lang::Jsx => &[
"identifier",
"property_identifier",
"shorthand_property_identifier",
"shorthand_property_identifier_pattern",
],
#[cfg(feature = "lang-kotlin")]
Lang::Kotlin => &["identifier"],
#[cfg(feature = "lang-zig")]
Lang::Zig => &["identifier"],
#[cfg(feature = "lang-bash")]
Lang::Bash => &["word", "variable_name"],
#[cfg(feature = "lang-gdscript")]
Lang::GdScript => &["identifier", "name"],
#[cfg(feature = "lang-markdown")]
Lang::Markdown => &[],
}
}
fn occurrence_is_renamable(lang: Lang, node: Node) -> bool {
match lang {
#[cfg(feature = "lang-bash")]
Lang::Bash => {
if node.kind() != "word" {
return true;
}
node.parent().is_some_and(|parent| {
matches!(parent.kind(), "function_definition" | "command_name")
})
}
_ => {
let _ = node;
true
}
}
}
#[allow(unused_variables)]
fn occurrence_matches_target(
lang: Lang,
node: Node,
source: &[u8],
target: RenameTarget,
) -> bool {
if target == RenameTarget::Unresolved {
return true;
}
match lang {
#[cfg(feature = "lang-rust")]
Lang::Rust => rust_occurrence_matches_target(node, target),
#[cfg(feature = "lang-python")]
Lang::Python => python_occurrence_matches_target(node, source, target),
#[cfg(feature = "lang-gdscript")]
Lang::GdScript => gdscript_occurrence_matches_target(node, target),
#[cfg(feature = "lang-typescript")]
Lang::TypeScript | Lang::Tsx => js_like_occurrence_matches_target(node, target),
#[cfg(feature = "lang-javascript")]
Lang::JavaScript | Lang::Jsx => js_like_occurrence_matches_target(node, target),
#[cfg(feature = "lang-kotlin")]
Lang::Kotlin => kotlin_occurrence_matches_target(node, source, target),
#[cfg(feature = "lang-zig")]
Lang::Zig => zig_occurrence_matches_target(node, source, target),
_ => {
let _ = node;
true
}
}
}
#[cfg(feature = "lang-python")]
fn python_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
let Some(attribute) = node.parent().filter(|parent| parent.kind() == "attribute") else {
return true;
};
if attribute
.child_by_field_name("attribute")
.is_none_or(|name| name.id() != node.id())
{
return true;
}
if python_receiver_is_imported_module(attribute, source) {
return true;
}
target == RenameTarget::Callable
&& attribute.parent().is_some_and(|call| {
call.kind() == "call"
&& call
.child_by_field_name("function")
.is_some_and(|function| function.id() == attribute.id())
})
}
#[cfg(feature = "lang-python")]
fn python_receiver_is_imported_module(attribute: Node, source: &[u8]) -> bool {
let Some(mut object) = attribute.child_by_field_name("object") else {
return false;
};
while object.kind() == "attribute" {
let Some(inner) = object.child_by_field_name("object") else {
return false;
};
object = inner;
}
if object.kind() != "identifier" {
return false;
}
let Ok(name) = object.utf8_text(source) else {
return false;
};
python_file_imports_module(attribute, name, source)
}
#[cfg(feature = "lang-python")]
fn python_file_imports_module(node: Node, name: &str, source: &[u8]) -> bool {
let mut root = node;
while let Some(parent) = root.parent() {
root = parent;
}
let mut cursor = root.walk();
let mut descend = true;
loop {
if descend {
let current = cursor.node();
if current.kind() == "import_statement"
&& python_import_binds(current, name, source)
{
return true;
}
if cursor.goto_first_child() {
continue;
}
}
if cursor.goto_next_sibling() {
descend = true;
continue;
}
if !cursor.goto_parent() {
return false;
}
descend = false;
}
}
#[cfg(feature = "lang-python")]
fn python_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
let mut cursor = import.walk();
import.named_children(&mut cursor).any(|clause| {
let bound = match clause.kind() {
"aliased_import" => clause.child_by_field_name("alias"),
"dotted_name" => clause.named_child(0),
_ => None,
};
bound.is_some_and(|bound| bound.utf8_text(source).is_ok_and(|text| text == name))
})
}
#[cfg(feature = "lang-kotlin")]
fn kotlin_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
let Some(navigation) = node
.parent()
.filter(|parent| parent.kind() == "navigation_expression")
else {
return true;
};
if node.prev_named_sibling().is_none() {
return true;
}
if kotlin_receiver_is_namespace(navigation, source) {
return true;
}
target == RenameTarget::Callable
&& navigation.parent().is_some_and(|call| {
call.kind() == "call_expression"
&& call
.named_child(0)
.is_some_and(|function| function.id() == navigation.id())
})
}
#[cfg(feature = "lang-kotlin")]
fn kotlin_receiver_is_namespace(navigation: Node, source: &[u8]) -> bool {
let mut receiver = navigation;
while receiver.kind() == "navigation_expression" {
let Some(inner) = receiver.named_child(0) else {
return false;
};
receiver = inner;
}
if receiver.kind() != "identifier" {
return false;
}
let Ok(name) = receiver.utf8_text(source) else {
return false;
};
kotlin_file_declares_type(navigation, name, source)
|| kotlin_file_imports_name(navigation, name, source)
}
#[cfg(feature = "lang-kotlin")]
fn kotlin_file_declares_type(node: Node, name: &str, source: &[u8]) -> bool {
let mut root = node;
while let Some(parent) = root.parent() {
root = parent;
}
let mut cursor = root.walk();
let mut descend = true;
loop {
if descend {
let current = cursor.node();
if matches!(
current.kind(),
"class_declaration" | "object_declaration" | "interface_declaration"
) && current
.child_by_field_name("name")
.and_then(|declared| declared.utf8_text(source).ok())
== Some(name)
{
return true;
}
if cursor.goto_first_child() {
continue;
}
}
if cursor.goto_next_sibling() {
descend = true;
continue;
}
if !cursor.goto_parent() {
return false;
}
descend = false;
}
}
#[cfg(feature = "lang-kotlin")]
fn kotlin_file_imports_name(node: Node, name: &str, source: &[u8]) -> bool {
let mut root = node;
while let Some(parent) = root.parent() {
root = parent;
}
let mut cursor = root.walk();
let mut descend = true;
loop {
if descend {
let current = cursor.node();
if current.kind() == "import" && kotlin_import_binds(current, name, source) {
return true;
}
if cursor.goto_first_child() {
continue;
}
}
if cursor.goto_next_sibling() {
descend = true;
continue;
}
if !cursor.goto_parent() {
return false;
}
descend = false;
}
}
#[cfg(feature = "lang-kotlin")]
fn kotlin_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
let mut cursor = import.walk();
let children = import.named_children(&mut cursor).collect::<Vec<_>>();
if let Some(alias) = children
.get(1)
.filter(|child| child.kind() == "identifier")
{
return alias
.utf8_text(source)
.is_ok_and(|bound_name| bound_name == name);
}
children
.first()
.filter(|path| matches!(path.kind(), "identifier" | "qualified_identifier"))
.and_then(|path| path.utf8_text(source).ok())
.and_then(|path| path.rsplit('.').next())
.is_some_and(|bound_name| bound_name == name)
}
#[cfg(feature = "lang-zig")]
fn zig_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
let Some(parent) = node.parent() else {
return true;
};
match parent.kind() {
"container_field" => parent
.child_by_field_name("name")
.is_none_or(|name| name.id() != node.id()),
"field_expression" => {
if parent
.child_by_field_name("member")
.is_none_or(|member| member.id() != node.id())
{
return true;
}
if zig_receiver_is_namespace(parent, source) {
return true;
}
target == RenameTarget::Callable
&& parent.parent().is_some_and(|call| {
call.kind() == "call_expression"
&& call
.child_by_field_name("function")
.is_some_and(|function| function.id() == parent.id())
})
}
_ => true,
}
}
#[cfg(feature = "lang-zig")]
fn zig_receiver_is_namespace(field_expression: Node, source: &[u8]) -> bool {
let Some(mut object) = field_expression.child_by_field_name("object") else {
return false;
};
while object.kind() == "field_expression" {
let Some(inner) = object.child_by_field_name("object") else {
return false;
};
object = inner;
}
match object.kind() {
"builtin_function" => zig_is_import_builtin(object, source),
"identifier" => object
.utf8_text(source)
.is_ok_and(|name| zig_file_binds_namespace(field_expression, name, source)),
_ => false,
}
}
#[cfg(feature = "lang-zig")]
fn zig_is_import_builtin(builtin: Node, source: &[u8]) -> bool {
let mut cursor = builtin.walk();
builtin.named_children(&mut cursor).any(|child| {
child.kind() == "builtin_identifier"
&& child.utf8_text(source).is_ok_and(|text| text == "@import")
})
}
#[cfg(feature = "lang-zig")]
fn zig_file_binds_namespace(node: Node, name: &str, source: &[u8]) -> bool {
let mut root = node;
while let Some(parent) = root.parent() {
root = parent;
}
let mut cursor = root.walk();
let mut descend = true;
loop {
if descend {
let current = cursor.node();
if current.kind() == "variable_declaration"
&& zig_declaration_binds_namespace(current, name, source)
{
return true;
}
if cursor.goto_first_child() {
continue;
}
}
if cursor.goto_next_sibling() {
descend = true;
continue;
}
if !cursor.goto_parent() {
return false;
}
descend = false;
}
}
#[cfg(feature = "lang-zig")]
fn zig_declaration_binds_namespace(declaration: Node, name: &str, source: &[u8]) -> bool {
let mut cursor = declaration.walk();
let children: Vec<Node> = declaration.named_children(&mut cursor).collect();
let binds_name = children.iter().any(|child| {
child.kind() == "identifier" && child.utf8_text(source).is_ok_and(|text| text == name)
});
if !binds_name {
return false;
}
children.iter().any(|child| match child.kind() {
"builtin_function" => zig_is_import_builtin(*child, source),
"struct_declaration" | "enum_declaration" | "union_declaration"
| "opaque_declaration" => true,
_ => false,
})
}
#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
fn js_like_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
match node.kind() {
"property_identifier" => false,
"type_identifier" => target == RenameTarget::Type,
_ => true,
}
}
#[allow(unused_variables)]
fn occurrence_expands_shorthand_key(lang: Lang, node: Node, target: RenameTarget) -> bool {
if target == RenameTarget::Unresolved {
return false;
}
match lang {
#[cfg(feature = "lang-typescript")]
Lang::TypeScript | Lang::Tsx => js_like_shorthand_key(node),
#[cfg(feature = "lang-javascript")]
Lang::JavaScript | Lang::Jsx => js_like_shorthand_key(node),
_ => false,
}
}
#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
fn js_like_shorthand_key(node: Node) -> bool {
node.kind() == "shorthand_property_identifier"
&& node.parent().is_some_and(|parent| parent.kind() == "object")
}
#[cfg(feature = "lang-rust")]
fn rust_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
match node.kind() {
"field_identifier" => {
target == RenameTarget::Callable && parent_kind == "field_expression" && {
node.parent()
.and_then(|field_expression| {
let call = field_expression.parent()?;
(call.kind() == "call_expression"
&& call.child_by_field_name("function")?.id() == field_expression.id())
.then_some(())
})
.is_some()
}
}
"shorthand_field_identifier" => target == RenameTarget::Value,
"identifier" if parent_kind == "shorthand_field_initializer" => {
matches!(target, RenameTarget::Value)
}
"type_identifier" => target == RenameTarget::Type,
_ => true,
}
}
#[cfg(feature = "lang-gdscript")]
fn gdscript_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
match node.kind() {
"name" => {
let declares: &[&str] = match target {
RenameTarget::Callable => &["function_definition"],
RenameTarget::Signal => &["signal_statement"],
RenameTarget::Type => &["class_definition", "class_name_statement", "enum_definition"],
RenameTarget::Value => &[
"variable_statement",
"const_statement",
"export_variable_statement",
"onready_variable_statement",
],
RenameTarget::Unresolved => return true,
};
declares.contains(&parent_kind)
}
"identifier" if parent_kind == "parameters" => false,
_ => true,
}
}
pub fn identifier_occurrences(
lang: Lang,
source: &[u8],
name: &str,
) -> Result<Vec<IdentifierOccurrence>> {
identifier_occurrences_for(lang, source, name, RenameTarget::Unresolved)
}
pub fn identifier_occurrences_for(
lang: Lang,
source: &[u8],
name: &str,
target: RenameTarget,
) -> Result<Vec<IdentifierOccurrence>> {
let kinds = identifier_node_kinds(lang);
if kinds.is_empty() || name.is_empty() {
return Ok(Vec::new());
}
let ts_lang = lang.tree_sitter_language();
let mut parser = Parser::new();
parser.set_language(&ts_lang)?;
let tree = parser
.parse(source, None)
.ok_or_else(|| anyhow::anyhow!("parse failed"))?;
let mut occurrences = Vec::new();
let mut shadowing_declaration_line: Option<usize> = None;
let mut saw_ambiguous_reference = false;
let mut cursor = tree.walk();
let mut descend = true;
loop {
if descend {
let node = cursor.node();
if kinds.contains(&node.kind())
&& node.utf8_text(source).is_ok_and(|it| it == name)
&& occurrence_is_renamable(lang, node)
{
if occurrence_matches_target(lang, node, source, target) {
occurrences.push(IdentifierOccurrence {
start_byte: node.start_byte(),
end_byte: node.end_byte(),
expands_shorthand_key: occurrence_expands_shorthand_key(
lang, node, target,
),
});
saw_ambiguous_reference |= occurrence_is_ambiguous_reference(lang, node, target);
} else if shadowing_declaration_line.is_none()
&& occurrence_shadows_target(lang, node, target)
{
shadowing_declaration_line = Some(node.start_position().row + 1);
}
}
if cursor.goto_first_child() {
continue;
}
}
if cursor.goto_next_sibling() {
descend = true;
continue;
}
if !cursor.goto_parent() {
break;
}
descend = false;
}
occurrences.sort_by_key(|occurrence| (occurrence.start_byte, occurrence.end_byte));
occurrences.dedup();
if let Some(line) = shadowing_declaration_line
&& saw_ambiguous_reference
{
anyhow::bail!(
"rename_symbol refuses {name:?}: a same-named declaration on line {line} shadows it, and a bare reference cannot say which one it belongs to"
);
}
Ok(occurrences)
}
fn occurrence_shadows_target(lang: Lang, node: Node, target: RenameTarget) -> bool {
match lang {
#[cfg(feature = "lang-gdscript")]
Lang::GdScript => {
if target != RenameTarget::Callable {
return false;
}
let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
match node.kind() {
"name" => matches!(
parent_kind,
"variable_statement"
| "const_statement"
| "export_variable_statement"
| "onready_variable_statement"
),
"identifier" => parent_kind == "parameters",
_ => false,
}
}
_ => {
let _ = (node, target);
false
}
}
}
fn occurrence_is_ambiguous_reference(lang: Lang, node: Node, target: RenameTarget) -> bool {
match lang {
#[cfg(feature = "lang-gdscript")]
Lang::GdScript => {
if target != RenameTarget::Callable || node.kind() != "identifier" {
return false;
}
let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
!matches!(parent_kind, "call" | "attribute_call" | "base_call")
}
_ => {
let _ = (node, target);
false
}
}
}
pub fn replace_occurrences(
source: &str,
occurrences: &[IdentifierOccurrence],
replacement: &str,
) -> (String, usize) {
let mut out = String::with_capacity(source.len());
let mut last = 0usize;
let mut replaced = 0usize;
for occurrence in occurrences {
if occurrence.start_byte < last {
continue;
}
out.push_str(&source[last..occurrence.start_byte]);
if occurrence.expands_shorthand_key {
out.push_str(&source[occurrence.start_byte..occurrence.end_byte]);
out.push_str(": ");
}
out.push_str(replacement);
last = occurrence.end_byte;
replaced += 1;
}
out.push_str(&source[last..]);
(out, replaced)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "lang-rust")]
const RUST_SOURCE: &str = r#"/// doc widget_count
fn widget_count() -> usize { 3 }
fn describe() -> String {
// widget_count comment
let label = "widget_count";
format!("{label}: {}", widget_count())
}
"#;
#[cfg(feature = "lang-rust")]
#[test]
fn rust_skips_strings_and_comments_but_reaches_macro_arguments() {
let found =
identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
assert_eq!(
found.len(),
2,
"expected the definition and the macro-argument call, got {found:?}"
);
for occurrence in &found {
let before = &RUST_SOURCE[..occurrence.start_byte];
assert!(
!before.ends_with("/// doc ") && !before.ends_with("// "),
"occurrence at {} is inside a comment",
occurrence.start_byte
);
assert!(
!before.ends_with('"'),
"occurrence at {} is inside a string literal",
occurrence.start_byte
);
}
}
#[cfg(feature = "lang-rust")]
#[test]
fn replacing_rust_occurrences_leaves_prose_and_data_alone() {
let found =
identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
let (out, replaced) = replace_occurrences(RUST_SOURCE, &found, "gadget_count");
assert_eq!(replaced, 2);
assert!(out.contains("fn gadget_count()"), "definition not renamed");
assert!(
out.contains("gadget_count())"),
"macro-argument call not renamed"
);
assert!(
out.contains("/// doc widget_count"),
"doc comment was renamed"
);
assert!(
out.contains("// widget_count comment"),
"line comment was renamed"
);
assert!(
out.contains("\"widget_count\""),
"string literal was renamed"
);
}
#[cfg(feature = "lang-python")]
#[test]
fn python_skips_strings_and_comments() {
let source = "def widget_count():\n # widget_count comment\n return \"widget_count\"\n\nwidget_count()\n";
let found = identifier_occurrences(Lang::Python, source.as_bytes(), "widget_count").unwrap();
assert_eq!(found.len(), 2, "got {found:?}");
let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
assert_eq!(replaced, 2);
assert!(out.contains("def gadget_count()"));
assert!(out.contains("gadget_count()\n"));
assert!(out.contains("# widget_count comment"));
assert!(out.contains("\"widget_count\""));
}
#[cfg(feature = "lang-python")]
#[test]
fn python_callable_narrowing_keeps_method_calls_but_skips_attribute_reads() {
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";
let found = identifier_occurrences_for(
Lang::Python,
source.as_bytes(),
"widget_count",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
assert_eq!(replaced, 4, "got {found:?}\n{out}");
assert!(out.contains("def gadget_count():"));
assert!(out.contains("def gadget_count(self):"));
assert!(out.contains("called = panel.gadget_count()"));
assert!(out.contains("direct = gadget_count()"));
assert!(out.contains("read = panel.widget_count\n"));
}
#[cfg(feature = "lang-python")]
#[test]
fn python_narrowing_keeps_imported_module_attributes_including_bare_reads() {
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";
let found = identifier_occurrences_for(
Lang::Python,
source.as_bytes(),
"widget_count",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
assert_eq!(replaced, 4, "got {found:?}\n{out}");
assert!(out.contains("def gadget_count():"), "{out}");
assert!(
out.contains("module_read = mod.gadget_count\n"),
"an imported-module read was dropped:\n{out}"
);
assert!(out.contains("module_call = mod.gadget_count()"), "{out}");
assert!(
out.contains("aliased_read = aliased.gadget_count"),
"an aliased-import read was dropped:\n{out}"
);
assert!(
out.contains("read = panel.widget_count\n"),
"an instance attribute read was renamed:\n{out}"
);
}
#[cfg(feature = "lang-kotlin")]
#[test]
fn kotlin_callable_narrowing_keeps_method_calls_but_skips_navigation_reads() {
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";
let found = identifier_occurrences_for(
Lang::Kotlin,
source.as_bytes(),
"widgetCount",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
assert_eq!(replaced, 4, "got {found:?}\n{out}");
assert!(out.contains("fun gadgetCount(): Int = 1"));
assert!(out.contains("fun gadgetCount(): Int = 2"));
assert!(out.contains("val called = panel.gadgetCount()"));
assert!(out.contains("val direct = gadgetCount()"));
assert!(out.contains("val read = panel.widgetCount\n"));
}
#[cfg(feature = "lang-kotlin")]
#[test]
fn kotlin_narrowing_keeps_members_of_types_declared_in_the_file() {
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";
let found = identifier_occurrences_for(
Lang::Kotlin,
source.as_bytes(),
"widgetCount",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
assert_eq!(replaced, 4, "got {found:?}\n{out}");
assert!(out.contains("fun gadgetCount(): Int = 2"), "{out}");
assert!(out.contains("fun gadgetCount(): Int = 3"), "{out}");
assert!(
out.contains("val fromClass = Panel.gadgetCount\n"),
"a companion member read was dropped:\n{out}"
);
assert!(
out.contains("val fromObject = Registry.gadgetCount()"),
"an object member call was dropped:\n{out}"
);
assert!(
out.contains("val fromValue = panel.widgetCount\n"),
"a value's member read was renamed:\n{out}"
);
}
#[cfg(feature = "lang-kotlin")]
#[test]
fn kotlin_narrowing_keeps_members_of_imported_names() {
let source = "import widgets.Panel\n\
import widgets.Registry as ExternalRegistry\n\
\n\
val fromClass = Panel.widgetCount\n\
val fromAlias = ExternalRegistry.widgetCount()\n\
val fromValue = panel.widgetCount\n";
let found = identifier_occurrences_for(
Lang::Kotlin,
source.as_bytes(),
"widgetCount",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
assert_eq!(replaced, 2, "got {found:?}\n{out}");
assert!(
out.contains("val fromClass = Panel.gadgetCount\n"),
"{out}"
);
assert!(
out.contains("val fromAlias = ExternalRegistry.gadgetCount()\n"),
"{out}"
);
assert!(out.contains("val fromValue = panel.widgetCount\n"), "{out}");
}
#[cfg(feature = "lang-typescript")]
#[test]
fn typescript_skips_strings_and_comments() {
let source = "// widgetCount comment\nfunction widgetCount(): number { return 1; }\nconst label = \"widgetCount\";\nwidgetCount();\n";
let found =
identifier_occurrences(Lang::TypeScript, source.as_bytes(), "widgetCount").unwrap();
assert_eq!(found.len(), 2, "got {found:?}");
let (out, _) = replace_occurrences(source, &found, "gadgetCount");
assert!(out.contains("function gadgetCount()"));
assert!(out.contains("// widgetCount comment"));
assert!(out.contains("\"widgetCount\""));
}
#[cfg(feature = "lang-bash")]
const BASH_SOURCE: &str = r#"widget_count() {
echo widget_count
local label="widget_count"
# widget_count comment
echo "$widget_count"
}
widget_count
"#;
#[cfg(feature = "lang-bash")]
#[test]
fn bash_renames_names_but_not_arguments_prose_or_data() {
let found =
identifier_occurrences(Lang::Bash, BASH_SOURCE.as_bytes(), "widget_count").unwrap();
assert_eq!(found.len(), 3, "got {found:?}");
let (out, replaced) = replace_occurrences(BASH_SOURCE, &found, "gadget_count");
assert_eq!(replaced, 3);
assert!(out.contains("gadget_count() {"), "definition not renamed");
assert!(
out.contains("echo \"$gadget_count\""),
"expansion not renamed"
);
assert!(
out.contains("}\ngadget_count\n"),
"bare call not renamed:\n{out}"
);
assert!(
out.contains("echo widget_count\n"),
"an unquoted argument was renamed, which rewrites data:\n{out}"
);
assert!(out.contains("label=\"widget_count\""), "string was renamed");
assert!(
out.contains("# widget_count comment"),
"comment was renamed"
);
}
#[cfg(feature = "lang-zig")]
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";
#[cfg(feature = "lang-zig")]
#[test]
fn zig_callable_narrowing_keeps_namespace_members_but_skips_field_reads() {
let found = identifier_occurrences_for(
Lang::Zig,
ZIG_MEMBER_SOURCE.as_bytes(),
"widget_count",
RenameTarget::Callable,
)
.unwrap();
let (out, replaced) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
assert_eq!(replaced, 5, "got {found:?}\n{out}");
assert!(out.contains("pub fn gadget_count() u32"), "{out}");
assert!(out.contains("return gadget_count() +"), "{out}");
assert!(out.contains("m.gadget_count()"), "import call dropped:\n{out}");
assert!(
out.contains("m.gadget_count +"),
"import read dropped, which breaks every cross-file reference:\n{out}"
);
assert!(
out.contains("Panel.gadget_count;"),
"container-type member dropped:\n{out}"
);
assert!(
out.contains(" widget_count: u32 = 0,"),
"a struct field declaration was renamed:\n{out}"
);
assert!(
out.contains("p.widget_count +"),
"a field read off a value was renamed:\n{out}"
);
assert!(
out.contains("return self.widget_count;"),
"a field read off self was renamed:\n{out}"
);
}
#[cfg(feature = "lang-zig")]
#[test]
fn zig_value_narrowing_keeps_namespace_members_and_drops_struct_fields() {
let found = identifier_occurrences_for(
Lang::Zig,
ZIG_MEMBER_SOURCE.as_bytes(),
"widget_count",
RenameTarget::Value,
)
.unwrap();
let (out, _) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
assert!(
out.contains("m.gadget_count +"),
"an import-qualified const read was dropped:\n{out}"
);
assert!(
out.contains("Panel.gadget_count;"),
"a container-type const read was dropped:\n{out}"
);
assert!(
out.contains("p.widget_count +"),
"a struct field read was renamed by a const rename:\n{out}"
);
assert!(
out.contains(" widget_count: u32 = 0,"),
"the field declaration is not an indexed symbol and must not move:\n{out}"
);
}
#[cfg(feature = "lang-zig")]
#[test]
fn zig_skips_strings_and_comments() {
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";
let found = identifier_occurrences(Lang::Zig, source.as_bytes(), "widget_count").unwrap();
assert_eq!(found.len(), 2, "got {found:?}");
let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
assert_eq!(replaced, 2);
assert!(out.contains("pub fn gadget_count()"), "definition not renamed");
assert!(out.contains("return gadget_count();"), "call not renamed");
assert!(
out.contains("// widget_count comment"),
"comment was renamed"
);
assert!(out.contains("\"widget_count\""), "string was renamed");
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn gdscript_renames_declaration_and_reference_but_not_prose() {
let source = "# widget_count comment\nfunc widget_count():\n\tvar label = \"widget_count\"\n\treturn label\n\nfunc caller():\n\treturn widget_count()\n";
let found =
identifier_occurrences(Lang::GdScript, source.as_bytes(), "widget_count").unwrap();
assert_eq!(found.len(), 2, "got {found:?}");
let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
assert_eq!(replaced, 2);
assert!(out.contains("func gadget_count():"), "definition not renamed");
assert!(out.contains("return gadget_count()"), "call not renamed");
assert!(
out.contains("# widget_count comment"),
"comment was renamed"
);
assert!(out.contains("\"widget_count\""), "string was renamed");
}
#[cfg(feature = "lang-rust")]
const RUST_FIELD_SOURCE: &str = r#"struct Meter { count: usize }
fn count() -> usize { 3 }
impl Meter {
fn read(&self) -> usize { self.count }
fn count(&self) -> usize { self.count }
}
fn use_it(m: &Meter) -> usize { m.count() + m.count + count() }
fn build() -> Meter { Meter { count: 1 } }
"#;
#[cfg(feature = "lang-rust")]
#[test]
fn renaming_a_rust_function_leaves_an_identically_named_field_alone() {
let found =
identifier_occurrences_for(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count", RenameTarget::Callable)
.unwrap();
let (out, _) = replace_occurrences(RUST_FIELD_SOURCE, &found, "tally");
assert!(out.contains("fn tally() -> usize"), "free fn:\n{out}");
assert!(out.contains("fn tally(&self)"), "inherent method:\n{out}");
assert!(out.contains("m.tally()"), "method call:\n{out}");
assert!(out.contains("+ tally()"), "free call:\n{out}");
assert!(
out.contains("struct Meter { count: usize }"),
"field declaration was renamed:\n{out}"
);
assert!(
out.contains("{ self.count }"),
"field read was renamed:\n{out}"
);
assert!(
out.contains("m.count +"),
"field read was renamed:\n{out}"
);
assert!(
out.contains("Meter { count: 1 }"),
"struct literal field was renamed:\n{out}"
);
}
#[cfg(feature = "lang-rust")]
#[test]
fn an_unresolved_rust_target_keeps_the_pre_narrowing_behaviour() {
let narrowed = identifier_occurrences_for(
Lang::Rust,
RUST_FIELD_SOURCE.as_bytes(),
"count",
RenameTarget::Callable,
)
.unwrap();
let wide = identifier_occurrences(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count").unwrap();
assert!(
wide.len() > narrowed.len(),
"narrowing dropped nothing: {} vs {}",
wide.len(),
narrowed.len()
);
}
#[cfg(feature = "lang-rust")]
#[test]
fn a_field_access_inside_a_macro_is_still_renamed() {
let source = "struct Meter { count: usize }\nfn count() -> usize { 3 }\nfn f(m: &Meter) -> String { format!(\"{}\", m.count) }\n";
let found =
identifier_occurrences_for(Lang::Rust, source.as_bytes(), "count", RenameTarget::Callable)
.unwrap();
let (out, _) = replace_occurrences(source, &found, "tally");
assert!(out.contains("m.tally)"), "expected the known over-rename:\n{out}");
assert!(
out.contains("struct Meter { count: usize }"),
"the field declaration is outside the macro and must survive:\n{out}"
);
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn renaming_a_gdscript_func_leaves_an_identically_named_var_declaration_alone() {
let source = "func count():\n\tvar count = 1\n\treturn 2\n\nfunc caller():\n\treturn count()\n";
let found =
identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
.unwrap();
let (out, _) = replace_occurrences(source, &found, "tally");
assert!(out.contains("func tally():"), "declaration:\n{out}");
assert!(out.contains("return tally()"), "call:\n{out}");
assert!(
out.contains("var count = 1"),
"the local var declaration was renamed:\n{out}"
);
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn a_gdscript_local_that_shadows_the_target_and_is_read_refuses() {
let source = "func count():\n\tvar count = 1\n\treturn count\n\nfunc caller():\n\treturn count()\n";
let err = identifier_occurrences_for(
Lang::GdScript,
source.as_bytes(),
"count",
RenameTarget::Callable,
)
.unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("shadows it"), "{message}");
assert!(message.contains("line 2"), "{message}");
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn a_gdscript_callee_is_never_ambiguous() {
let source = "func count():\n\treturn 1\n\nfunc caller():\n\treturn count() + count()\n";
let found = identifier_occurrences_for(
Lang::GdScript,
source.as_bytes(),
"count",
RenameTarget::Callable,
)
.unwrap();
assert_eq!(found.len(), 3, "got {found:?}");
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn renaming_a_gdscript_var_leaves_the_function_declaration_alone() {
let source = "var count = 1\nfunc count():\n\treturn count\n";
let found =
identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Value)
.unwrap();
let (out, _) = replace_occurrences(source, &found, "tally");
assert!(out.contains("var tally = 1"), "var declaration:\n{out}");
assert!(
out.contains("func count():"),
"the function declaration was renamed:\n{out}"
);
}
#[cfg(feature = "lang-gdscript")]
#[test]
fn a_gdscript_parameter_is_a_binding_not_a_reference() {
let shadowed = "func caller(count):\n\treturn count\n";
let err = identifier_occurrences_for(
Lang::GdScript,
shadowed.as_bytes(),
"count",
RenameTarget::Callable,
)
.unwrap_err();
assert!(format!("{err:#}").contains("shadows it"), "{err:#}");
let source = "func caller(count):\n\treturn 1\n";
let found =
identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
.unwrap();
let (out, _) = replace_occurrences(source, &found, "tally");
assert!(
out.contains("func caller(count):"),
"a parameter declaration was renamed:\n{out}"
);
}
#[cfg(feature = "lang-typescript")]
const TS_PROPERTY_SOURCE: &str = r#"function beta(v: number) { return v; }
const keyed = { beta: 1 };
const shorthand = { beta };
class K { beta() { return 2; } }
const k = new K();
const read = k.beta() + keyed.beta + beta(3);
export { beta };
"#;
#[cfg(feature = "lang-typescript")]
#[test]
fn renaming_a_typescript_function_leaves_properties_alone() {
let found = identifier_occurrences_for(
Lang::TypeScript,
TS_PROPERTY_SOURCE.as_bytes(),
"beta",
RenameTarget::Callable,
)
.unwrap();
let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
assert!(out.contains("function gamma(v: number)"), "declaration:
{out}");
assert!(out.contains("+ gamma(3)"), "call:
{out}");
assert!(out.contains("export { gamma };"), "export:
{out}");
assert!(out.contains("{ beta: 1 }"), "object key was renamed:
{out}");
assert!(
out.contains("class K { beta()"),
"class method was renamed:
{out}"
);
assert!(out.contains("k.beta()"), "member call was renamed:
{out}");
assert!(out.contains("keyed.beta"), "member read was renamed:
{out}");
}
#[cfg(feature = "lang-typescript")]
#[test]
fn a_javascript_object_shorthand_is_expanded_rather_than_overwritten() {
let found = identifier_occurrences_for(
Lang::TypeScript,
TS_PROPERTY_SOURCE.as_bytes(),
"beta",
RenameTarget::Callable,
)
.unwrap();
let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
assert!(
out.contains("const shorthand = { beta: gamma };"),
"shorthand was not expanded:
{out}"
);
}
#[cfg(feature = "lang-typescript")]
#[test]
fn a_destructuring_pattern_is_renamed_in_place_not_expanded() {
let source = "import * as mod from './mod';
const { beta } = mod;
beta();
";
let found =
identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "beta", RenameTarget::Callable)
.unwrap();
let (out, _) = replace_occurrences(source, &found, "gamma");
assert!(out.contains("const { gamma } = mod;"), "{out}");
assert!(!out.contains("beta: gamma"), "pattern was expanded:
{out}");
}
#[cfg(feature = "lang-typescript")]
#[test]
fn a_typescript_type_rename_keeps_type_identifiers_and_drops_properties() {
let source = "type Beta = number;
const o = { Beta: 1 };
const v: Beta = 1;
export type { Beta };
";
let callable = identifier_occurrences_for(
Lang::TypeScript,
source.as_bytes(),
"Beta",
RenameTarget::Callable,
)
.unwrap();
let typed =
identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "Beta", RenameTarget::Type)
.unwrap();
assert!(
typed.len() > callable.len(),
"a type rename must reach type_identifier positions a callable rename does not: {typed:?} vs {callable:?}"
);
let (out, _) = replace_occurrences(source, &typed, "Gamma");
assert!(out.contains("type Gamma = number;"), "{out}");
assert!(out.contains("const v: Gamma = 1;"), "{out}");
assert!(out.contains("{ Beta: 1 }"), "object key was renamed:
{out}");
}
#[test]
fn indexed_symbol_kinds_map_onto_what_a_grammar_can_check() {
assert_eq!(RenameTarget::from_indexed_kind("function"), RenameTarget::Callable);
assert_eq!(RenameTarget::from_indexed_kind("signal"), RenameTarget::Signal);
assert_eq!(RenameTarget::from_indexed_kind("struct"), RenameTarget::Type);
assert_eq!(RenameTarget::from_indexed_kind("class"), RenameTarget::Type);
assert_eq!(RenameTarget::from_indexed_kind("variable"), RenameTarget::Value);
assert_eq!(RenameTarget::from_indexed_kind("const"), RenameTarget::Value);
assert_eq!(RenameTarget::from_indexed_kind("heading"), RenameTarget::Unresolved);
assert_eq!(RenameTarget::from_indexed_kind(""), RenameTarget::Unresolved);
assert_eq!(RenameTarget::default(), RenameTarget::Unresolved);
}
#[test]
fn a_name_that_only_appears_in_prose_has_no_occurrences() {
#[cfg(feature = "lang-rust")]
{
let source = "// widget_count\nfn other() {}\n";
let found =
identifier_occurrences(Lang::Rust, source.as_bytes(), "widget_count").unwrap();
assert!(found.is_empty(), "got {found:?}");
}
}
#[cfg(feature = "lang-markdown")]
#[test]
fn markdown_has_no_identifier_kinds() {
assert!(identifier_node_kinds(Lang::Markdown).is_empty());
assert!(
identifier_occurrences(Lang::Markdown, b"# widget_count\n", "widget_count")
.unwrap()
.is_empty()
);
}
#[test]
fn every_indexed_language_declares_its_identifier_kinds() {
for lang in Lang::all() {
let kinds = identifier_node_kinds(lang);
if lang.name() == "markdown" {
continue;
}
assert!(
!kinds.is_empty(),
"{} declares no identifier node kinds",
lang.name()
);
let ts_lang = lang.tree_sitter_language();
for kind in kinds {
assert!(
ts_lang.id_for_node_kind(kind, true) != 0,
"{} declares node kind {kind:?}, which its grammar does not have",
lang.name()
);
}
}
}
}