1use std::path::Path;
21
22use normalize_languages::parsers::parse_with_grammar;
23use normalize_languages::support_for_path;
24
25use crate::{PlannedEdit, RefactoringPlan};
26
27pub struct InlineVariableOutcome {
29 pub plan: RefactoringPlan,
30 pub name: String,
32 pub references_replaced: usize,
34}
35
36pub 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 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 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 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 let decl_node = find_declaration_node(&ident_node, grammar)?;
93
94 let initializer = extract_initializer(content, &decl_node, grammar)?;
96 let init_text = content[initializer.start_byte()..initializer.end_byte()].to_string();
97
98 let scope_node = find_scope_node(&decl_node)
100 .ok_or_else(|| "Could not find a scope containing the declaration".to_string())?;
101
102 let decl_stmt = find_declaration_statement(&decl_node, &scope_node)?;
104
105 let refs = collect_references(content, &scope_node, var_name, &decl_node, grammar)?;
107
108 let replacement = if needs_parens(&initializer) {
112 format!("({})", init_text)
113 } else {
114 init_text.clone()
115 };
116
117 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 let decl_stmt_start = decl_stmt.start_byte();
131 let decl_stmt_end = decl_stmt.end_byte();
132
133 let remove_start = line_start(content, decl_stmt_start);
135 let remove_end = line_end_incl(content, decl_stmt_end);
136
137 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 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 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
179fn 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 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 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 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 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
247fn is_binding_ident_in_rust_let(
249 let_decl: &tree_sitter::Node<'_>,
250 ident: &tree_sitter::Node<'_>,
251) -> bool {
252 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 if child.kind() == "identifier" && child.id() == ident.id() {
263 return true;
264 }
265 }
267 let _ = saw_eq;
268 false
269}
270
271fn is_binding_ident_in_js_decl(
273 decl: &tree_sitter::Node<'_>,
274 ident: &tree_sitter::Node<'_>,
275) -> bool {
276 let mut cursor = decl.walk();
278 for child in decl.children(&mut cursor) {
279 if child.kind() == "variable_declarator" {
280 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
291fn is_binding_ident_in_python_assign(
293 assign: &tree_sitter::Node<'_>,
294 ident: &tree_sitter::Node<'_>,
295) -> bool {
296 if let Some(left) = assign.child_by_field_name("left") {
298 return left.id() == ident.id();
299 }
300 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
310fn 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 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 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 if let Some(right) = decl.child_by_field_name("right") {
355 return Ok(right);
356 }
357 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 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
389fn 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 "block"
405 | "module"
407 | "body"
408 | "program"
410 | "statement_block"
411 | "source_file"
413 | "function_body"
414 | "class_body"
415 )
416}
417
418fn 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 return Ok(current);
431 }
432 current = parent;
433 }
434}
435
436fn 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 walk_tree(&mut cursor, |node| {
452 if node.id() == decl.id() {
454 return WalkAction::SkipChildren;
455 }
456 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 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
481fn 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 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 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
533fn 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 if parent.kind() == "assignment_expression"
544 && let Some(left) = parent.child_by_field_name("left")
545 {
546 return left.id() == node.id();
547 }
548 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 if parent.kind() == "assignment_expression"
559 && let Some(left) = parent.child_by_field_name("left")
560 {
561 return left.id() == node.id();
562 }
563 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 if parent.kind() == "assignment"
574 && let Some(left) = parent.child_by_field_name("left")
575 {
576 return left.id() == node.id();
577 }
578 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
590fn needs_parens(node: &tree_sitter::Node<'_>) -> bool {
595 matches!(
596 node.kind(),
597 "binary_expression"
598 | "binary_operator" | "conditional_expression"
600 | "ternary_expression"
601 | "boolean_operator" | "comparison_operator" | "not_operator" | "await_expression"
605 | "yield_expression"
606 | "range_expression" | "as_expression" | "reference_expression" )
610}
611
612fn has_side_effects(node: &tree_sitter::Node<'_>) -> bool {
616 match node.kind() {
617 "call_expression" | "call" | "method_call_expression" | "await_expression" => true,
618 _ => {
619 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
631fn line_start(content: &str, pos: usize) -> usize {
633 content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0)
634}
635
636fn line_end_incl(content: &str, pos: usize) -> usize {
639 match content[pos..].find('\n') {
640 Some(offset) => pos + offset + 1, None => content.len(), }
643}
644
645pub 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 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 assert!(
718 !new_content.contains("let x = 1 + 2"),
719 "declaration should be removed, got:\n{}",
720 new_content
721 );
722 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 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 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 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 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 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}