Skip to main content

tsift_graph/
rename.rs

1//! Identifier occurrence collection for symbol renames.
2//!
3//! A rename used to be a substring scan with an identifier-boundary guard. That
4//! shape cannot tell an identifier from the same characters inside a string
5//! literal or a comment, so `rename_symbol` silently rewrote both — a string
6//! literal is data, and rewriting it changes behaviour rather than names.
7//!
8//! Here the walk is restricted to the node kinds that *are* identifiers in each
9//! grammar. Comments and string bodies are different node kinds, so they drop
10//! out by construction; there is no comment or string special case below, and a
11//! new quoting or comment form cannot reintroduce the bug.
12//!
13//! A kind filter alone is still coarser than the language: several grammars
14//! spell two unrelated declarations with the same node kind — a Rust struct
15//! field and a method call are both `field_identifier`, a GDScript `func` and a
16//! local `var` are both `name`. Where the *position* in the tree separates
17//! them, [`RenameTarget`] carries what the index resolved the symbol to be and
18//! the walk drops occurrences that cannot be that thing. Where position does
19//! not separate them, the occurrence is kept: under-renaming leaves a caller
20//! pointing at a name that no longer exists, which is worse than the
21//! over-renaming it would avoid.
22
23use crate::lang::Lang;
24use anyhow::Result;
25use tree_sitter::{Node, Parser};
26
27/// What the index resolved the rename target to be.
28///
29/// Grammars distinguish a declaration from a reference far more often than they
30/// distinguish two same-named declarations, so this is the only input that lets
31/// the walk tell `fn count()` from `struct S { count: usize }`. It comes from
32/// the indexed symbol's kind, and [`RenameTarget::Unresolved`] — the default
33/// when nothing resolved — accepts every identifier kind, which is exactly the
34/// behaviour before this existed.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum RenameTarget {
37    /// A function or method.
38    Callable,
39    /// A GDScript `signal`. Declared and connected by name, but never a
40    /// function, and the two have distinct declaration nodes.
41    Signal,
42    /// A type-level name: struct, enum, trait, class, interface, type alias.
43    Type,
44    /// A value binding: const, static, or variable.
45    Value,
46    /// Unresolved, or an indexed kind that maps to none of the above.
47    #[default]
48    Unresolved,
49}
50
51impl RenameTarget {
52    /// Map an indexed symbol kind onto what the grammar can check.
53    ///
54    /// The input strings are the capture names in `Lang::symbol_query`, so an
55    /// unrecognized one means a new capture was added without deciding what it
56    /// is. That falls to `Unresolved`, which is the permissive answer — a new
57    /// symbol kind must not silently start dropping occurrences.
58    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" => Self::Type,
65            "const" | "static" | "variable" => Self::Value,
66            _ => Self::Unresolved,
67        }
68    }
69}
70
71/// The byte span of one identifier occurrence, as a half-open range.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct IdentifierOccurrence {
74    pub start_byte: usize,
75    pub end_byte: usize,
76    /// This occurrence is a JS-like object-literal shorthand (`{ beta }`),
77    /// where the one token is both the property name and a read of the binding.
78    /// Overwriting the span would silently rename the property too, so the
79    /// splice writes `beta: newName` and both survive.
80    pub expands_shorthand_key: bool,
81}
82
83/// Node kinds that carry a bare identifier in this language's grammar.
84///
85/// An empty slice means the language has no identifier concept a rename could
86/// target (Markdown), which callers must treat as "not renamable" rather than
87/// as "no occurrences found".
88pub fn identifier_node_kinds(lang: Lang) -> &'static [&'static str] {
89    match lang {
90        // Rust identifiers inside macro arguments live under an opaque
91        // `token_tree`, but they are still named `identifier` nodes, so a
92        // `foo()` call inside `assert_eq!`/`format!` is reached by this walk.
93        #[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        // Bash has no separate identifier node: a command name and a function
122        // name are both `word`, and an expansion is `variable_name`. `word` is
123        // also every unquoted argument, so kind alone is not enough here —
124        // `occurrence_is_renamable` narrows it to the name positions.
125        #[cfg(feature = "lang-bash")]
126        Lang::Bash => &["word", "variable_name"],
127        // Go splits selectors and struct-field/method names into
128        // `field_identifier`, and type positions into `type_identifier`; a bare
129        // reference or declaration is `identifier`. `package_identifier` is
130        // deliberately absent — renaming a package is a directory move.
131        #[cfg(feature = "lang-go")]
132        Lang::Go => &["identifier", "type_identifier", "field_identifier"],
133        // GDScript splits the two: `name` is the declared name of a statement
134        // or block, `identifier` is every reference to one.
135        #[cfg(feature = "lang-gdscript")]
136        Lang::GdScript => &["identifier", "name"],
137        // Markdown has headings, not identifiers; `rename_heading` is its kind.
138        #[cfg(feature = "lang-markdown")]
139        Lang::Markdown => &[],
140    }
141}
142
143/// Whether an identifier-kind node sits in a *naming* position.
144///
145/// For most grammars the node kind settles it, and this is unconditionally
146/// true. Bash is the exception that forces the check to exist: a bare `word`
147/// is the function name in `deploy() { … }`, the command name in `deploy`,
148/// **and** every unquoted argument, so `echo deploy` would otherwise have a
149/// rename rewrite an argument that is data. Restricting `word` to the
150/// declaration and command-name positions keeps arguments out, the same way
151/// the kind filter keeps strings and comments out for every other language.
152fn occurrence_is_renamable(lang: Lang, node: Node) -> bool {
153    match lang {
154        #[cfg(feature = "lang-bash")]
155        Lang::Bash => {
156            if node.kind() != "word" {
157                // `variable_name` is only ever a variable, in an assignment or
158                // an expansion.
159                return true;
160            }
161            node.parent().is_some_and(|parent| {
162                matches!(parent.kind(), "function_definition" | "command_name")
163            })
164        }
165        _ => {
166            let _ = node;
167            true
168        }
169    }
170}
171
172/// Whether an identifier-kind node could be *this* symbol.
173///
174/// Only positions the grammar makes unambiguous are ruled out. Anything the
175/// tree cannot attribute — a bare `count` reference in GDScript, a Rust
176/// `x.count()` where `count` might be an inherent method or a trait method on
177/// something else — is kept, because dropping it silently breaks a caller.
178#[allow(unused_variables)]
179fn occurrence_matches_target(
180    lang: Lang,
181    node: Node,
182    source: &[u8],
183    target: RenameTarget,
184) -> bool {
185    if target == RenameTarget::Unresolved {
186        return true;
187    }
188    match lang {
189        #[cfg(feature = "lang-rust")]
190        Lang::Rust => rust_occurrence_matches_target(node, target),
191        #[cfg(feature = "lang-python")]
192        Lang::Python => python_occurrence_matches_target(node, source, target),
193        #[cfg(feature = "lang-gdscript")]
194        Lang::GdScript => gdscript_occurrence_matches_target(node, target),
195        #[cfg(feature = "lang-typescript")]
196        Lang::TypeScript | Lang::Tsx => js_like_occurrence_matches_target(node, target),
197        #[cfg(feature = "lang-javascript")]
198        Lang::JavaScript | Lang::Jsx => js_like_occurrence_matches_target(node, target),
199        #[cfg(feature = "lang-kotlin")]
200        Lang::Kotlin => kotlin_occurrence_matches_target(node, source, target),
201        #[cfg(feature = "lang-zig")]
202        Lang::Zig => zig_occurrence_matches_target(node, source, target),
203        #[cfg(feature = "lang-go")]
204        Lang::Go => go_occurrence_matches_target(node, source, target),
205        _ => {
206            let _ = node;
207            true
208        }
209    }
210}
211
212/// Go spells a struct field declaration, a field read, a method name, and a
213/// package-qualified reference all as `field_identifier` (`#goindex`).
214///
215/// The declaration position is decidable and is never a `Lang::symbol_query`
216/// capture, so it is ruled out outright. For a selector, the receiver settles
217/// it: a package name this file imported reaches a package-level declaration
218/// and must be renamed; anything else is a value, where only the callee
219/// position of a call can still be the function being renamed.
220#[cfg(feature = "lang-go")]
221fn go_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
222    let Some(parent) = node.parent() else {
223        return true;
224    };
225    match parent.kind() {
226        // A struct field declaration. `Lang::symbol_query` captures struct
227        // *type* names, never their field names, so this can never be the
228        // symbol a resolved rename selected.
229        "field_declaration" => !parent
230            .children_by_field_name("name", &mut parent.walk())
231            .any(|name| name.id() == node.id()),
232        "selector_expression" => {
233            if parent
234                .child_by_field_name("field")
235                .is_none_or(|field| field.id() != node.id())
236            {
237                return true;
238            }
239            if go_receiver_is_imported_package(parent, source) {
240                return true;
241            }
242            target == RenameTarget::Callable
243                && parent.parent().is_some_and(|call| {
244                    call.kind() == "call_expression"
245                        && call
246                            .child_by_field_name("function")
247                            .is_some_and(|function| function.id() == parent.id())
248                })
249        }
250        _ => true,
251    }
252}
253
254/// Whether the receiver of a `selector_expression` is a package name bound by
255/// this file's imports.
256///
257/// `import "net/http"` binds `http`; `import h "net/http"` binds `h`. A local
258/// variable that shadows an imported package resolves to "package" here and
259/// keeps the occurrence — the over-renaming direction, which this module
260/// prefers because an extra rename is visible and a dropped one is not.
261#[cfg(feature = "lang-go")]
262fn go_receiver_is_imported_package(selector: Node, source: &[u8]) -> bool {
263    let Some(mut operand) = selector.child_by_field_name("operand") else {
264        return false;
265    };
266    while operand.kind() == "selector_expression" {
267        let Some(inner) = operand.child_by_field_name("operand") else {
268            return false;
269        };
270        operand = inner;
271    }
272    if operand.kind() != "identifier" && operand.kind() != "package_identifier" {
273        return false;
274    }
275    let Ok(name) = operand.utf8_text(source) else {
276        return false;
277    };
278    go_file_imports_package(selector, name, source)
279}
280
281#[cfg(feature = "lang-go")]
282fn go_file_imports_package(node: Node, name: &str, source: &[u8]) -> bool {
283    let mut root = node;
284    while let Some(parent) = root.parent() {
285        root = parent;
286    }
287    let mut found = false;
288    go_walk_import_specs(root, source, &mut |bound| {
289        if bound == name {
290            found = true;
291        }
292    });
293    found
294}
295
296/// Call `visit` with the local name each `import_spec` in the tree binds.
297#[cfg(feature = "lang-go")]
298fn go_walk_import_specs(node: Node, source: &[u8], visit: &mut impl FnMut(&str)) {
299    if node.kind() == "import_spec" {
300        if let Some(alias) = node.child_by_field_name("name")
301            && let Ok(text) = alias.utf8_text(source)
302        {
303            visit(text);
304            return;
305        }
306        if let Some(path) = node.child_by_field_name("path")
307            && let Ok(text) = path.utf8_text(source)
308        {
309            let trimmed = text.trim_matches('"');
310            if let Some(last) = trimmed.rsplit('/').next() {
311                visit(last);
312            }
313        }
314        return;
315    }
316    let mut cursor = node.walk();
317    for child in node.children(&mut cursor) {
318        go_walk_import_specs(child, source, visit);
319    }
320}
321
322/// Python uses `identifier` for both a binding and the attribute in `obj.name`.
323/// The attribute cannot be a module-level binding, except in two positions:
324/// methods are indexed as callables, so `obj.name()` is a real rename target,
325/// and `mod.name` is the module-level binding itself when `mod` is a module
326/// this file imported. Dropping that second case is a silent under-rename —
327/// `import mod` is half of how Python spells a cross-module reference, and the
328/// rename runs across files.
329#[cfg(feature = "lang-python")]
330fn python_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
331    let Some(attribute) = node.parent().filter(|parent| parent.kind() == "attribute") else {
332        return true;
333    };
334    if attribute
335        .child_by_field_name("attribute")
336        .is_none_or(|name| name.id() != node.id())
337    {
338        return true;
339    }
340    if python_receiver_is_imported_module(attribute, source) {
341        return true;
342    }
343
344    target == RenameTarget::Callable
345        && attribute.parent().is_some_and(|call| {
346            call.kind() == "call"
347                && call
348                    .child_by_field_name("function")
349                    .is_some_and(|function| function.id() == attribute.id())
350        })
351}
352
353/// Whether the receiver of this `attribute` is a module bound by `import`.
354///
355/// Only `import mod` / `import pkg.mod as alias` bind a name that is reached
356/// with a dot; `from mod import name` binds `name` directly and never produces
357/// an attribute position. A chained receiver (`pkg.sub.name`) is resolved by
358/// walking to the root of the chain, which is the imported name.
359///
360/// A local variable that shadows an imported module resolves to "module" here
361/// and keeps the occurrence. That is the over-renaming direction, which this
362/// module prefers: an extra rename is visible, a dropped one is not.
363#[cfg(feature = "lang-python")]
364fn python_receiver_is_imported_module(attribute: Node, source: &[u8]) -> bool {
365    let Some(mut object) = attribute.child_by_field_name("object") else {
366        return false;
367    };
368    while object.kind() == "attribute" {
369        let Some(inner) = object.child_by_field_name("object") else {
370            return false;
371        };
372        object = inner;
373    }
374    if object.kind() != "identifier" {
375        return false;
376    }
377    let Ok(name) = object.utf8_text(source) else {
378        return false;
379    };
380    python_file_imports_module(attribute, name, source)
381}
382
383/// Whether the file holding `node` binds `name` with an `import` statement.
384#[cfg(feature = "lang-python")]
385fn python_file_imports_module(node: Node, name: &str, source: &[u8]) -> bool {
386    let mut root = node;
387    while let Some(parent) = root.parent() {
388        root = parent;
389    }
390    let mut cursor = root.walk();
391    let mut descend = true;
392    loop {
393        if descend {
394            let current = cursor.node();
395            if current.kind() == "import_statement"
396                && python_import_binds(current, name, source)
397            {
398                return true;
399            }
400            if cursor.goto_first_child() {
401                continue;
402            }
403        }
404        if cursor.goto_next_sibling() {
405            descend = true;
406            continue;
407        }
408        if !cursor.goto_parent() {
409            return false;
410        }
411        descend = false;
412    }
413}
414
415/// The name one `import_statement` clause binds: the alias when there is one,
416/// otherwise the first segment of the dotted path — `import pkg.mod` binds
417/// `pkg`, not `mod`.
418#[cfg(feature = "lang-python")]
419fn python_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
420    let mut cursor = import.walk();
421    import.named_children(&mut cursor).any(|clause| {
422        let bound = match clause.kind() {
423            "aliased_import" => clause.child_by_field_name("alias"),
424            "dotted_name" => clause.named_child(0),
425            _ => None,
426        };
427        bound.is_some_and(|bound| bound.utf8_text(source).is_ok_and(|text| text == name))
428    })
429}
430
431/// Kotlin's first `navigation_expression` identifier is the receiver binding;
432/// every later identifier is a member. A member is a rename target in the callee
433/// position, because Kotlin indexes methods as callables, and whenever the
434/// receiver is a type declared in this file or a name bound by an import —
435/// `Panel.widgetCount` reaches a companion member and `Registry.widgetCount` an
436/// `object` member, both of which the index holds as declarations, so dropping
437/// them is an under-rename.
438#[cfg(feature = "lang-kotlin")]
439fn kotlin_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
440    let Some(navigation) = node
441        .parent()
442        .filter(|parent| parent.kind() == "navigation_expression")
443    else {
444        return true;
445    };
446    if node.prev_named_sibling().is_none() {
447        return true;
448    }
449    if kotlin_receiver_is_namespace(navigation, source) {
450        return true;
451    }
452
453    target == RenameTarget::Callable
454        && navigation.parent().is_some_and(|call| {
455            call.kind() == "call_expression"
456                && call
457                    .named_child(0)
458                    .is_some_and(|function| function.id() == navigation.id())
459        })
460}
461
462/// Whether the receiver of this `navigation_expression` is a namespace: a type
463/// declared in this file or a name bound by an import.
464#[cfg(feature = "lang-kotlin")]
465fn kotlin_receiver_is_namespace(navigation: Node, source: &[u8]) -> bool {
466    let mut receiver = navigation;
467    while receiver.kind() == "navigation_expression" {
468        let Some(inner) = receiver.named_child(0) else {
469            return false;
470        };
471        receiver = inner;
472    }
473    if receiver.kind() != "identifier" {
474        return false;
475    }
476    let Ok(name) = receiver.utf8_text(source) else {
477        return false;
478    };
479    kotlin_file_declares_type(navigation, name, source)
480        || kotlin_file_imports_name(navigation, name, source)
481}
482
483/// Whether the file holding `node` declares a class, interface, or object named
484/// `name`.
485#[cfg(feature = "lang-kotlin")]
486fn kotlin_file_declares_type(node: Node, name: &str, source: &[u8]) -> bool {
487    let mut root = node;
488    while let Some(parent) = root.parent() {
489        root = parent;
490    }
491    let mut cursor = root.walk();
492    let mut descend = true;
493    loop {
494        if descend {
495            let current = cursor.node();
496            if matches!(
497                current.kind(),
498                "class_declaration" | "object_declaration" | "interface_declaration"
499            ) && current
500                .child_by_field_name("name")
501                .and_then(|declared| declared.utf8_text(source).ok())
502                == Some(name)
503            {
504                return true;
505            }
506            if cursor.goto_first_child() {
507                continue;
508            }
509        }
510        if cursor.goto_next_sibling() {
511            descend = true;
512            continue;
513        }
514        if !cursor.goto_parent() {
515            return false;
516        }
517        descend = false;
518    }
519}
520
521/// Whether the file holding `node` imports a declaration under `name`.
522///
523/// A Kotlin import binds the last path segment unless an `as` alias is present.
524/// Wildcard imports do not prove which names they bind, so they remain
525/// deliberately unresolved.
526#[cfg(feature = "lang-kotlin")]
527fn kotlin_file_imports_name(node: Node, name: &str, source: &[u8]) -> bool {
528    let mut root = node;
529    while let Some(parent) = root.parent() {
530        root = parent;
531    }
532    let mut cursor = root.walk();
533    let mut descend = true;
534    loop {
535        if descend {
536            let current = cursor.node();
537            if current.kind() == "import" && kotlin_import_binds(current, name, source) {
538                return true;
539            }
540            if cursor.goto_first_child() {
541                continue;
542            }
543        }
544        if cursor.goto_next_sibling() {
545            descend = true;
546            continue;
547        }
548        if !cursor.goto_parent() {
549            return false;
550        }
551        descend = false;
552    }
553}
554
555#[cfg(feature = "lang-kotlin")]
556fn kotlin_import_binds(import: Node, name: &str, source: &[u8]) -> bool {
557    let mut cursor = import.walk();
558    let children = import.named_children(&mut cursor).collect::<Vec<_>>();
559    if let Some(alias) = children
560        .get(1)
561        .filter(|child| child.kind() == "identifier")
562    {
563        return alias
564            .utf8_text(source)
565            .is_ok_and(|bound_name| bound_name == name);
566    }
567    children
568        .first()
569        .filter(|path| matches!(path.kind(), "identifier" | "qualified_identifier"))
570        .and_then(|path| path.utf8_text(source).ok())
571        .and_then(|path| path.rsplit('.').next())
572        .is_some_and(|bound_name| bound_name == name)
573}
574
575/// Zig spells a struct field declaration and every member access with the same
576/// flat `identifier` kind as a binding, so position is the only separator.
577///
578/// The member of `x.name` is *not* treated the way Python and Kotlin members
579/// are, because Zig has no import-into-namespace form: `@import("m.zig").name`
580/// and `Type.name` are the only ways to reach another declaration, and both are
581/// `field_expression` members. Dropping them by position would leave every
582/// cross-file reference of a renamed `const`, type, or non-called function
583/// pointing at a name that no longer exists. So a member is kept whenever its
584/// receiver chain roots in a *namespace* — an `@import` binding or a container
585/// type — and dropped only when the receiver is an ordinary value, where the
586/// member is a struct field. The callee exception applies there for the same
587/// reason it does elsewhere: Zig indexes methods as `function_declaration`.
588#[cfg(feature = "lang-zig")]
589fn zig_occurrence_matches_target(node: Node, source: &[u8], target: RenameTarget) -> bool {
590    let Some(parent) = node.parent() else {
591        return true;
592    };
593    match parent.kind() {
594        // `container_field` is a struct/enum/union field declaration. No
595        // capture in `Lang::symbol_query` produces one, so it can never be the
596        // symbol a resolved rename selected.
597        "container_field" => parent
598            .child_by_field_name("name")
599            .is_none_or(|name| name.id() != node.id()),
600        "field_expression" => {
601            if parent
602                .child_by_field_name("member")
603                .is_none_or(|member| member.id() != node.id())
604            {
605                return true;
606            }
607            if zig_receiver_is_namespace(parent, source) {
608                return true;
609            }
610            target == RenameTarget::Callable
611                && parent.parent().is_some_and(|call| {
612                    call.kind() == "call_expression"
613                        && call
614                            .child_by_field_name("function")
615                            .is_some_and(|function| function.id() == parent.id())
616                })
617        }
618        _ => true,
619    }
620}
621
622/// Whether the receiver of `field_expression` is a namespace rather than a value.
623///
624/// `@import("m.zig").name` is a namespace outright. An identifier receiver is a
625/// namespace when this file binds it to an `@import` or to a container type —
626/// `const m = @import("m.zig")`, `const Panel = struct { ... }` — because a Zig
627/// container type doubles as the namespace holding its declarations. A chained
628/// receiver (`m.Sub.name`) is resolved by walking to the root of the chain.
629///
630/// Anything this cannot prove is *not* a namespace, which is the conservative
631/// answer only because the caller's fallback for a value receiver still keeps
632/// the callee position. A receiver whose binding lives in another file resolves
633/// to `false` here; that case is the struct-field reading it is indistinguishable
634/// from, and the call site is still renamed.
635#[cfg(feature = "lang-zig")]
636fn zig_receiver_is_namespace(field_expression: Node, source: &[u8]) -> bool {
637    let Some(mut object) = field_expression.child_by_field_name("object") else {
638        return false;
639    };
640    while object.kind() == "field_expression" {
641        let Some(inner) = object.child_by_field_name("object") else {
642            return false;
643        };
644        object = inner;
645    }
646    match object.kind() {
647        "builtin_function" => zig_is_import_builtin(object, source),
648        "identifier" => object
649            .utf8_text(source)
650            .is_ok_and(|name| zig_file_binds_namespace(field_expression, name, source)),
651        _ => false,
652    }
653}
654
655/// Whether this `builtin_function` node is an `@import(...)` call.
656#[cfg(feature = "lang-zig")]
657fn zig_is_import_builtin(builtin: Node, source: &[u8]) -> bool {
658    let mut cursor = builtin.walk();
659    builtin.named_children(&mut cursor).any(|child| {
660        child.kind() == "builtin_identifier"
661            && child.utf8_text(source).is_ok_and(|text| text == "@import")
662    })
663}
664
665/// Whether the file holding `node` binds `name` to an `@import` or a container
666/// type declaration.
667///
668/// Only whole-file scanning can answer this, and it runs once per *matching*
669/// occurrence — the walk has already filtered to identifiers whose text is the
670/// symbol being renamed — so it is bounded by the number of member positions
671/// that spell the renamed name, not by the file's identifier count.
672#[cfg(feature = "lang-zig")]
673fn zig_file_binds_namespace(node: Node, name: &str, source: &[u8]) -> bool {
674    let mut root = node;
675    while let Some(parent) = root.parent() {
676        root = parent;
677    }
678    let mut cursor = root.walk();
679    let mut descend = true;
680    loop {
681        if descend {
682            let current = cursor.node();
683            if current.kind() == "variable_declaration"
684                && zig_declaration_binds_namespace(current, name, source)
685            {
686                return true;
687            }
688            if cursor.goto_first_child() {
689                continue;
690            }
691        }
692        if cursor.goto_next_sibling() {
693            descend = true;
694            continue;
695        }
696        if !cursor.goto_parent() {
697            return false;
698        }
699        descend = false;
700    }
701}
702
703/// Whether one `variable_declaration` binds `name` to a namespace value.
704#[cfg(feature = "lang-zig")]
705fn zig_declaration_binds_namespace(declaration: Node, name: &str, source: &[u8]) -> bool {
706    let mut cursor = declaration.walk();
707    let children: Vec<Node> = declaration.named_children(&mut cursor).collect();
708    let binds_name = children.iter().any(|child| {
709        child.kind() == "identifier" && child.utf8_text(source).is_ok_and(|text| text == name)
710    });
711    if !binds_name {
712        return false;
713    }
714    children.iter().any(|child| match child.kind() {
715        "builtin_function" => zig_is_import_builtin(*child, source),
716        // A Zig container type is also the namespace holding its declarations,
717        // so `Panel.method` reaches a `function_declaration` the index has.
718        "struct_declaration" | "enum_declaration" | "union_declaration"
719        | "opaque_declaration" => true,
720        _ => false,
721    })
722}
723
724/// The JS-like grammars spell every property `property_identifier`, whether it
725/// is an object-literal key, a class method, or a member access. None of those
726/// is the module-level binding a rename resolves to — `Lang::symbol_query`
727/// indexes `function_declaration`, `class_declaration`, and arrow-valued
728/// `variable_declarator`, and nothing else — so a resolved rename must leave
729/// them alone.
730#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
731fn js_like_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
732    match node.kind() {
733        "property_identifier" => false,
734        "type_identifier" => target == RenameTarget::Type,
735        _ => true,
736    }
737}
738
739/// Whether this occurrence must be written as `key: replacement`.
740///
741/// `{ beta }` is one token doing two jobs: it names the property *and* reads
742/// the binding. Overwriting the span renames the property as a side effect;
743/// skipping it leaves a read of a name that no longer exists. Expanding to
744/// `beta: gamma` is the only spelling where both stay correct, and it is
745/// exactly what the shorthand desugars to.
746#[allow(unused_variables)]
747fn occurrence_expands_shorthand_key(lang: Lang, node: Node, target: RenameTarget) -> bool {
748    if target == RenameTarget::Unresolved {
749        return false;
750    }
751    match lang {
752        #[cfg(feature = "lang-typescript")]
753        Lang::TypeScript | Lang::Tsx => js_like_shorthand_key(node),
754        #[cfg(feature = "lang-javascript")]
755        Lang::JavaScript | Lang::Jsx => js_like_shorthand_key(node),
756        _ => false,
757    }
758}
759
760/// An object-literal shorthand, and deliberately *not* a destructuring pattern.
761///
762/// `const { beta } = mod` is `shorthand_property_identifier_pattern`: there the
763/// token reads a property off `mod` and declares a local of the same name, so
764/// the correct rewrite depends on whether `mod` is the module whose export was
765/// renamed — which is the common case, and which plain span renaming already
766/// gets right. Expanding it would be wrong for that case, so it is left alone.
767#[cfg(any(feature = "lang-typescript", feature = "lang-javascript"))]
768fn js_like_shorthand_key(node: Node) -> bool {
769    node.kind() == "shorthand_property_identifier"
770        && node.parent().is_some_and(|parent| parent.kind() == "object")
771}
772
773/// Rust spells three unrelated things `field_identifier`: a struct field
774/// declaration, a field read, and the method in `x.method()`. The first two
775/// cannot be a function, and the third must stay, or renaming a method would
776/// leave every call site broken.
777#[cfg(feature = "lang-rust")]
778fn rust_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
779    let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
780    match node.kind() {
781        "field_identifier" => {
782            // `x.count()` parses as a `call_expression` whose `function` is the
783            // `field_expression` holding this node. Every other position — a
784            // `field_declaration`, a `field_initializer`, a bare `x.count` read
785            // — is a field, which a function/type/value rename must not touch.
786            target == RenameTarget::Callable && parent_kind == "field_expression" && {
787                node.parent()
788                    .and_then(|field_expression| {
789                        let call = field_expression.parent()?;
790                        (call.kind() == "call_expression"
791                            && call.child_by_field_name("function")?.id() == field_expression.id())
792                        .then_some(())
793                    })
794                    .is_some()
795            }
796        }
797        "shorthand_field_identifier" => target == RenameTarget::Value,
798        // `S { count }` desugars to `count: count`, so the identifier names a
799        // *field* as well as reading a binding. Renaming only the read would
800        // change the field too, so a function or type rename skips it; a value
801        // rename keeps the pre-existing behaviour.
802        "identifier" if parent_kind == "shorthand_field_initializer" => {
803            matches!(target, RenameTarget::Value)
804        }
805        "type_identifier" => target == RenameTarget::Type,
806        _ => true,
807    }
808}
809
810/// GDScript spells every declared name `name`, from `func` to a local `var`,
811/// and every reference `identifier`. The declaration node therefore says which
812/// kind of thing is being declared, and a rename of one kind must not rewrite
813/// another's declaration.
814#[cfg(feature = "lang-gdscript")]
815fn gdscript_occurrence_matches_target(node: Node, target: RenameTarget) -> bool {
816    let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
817    match node.kind() {
818        "name" => {
819            let declares: &[&str] = match target {
820                RenameTarget::Callable => &["function_definition"],
821                RenameTarget::Signal => &["signal_statement"],
822                RenameTarget::Type => &["class_definition", "class_name_statement", "enum_definition"],
823                RenameTarget::Value => &[
824                    "variable_statement",
825                    "const_statement",
826                    "export_variable_statement",
827                    "onready_variable_statement",
828                ],
829                RenameTarget::Unresolved => return true,
830            };
831            declares.contains(&parent_kind)
832        }
833        // A parameter is a fresh binding that shadows, never a reference to the
834        // module-level symbol being renamed.
835        "identifier" if parent_kind == "parameters" => false,
836        _ => true,
837    }
838}
839
840/// Every occurrence of `name` that is a real identifier node, in source order.
841///
842/// Returns an empty vector when the name never appears as an identifier, which
843/// is distinct from it appearing only inside strings or comments — both look
844/// the same to the caller, and both mean "there is nothing here to rename".
845pub fn identifier_occurrences(
846    lang: Lang,
847    source: &[u8],
848    name: &str,
849) -> Result<Vec<IdentifierOccurrence>> {
850    identifier_occurrences_for(lang, source, name, RenameTarget::Unresolved)
851}
852
853/// The same walk, narrowed to occurrences that could be `target`.
854pub fn identifier_occurrences_for(
855    lang: Lang,
856    source: &[u8],
857    name: &str,
858    target: RenameTarget,
859) -> Result<Vec<IdentifierOccurrence>> {
860    let kinds = identifier_node_kinds(lang);
861    if kinds.is_empty() || name.is_empty() {
862        return Ok(Vec::new());
863    }
864
865    let ts_lang = lang.tree_sitter_language();
866    let mut parser = Parser::new();
867    parser.set_language(&ts_lang)?;
868    let tree = parser
869        .parse(source, None)
870        .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
871
872    let mut occurrences = Vec::new();
873    // A declaration of the same name that the target narrowing rejected, and
874    // that *shadows* the target rather than merely coexisting with it.
875    let mut shadowing_declaration_line: Option<usize> = None;
876    // A reference the grammar cannot attribute to either one.
877    let mut saw_ambiguous_reference = false;
878    let mut cursor = tree.walk();
879    let mut descend = true;
880    loop {
881        if descend {
882            let node = cursor.node();
883            if kinds.contains(&node.kind())
884                && node.utf8_text(source).is_ok_and(|it| it == name)
885                && occurrence_is_renamable(lang, node)
886            {
887                if occurrence_matches_target(lang, node, source, target) {
888                    occurrences.push(IdentifierOccurrence {
889                        start_byte: node.start_byte(),
890                        end_byte: node.end_byte(),
891                        expands_shorthand_key: occurrence_expands_shorthand_key(
892                            lang, node, target,
893                        ),
894                    });
895                    saw_ambiguous_reference |= occurrence_is_ambiguous_reference(lang, node, target);
896                } else if shadowing_declaration_line.is_none()
897                    && occurrence_shadows_target(lang, node, target)
898                {
899                    shadowing_declaration_line = Some(node.start_position().row + 1);
900                }
901            }
902            if cursor.goto_first_child() {
903                continue;
904            }
905        }
906        if cursor.goto_next_sibling() {
907            descend = true;
908            continue;
909        }
910        if !cursor.goto_parent() {
911            break;
912        }
913        descend = false;
914    }
915
916    // A pre-order walk already yields these in source order, but nested
917    // grammars can nest an identifier inside another identifier-kind node, and
918    // every caller splices spans left to right.
919    occurrences.sort_by_key(|occurrence| (occurrence.start_byte, occurrence.end_byte));
920    occurrences.dedup();
921
922    // Narrowing a declaration out while still rewriting references to it would
923    // produce a file where the declaration keeps the old name and a read of it
924    // carries the new one — internally inconsistent, and worse than either
925    // renaming both or renaming neither. Where the grammar cannot separate the
926    // two, refuse and name the shadow, the same way an unattributable
927    // cross-file reference refuses instead of guessing.
928    if let Some(line) = shadowing_declaration_line
929        && saw_ambiguous_reference
930    {
931        anyhow::bail!(
932            "rename_symbol refuses {name:?}: a same-named declaration on line {line} shadows it, and a bare reference cannot say which one it belongs to"
933        );
934    }
935    Ok(occurrences)
936}
937
938/// A rejected declaration that *shadows* the rename target inside this file.
939///
940/// Two Rust `field_identifier` positions are not shadows: a field and a
941/// function are reached through different syntax, so no reference is ambiguous.
942/// A GDScript local `var` is a shadow: within its scope a bare `count` is the
943/// variable, not the function, and the grammar spells both the same.
944fn occurrence_shadows_target(lang: Lang, node: Node, target: RenameTarget) -> bool {
945    match lang {
946        #[cfg(feature = "lang-gdscript")]
947        Lang::GdScript => {
948            if target != RenameTarget::Callable {
949                return false;
950            }
951            let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
952            match node.kind() {
953                "name" => matches!(
954                    parent_kind,
955                    "variable_statement"
956                        | "const_statement"
957                        | "export_variable_statement"
958                        | "onready_variable_statement"
959                ),
960                "identifier" => parent_kind == "parameters",
961                _ => false,
962            }
963        }
964        _ => {
965            let _ = (node, target);
966            false
967        }
968    }
969}
970
971/// A kept occurrence that a shadowing declaration would make ambiguous.
972///
973/// A callee is never ambiguous — `count()` is the function whatever else is in
974/// scope. A bare read is, because it could be either.
975fn occurrence_is_ambiguous_reference(lang: Lang, node: Node, target: RenameTarget) -> bool {
976    match lang {
977        #[cfg(feature = "lang-gdscript")]
978        Lang::GdScript => {
979            if target != RenameTarget::Callable || node.kind() != "identifier" {
980                return false;
981            }
982            let parent_kind = node.parent().map(|parent| parent.kind()).unwrap_or("");
983            !matches!(parent_kind, "call" | "attribute_call" | "base_call")
984        }
985        _ => {
986            let _ = (node, target);
987            false
988        }
989    }
990}
991
992/// Splice `replacement` over every occurrence span, returning the new source
993/// and the number of substitutions.
994pub fn replace_occurrences(
995    source: &str,
996    occurrences: &[IdentifierOccurrence],
997    replacement: &str,
998) -> (String, usize) {
999    let mut out = String::with_capacity(source.len());
1000    let mut last = 0usize;
1001    let mut replaced = 0usize;
1002    for occurrence in occurrences {
1003        if occurrence.start_byte < last {
1004            // Overlapping spans would corrupt the splice; the first one wins.
1005            continue;
1006        }
1007        out.push_str(&source[last..occurrence.start_byte]);
1008        if occurrence.expands_shorthand_key {
1009            // `{ beta }` becomes `{ beta: gamma }`: the property keeps its name,
1010            // the value follows the rename.
1011            out.push_str(&source[occurrence.start_byte..occurrence.end_byte]);
1012            out.push_str(": ");
1013        }
1014        out.push_str(replacement);
1015        last = occurrence.end_byte;
1016        replaced += 1;
1017    }
1018    out.push_str(&source[last..]);
1019    (out, replaced)
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    #[cfg(feature = "lang-rust")]
1027    const RUST_SOURCE: &str = r#"/// doc widget_count
1028fn widget_count() -> usize { 3 }
1029
1030fn describe() -> String {
1031    // widget_count comment
1032    let label = "widget_count";
1033    format!("{label}: {}", widget_count())
1034}
1035"#;
1036
1037    #[cfg(feature = "lang-rust")]
1038    #[test]
1039    fn rust_skips_strings_and_comments_but_reaches_macro_arguments() {
1040        let found =
1041            identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
1042        // The definition and the call inside `format!` — not the doc comment,
1043        // the line comment, or the string literal.
1044        assert_eq!(
1045            found.len(),
1046            2,
1047            "expected the definition and the macro-argument call, got {found:?}"
1048        );
1049        for occurrence in &found {
1050            let before = &RUST_SOURCE[..occurrence.start_byte];
1051            assert!(
1052                !before.ends_with("/// doc ") && !before.ends_with("// "),
1053                "occurrence at {} is inside a comment",
1054                occurrence.start_byte
1055            );
1056            assert!(
1057                !before.ends_with('"'),
1058                "occurrence at {} is inside a string literal",
1059                occurrence.start_byte
1060            );
1061        }
1062    }
1063
1064    #[cfg(feature = "lang-rust")]
1065    #[test]
1066    fn replacing_rust_occurrences_leaves_prose_and_data_alone() {
1067        let found =
1068            identifier_occurrences(Lang::Rust, RUST_SOURCE.as_bytes(), "widget_count").unwrap();
1069        let (out, replaced) = replace_occurrences(RUST_SOURCE, &found, "gadget_count");
1070        assert_eq!(replaced, 2);
1071        assert!(out.contains("fn gadget_count()"), "definition not renamed");
1072        assert!(
1073            out.contains("gadget_count())"),
1074            "macro-argument call not renamed"
1075        );
1076        assert!(
1077            out.contains("/// doc widget_count"),
1078            "doc comment was renamed"
1079        );
1080        assert!(
1081            out.contains("// widget_count comment"),
1082            "line comment was renamed"
1083        );
1084        assert!(
1085            out.contains("\"widget_count\""),
1086            "string literal was renamed"
1087        );
1088    }
1089
1090    #[cfg(feature = "lang-python")]
1091    #[test]
1092    fn python_skips_strings_and_comments() {
1093        let source = "def widget_count():\n    # widget_count comment\n    return \"widget_count\"\n\nwidget_count()\n";
1094        let found = identifier_occurrences(Lang::Python, source.as_bytes(), "widget_count").unwrap();
1095        assert_eq!(found.len(), 2, "got {found:?}");
1096        let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1097        assert_eq!(replaced, 2);
1098        assert!(out.contains("def gadget_count()"));
1099        assert!(out.contains("gadget_count()\n"));
1100        assert!(out.contains("# widget_count comment"));
1101        assert!(out.contains("\"widget_count\""));
1102    }
1103
1104    #[cfg(feature = "lang-python")]
1105    #[test]
1106    fn python_callable_narrowing_keeps_method_calls_but_skips_attribute_reads() {
1107        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";
1108        let found = identifier_occurrences_for(
1109            Lang::Python,
1110            source.as_bytes(),
1111            "widget_count",
1112            RenameTarget::Callable,
1113        )
1114        .unwrap();
1115        let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1116
1117        assert_eq!(replaced, 4, "got {found:?}\n{out}");
1118        assert!(out.contains("def gadget_count():"));
1119        assert!(out.contains("def gadget_count(self):"));
1120        assert!(out.contains("called = panel.gadget_count()"));
1121        assert!(out.contains("direct = gadget_count()"));
1122        assert!(out.contains("read = panel.widget_count\n"));
1123    }
1124
1125    /// The attribute rule must not swallow `mod.name`. `import mod` is half of
1126    /// how Python spells a cross-module reference, and the rename is cross-file,
1127    /// so dropping it renames the definition and leaves every reader broken.
1128    #[cfg(feature = "lang-python")]
1129    #[test]
1130    fn python_narrowing_keeps_imported_module_attributes_including_bare_reads() {
1131        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";
1132        let found = identifier_occurrences_for(
1133            Lang::Python,
1134            source.as_bytes(),
1135            "widget_count",
1136            RenameTarget::Callable,
1137        )
1138        .unwrap();
1139        let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1140
1141        assert_eq!(replaced, 4, "got {found:?}\n{out}");
1142        assert!(out.contains("def gadget_count():"), "{out}");
1143        assert!(
1144            out.contains("module_read = mod.gadget_count\n"),
1145            "an imported-module read was dropped:\n{out}"
1146        );
1147        assert!(out.contains("module_call = mod.gadget_count()"), "{out}");
1148        assert!(
1149            out.contains("aliased_read = aliased.gadget_count"),
1150            "an aliased-import read was dropped:\n{out}"
1151        );
1152        assert!(
1153            out.contains("read = panel.widget_count\n"),
1154            "an instance attribute read was renamed:\n{out}"
1155        );
1156    }
1157
1158    #[cfg(feature = "lang-kotlin")]
1159    #[test]
1160    fn kotlin_callable_narrowing_keeps_method_calls_but_skips_navigation_reads() {
1161        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";
1162        let found = identifier_occurrences_for(
1163            Lang::Kotlin,
1164            source.as_bytes(),
1165            "widgetCount",
1166            RenameTarget::Callable,
1167        )
1168        .unwrap();
1169        let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1170
1171        assert_eq!(replaced, 4, "got {found:?}\n{out}");
1172        assert!(out.contains("fun gadgetCount(): Int = 1"));
1173        assert!(out.contains("fun gadgetCount(): Int = 2"));
1174        assert!(out.contains("val called = panel.gadgetCount()"));
1175        assert!(out.contains("val direct = gadgetCount()"));
1176        assert!(out.contains("val read = panel.widgetCount\n"));
1177    }
1178
1179    /// A receiver that names a declared type is a namespace, not a value, so its
1180    /// member is a declaration the index holds. Dropping it renames the
1181    /// companion/object declaration and leaves the qualified access behind.
1182    #[cfg(feature = "lang-kotlin")]
1183    #[test]
1184    fn kotlin_narrowing_keeps_members_of_types_declared_in_the_file() {
1185        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";
1186        let found = identifier_occurrences_for(
1187            Lang::Kotlin,
1188            source.as_bytes(),
1189            "widgetCount",
1190            RenameTarget::Callable,
1191        )
1192        .unwrap();
1193        let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1194
1195        assert_eq!(replaced, 4, "got {found:?}\n{out}");
1196        assert!(out.contains("fun gadgetCount(): Int = 2"), "{out}");
1197        assert!(out.contains("fun gadgetCount(): Int = 3"), "{out}");
1198        assert!(
1199            out.contains("val fromClass = Panel.gadgetCount\n"),
1200            "a companion member read was dropped:\n{out}"
1201        );
1202        assert!(
1203            out.contains("val fromObject = Registry.gadgetCount()"),
1204            "an object member call was dropped:\n{out}"
1205        );
1206        assert!(
1207            out.contains("val fromValue = panel.widgetCount\n"),
1208            "a value's member read was renamed:\n{out}"
1209        );
1210    }
1211
1212    /// Imports bind external declarations into the local namespace. Qualified
1213    /// reads through the imported name or alias must survive callable narrowing,
1214    /// even when they are not immediately called.
1215    #[cfg(feature = "lang-kotlin")]
1216    #[test]
1217    fn kotlin_narrowing_keeps_members_of_imported_names() {
1218        let source = "import widgets.Panel\n\
1219import widgets.Registry as ExternalRegistry\n\
1220\n\
1221val fromClass = Panel.widgetCount\n\
1222val fromAlias = ExternalRegistry.widgetCount()\n\
1223val fromValue = panel.widgetCount\n";
1224        let found = identifier_occurrences_for(
1225            Lang::Kotlin,
1226            source.as_bytes(),
1227            "widgetCount",
1228            RenameTarget::Callable,
1229        )
1230        .unwrap();
1231        let (out, replaced) = replace_occurrences(source, &found, "gadgetCount");
1232
1233        assert_eq!(replaced, 2, "got {found:?}\n{out}");
1234        assert!(
1235            out.contains("val fromClass = Panel.gadgetCount\n"),
1236            "{out}"
1237        );
1238        assert!(
1239            out.contains("val fromAlias = ExternalRegistry.gadgetCount()\n"),
1240            "{out}"
1241        );
1242        assert!(out.contains("val fromValue = panel.widgetCount\n"), "{out}");
1243    }
1244
1245    #[cfg(feature = "lang-typescript")]
1246    #[test]
1247    fn typescript_skips_strings_and_comments() {
1248        let source = "// widgetCount comment\nfunction widgetCount(): number { return 1; }\nconst label = \"widgetCount\";\nwidgetCount();\n";
1249        let found =
1250            identifier_occurrences(Lang::TypeScript, source.as_bytes(), "widgetCount").unwrap();
1251        assert_eq!(found.len(), 2, "got {found:?}");
1252        let (out, _) = replace_occurrences(source, &found, "gadgetCount");
1253        assert!(out.contains("function gadgetCount()"));
1254        assert!(out.contains("// widgetCount comment"));
1255        assert!(out.contains("\"widgetCount\""));
1256    }
1257
1258    #[cfg(feature = "lang-bash")]
1259    const BASH_SOURCE: &str = r#"widget_count() {
1260  echo widget_count
1261  local label="widget_count"
1262  # widget_count comment
1263  echo "$widget_count"
1264}
1265widget_count
1266"#;
1267
1268    #[cfg(feature = "lang-bash")]
1269    #[test]
1270    fn bash_renames_names_but_not_arguments_prose_or_data() {
1271        let found =
1272            identifier_occurrences(Lang::Bash, BASH_SOURCE.as_bytes(), "widget_count").unwrap();
1273        // The definition, the `$widget_count` expansion, and the bare call —
1274        // not the `echo widget_count` argument, the string, or the comment.
1275        assert_eq!(found.len(), 3, "got {found:?}");
1276        let (out, replaced) = replace_occurrences(BASH_SOURCE, &found, "gadget_count");
1277        assert_eq!(replaced, 3);
1278        assert!(out.contains("gadget_count() {"), "definition not renamed");
1279        assert!(
1280            out.contains("echo \"$gadget_count\""),
1281            "expansion not renamed"
1282        );
1283        assert!(
1284            out.contains("}\ngadget_count\n"),
1285            "bare call not renamed:\n{out}"
1286        );
1287        assert!(
1288            out.contains("echo widget_count\n"),
1289            "an unquoted argument was renamed, which rewrites data:\n{out}"
1290        );
1291        assert!(out.contains("label=\"widget_count\""), "string was renamed");
1292        assert!(
1293            out.contains("# widget_count comment"),
1294            "comment was renamed"
1295        );
1296    }
1297
1298    #[cfg(feature = "lang-zig")]
1299    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";
1300
1301    /// The member positions Zig cannot narrow by the callee rule alone. A field
1302    /// read off a value is dropped; a namespace member is kept whether or not
1303    /// it is called, because `@import(...)` and a container type are the only
1304    /// ways Zig reaches another declaration.
1305    #[cfg(feature = "lang-zig")]
1306    #[test]
1307    fn zig_callable_narrowing_keeps_namespace_members_but_skips_field_reads() {
1308        let found = identifier_occurrences_for(
1309            Lang::Zig,
1310            ZIG_MEMBER_SOURCE.as_bytes(),
1311            "widget_count",
1312            RenameTarget::Callable,
1313        )
1314        .unwrap();
1315        let (out, replaced) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
1316
1317        assert_eq!(replaced, 5, "got {found:?}\n{out}");
1318        assert!(out.contains("pub fn gadget_count() u32"), "{out}");
1319        assert!(out.contains("return gadget_count() +"), "{out}");
1320        assert!(out.contains("m.gadget_count()"), "import call dropped:\n{out}");
1321        assert!(
1322            out.contains("m.gadget_count +"),
1323            "import read dropped, which breaks every cross-file reference:\n{out}"
1324        );
1325        assert!(
1326            out.contains("Panel.gadget_count;"),
1327            "container-type member dropped:\n{out}"
1328        );
1329        assert!(
1330            out.contains("    widget_count: u32 = 0,"),
1331            "a struct field declaration was renamed:\n{out}"
1332        );
1333        assert!(
1334            out.contains("p.widget_count +"),
1335            "a field read off a value was renamed:\n{out}"
1336        );
1337        assert!(
1338            out.contains("return self.widget_count;"),
1339            "a field read off self was renamed:\n{out}"
1340        );
1341    }
1342
1343    /// A `const` rename keeps the namespace members — those name a module-level
1344    /// declaration in another file — and drops the struct field: no capture in
1345    /// `Lang::symbol_query` produces a `container_field`, so a field is never the
1346    /// symbol a resolved rename selected, and a field read off a value receiver
1347    /// is the one member position the grammar does attribute.
1348    #[cfg(feature = "lang-zig")]
1349    #[test]
1350    fn zig_value_narrowing_keeps_namespace_members_and_drops_struct_fields() {
1351        let found = identifier_occurrences_for(
1352            Lang::Zig,
1353            ZIG_MEMBER_SOURCE.as_bytes(),
1354            "widget_count",
1355            RenameTarget::Value,
1356        )
1357        .unwrap();
1358        let (out, _) = replace_occurrences(ZIG_MEMBER_SOURCE, &found, "gadget_count");
1359
1360        assert!(
1361            out.contains("m.gadget_count +"),
1362            "an import-qualified const read was dropped:\n{out}"
1363        );
1364        assert!(
1365            out.contains("Panel.gadget_count;"),
1366            "a container-type const read was dropped:\n{out}"
1367        );
1368        assert!(
1369            out.contains("p.widget_count +"),
1370            "a struct field read was renamed by a const rename:\n{out}"
1371        );
1372        assert!(
1373            out.contains("    widget_count: u32 = 0,"),
1374            "the field declaration is not an indexed symbol and must not move:\n{out}"
1375        );
1376    }
1377
1378    #[cfg(feature = "lang-zig")]
1379    #[test]
1380    fn zig_skips_strings_and_comments() {
1381        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";
1382        let found = identifier_occurrences(Lang::Zig, source.as_bytes(), "widget_count").unwrap();
1383        assert_eq!(found.len(), 2, "got {found:?}");
1384        let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1385        assert_eq!(replaced, 2);
1386        assert!(out.contains("pub fn gadget_count()"), "definition not renamed");
1387        assert!(out.contains("return gadget_count();"), "call not renamed");
1388        assert!(
1389            out.contains("// widget_count comment"),
1390            "comment was renamed"
1391        );
1392        assert!(out.contains("\"widget_count\""), "string was renamed");
1393    }
1394
1395    #[cfg(feature = "lang-gdscript")]
1396    #[test]
1397    fn gdscript_renames_declaration_and_reference_but_not_prose() {
1398        let source = "# widget_count comment\nfunc widget_count():\n\tvar label = \"widget_count\"\n\treturn label\n\nfunc caller():\n\treturn widget_count()\n";
1399        let found =
1400            identifier_occurrences(Lang::GdScript, source.as_bytes(), "widget_count").unwrap();
1401        // GDScript names a declaration with `name` and every reference with
1402        // `identifier`; the rename has to reach both kinds.
1403        assert_eq!(found.len(), 2, "got {found:?}");
1404        let (out, replaced) = replace_occurrences(source, &found, "gadget_count");
1405        assert_eq!(replaced, 2);
1406        assert!(out.contains("func gadget_count():"), "definition not renamed");
1407        assert!(out.contains("return gadget_count()"), "call not renamed");
1408        assert!(
1409            out.contains("# widget_count comment"),
1410            "comment was renamed"
1411        );
1412        assert!(out.contains("\"widget_count\""), "string was renamed");
1413    }
1414
1415    #[cfg(feature = "lang-rust")]
1416    const RUST_FIELD_SOURCE: &str = r#"struct Meter { count: usize }
1417fn count() -> usize { 3 }
1418impl Meter {
1419    fn read(&self) -> usize { self.count }
1420    fn count(&self) -> usize { self.count }
1421}
1422fn use_it(m: &Meter) -> usize { m.count() + m.count + count() }
1423fn build() -> Meter { Meter { count: 1 } }
1424"#;
1425
1426    #[cfg(feature = "lang-rust")]
1427    #[test]
1428    fn renaming_a_rust_function_leaves_an_identically_named_field_alone() {
1429        let found =
1430            identifier_occurrences_for(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count", RenameTarget::Callable)
1431                .unwrap();
1432        let (out, _) = replace_occurrences(RUST_FIELD_SOURCE, &found, "tally");
1433        // Renamed: the free fn, the inherent method, the method call, the call.
1434        assert!(out.contains("fn tally() -> usize"), "free fn:\n{out}");
1435        assert!(out.contains("fn tally(&self)"), "inherent method:\n{out}");
1436        assert!(out.contains("m.tally()"), "method call:\n{out}");
1437        assert!(out.contains("+ tally()"), "free call:\n{out}");
1438        // Untouched: every position that is a field, not a function.
1439        assert!(
1440            out.contains("struct Meter { count: usize }"),
1441            "field declaration was renamed:\n{out}"
1442        );
1443        assert!(
1444            out.contains("{ self.count }"),
1445            "field read was renamed:\n{out}"
1446        );
1447        assert!(
1448            out.contains("m.count +"),
1449            "field read was renamed:\n{out}"
1450        );
1451        assert!(
1452            out.contains("Meter { count: 1 }"),
1453            "struct literal field was renamed:\n{out}"
1454        );
1455    }
1456
1457    #[cfg(feature = "lang-rust")]
1458    #[test]
1459    fn an_unresolved_rust_target_keeps_the_pre_narrowing_behaviour() {
1460        // With no resolved symbol there is nothing to narrow by, and dropping
1461        // occurrences on a guess would silently under-rename.
1462        let narrowed = identifier_occurrences_for(
1463            Lang::Rust,
1464            RUST_FIELD_SOURCE.as_bytes(),
1465            "count",
1466            RenameTarget::Callable,
1467        )
1468        .unwrap();
1469        let wide = identifier_occurrences(Lang::Rust, RUST_FIELD_SOURCE.as_bytes(), "count").unwrap();
1470        assert!(
1471            wide.len() > narrowed.len(),
1472            "narrowing dropped nothing: {} vs {}",
1473            wide.len(),
1474            narrowed.len()
1475        );
1476    }
1477
1478    #[cfg(feature = "lang-rust")]
1479    #[test]
1480    fn a_field_access_inside_a_macro_is_still_renamed() {
1481        // Known limitation, pinned rather than left as folklore. tree-sitter
1482        // parses macro arguments as an opaque `token_tree`, so `m.count`
1483        // inside `format!` is a bare `identifier` with no `field_expression`
1484        // around it — the position rule has nothing to read. Over-renaming is
1485        // the deliberate side to err on: the alternative is dropping the real
1486        // call sites inside macros that the walk exists to reach.
1487        let source = "struct Meter { count: usize }\nfn count() -> usize { 3 }\nfn f(m: &Meter) -> String { format!(\"{}\", m.count) }\n";
1488        let found =
1489            identifier_occurrences_for(Lang::Rust, source.as_bytes(), "count", RenameTarget::Callable)
1490                .unwrap();
1491        let (out, _) = replace_occurrences(source, &found, "tally");
1492        assert!(out.contains("m.tally)"), "expected the known over-rename:\n{out}");
1493        assert!(
1494            out.contains("struct Meter { count: usize }"),
1495            "the field declaration is outside the macro and must survive:\n{out}"
1496        );
1497    }
1498
1499    #[cfg(feature = "lang-gdscript")]
1500    #[test]
1501    fn renaming_a_gdscript_func_leaves_an_identically_named_var_declaration_alone() {
1502        // The local is declared but never read by name, so nothing here is
1503        // ambiguous and the rename can proceed.
1504        let source = "func count():\n\tvar count = 1\n\treturn 2\n\nfunc caller():\n\treturn count()\n";
1505        let found =
1506            identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
1507                .unwrap();
1508        let (out, _) = replace_occurrences(source, &found, "tally");
1509        assert!(out.contains("func tally():"), "declaration:\n{out}");
1510        assert!(out.contains("return tally()"), "call:\n{out}");
1511        assert!(
1512            out.contains("var count = 1"),
1513            "the local var declaration was renamed:\n{out}"
1514        );
1515    }
1516
1517    #[cfg(feature = "lang-gdscript")]
1518    #[test]
1519    fn a_gdscript_local_that_shadows_the_target_and_is_read_refuses() {
1520        // Renaming the `func` but not the shadowing `var` while still rewriting
1521        // `return count` would leave the declaration on the old name and its
1522        // read on the new one. Refusing names the shadow instead of guessing.
1523        let source = "func count():\n\tvar count = 1\n\treturn count\n\nfunc caller():\n\treturn count()\n";
1524        let err = identifier_occurrences_for(
1525            Lang::GdScript,
1526            source.as_bytes(),
1527            "count",
1528            RenameTarget::Callable,
1529        )
1530        .unwrap_err();
1531        let message = format!("{err:#}");
1532        assert!(message.contains("shadows it"), "{message}");
1533        assert!(message.contains("line 2"), "{message}");
1534    }
1535
1536    #[cfg(feature = "lang-gdscript")]
1537    #[test]
1538    fn a_gdscript_callee_is_never_ambiguous() {
1539        // A call site is the function whatever else is in scope, so a shadow
1540        // that is only ever *called* is not a reason to refuse.
1541        let source = "func count():\n\treturn 1\n\nfunc caller():\n\treturn count() + count()\n";
1542        let found = identifier_occurrences_for(
1543            Lang::GdScript,
1544            source.as_bytes(),
1545            "count",
1546            RenameTarget::Callable,
1547        )
1548        .unwrap();
1549        assert_eq!(found.len(), 3, "got {found:?}");
1550    }
1551
1552    #[cfg(feature = "lang-gdscript")]
1553    #[test]
1554    fn renaming_a_gdscript_var_leaves_the_function_declaration_alone() {
1555        // The mirror case: the same two `name` nodes, the other target kind.
1556        let source = "var count = 1\nfunc count():\n\treturn count\n";
1557        let found =
1558            identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Value)
1559                .unwrap();
1560        let (out, _) = replace_occurrences(source, &found, "tally");
1561        assert!(out.contains("var tally = 1"), "var declaration:\n{out}");
1562        assert!(
1563            out.contains("func count():"),
1564            "the function declaration was renamed:\n{out}"
1565        );
1566    }
1567
1568    #[cfg(feature = "lang-gdscript")]
1569    #[test]
1570    fn a_gdscript_parameter_is_a_binding_not_a_reference() {
1571        // The parameter shadows, and `return count` reads it, so this refuses
1572        // rather than renaming the read out from under the declaration.
1573        let shadowed = "func caller(count):\n\treturn count\n";
1574        let err = identifier_occurrences_for(
1575            Lang::GdScript,
1576            shadowed.as_bytes(),
1577            "count",
1578            RenameTarget::Callable,
1579        )
1580        .unwrap_err();
1581        assert!(format!("{err:#}").contains("shadows it"), "{err:#}");
1582
1583        // With nothing reading the parameter, the declaration is simply left
1584        // alone: it is a fresh binding, never a reference to our function.
1585        let source = "func caller(count):\n\treturn 1\n";
1586        let found =
1587            identifier_occurrences_for(Lang::GdScript, source.as_bytes(), "count", RenameTarget::Callable)
1588                .unwrap();
1589        let (out, _) = replace_occurrences(source, &found, "tally");
1590        assert!(
1591            out.contains("func caller(count):"),
1592            "a parameter declaration was renamed:\n{out}"
1593        );
1594    }
1595
1596    #[cfg(feature = "lang-typescript")]
1597    const TS_PROPERTY_SOURCE: &str = r#"function beta(v: number) { return v; }
1598const keyed = { beta: 1 };
1599const shorthand = { beta };
1600class K { beta() { return 2; } }
1601const k = new K();
1602const read = k.beta() + keyed.beta + beta(3);
1603export { beta };
1604"#;
1605
1606    #[cfg(feature = "lang-typescript")]
1607    #[test]
1608    fn renaming_a_typescript_function_leaves_properties_alone() {
1609        let found = identifier_occurrences_for(
1610            Lang::TypeScript,
1611            TS_PROPERTY_SOURCE.as_bytes(),
1612            "beta",
1613            RenameTarget::Callable,
1614        )
1615        .unwrap();
1616        let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
1617        // Renamed: the declaration, the call, and the export specifier.
1618        assert!(out.contains("function gamma(v: number)"), "declaration:
1619{out}");
1620        assert!(out.contains("+ gamma(3)"), "call:
1621{out}");
1622        assert!(out.contains("export { gamma };"), "export:
1623{out}");
1624        // Untouched: every property position.
1625        assert!(out.contains("{ beta: 1 }"), "object key was renamed:
1626{out}");
1627        assert!(
1628            out.contains("class K { beta()"),
1629            "class method was renamed:
1630{out}"
1631        );
1632        assert!(out.contains("k.beta()"), "member call was renamed:
1633{out}");
1634        assert!(out.contains("keyed.beta"), "member read was renamed:
1635{out}");
1636    }
1637
1638    #[cfg(feature = "lang-typescript")]
1639    #[test]
1640    fn a_javascript_object_shorthand_is_expanded_rather_than_overwritten() {
1641        // `{ beta }` names the property and reads the binding. Overwriting the
1642        // span would rename the property too; skipping it would leave a read of
1643        // a name that no longer exists.
1644        let found = identifier_occurrences_for(
1645            Lang::TypeScript,
1646            TS_PROPERTY_SOURCE.as_bytes(),
1647            "beta",
1648            RenameTarget::Callable,
1649        )
1650        .unwrap();
1651        let (out, _) = replace_occurrences(TS_PROPERTY_SOURCE, &found, "gamma");
1652        assert!(
1653            out.contains("const shorthand = { beta: gamma };"),
1654            "shorthand was not expanded:
1655{out}"
1656        );
1657    }
1658
1659    #[cfg(feature = "lang-typescript")]
1660    #[test]
1661    fn a_destructuring_pattern_is_renamed_in_place_not_expanded() {
1662        // `const { beta } = mod` reads a property off `mod`. When `mod` is the
1663        // module whose export was renamed — the common case — renaming the span
1664        // is exactly right, and expanding it would be wrong.
1665        let source = "import * as mod from './mod';
1666const { beta } = mod;
1667beta();
1668";
1669        let found =
1670            identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "beta", RenameTarget::Callable)
1671                .unwrap();
1672        let (out, _) = replace_occurrences(source, &found, "gamma");
1673        assert!(out.contains("const { gamma } = mod;"), "{out}");
1674        assert!(!out.contains("beta: gamma"), "pattern was expanded:
1675{out}");
1676    }
1677
1678    #[cfg(feature = "lang-typescript")]
1679    #[test]
1680    fn a_typescript_type_rename_keeps_type_identifiers_and_drops_properties() {
1681        let source = "type Beta = number;
1682const o = { Beta: 1 };
1683const v: Beta = 1;
1684export type { Beta };
1685";
1686        let callable = identifier_occurrences_for(
1687            Lang::TypeScript,
1688            source.as_bytes(),
1689            "Beta",
1690            RenameTarget::Callable,
1691        )
1692        .unwrap();
1693        let typed =
1694            identifier_occurrences_for(Lang::TypeScript, source.as_bytes(), "Beta", RenameTarget::Type)
1695                .unwrap();
1696        assert!(
1697            typed.len() > callable.len(),
1698            "a type rename must reach type_identifier positions a callable rename does not: {typed:?} vs {callable:?}"
1699        );
1700        let (out, _) = replace_occurrences(source, &typed, "Gamma");
1701        assert!(out.contains("type Gamma = number;"), "{out}");
1702        assert!(out.contains("const v: Gamma = 1;"), "{out}");
1703        assert!(out.contains("{ Beta: 1 }"), "object key was renamed:
1704{out}");
1705    }
1706
1707    #[test]
1708    fn indexed_symbol_kinds_map_onto_what_a_grammar_can_check() {
1709        assert_eq!(RenameTarget::from_indexed_kind("function"), RenameTarget::Callable);
1710        assert_eq!(RenameTarget::from_indexed_kind("signal"), RenameTarget::Signal);
1711        assert_eq!(RenameTarget::from_indexed_kind("struct"), RenameTarget::Type);
1712        assert_eq!(RenameTarget::from_indexed_kind("class"), RenameTarget::Type);
1713        assert_eq!(RenameTarget::from_indexed_kind("variable"), RenameTarget::Value);
1714        assert_eq!(RenameTarget::from_indexed_kind("const"), RenameTarget::Value);
1715        // An unrecognized kind must be permissive, never silently narrowing.
1716        assert_eq!(RenameTarget::from_indexed_kind("heading"), RenameTarget::Unresolved);
1717        assert_eq!(RenameTarget::from_indexed_kind(""), RenameTarget::Unresolved);
1718        assert_eq!(RenameTarget::default(), RenameTarget::Unresolved);
1719    }
1720
1721    #[test]
1722    fn a_name_that_only_appears_in_prose_has_no_occurrences() {
1723        #[cfg(feature = "lang-rust")]
1724        {
1725            let source = "// widget_count\nfn other() {}\n";
1726            let found =
1727                identifier_occurrences(Lang::Rust, source.as_bytes(), "widget_count").unwrap();
1728            assert!(found.is_empty(), "got {found:?}");
1729        }
1730    }
1731
1732    #[cfg(feature = "lang-markdown")]
1733    #[test]
1734    fn markdown_has_no_identifier_kinds() {
1735        assert!(identifier_node_kinds(Lang::Markdown).is_empty());
1736        assert!(
1737            identifier_occurrences(Lang::Markdown, b"# widget_count\n", "widget_count")
1738                .unwrap()
1739                .is_empty()
1740        );
1741    }
1742
1743    #[test]
1744    fn every_indexed_language_declares_its_identifier_kinds() {
1745        // A `Lang` variant added with no entry here would silently return an
1746        // empty set and make every rename in that language a no-op.
1747        for lang in Lang::all() {
1748            let kinds = identifier_node_kinds(lang);
1749            if lang.name() == "markdown" {
1750                continue;
1751            }
1752            assert!(
1753                !kinds.is_empty(),
1754                "{} declares no identifier node kinds",
1755                lang.name()
1756            );
1757            let ts_lang = lang.tree_sitter_language();
1758            for kind in kinds {
1759                assert!(
1760                    ts_lang.id_for_node_kind(kind, true) != 0,
1761                    "{} declares node kind {kind:?}, which its grammar does not have",
1762                    lang.name()
1763                );
1764            }
1765        }
1766    }
1767}