Skip to main content

normalize_refactor/
inline_variable.rs

1//! Inline-variable recipe: replace all uses of a variable with its initializer and remove the binding.
2//!
3//! Algorithm:
4//! 1. Parse the file with tree-sitter.
5//! 2. Locate the variable declaration node at the given position (line:col of the variable name).
6//! 3. Extract: variable name, initializer expression text, declaration node byte range,
7//!    and the scope node (the function/block that contains the declaration).
8//! 4. Within that scope: walk all identifier nodes, collect those whose text matches the
9//!    variable name (conservative: skip if ambiguous).
10//! 5. Check for reassignments — error out if any exist.
11//! 6. Replace each reference with the initializer expression text (wrapping in parens if needed).
12//! 7. Remove the declaration statement line.
13//!
14//! Language-specific declaration node kinds:
15//! - Rust: `let_declaration`  — pattern child is `identifier` or `_pattern`, value child via `=`
16//! - JS/TS: `lexical_declaration` (const/let) / `variable_declaration` (var) — contains
17//!   `variable_declarator` which has `name: identifier` and `value: <expr>`
18//! - Python: `assignment` — left child is `identifier`, right is the value
19
20use std::path::Path;
21
22use normalize_languages::parsers::parse_with_grammar;
23use normalize_languages::support_for_path;
24
25use crate::{PlannedEdit, RefactoringPlan};
26
27/// Outcome of a successful inline-variable plan.
28pub struct InlineVariableOutcome {
29    pub plan: RefactoringPlan,
30    /// The variable name that was inlined.
31    pub name: String,
32    /// Number of use-sites replaced (not counting the declaration removal).
33    pub references_replaced: usize,
34}
35
36/// Build an inline-variable plan without touching the filesystem.
37///
38/// `file` is the absolute path to the file (used for language detection).
39/// `content` is the current file content.
40/// `line` and `col` are 1-based (pointing at the variable name in its declaration).
41pub fn plan_inline_variable(
42    file: &Path,
43    content: &str,
44    line: usize,
45    col: usize,
46) -> Result<InlineVariableOutcome, String> {
47    if line == 0 || col == 0 {
48        return Err("Line and column numbers are 1-based".to_string());
49    }
50
51    // Determine grammar from path.
52    let support = support_for_path(file)
53        .ok_or_else(|| format!("No language support for {}", file.display()))?;
54    let grammar = support.grammar_name();
55
56    let tree = parse_with_grammar(grammar, content).ok_or_else(|| {
57        format!(
58            "Grammar '{}' not available — install grammars with `normalize grammars install`",
59            grammar
60        )
61    })?;
62
63    let root_node = tree.root_node();
64
65    // Convert line:col to byte offset.
66    let target_byte = line_col_to_byte(content, line, col).ok_or_else(|| {
67        format!(
68            "Position {}:{} is out of bounds for file of {} bytes",
69            line,
70            col,
71            content.len()
72        )
73    })?;
74
75    // Find the identifier node at the given position.
76    let ident_node = root_node
77        .descendant_for_byte_range(target_byte, target_byte + 1)
78        .ok_or_else(|| format!("No AST node found at {}:{}", line, col))?;
79
80    if ident_node.kind() != "identifier" {
81        return Err(format!(
82            "Position {}:{} points to a '{}' node, not a variable name (identifier)",
83            line,
84            col,
85            ident_node.kind()
86        ));
87    }
88
89    let var_name = &content[ident_node.start_byte()..ident_node.end_byte()];
90
91    // Walk up to find the declaration node.
92    let decl_node = find_declaration_node(&ident_node, grammar)?;
93
94    // Extract the initializer expression.
95    let initializer = extract_initializer(content, &decl_node, grammar)?;
96    let init_text = content[initializer.start_byte()..initializer.end_byte()].to_string();
97
98    // Find the scope node (the block/function/module containing the declaration).
99    let scope_node = find_scope_node(&decl_node)
100        .ok_or_else(|| "Could not find a scope containing the declaration".to_string())?;
101
102    // Find the declaration statement (the direct child of the scope block).
103    let decl_stmt = find_declaration_statement(&decl_node, &scope_node)?;
104
105    // Walk the scope to find all references and check for reassignments.
106    let refs = collect_references(content, &scope_node, var_name, &decl_node, grammar)?;
107
108    // Decide whether to wrap the init_text in parentheses.
109    // Wrap if the initializer is a binary expression, conditional, or similar compound expression
110    // that could have precedence issues when substituted inline.
111    let replacement = if needs_parens(&initializer) {
112        format!("({})", init_text)
113    } else {
114        init_text.clone()
115    };
116
117    // Warn if the initializer has side effects and there are multiple references.
118    let mut warnings = vec![];
119    if refs.len() > 1 && has_side_effects(&initializer) {
120        warnings.push(format!(
121            "inlining '{}' may change evaluation count: initializer appears to have side effects and is used {} times",
122            var_name, refs.len()
123        ));
124    }
125
126    // Build the new file content. We apply edits from back-to-front to preserve byte offsets.
127    // 1. Collect all edit sites: references (sorted by start byte desc) + declaration removal.
128    // 2. Also compute the declaration line range to remove.
129
130    let decl_stmt_start = decl_stmt.start_byte();
131    let decl_stmt_end = decl_stmt.end_byte();
132
133    // The line to remove: from start of line through the newline.
134    let remove_start = line_start(content, decl_stmt_start);
135    let remove_end = line_end_incl(content, decl_stmt_end);
136
137    // Sort references by start byte descending so we can apply back-to-front.
138    let mut sorted_refs = refs.clone();
139    sorted_refs.sort_by(|a, b| b.cmp(a));
140
141    let mut new_content = content.to_string();
142
143    // Apply reference replacements first (back-to-front).
144    for &ref_start in &sorted_refs {
145        let ref_end = ref_start + var_name.len();
146        new_content.replace_range(ref_start..ref_end, &replacement);
147    }
148
149    // Now remove the declaration line. Because all references come after the declaration
150    // (scope-wise), the declaration line's byte position in new_content has shifted by
151    // (replacement.len() - var_name.len()) * count_refs_after_decl. However, since
152    // refs are *after* the declaration in byte position, we need to account for them.
153    //
154    // Actually, since refs are at higher byte offsets than the declaration, replacing them
155    // back-to-front does NOT shift the declaration's byte range. The declaration comes first
156    // in the file. So `remove_start`/`remove_end` are still valid in `new_content`.
157    new_content.replace_range(remove_start..remove_end, "");
158
159    let references_replaced = sorted_refs.len();
160
161    let plan = RefactoringPlan {
162        operation: "inline_variable".to_string(),
163        edits: vec![PlannedEdit {
164            file: file.to_path_buf(),
165            original: content.to_string(),
166            new_content,
167            description: format!("inline variable '{}'", var_name),
168        }],
169        warnings,
170    };
171
172    Ok(InlineVariableOutcome {
173        plan,
174        name: var_name.to_string(),
175        references_replaced,
176    })
177}
178
179/// Find the declaration node (let_declaration, lexical_declaration, variable_declaration,
180/// or assignment) that contains the given identifier as the bound name.
181fn find_declaration_node<'a>(
182    ident: &tree_sitter::Node<'a>,
183    grammar: &str,
184) -> Result<tree_sitter::Node<'a>, String> {
185    let mut current = *ident;
186    loop {
187        let Some(parent) = current.parent() else {
188            return Err(
189                "Identifier is not inside a variable declaration — cannot inline".to_string(),
190            );
191        };
192        match grammar {
193            "rust" => {
194                if parent.kind() == "let_declaration" {
195                    // Verify the ident is the binding pattern, not the initializer.
196                    // In a let_declaration, the pattern is the first named child (before `=`).
197                    if is_binding_ident_in_rust_let(&parent, ident) {
198                        return Ok(parent);
199                    }
200                    return Err(
201                        "Identifier is in the initializer, not the binding pattern".to_string()
202                    );
203                }
204            }
205            "javascript" | "typescript" | "tsx" => {
206                if matches!(
207                    parent.kind(),
208                    "lexical_declaration" | "variable_declaration"
209                ) {
210                    // The ident should be inside a variable_declarator.name
211                    if is_binding_ident_in_js_decl(&parent, ident) {
212                        return Ok(parent);
213                    }
214                    return Err(
215                        "Identifier is in the initializer, not the binding name".to_string()
216                    );
217                }
218            }
219            "python" => {
220                if parent.kind() == "assignment" {
221                    // In Python, the left side is the target.
222                    if is_binding_ident_in_python_assign(&parent, ident) {
223                        return Ok(parent);
224                    }
225                    return Err(
226                        "Identifier is in the right-hand side, not the binding target".to_string(),
227                    );
228                }
229            }
230            _ => {
231                // Generic: accept common patterns
232                if matches!(
233                    parent.kind(),
234                    "let_declaration"
235                        | "lexical_declaration"
236                        | "variable_declaration"
237                        | "assignment"
238                ) {
239                    return Ok(parent);
240                }
241            }
242        }
243        current = parent;
244    }
245}
246
247/// Check if `ident` is the binding name in a Rust `let_declaration`.
248fn is_binding_ident_in_rust_let(
249    let_decl: &tree_sitter::Node<'_>,
250    ident: &tree_sitter::Node<'_>,
251) -> bool {
252    // Structure: (let_declaration (identifier) "=" <expr> ";")
253    // Walk the let_decl's children to find the pattern (before "=").
254    let mut cursor = let_decl.walk();
255    let mut saw_eq = false;
256    for child in let_decl.children(&mut cursor) {
257        if child.kind() == "=" {
258            saw_eq = true;
259            break;
260        }
261        // The pattern is the first named child (identifier, or destructuring pattern).
262        if child.kind() == "identifier" && child.id() == ident.id() {
263            return true;
264        }
265        // Could also be a mutable_specifier before the ident.
266    }
267    let _ = saw_eq;
268    false
269}
270
271/// Check if `ident` is the binding name in a JS/TS `lexical_declaration` or `variable_declaration`.
272fn is_binding_ident_in_js_decl(
273    decl: &tree_sitter::Node<'_>,
274    ident: &tree_sitter::Node<'_>,
275) -> bool {
276    // Structure: (lexical_declaration "const"/"let" (variable_declarator name: (identifier) value: <expr>))
277    let mut cursor = decl.walk();
278    for child in decl.children(&mut cursor) {
279        if child.kind() == "variable_declarator" {
280            // The `name` field is the identifier.
281            if let Some(name_node) = child.child_by_field_name("name")
282                && name_node.id() == ident.id()
283            {
284                return true;
285            }
286        }
287    }
288    false
289}
290
291/// Check if `ident` is on the left side of a Python `assignment`.
292fn is_binding_ident_in_python_assign(
293    assign: &tree_sitter::Node<'_>,
294    ident: &tree_sitter::Node<'_>,
295) -> bool {
296    // Structure: (assignment left: (identifier) right: <expr>)
297    if let Some(left) = assign.child_by_field_name("left") {
298        return left.id() == ident.id();
299    }
300    // Fallback: first child that is an identifier is the left side.
301    let mut cursor = assign.walk();
302    if let Some(child) = assign.children(&mut cursor).next()
303        && child.kind() == "identifier"
304    {
305        return child.id() == ident.id();
306    }
307    false
308}
309
310/// Extract the initializer expression node from a declaration node.
311fn extract_initializer<'a>(
312    content: &str,
313    decl: &tree_sitter::Node<'a>,
314    grammar: &str,
315) -> Result<tree_sitter::Node<'a>, String> {
316    match grammar {
317        "rust" => {
318            // let_declaration: (let "mut"? <pattern> ":" <type>? "=" <value> ";")
319            // The value comes after "=".
320            let mut cursor = decl.walk();
321            let mut after_eq = false;
322            for child in decl.children(&mut cursor) {
323                if child.kind() == "=" {
324                    after_eq = true;
325                    continue;
326                }
327                if after_eq && child.kind() != ";" && child.is_named() {
328                    return Ok(child);
329                }
330            }
331            Err(format!(
332                "Variable has no initializer — cannot inline (content: {:?})",
333                &content[decl.start_byte()..decl.end_byte()]
334            ))
335        }
336        "javascript" | "typescript" | "tsx" => {
337            // lexical_declaration/variable_declaration contains a variable_declarator.
338            let mut cursor = decl.walk();
339            for child in decl.children(&mut cursor) {
340                if child.kind() == "variable_declarator" {
341                    if let Some(val) = child.child_by_field_name("value") {
342                        return Ok(val);
343                    }
344                    return Err(format!(
345                        "Variable '{}' has no initializer — cannot inline",
346                        &content[decl.start_byte()..decl.end_byte()]
347                    ));
348                }
349            }
350            Err("Could not find variable_declarator in declaration".to_string())
351        }
352        "python" => {
353            // assignment: left "=" right
354            if let Some(right) = decl.child_by_field_name("right") {
355                return Ok(right);
356            }
357            // Fallback: node after "="
358            let mut cursor = decl.walk();
359            let mut after_eq = false;
360            for child in decl.children(&mut cursor) {
361                if child.kind() == "=" {
362                    after_eq = true;
363                    continue;
364                }
365                if after_eq && child.is_named() {
366                    return Ok(child);
367                }
368            }
369            Err("Python assignment has no right-hand side — cannot inline".to_string())
370        }
371        _ => {
372            // Generic: find value after "=".
373            let mut cursor = decl.walk();
374            let mut after_eq = false;
375            for child in decl.children(&mut cursor) {
376                if child.kind() == "=" {
377                    after_eq = true;
378                    continue;
379                }
380                if after_eq && child.is_named() {
381                    return Ok(child);
382                }
383            }
384            Err("Declaration has no initializer — cannot inline".to_string())
385        }
386    }
387}
388
389/// Find the innermost scope node (block/function body/module) that contains the declaration.
390fn find_scope_node<'a>(decl: &tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
391    let mut current = decl.parent()?;
392    loop {
393        if is_scope_kind(current.kind()) {
394            return Some(current);
395        }
396        current = current.parent()?;
397    }
398}
399
400fn is_scope_kind(kind: &str) -> bool {
401    matches!(
402        kind,
403        // Rust
404        "block"
405        // Python
406        | "module"
407        | "body"
408        // JS/TS
409        | "program"
410        | "statement_block"
411        // Generic
412        | "source_file"
413        | "function_body"
414        | "class_body"
415    )
416}
417
418/// Find the statement node that is the direct child of scope_node and contains decl.
419fn find_declaration_statement<'a>(
420    decl: &tree_sitter::Node<'a>,
421    scope: &tree_sitter::Node<'a>,
422) -> Result<tree_sitter::Node<'a>, String> {
423    let mut current = *decl;
424    loop {
425        let Some(parent) = current.parent() else {
426            return Err("Could not find declaration statement within scope".to_string());
427        };
428        if parent.id() == scope.id() {
429            // current is a direct child of scope.
430            return Ok(current);
431        }
432        current = parent;
433    }
434}
435
436/// Walk all nodes in `scope`, collect byte offsets of identifier nodes matching `var_name`.
437///
438/// Excludes the declaration node itself.
439/// Returns Err if any reassignment is found.
440fn collect_references(
441    content: &str,
442    scope: &tree_sitter::Node<'_>,
443    var_name: &str,
444    decl: &tree_sitter::Node<'_>,
445    grammar: &str,
446) -> Result<Vec<usize>, String> {
447    let mut refs: Vec<usize> = vec![];
448    let mut cursor = scope.walk();
449
450    // We need to walk the entire subtree of scope, depth-first.
451    walk_tree(&mut cursor, |node| {
452        // Skip the declaration itself.
453        if node.id() == decl.id() {
454            return WalkAction::SkipChildren;
455        }
456        // Only look at identifier nodes.
457        if node.kind() != "identifier" {
458            return WalkAction::Continue;
459        }
460        let text = &content[node.start_byte()..node.end_byte()];
461        if text != var_name {
462            return WalkAction::Continue;
463        }
464        // Check if this identifier is a reassignment target.
465        if is_reassignment(node, grammar) {
466            return WalkAction::Reassignment;
467        }
468        refs.push(node.start_byte());
469        WalkAction::Continue
470    })?;
471
472    Ok(refs)
473}
474
475enum WalkAction {
476    Continue,
477    SkipChildren,
478    Reassignment,
479}
480
481/// Walk the tree depth-first, calling `f` on each node.
482///
483/// Returns Err("cannot inline: variable is reassigned at line N") if f returns Reassignment.
484fn walk_tree<F>(cursor: &mut tree_sitter::TreeCursor<'_>, mut f: F) -> Result<(), String>
485where
486    F: FnMut(tree_sitter::Node<'_>) -> WalkAction,
487{
488    loop {
489        let node = cursor.node();
490        match f(node) {
491            WalkAction::SkipChildren => {
492                // Try to go to next sibling, else go to parent's next sibling.
493                if cursor.goto_next_sibling() {
494                    continue;
495                }
496                loop {
497                    if !cursor.goto_parent() {
498                        return Ok(());
499                    }
500                    if cursor.goto_next_sibling() {
501                        break;
502                    }
503                }
504            }
505            WalkAction::Reassignment => {
506                let ln = node.start_position().row + 1;
507                return Err(format!(
508                    "cannot inline: variable is reassigned at line {}",
509                    ln
510                ));
511            }
512            WalkAction::Continue => {
513                if cursor.goto_first_child() {
514                    continue;
515                }
516                // Leaf node: go to next sibling or back up.
517                if cursor.goto_next_sibling() {
518                    continue;
519                }
520                loop {
521                    if !cursor.goto_parent() {
522                        return Ok(());
523                    }
524                    if cursor.goto_next_sibling() {
525                        break;
526                    }
527                }
528            }
529        }
530    }
531}
532
533/// Check if an identifier node is a reassignment target (not a use).
534///
535/// Conservative: only flags clearly identifiable reassignment patterns.
536fn is_reassignment(node: tree_sitter::Node<'_>, grammar: &str) -> bool {
537    let Some(parent) = node.parent() else {
538        return false;
539    };
540    match grammar {
541        "rust" => {
542            // assignment_expression where the left side is this ident.
543            if parent.kind() == "assignment_expression"
544                && let Some(left) = parent.child_by_field_name("left")
545            {
546                return left.id() == node.id();
547            }
548            // compound_assignment_expression
549            if parent.kind() == "compound_assignment_expr"
550                && let Some(left) = parent.child_by_field_name("left")
551            {
552                return left.id() == node.id();
553            }
554            false
555        }
556        "javascript" | "typescript" | "tsx" => {
557            // assignment_expression where the left side is this ident.
558            if parent.kind() == "assignment_expression"
559                && let Some(left) = parent.child_by_field_name("left")
560            {
561                return left.id() == node.id();
562            }
563            // augmented_assignment_expression
564            if parent.kind() == "augmented_assignment_expression"
565                && let Some(left) = parent.child_by_field_name("left")
566            {
567                return left.id() == node.id();
568            }
569            false
570        }
571        "python" => {
572            // assignment where the left side is this ident (and it's not the original decl).
573            if parent.kind() == "assignment"
574                && let Some(left) = parent.child_by_field_name("left")
575            {
576                return left.id() == node.id();
577            }
578            // augmented_assignment
579            if parent.kind() == "augmented_assignment"
580                && let Some(left) = parent.child_by_field_name("left")
581            {
582                return left.id() == node.id();
583            }
584            false
585        }
586        _ => false,
587    }
588}
589
590/// Returns true if the expression node likely needs parentheses when substituted inline.
591///
592/// We wrap binary expressions, conditional/ternary expressions, and logical expressions
593/// to be safe. Simple literals, identifiers, and call expressions don't need wrapping.
594fn needs_parens(node: &tree_sitter::Node<'_>) -> bool {
595    matches!(
596        node.kind(),
597        "binary_expression"
598            | "binary_operator"  // Python
599            | "conditional_expression"
600            | "ternary_expression"
601            | "boolean_operator"  // Python
602            | "comparison_operator"  // Python
603            | "not_operator"        // Python
604            | "await_expression"
605            | "yield_expression"
606            | "range_expression"   // Rust
607            | "as_expression"      // Rust
608            | "reference_expression" // Rust
609    )
610}
611
612/// Heuristic: does the expression likely have side effects?
613///
614/// We flag function/method calls and await expressions as having potential side effects.
615fn has_side_effects(node: &tree_sitter::Node<'_>) -> bool {
616    match node.kind() {
617        "call_expression" | "call" | "method_call_expression" | "await_expression" => true,
618        _ => {
619            // Recurse into children.
620            let mut cursor = node.walk();
621            for child in node.children(&mut cursor) {
622                if has_side_effects(&child) {
623                    return true;
624                }
625            }
626            false
627        }
628    }
629}
630
631/// Return the byte position of the start of the line containing `pos`.
632fn line_start(content: &str, pos: usize) -> usize {
633    content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0)
634}
635
636/// Return the byte position just past the end of the line containing `pos`.
637/// Includes the trailing newline character if present.
638fn line_end_incl(content: &str, pos: usize) -> usize {
639    match content[pos..].find('\n') {
640        Some(offset) => pos + offset + 1, // include the newline
641        None => content.len(),            // last line without trailing newline
642    }
643}
644
645/// Convert a 1-based line:col pair to a byte offset in `content`.
646pub fn line_col_to_byte(content: &str, line: usize, col: usize) -> Option<usize> {
647    let mut current_line = 1usize;
648    let mut current_col = 1usize;
649    for (byte_pos, ch) in content.char_indices() {
650        if current_line == line && current_col == col {
651            return Some(byte_pos);
652        }
653        if ch == '\n' {
654            current_line += 1;
655            current_col = 1;
656        } else {
657            current_col += 1;
658        }
659    }
660    if current_line == line && current_col == col {
661        return Some(content.len());
662    }
663    None
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use std::path::PathBuf;
670
671    fn rust_file() -> PathBuf {
672        PathBuf::from("test.rs")
673    }
674
675    fn ts_file() -> PathBuf {
676        PathBuf::from("test.ts")
677    }
678
679    fn py_file() -> PathBuf {
680        PathBuf::from("test.py")
681    }
682
683    fn js_file() -> PathBuf {
684        PathBuf::from("test.js")
685    }
686
687    /// Find the 1-based line:col of the first occurrence of `needle` in `content`.
688    fn find_pos(content: &str, needle: &str) -> (usize, usize) {
689        let byte_pos = content
690            .find(needle)
691            .unwrap_or_else(|| panic!("needle {:?} not found", needle));
692        let mut line = 1usize;
693        let mut col = 1usize;
694        for (i, ch) in content.char_indices() {
695            if i == byte_pos {
696                break;
697            }
698            if ch == '\n' {
699                line += 1;
700                col = 1;
701            } else {
702                col += 1;
703            }
704        }
705        (line, col)
706    }
707
708    #[test]
709    fn test_rust_inline_simple() {
710        let content = "fn main() {\n    let x = 1 + 2;\n    println!(\"{}\", x);\n}\n";
711        let (line, col) = find_pos(content, "x = 1 + 2");
712        let outcome = plan_inline_variable(&rust_file(), content, line, col).unwrap();
713        assert_eq!(outcome.name, "x");
714        assert_eq!(outcome.references_replaced, 1);
715        let new_content = &outcome.plan.edits[0].new_content;
716        // Declaration removed.
717        assert!(
718            !new_content.contains("let x = 1 + 2"),
719            "declaration should be removed, got:\n{}",
720            new_content
721        );
722        // Reference replaced — binary expression wrapped in parens.
723        assert!(
724            new_content.contains("(1 + 2)"),
725            "expected parens-wrapped replacement, got:\n{}",
726            new_content
727        );
728    }
729
730    #[test]
731    fn test_rust_inline_no_references() {
732        let content = "fn main() {\n    let x = 42;\n    println!(\"hello\");\n}\n";
733        let (line, col) = find_pos(content, "x = 42");
734        let outcome = plan_inline_variable(&rust_file(), content, line, col).unwrap();
735        assert_eq!(outcome.references_replaced, 0);
736        // Declaration should be removed even with 0 references.
737        let new_content = &outcome.plan.edits[0].new_content;
738        assert!(
739            !new_content.contains("let x = 42"),
740            "declaration should be removed, got:\n{}",
741            new_content
742        );
743    }
744
745    #[test]
746    fn test_rust_inline_identifier_initializer() {
747        // Identifier initializer — no parens needed.
748        let content = "fn main() {\n    let x = some_val;\n    let y = x + 1;\n}\n";
749        let (line, col) = find_pos(content, "x = some_val");
750        let outcome = plan_inline_variable(&rust_file(), content, line, col).unwrap();
751        let new_content = &outcome.plan.edits[0].new_content;
752        // No parens around a bare identifier.
753        assert!(
754            new_content.contains("some_val + 1"),
755            "expected no parens for identifier, got:\n{}",
756            new_content
757        );
758    }
759
760    #[test]
761    fn test_rust_error_on_reassignment() {
762        let content = "fn main() {\n    let mut x = 1;\n    x = 2;\n    println!(\"{}\", x);\n}\n";
763        let (line, col) = find_pos(content, "x = 1");
764        let result = plan_inline_variable(&rust_file(), content, line, col);
765        let msg = result.err().expect("should error on reassignment");
766        assert!(
767            msg.contains("reassigned"),
768            "error should mention reassignment, got: {}",
769            msg
770        );
771    }
772
773    #[test]
774    fn test_typescript_inline_const() {
775        let content = "function main() {\n    const x = 1 + 2;\n    console.log(x);\n}\n";
776        let (line, col) = find_pos(content, "x = 1 + 2");
777        let outcome = plan_inline_variable(&ts_file(), content, line, col).unwrap();
778        assert_eq!(outcome.name, "x");
779        assert_eq!(outcome.references_replaced, 1);
780        let new_content = &outcome.plan.edits[0].new_content;
781        assert!(
782            !new_content.contains("const x = 1 + 2"),
783            "declaration should be removed, got:\n{}",
784            new_content
785        );
786        assert!(
787            new_content.contains("(1 + 2)"),
788            "expected wrapped replacement, got:\n{}",
789            new_content
790        );
791    }
792
793    #[test]
794    fn test_javascript_inline_var() {
795        let content = "function main() {\n    var x = foo();\n    return x;\n}\n";
796        let (line, col) = find_pos(content, "x = foo()");
797        let outcome = plan_inline_variable(&js_file(), content, line, col).unwrap();
798        assert_eq!(outcome.references_replaced, 1);
799        let new_content = &outcome.plan.edits[0].new_content;
800        assert!(
801            !new_content.contains("var x = foo()"),
802            "declaration removed, got:\n{}",
803            new_content
804        );
805        assert!(
806            new_content.contains("return foo()"),
807            "expected foo() inlined, got:\n{}",
808            new_content
809        );
810    }
811
812    #[test]
813    fn test_python_inline_assignment() {
814        let content = "def main():\n    x = 1 + 2\n    print(x)\n";
815        let (line, col) = find_pos(content, "x = 1 + 2");
816        let outcome = plan_inline_variable(&py_file(), content, line, col).unwrap();
817        assert_eq!(outcome.references_replaced, 1);
818        let new_content = &outcome.plan.edits[0].new_content;
819        assert!(
820            !new_content.contains("x = 1 + 2"),
821            "declaration removed, got:\n{}",
822            new_content
823        );
824        assert!(
825            new_content.contains("print((1 + 2))"),
826            "expected wrapped replacement, got:\n{}",
827            new_content
828        );
829    }
830
831    #[test]
832    fn test_error_on_no_initializer() {
833        // A `let x;` in Rust has no initializer.
834        let content = "fn main() {\n    let x;\n    x = 5;\n    println!(\"{}\", x);\n}\n";
835        let (line, col) = find_pos(content, "x;");
836        let result = plan_inline_variable(&rust_file(), content, line, col);
837        // This will error because x is reassigned, OR because there's no initializer.
838        assert!(
839            result.is_err(),
840            "should error on missing initializer or reassignment"
841        );
842    }
843
844    #[test]
845    fn test_multiple_references_warns_on_side_effects() {
846        let content = "fn main() {\n    let x = foo();\n    let _a = x;\n    let _b = x;\n}\n";
847        let (line, col) = find_pos(content, "x = foo()");
848        let outcome = plan_inline_variable(&rust_file(), content, line, col).unwrap();
849        assert_eq!(outcome.references_replaced, 2);
850        assert!(
851            !outcome.plan.warnings.is_empty(),
852            "should warn about side effects with multiple references"
853        );
854    }
855}