Skip to main content

normalize_refactor/
add_parameter.rs

1//! Add-parameter recipe: insert a new parameter into a function signature and update all call sites.
2//!
3//! Algorithm:
4//! 1. Parse `file` with tree-sitter; locate the function declaration named `function_name`.
5//! 2. Find the parameter list node; determine insertion byte position.
6//! 3. Build the parameter text for the definition (name + optional type annotation).
7//! 4. Find all call sites via the facts index (`find_callers`). Falls back to text
8//!    search if the index is unavailable, with a warning.
9//! 5. At each call site: find the argument list; insert `default_value` at `position`.
10//! 6. Return a `RefactoringPlan` with one `PlannedEdit` per affected file.
11//!
12//! Language support: Rust, TypeScript/JavaScript, Python.
13//! Node kinds by language:
14//! - Rust: `function_item` → `parameters`, args in `arguments`
15//! - Python: `function_definition` → `parameters`, args in `argument_list`
16//! - JS/TS: `function_declaration`/`function`/`method_definition`/`arrow_function`
17//!   → `formal_parameters`, args in `arguments`
18
19use 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
27/// Outcome of a successful add-parameter plan.
28pub struct AddParameterOutcome {
29    pub plan: RefactoringPlan,
30    /// Number of call sites updated.
31    pub call_sites_updated: usize,
32}
33
34/// Build an add-parameter plan without touching the filesystem.
35///
36/// `file` is the path to the file containing the function definition.
37/// `function_name` is the name of the function to modify.
38/// `param_name` is the name of the new parameter.
39/// `param_type` is the optional type annotation (for typed languages).
40/// `default_value` is the value to insert at call sites.
41/// `position` is the 0-based index to insert at; `None` means last.
42pub 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    // 1. Parse definition file and locate the parameter list.
56    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    // 2. Find call sites via the index.
69    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    // 3. Group callers by file.
80    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    // 4. Update each call site file.
89    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 the definition and caller are the same file, merge edits.
111                if abs_path == def_abs_path {
112                    // Replace the definition edit with a merged one.
113                    let merged = merge_edits(&edits[0], &edit)?;
114                    edits[0] = merged;
115                } else {
116                    edits.push(edit);
117                }
118            }
119            Ok(None) => {
120                // No call sites matched on those lines — stale index data.
121            }
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
138// ── Definition editing ────────────────────────────────────────────────────────
139
140/// Build a `PlannedEdit` inserting the new parameter into the function definition.
141fn 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        &params_range,
173        &param_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
186// ── Call-site editing ─────────────────────────────────────────────────────────
187
188/// Build a `PlannedEdit` inserting `default_value` into every call to `function_name`
189/// on the listed lines.
190fn 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    // Collect all argument list ranges for calls to `function_name` on the given lines.
210    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    // Apply edits from last to first so byte offsets stay valid.
223    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
243// ── List manipulation ─────────────────────────────────────────────────────────
244
245enum ListKind {
246    Params,
247    Args,
248}
249
250/// A parameter list or argument list — just the open-paren byte offset, the close-paren
251/// byte offset, and the (sorted) byte offsets of the commas between items.
252struct ListRange {
253    /// Byte offset of `(`.
254    open_paren: usize,
255    /// Byte offset of `)`.
256    close_paren: usize,
257    /// Byte offsets immediately after each `,` separator (i.e. where item N+1 starts).
258    comma_positions: Vec<usize>,
259    /// Number of existing items.
260    item_count: usize,
261}
262
263/// Insert `text` at the `position`-th slot (0-based; None = last) in the list described
264/// by `range`.
265fn 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        // Empty list → just insert.
279        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); // default: append
286
287    if pos == 0 {
288        // Insert before first item.
289        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        // Append after last item.
297        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    // Insert before the item at `pos` (i.e. after the (pos-1)th comma).
304    let after_comma = range.comma_positions[pos - 1];
305    // Skip whitespace after comma.
306    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
313// ── Tree-sitter traversal ─────────────────────────────────────────────────────
314
315/// Walk the tree depth-first, calling `f` on each node.
316fn 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
324/// Find the parameter list range for a function named `name`.
325fn 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        // Check if this function's name matches.
343        if !function_name_matches(&node, content, name) {
344            return;
345        }
346        // Find the parameter list child.
347        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
358/// Find argument list ranges for calls to `function_name` on the given (1-based) lines.
359fn 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        // The call node's 1-based start line.
375        let node_line = node.start_position().row + 1;
376        if !call_lines.contains(&node_line) {
377            return;
378        }
379        // Check that this is a call to `function_name`.
380        if !call_matches_name(&node, content, function_name) {
381            return;
382        }
383        // Find the argument list.
384        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
395/// Build a `ListRange` from a parameter-list or argument-list node.
396fn 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    // Count named items and find comma positions.
401    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                // Named node → an actual item.
413                item_count += 1;
414            }
415            _ => {}
416        }
417        let _ = content; // not needed at this step
418    }
419
420    ListRange {
421        open_paren,
422        close_paren,
423        comma_positions,
424        item_count,
425    }
426}
427
428// ── Language-specific helpers ─────────────────────────────────────────────────
429
430fn 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
470/// Build the parameter text for the definition.
471fn 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
490/// Check whether a function node's name identifier matches `name`.
491fn 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
502/// Check whether a call node refers to `function_name`.
503///
504/// We look at the first "function" child of the call (the callee) and check for an
505/// `identifier` or `field_expression`/`member_expression` whose tail is `function_name`.
506fn 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        // method call: obj.method(...)
515        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        // attribute call in Python: A.method() uses attribute node
530        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
545// ── Merge helper ──────────────────────────────────────────────────────────────
546
547/// Merge two `PlannedEdit`s for the same file: apply the second edit's transformation
548/// on top of the first edit's `new_content`.
549fn 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    // The second edit was computed from `original` content; we need to re-apply it
558    // on top of `first.new_content`. Since both operate on distinct byte ranges
559    // (signature vs call site) in the same file, we apply the second diff as a
560    // string replacement: find what changed from second.original → second.new_content
561    // and apply the same replacement to first.new_content.
562    //
563    // Simple approach: the second edit adds text at a specific byte offset. Because
564    // the definition edit is at the signature (earlier in the file for typical layout
565    // than calls to itself), we apply both edits on the original and take the
566    // result. Both edits are independent insertions at different byte offsets.
567    //
568    // If the definition is *after* the self-call (unusual but possible), the byte
569    // offsets shift. We handle this by re-applying both edits from scratch on the
570    // original content.
571    let original = &first.original;
572    // Find definition insertion: diff first.original → first.new_content
573    // Find arg insertion: diff second.original → second.new_content
574    // Both diffs are single string insertions. Apply them both to `original`,
575    // adjusting offsets.
576    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    // Apply in reverse order so offsets stay valid.
581    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
597/// Extract the single insertion made from `original` → `new_content`.
598/// Returns `(byte_offset, inserted_text)`.
599/// If the diff is not a simple insertion, returns an error.
600fn extract_insertion(original: &str, new_content: &str) -> Result<(usize, String), String> {
601    // Find the first differing byte.
602    let orig_bytes = original.as_bytes();
603    let new_bytes = new_content.as_bytes();
604
605    // Both strings share a common prefix.
606    let prefix_len = orig_bytes
607        .iter()
608        .zip(new_bytes.iter())
609        .take_while(|(a, b)| a == b)
610        .count();
611
612    // Both strings share a common suffix (reading from the end).
613    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    // ── Rust ──────────────────────────────────────────────────────────────────
654
655    #[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        // Warning about missing index.
691        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        // b: String should come before a: i32
720        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    // ── Python ────────────────────────────────────────────────────────────────
750
751    #[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    // ── TypeScript ────────────────────────────────────────────────────────────
776
777    #[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    // ── extract_insertion ─────────────────────────────────────────────────────
810
811    #[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        // Common prefix is "fn f(a: i32" (11 bytes); insertion is ", b: String"
817        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        // Common prefix is "fn f(" (5 bytes); insertion is "b: String, "
827        assert_eq!(pos, 5);
828        assert_eq!(text, "b: String, ");
829    }
830
831    // ── PathBuf helper ────────────────────────────────────────────────────────
832
833    #[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}