1use std::collections::HashMap;
20use std::path::Path;
21
22use normalize_languages::parsers::parse_with_grammar;
23use normalize_languages::support_for_path;
24
25use crate::{PlannedEdit, RefactoringContext, RefactoringPlan};
26
27pub struct AddParameterOutcome {
29 pub plan: RefactoringPlan,
30 pub call_sites_updated: usize,
32}
33
34pub async fn plan_add_parameter(
43 ctx: &RefactoringContext,
44 def_rel_path: &str,
45 function_name: &str,
46 param_name: &str,
47 param_type: Option<&str>,
48 default_value: &str,
49 position: Option<usize>,
50) -> Result<AddParameterOutcome, String> {
51 let def_abs_path = ctx.root.join(def_rel_path);
52 let def_content = std::fs::read_to_string(&def_abs_path)
53 .map_err(|e| format!("Error reading {}: {}", def_rel_path, e))?;
54
55 let def_edit = plan_add_param_in_definition(
57 &def_abs_path,
58 &def_content,
59 function_name,
60 param_name,
61 param_type,
62 position,
63 )?;
64
65 let mut edits: Vec<PlannedEdit> = vec![def_edit];
66 let mut warnings: Vec<String> = vec![];
67
68 let refs = crate::actions::find_references(ctx, function_name, def_rel_path).await;
70
71 if ctx.index.is_none() {
72 warnings.push(
73 "Index not available; only updated definition file. \
74 Run `normalize structure rebuild` to enable call-site updates."
75 .to_string(),
76 );
77 }
78
79 let mut callers_by_file: HashMap<String, Vec<usize>> = HashMap::new();
81 for caller in &refs.callers {
82 callers_by_file
83 .entry(caller.file.clone())
84 .or_default()
85 .push(caller.line);
86 }
87
88 let mut call_sites_updated = 0usize;
90 for (rel_path, call_lines) in &callers_by_file {
91 let abs_path = ctx.root.join(rel_path);
92 let content = match std::fs::read_to_string(&abs_path) {
93 Ok(c) => c,
94 Err(_) => {
95 warnings.push(format!("Could not read caller file: {}", rel_path));
96 continue;
97 }
98 };
99
100 match plan_add_arg_in_file(
101 &abs_path,
102 &content,
103 function_name,
104 call_lines,
105 default_value,
106 position,
107 ) {
108 Ok(Some(edit)) => {
109 call_sites_updated += call_lines.len();
110 if abs_path == def_abs_path {
112 let merged = merge_edits(&edits[0], &edit)?;
114 edits[0] = merged;
115 } else {
116 edits.push(edit);
117 }
118 }
119 Ok(None) => {
120 }
122 Err(e) => {
123 warnings.push(format!("Could not update {}: {}", rel_path, e));
124 }
125 }
126 }
127
128 Ok(AddParameterOutcome {
129 plan: RefactoringPlan {
130 operation: "add_parameter".to_string(),
131 edits,
132 warnings,
133 },
134 call_sites_updated,
135 })
136}
137
138fn plan_add_param_in_definition(
142 file: &Path,
143 content: &str,
144 function_name: &str,
145 param_name: &str,
146 param_type: Option<&str>,
147 position: Option<usize>,
148) -> Result<PlannedEdit, String> {
149 let support = support_for_path(file)
150 .ok_or_else(|| format!("No language support for {}", file.display()))?;
151 let grammar = support.grammar_name();
152
153 let tree = parse_with_grammar(grammar, content).ok_or_else(|| {
154 format!(
155 "Grammar '{}' not available — install grammars with `normalize grammars install`",
156 grammar
157 )
158 })?;
159
160 let params_range = find_param_list(&tree.root_node(), content, grammar, function_name)
161 .ok_or_else(|| {
162 format!(
163 "Function '{}' not found in {}",
164 function_name,
165 file.display()
166 )
167 })?;
168
169 let param_text = format_param(grammar, param_name, param_type);
170 let new_content = insert_into_list(
171 content,
172 ¶ms_range,
173 ¶m_text,
174 position,
175 ListKind::Params,
176 );
177
178 Ok(PlannedEdit {
179 file: file.to_path_buf(),
180 original: content.to_string(),
181 new_content,
182 description: format!("add parameter '{}' to '{}'", param_name, function_name),
183 })
184}
185
186fn plan_add_arg_in_file(
191 file: &Path,
192 content: &str,
193 function_name: &str,
194 call_lines: &[usize],
195 default_value: &str,
196 position: Option<usize>,
197) -> Result<Option<PlannedEdit>, String> {
198 let support = support_for_path(file)
199 .ok_or_else(|| format!("No language support for {}", file.display()))?;
200 let grammar = support.grammar_name();
201
202 let tree = parse_with_grammar(grammar, content).ok_or_else(|| {
203 format!(
204 "Grammar '{}' not available — install grammars with `normalize grammars install`",
205 grammar
206 )
207 })?;
208
209 let ranges = find_call_arg_lists(
211 &tree.root_node(),
212 content,
213 grammar,
214 function_name,
215 call_lines,
216 );
217
218 if ranges.is_empty() {
219 return Ok(None);
220 }
221
222 let mut sorted = ranges;
224 sorted.sort_by(|a, b| b.open_paren.cmp(&a.open_paren));
225
226 let mut new_content = content.to_string();
227 for r in &sorted {
228 let chunk = insert_into_list(&new_content, r, default_value, position, ListKind::Args);
229 new_content = chunk;
230 }
231
232 Ok(Some(PlannedEdit {
233 file: file.to_path_buf(),
234 original: content.to_string(),
235 new_content,
236 description: format!(
237 "add argument '{}' to calls of '{}'",
238 default_value, function_name
239 ),
240 }))
241}
242
243enum ListKind {
246 Params,
247 Args,
248}
249
250struct ListRange {
253 open_paren: usize,
255 close_paren: usize,
257 comma_positions: Vec<usize>,
259 item_count: usize,
261}
262
263fn insert_into_list(
266 content: &str,
267 range: &ListRange,
268 text: &str,
269 position: Option<usize>,
270 kind: ListKind,
271) -> String {
272 let separator = match kind {
273 ListKind::Params => ", ",
274 ListKind::Args => ", ",
275 };
276
277 if range.item_count == 0 {
278 let insert_at = range.open_paren + 1;
280 let mut out = content.to_string();
281 out.insert_str(insert_at, text);
282 return out;
283 }
284
285 let pos = position.unwrap_or(range.item_count); if pos == 0 {
288 let insert_at = range.open_paren + 1;
290 let mut out = content.to_string();
291 out.insert_str(insert_at, &format!("{}{}", text, separator));
292 return out;
293 }
294
295 if pos >= range.item_count {
296 let insert_at = range.close_paren;
298 let mut out = content.to_string();
299 out.insert_str(insert_at, &format!("{}{}", separator, text));
300 return out;
301 }
302
303 let after_comma = range.comma_positions[pos - 1];
305 let ws = content[after_comma..].len() - content[after_comma..].trim_start().len();
307 let insert_at = after_comma + ws;
308 let mut out = content.to_string();
309 out.insert_str(insert_at, &format!("{}{}", text, separator));
310 out
311}
312
313fn walk_tree(node: tree_sitter::Node<'_>, f: &mut impl FnMut(tree_sitter::Node<'_>)) {
317 f(node);
318 let mut cursor = node.walk();
319 for child in node.children(&mut cursor) {
320 walk_tree(child, f);
321 }
322}
323
324fn find_param_list(
326 root: &tree_sitter::Node<'_>,
327 content: &str,
328 grammar: &str,
329 name: &str,
330) -> Option<ListRange> {
331 let fn_kinds = function_item_kinds(grammar);
332 let param_list_kind = param_list_kind(grammar);
333
334 let mut result: Option<ListRange> = None;
335 walk_tree(*root, &mut |node| {
336 if result.is_some() {
337 return;
338 }
339 if !fn_kinds.contains(&node.kind()) {
340 return;
341 }
342 if !function_name_matches(&node, content, name) {
344 return;
345 }
346 let mut cursor = node.walk();
348 for child in node.children(&mut cursor) {
349 if child.kind() == param_list_kind {
350 result = Some(list_range_from_node(&child, content));
351 break;
352 }
353 }
354 });
355 result
356}
357
358fn find_call_arg_lists(
360 root: &tree_sitter::Node<'_>,
361 content: &str,
362 grammar: &str,
363 function_name: &str,
364 call_lines: &[usize],
365) -> Vec<ListRange> {
366 let call_kind = call_kind(grammar);
367 let arg_list_kind = arg_list_kind(grammar);
368
369 let mut results = vec![];
370 walk_tree(*root, &mut |node| {
371 if node.kind() != call_kind {
372 return;
373 }
374 let node_line = node.start_position().row + 1;
376 if !call_lines.contains(&node_line) {
377 return;
378 }
379 if !call_matches_name(&node, content, function_name) {
381 return;
382 }
383 let mut cursor = node.walk();
385 for child in node.children(&mut cursor) {
386 if child.kind() == arg_list_kind {
387 results.push(list_range_from_node(&child, content));
388 break;
389 }
390 }
391 });
392 results
393}
394
395fn list_range_from_node(node: &tree_sitter::Node<'_>, content: &str) -> ListRange {
397 let open_paren = node.start_byte();
398 let close_paren = node.end_byte().saturating_sub(1);
399
400 let mut comma_positions: Vec<usize> = vec![];
402 let mut item_count = 0usize;
403
404 let mut cursor = node.walk();
405 for child in node.children(&mut cursor) {
406 match child.kind() {
407 "(" | ")" => {}
408 "," => {
409 comma_positions.push(child.end_byte());
410 }
411 _ if !child.kind().starts_with('"') => {
412 item_count += 1;
414 }
415 _ => {}
416 }
417 let _ = content; }
419
420 ListRange {
421 open_paren,
422 close_paren,
423 comma_positions,
424 item_count,
425 }
426}
427
428fn function_item_kinds(grammar: &str) -> &'static [&'static str] {
431 match grammar {
432 "rust" => &["function_item", "function_signature_item"],
433 "python" => &["function_definition"],
434 "javascript" | "typescript" | "tsx" => &[
435 "function_declaration",
436 "function",
437 "method_definition",
438 "arrow_function",
439 ],
440 _ => &[
441 "function_item",
442 "function_declaration",
443 "function_definition",
444 ],
445 }
446}
447
448fn param_list_kind(grammar: &str) -> &'static str {
449 match grammar {
450 "python" => "parameters",
451 "javascript" | "typescript" | "tsx" => "formal_parameters",
452 _ => "parameters",
453 }
454}
455
456fn call_kind(grammar: &str) -> &'static str {
457 match grammar {
458 "python" => "call",
459 _ => "call_expression",
460 }
461}
462
463fn arg_list_kind(grammar: &str) -> &'static str {
464 match grammar {
465 "python" => "argument_list",
466 _ => "arguments",
467 }
468}
469
470fn format_param(grammar: &str, name: &str, ty: Option<&str>) -> String {
472 match grammar {
473 "rust" => match ty {
474 Some(t) => format!("{}: {}", name, t),
475 None => name.to_string(),
476 },
477 "typescript" | "tsx" => match ty {
478 Some(t) => format!("{}: {}", name, t),
479 None => name.to_string(),
480 },
481 "python" => name.to_string(),
482 "javascript" => name.to_string(),
483 _ => match ty {
484 Some(t) => format!("{}: {}", name, t),
485 None => name.to_string(),
486 },
487 }
488}
489
490fn function_name_matches(node: &tree_sitter::Node<'_>, content: &str, name: &str) -> bool {
492 let mut cursor = node.walk();
493 for child in node.children(&mut cursor) {
494 if child.kind() == "identifier" || child.kind() == "property_identifier" {
495 let text = &content[child.start_byte()..child.end_byte()];
496 return text == name;
497 }
498 }
499 false
500}
501
502fn call_matches_name(node: &tree_sitter::Node<'_>, content: &str, name: &str) -> bool {
507 let mut cursor = node.walk();
508 for child in node.children(&mut cursor) {
509 let kind = child.kind();
510 if kind == "identifier" || kind == "property_identifier" {
511 let text = &content[child.start_byte()..child.end_byte()];
512 return text == name;
513 }
514 if kind == "field_expression" || kind == "member_expression" {
516 let mut inner = child.walk();
517 for ic in child.children(&mut inner) {
518 if ic.kind() == "field_identifier"
519 || ic.kind() == "property_identifier"
520 || ic.kind() == "identifier"
521 {
522 let text = &content[ic.start_byte()..ic.end_byte()];
523 if text == name {
524 return true;
525 }
526 }
527 }
528 }
529 if kind == "attribute" {
531 let mut inner = child.walk();
532 for ic in child.children(&mut inner) {
533 if ic.kind() == "identifier" {
534 let text = &content[ic.start_byte()..ic.end_byte()];
535 if text == name {
536 return true;
537 }
538 }
539 }
540 }
541 }
542 false
543}
544
545fn merge_edits(first: &PlannedEdit, second: &PlannedEdit) -> Result<PlannedEdit, String> {
550 if first.file != second.file {
551 return Err(format!(
552 "Cannot merge edits for different files: {} vs {}",
553 first.file.display(),
554 second.file.display()
555 ));
556 }
557 let original = &first.original;
572 let (def_pos, def_text) = extract_insertion(original, &first.new_content)?;
577 let (arg_pos, arg_text) = extract_insertion(original, &second.new_content)?;
578
579 let mut new_content = original.clone();
580 if def_pos >= arg_pos {
582 new_content.insert_str(def_pos, &def_text);
583 new_content.insert_str(arg_pos, &arg_text);
584 } else {
585 new_content.insert_str(arg_pos, &arg_text);
586 new_content.insert_str(def_pos, &def_text);
587 }
588
589 Ok(PlannedEdit {
590 file: first.file.clone(),
591 original: original.clone(),
592 new_content,
593 description: format!("{} + {}", first.description, second.description),
594 })
595}
596
597fn extract_insertion(original: &str, new_content: &str) -> Result<(usize, String), String> {
601 let orig_bytes = original.as_bytes();
603 let new_bytes = new_content.as_bytes();
604
605 let prefix_len = orig_bytes
607 .iter()
608 .zip(new_bytes.iter())
609 .take_while(|(a, b)| a == b)
610 .count();
611
612 let orig_tail = &orig_bytes[prefix_len..];
614 let new_tail = &new_bytes[prefix_len..];
615 let suffix_len = orig_tail
616 .iter()
617 .rev()
618 .zip(new_tail.iter().rev())
619 .take_while(|(a, b)| a == b)
620 .count();
621
622 if orig_tail.len() != suffix_len {
623 return Err(format!(
624 "merge_edits: expected a pure insertion but found deletion of {} bytes at offset {}",
625 orig_tail.len() - suffix_len,
626 prefix_len
627 ));
628 }
629
630 let inserted_len = new_tail.len() - suffix_len;
631 let inserted = &new_content[prefix_len..prefix_len + inserted_len];
632 Ok((prefix_len, inserted.to_string()))
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use normalize_edit::Editor;
639
640 fn make_ctx(root: &Path) -> RefactoringContext {
641 RefactoringContext {
642 root: root.to_path_buf(),
643 editor: Editor::new(),
644 index: None,
645 loader: normalize_languages::GrammarLoader::new(),
646 }
647 }
648
649 fn grammar_available(name: &str) -> bool {
650 normalize_languages::parsers::parser_for(name).is_some()
651 }
652
653 #[tokio::test]
656 async fn rust_add_param_no_callers() {
657 if !grammar_available("rust") {
658 eprintln!("skipping: rust grammar not available");
659 return;
660 }
661 let dir = tempfile::tempdir().unwrap();
662 let file = dir.path().join("test.rs");
663 let content = "fn my_func(a: i32) -> bool {\n true\n}\n";
664 std::fs::write(&file, content).unwrap();
665
666 let ctx = make_ctx(dir.path());
667 let result = plan_add_parameter(
668 &ctx,
669 "test.rs",
670 "my_func",
671 "b",
672 Some("String"),
673 "String::new()",
674 None,
675 )
676 .await
677 .unwrap();
678
679 assert_eq!(result.call_sites_updated, 0);
680 let edit = &result.plan.edits[0];
681 assert!(
682 edit.new_content.contains("b: String"),
683 "expected 'b: String' in: {}",
684 edit.new_content
685 );
686 assert!(
687 edit.new_content.contains("a: i32"),
688 "expected 'a: i32' still present"
689 );
690 assert!(!result.plan.warnings.is_empty());
692 }
693
694 #[tokio::test]
695 async fn rust_add_param_at_position_zero() {
696 if !grammar_available("rust") {
697 eprintln!("skipping: rust grammar not available");
698 return;
699 }
700 let dir = tempfile::tempdir().unwrap();
701 let file = dir.path().join("test.rs");
702 let content = "fn my_func(a: i32) -> bool {\n true\n}\n";
703 std::fs::write(&file, content).unwrap();
704
705 let ctx = make_ctx(dir.path());
706 let result = plan_add_parameter(
707 &ctx,
708 "test.rs",
709 "my_func",
710 "b",
711 Some("String"),
712 "String::new()",
713 Some(0),
714 )
715 .await
716 .unwrap();
717
718 let edit = &result.plan.edits[0];
719 let b_pos = edit.new_content.find("b: String").unwrap();
721 let a_pos = edit.new_content.find("a: i32").unwrap();
722 assert!(b_pos < a_pos, "b should come before a");
723 }
724
725 #[tokio::test]
726 async fn rust_empty_param_list() {
727 if !grammar_available("rust") {
728 eprintln!("skipping: rust grammar not available");
729 return;
730 }
731 let dir = tempfile::tempdir().unwrap();
732 let file = dir.path().join("test.rs");
733 let content = "fn my_func() -> bool {\n true\n}\n";
734 std::fs::write(&file, content).unwrap();
735
736 let ctx = make_ctx(dir.path());
737 let result = plan_add_parameter(&ctx, "test.rs", "my_func", "x", Some("i32"), "0", None)
738 .await
739 .unwrap();
740
741 let edit = &result.plan.edits[0];
742 assert!(
743 edit.new_content.contains("fn my_func(x: i32)"),
744 "got: {}",
745 edit.new_content
746 );
747 }
748
749 #[tokio::test]
752 async fn python_add_param_no_callers() {
753 if !grammar_available("python") {
754 eprintln!("skipping: python grammar not available");
755 return;
756 }
757 let dir = tempfile::tempdir().unwrap();
758 let file = dir.path().join("test.py");
759 let content = "def my_func(a, b):\n return True\n";
760 std::fs::write(&file, content).unwrap();
761
762 let ctx = make_ctx(dir.path());
763 let result = plan_add_parameter(&ctx, "test.py", "my_func", "c", None, "None", None)
764 .await
765 .unwrap();
766
767 let edit = &result.plan.edits[0];
768 assert!(
769 edit.new_content.contains(", c)"),
770 "expected ', c)' in: {}",
771 edit.new_content
772 );
773 }
774
775 #[tokio::test]
778 async fn typescript_add_param_with_type() {
779 if !grammar_available("typescript") {
780 eprintln!("skipping: typescript grammar not available");
781 return;
782 }
783 let dir = tempfile::tempdir().unwrap();
784 let file = dir.path().join("test.ts");
785 let content = "function myFunc(a: number, b: string): boolean {\n return true;\n}\n";
786 std::fs::write(&file, content).unwrap();
787
788 let ctx = make_ctx(dir.path());
789 let result = plan_add_parameter(
790 &ctx,
791 "test.ts",
792 "myFunc",
793 "c",
794 Some("boolean"),
795 "false",
796 None,
797 )
798 .await
799 .unwrap();
800
801 let edit = &result.plan.edits[0];
802 assert!(
803 edit.new_content.contains("c: boolean"),
804 "expected 'c: boolean' in: {}",
805 edit.new_content
806 );
807 }
808
809 #[test]
812 fn extract_insertion_middle() {
813 let original = "fn f(a: i32) {}";
814 let new = "fn f(a: i32, b: String) {}";
815 let (pos, text) = extract_insertion(original, new).unwrap();
816 assert_eq!(pos, 11);
818 assert_eq!(text, ", b: String");
819 }
820
821 #[test]
822 fn extract_insertion_front() {
823 let original = "fn f(a: i32) {}";
824 let new = "fn f(b: String, a: i32) {}";
825 let (pos, text) = extract_insertion(original, new).unwrap();
826 assert_eq!(pos, 5);
828 assert_eq!(text, "b: String, ");
829 }
830
831 #[test]
834 fn function_not_found_returns_err() {
835 if !grammar_available("rust") {
836 eprintln!("skipping: rust grammar not available");
837 return;
838 }
839 let dir = tempfile::tempdir().unwrap();
840 let file = dir.path().join("test.rs");
841 std::fs::write(&file, "fn other() {}\n").unwrap();
842
843 let res =
844 plan_add_param_in_definition(&file, "fn other() {}\n", "nonexistent", "x", None, None);
845 assert!(res.is_err());
846 let err = res.err().unwrap();
847 assert!(
848 err.contains("not found"),
849 "expected 'not found' in: {}",
850 err
851 );
852 }
853}