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