Skip to main content

normalize_refactor/
inline_function.rs

1//! Inline-function recipe: replace a single-use function's call site with its body.
2//!
3//! Steps:
4//! 1. Locate the function definition at the given position (line:col may point to
5//!    the function name in the definition or in a call site)
6//! 2. Find all call sites of this function within the same file (name-match)
7//! 3. Verify the function is called exactly once (or `--force` to override)
8//! 4. Substitute arguments for parameters throughout the body
9//! 5. Replace the call expression with the inlined body
10//! 6. Remove the function definition
11//!
12//! Supported languages: JavaScript, TypeScript (function declarations,
13//! arrow-function const bindings). Python (`def`) and Rust (`fn`) are
14//! structurally similar but less tested.
15//!
16//! Conservative: aborts rather than generating broken code.
17//! - Multiple `return` statements → error
18//! - Non-trivial control flow → error
19//! - Grammar unavailable → error
20
21use std::path::Path;
22
23use normalize_languages::parsers::parse_with_grammar;
24use normalize_languages::support_for_path;
25
26use crate::{PlannedEdit, RefactoringContext, RefactoringPlan};
27
28// ── Public output type ────────────────────────────────────────────────
29
30/// Outcome details for a planned inline-function operation.
31pub struct InlineFunctionOutcome {
32    pub plan: RefactoringPlan,
33    pub function_name: String,
34    pub call_site_line: usize,
35}
36
37// ── Entry point ───────────────────────────────────────────────────────
38
39/// Build an inline-function plan without touching the filesystem.
40///
41/// `file_path` is the path of the file (absolute or relative; used for grammar
42/// detection and error messages). `content` is the file's current text.
43/// `line` and `col` are 1-based and point to the function name — either in the
44/// definition or at a call site. `force` overrides the single-use check.
45pub fn plan_inline_function(
46    _ctx: &RefactoringContext,
47    file_abs: &Path,
48    content: &str,
49    line: usize,
50    col: usize,
51    force: bool,
52) -> Result<InlineFunctionOutcome, String> {
53    // ── 1. Grammar ────────────────────────────────────────────────────
54    let support = support_for_path(file_abs).ok_or_else(|| {
55        let ext = file_abs
56            .extension()
57            .and_then(|e| e.to_str())
58            .unwrap_or("<unknown>");
59        format!("inline-function: no language support for .{ext} files")
60    })?;
61    let grammar = support.grammar_name();
62    let tree = parse_with_grammar(grammar, content).ok_or_else(|| {
63        format!(
64            "inline-function: grammar for {grammar} not loaded — run `normalize grammars install`"
65        )
66    })?;
67
68    // ── 2. Resolve cursor position → function name ────────────────────
69    let cursor_byte = line_col_to_byte(content, line, col)?;
70    let root = tree.root_node();
71
72    // Walk from the deepest node at the cursor position upward, looking for
73    // either a function definition or a call_expression / call site.
74    let function_name =
75        resolve_function_name_at(&root, content, cursor_byte, grammar).ok_or_else(|| {
76            format!("inline-function: no function definition or call found at {line}:{col}")
77        })?;
78
79    // ── 3. Find the function definition ──────────────────────────────
80    let def = find_function_def(&root, content, &function_name, grammar).ok_or_else(|| {
81        format!("inline-function: definition of '{function_name}' not found in this file")
82    })?;
83
84    // ── 4. Validate the function body ─────────────────────────────────
85    let body_text = extract_body_text(content, &def)?;
86
87    // ── 5. Find call sites ───────────────────────────────────────────
88    let call_sites = find_call_sites(&root, content, &function_name, grammar);
89
90    match call_sites.len() {
91        0 => {
92            return Err(format!(
93                "inline-function: '{function_name}' has no call sites in this file"
94            ));
95        }
96        1 => {}          // exactly one — proceed
97        _ if force => {} // multiple but --force
98        n => {
99            return Err(format!(
100                "inline-function: '{function_name}' is called {n} times; use --force to inline anyway (or inline the specific call manually)"
101            ));
102        }
103    }
104
105    let call_site = &call_sites[0];
106
107    // ── 6. Perform substitution ───────────────────────────────────────
108    let inlined = substitute_call(content, &def, call_site, &body_text)?;
109    let call_site_line = call_site.line;
110
111    // ── 7. Remove the function definition ────────────────────────────
112    // `inlined` already has the call replaced; now remove the def.
113    let final_content = remove_function_def(&inlined, &def, content)?;
114
115    let plan = RefactoringPlan {
116        operation: "inline-function".to_string(),
117        edits: vec![PlannedEdit {
118            file: file_abs.to_path_buf(),
119            original: content.to_string(),
120            new_content: final_content,
121            description: format!("inline {function_name}"),
122        }],
123        warnings: vec![],
124    };
125
126    Ok(InlineFunctionOutcome {
127        plan,
128        function_name,
129        call_site_line,
130    })
131}
132
133// ── Internal types ────────────────────────────────────────────────────
134
135/// A located function definition, with extracted parameter names and body span.
136struct FunctionDef {
137    /// The function name.
138    name: String,
139    /// Parameter names (positional, in order).
140    params: Vec<String>,
141    /// Byte range of the entire definition node (including any leading whitespace
142    /// up to the prior newline, for clean deletion).
143    def_start_byte: usize,
144    def_end_byte: usize,
145    /// Byte range of the function body (the `{ ... }` block, excluding braces).
146    body_start_byte: usize,
147    body_end_byte: usize,
148}
149
150/// A located call expression.
151struct CallSite {
152    /// Argument texts as they appear in source.
153    args: Vec<String>,
154    /// Byte range of the full call expression (e.g. `f(a, b)`).
155    call_start_byte: usize,
156    call_end_byte: usize,
157    /// 1-based line number of the call.
158    line: usize,
159}
160
161// ── Byte-position helpers ─────────────────────────────────────────────
162
163fn line_col_to_byte(content: &str, line: usize, col: usize) -> Result<usize, String> {
164    if line == 0 {
165        return Err("inline-function: line is 1-based; 0 is invalid".to_string());
166    }
167    let mut current_line = 1usize;
168    let mut line_start = 0usize;
169    for (i, ch) in content.char_indices() {
170        if current_line == line {
171            line_start = i;
172            break;
173        }
174        if ch == '\n' {
175            current_line += 1;
176        }
177        if current_line > line {
178            // Past end of file
179            return Err(format!(
180                "inline-function: line {line} is beyond end of file ({current_line} lines)"
181            ));
182        }
183    }
184    // If we reached the end without finding the line (file has exactly `line` lines
185    // with no trailing newline, so the loop exits without setting line_start for the
186    // last line):
187    if current_line < line {
188        // Check if content ends exactly at that line
189        return Err(format!(
190            "inline-function: line {line} is beyond end of file"
191        ));
192    }
193    let col_offset = col.saturating_sub(1); // 1-based → 0-based
194    let byte = line_start
195        + col_offset.min(
196            content[line_start..]
197                .find('\n')
198                .unwrap_or(content[line_start..].len()),
199        );
200    Ok(byte.min(content.len()))
201}
202
203fn byte_to_line(content: &str, byte: usize) -> usize {
204    content[..byte.min(content.len())]
205        .chars()
206        .filter(|&c| c == '\n')
207        .count()
208        + 1
209}
210
211// ── Grammar-aware traversal helpers ──────────────────────────────────
212
213/// Given a node at the cursor position, find the identifier name that is either
214/// a function name in a definition or a callee name in a call expression.
215fn resolve_function_name_at<'a>(
216    root: &tree_sitter::Node<'a>,
217    content: &str,
218    cursor_byte: usize,
219    _grammar: &str,
220) -> Option<String> {
221    let node = root.descendant_for_byte_range(cursor_byte, cursor_byte + 1)?;
222
223    // Walk up trying to identify a function definition or call expression.
224    let mut n = node;
225    loop {
226        let kind = n.kind();
227
228        // ── Function definitions ─────────────────────────────────────
229        // JS/TS: function_declaration, method_definition, arrow function via
230        //        lexical_declaration (const f = (...) => ...)
231        // Python: function_definition
232        // Rust: function_item
233        if is_function_def_kind(kind) {
234            // Extract the name child
235            if let Some(name_node) = find_name_child(&n, content) {
236                return Some(name_node);
237            }
238        }
239
240        // ── Arrow / const function bindings ──────────────────────────
241        // JS/TS: `const f = (...) => ...` or `const f = function(...) {...}`
242        if (kind == "lexical_declaration" || kind == "variable_declaration")
243            && let Some(name) = extract_arrow_def_name(&n, content)
244        {
245            return Some(name);
246        }
247
248        // ── Call expressions ─────────────────────────────────────────
249        // JS/TS/Python/Rust: call_expression
250        if (kind == "call_expression" || kind == "call")
251            && let Some(callee) = n
252                .child_by_field_name("function")
253                .or_else(|| n.child_by_field_name("callee"))
254        {
255            let callee_text = &content[callee.start_byte()..callee.end_byte()];
256            // Only simple identifier calls (not method calls like a.b())
257            if !callee_text.contains('.') && !callee_text.contains(':') {
258                return Some(callee_text.to_string());
259            }
260        }
261
262        match n.parent() {
263            Some(p) if p.id() != root.id() => n = p,
264            _ => break,
265        }
266    }
267    None
268}
269
270fn is_function_def_kind(kind: &str) -> bool {
271    matches!(
272        kind,
273        "function_declaration"
274            | "function_definition"      // Python
275            | "function_item"            // Rust
276            | "method_definition"
277            | "generator_function_declaration"
278    )
279}
280
281fn is_arrow_or_func_expr_kind(kind: &str) -> bool {
282    matches!(
283        kind,
284        "arrow_function" | "function_expression" | "generator_function"
285    )
286}
287
288/// Find a parameter-list child node by kind heuristic (fallback when field names aren't available).
289fn find_params_child<'a>(node: &tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
290    let mut c = node.walk();
291    let mut found = None;
292    if c.goto_first_child() {
293        loop {
294            let n = c.node();
295            if matches!(
296                n.kind(),
297                "formal_parameters" | "parameters" | "parameter_list"
298            ) {
299                found = Some(n);
300                break;
301            }
302            if !c.goto_next_sibling() {
303                break;
304            }
305        }
306    }
307    found
308}
309
310/// Find a body/block child node by kind heuristic.
311/// For `{ ... }` bodies: find `statement_block` / `block`.
312/// For arrow expression bodies: find the node after `=>`.
313fn find_body_child<'a>(node: &tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
314    // First pass: look for explicit block kinds.
315    let mut c = node.walk();
316    if c.goto_first_child() {
317        loop {
318            let n = c.node();
319            if matches!(n.kind(), "statement_block" | "block" | "function_body") {
320                return Some(n);
321            }
322            if !c.goto_next_sibling() {
323                break;
324            }
325        }
326    }
327    // Second pass: for arrow expressions, find the node after `=>`.
328    let mut c = node.walk();
329    let mut past_arrow = false;
330    if c.goto_first_child() {
331        loop {
332            let n = c.node();
333            if past_arrow && n.is_named() {
334                return Some(n);
335            }
336            if n.kind() == "=>" {
337                past_arrow = true;
338            }
339            if !c.goto_next_sibling() {
340                break;
341            }
342        }
343    }
344    None
345}
346
347/// Extract the function name from a definition node.
348fn find_name_child(node: &tree_sitter::Node<'_>, content: &str) -> Option<String> {
349    // Try the `name` field first (JS/TS function_declaration, Python function_definition)
350    if let Some(name_node) = node.child_by_field_name("name") {
351        return Some(content[name_node.start_byte()..name_node.end_byte()].to_string());
352    }
353    // Rust function_item uses `name`
354    None
355}
356
357/// If a `lexical_declaration` or `variable_declaration` node binds a function
358/// (arrow or function expression), return the variable name.
359fn extract_arrow_def_name(node: &tree_sitter::Node<'_>, content: &str) -> Option<String> {
360    let mut c = node.walk();
361    if c.goto_first_child() {
362        loop {
363            let child = c.node();
364            if child.kind() == "variable_declarator"
365                && let Some(name) = arrow_declarator_name(&child, content)
366            {
367                return Some(name);
368            }
369            if !c.goto_next_sibling() {
370                break;
371            }
372        }
373    }
374    None
375}
376
377/// Extract the name from a `variable_declarator` if its value is an arrow/function expression.
378/// Handles both field-based grammars and child-order-based grammars.
379fn arrow_declarator_name(decl: &tree_sitter::Node<'_>, content: &str) -> Option<String> {
380    // Try named field "name" + "value" first.
381    let name_via_field = decl.child_by_field_name("name").or_else(|| {
382        // Fallback: first named child that is an identifier.
383        let mut c = decl.walk();
384        let mut found = None;
385        if c.goto_first_child() {
386            loop {
387                let n = c.node();
388                if n.kind() == "identifier" {
389                    found = Some(n);
390                    break;
391                }
392                if !c.goto_next_sibling() {
393                    break;
394                }
395            }
396        }
397        found
398    });
399    let name_text = name_via_field.map(|n| content[n.start_byte()..n.end_byte()].to_string())?;
400
401    // Check if the value is an arrow/function expression, using either fields or children.
402    let has_func_value = decl
403        .child_by_field_name("value")
404        .map(|v| is_arrow_or_func_expr_kind(v.kind()))
405        .unwrap_or_else(|| {
406            // Fallback: scan children for an arrow/function expression.
407            let mut c = decl.walk();
408            let mut found = false;
409            if c.goto_first_child() {
410                loop {
411                    let n = c.node();
412                    if is_arrow_or_func_expr_kind(n.kind()) {
413                        found = true;
414                        break;
415                    }
416                    if !c.goto_next_sibling() {
417                        break;
418                    }
419                }
420            }
421            found
422        });
423
424    if has_func_value {
425        Some(name_text)
426    } else {
427        None
428    }
429}
430
431// ── Function definition finder ────────────────────────────────────────
432
433fn find_function_def(
434    root: &tree_sitter::Node<'_>,
435    content: &str,
436    name: &str,
437    _grammar: &str,
438) -> Option<FunctionDef> {
439    // Walk all nodes in the tree looking for function definitions named `name`.
440    let mut cursor = root.walk();
441    find_function_def_recursive(&mut cursor, *root, content, name)
442}
443
444fn find_function_def_recursive(
445    cursor: &mut tree_sitter::TreeCursor<'_>,
446    node: tree_sitter::Node<'_>,
447    content: &str,
448    name: &str,
449) -> Option<FunctionDef> {
450    let kind = node.kind();
451
452    // Check if this node is a function definition with the right name.
453    if is_function_def_kind(kind)
454        && let Some(found_name) = find_name_child(&node, content)
455        && found_name == name
456    {
457        return extract_function_def(&node, content, name, true);
458    }
459
460    // JS/TS: `const f = (...) => ...` — lexical_declaration containing an arrow_function
461    if (kind == "lexical_declaration" || kind == "variable_declaration")
462        && let Some(def) = try_extract_arrow_def(&node, content, name)
463    {
464        return Some(def);
465    }
466
467    // Recurse into children.
468    if cursor.goto_first_child() {
469        loop {
470            let child = cursor.node();
471            if let Some(result) = find_function_def_recursive(cursor, child, content, name) {
472                // Restore before returning so callers don't observe cursor side-effects.
473                // (tree-sitter cursors are position-stateful but we own this cursor.)
474                return Some(result);
475            }
476            if !cursor.goto_next_sibling() {
477                break;
478            }
479        }
480        cursor.goto_parent();
481    }
482
483    None
484}
485
486/// Try to extract an arrow-function definition from a `const f = (...) => ...` declaration.
487fn try_extract_arrow_def(
488    decl_node: &tree_sitter::Node<'_>,
489    content: &str,
490    name: &str,
491) -> Option<FunctionDef> {
492    // Look for a variable_declarator child with the right name binding a function.
493    let mut decl_cursor = decl_node.walk();
494    if decl_cursor.goto_first_child() {
495        loop {
496            let child = decl_cursor.node();
497            if child.kind() == "variable_declarator"
498                && arrow_declarator_name(&child, content).as_deref() == Some(name)
499            {
500                return extract_function_def(decl_node, content, name, true);
501            }
502            if !decl_cursor.goto_next_sibling() {
503                break;
504            }
505        }
506    }
507    None
508}
509
510/// Extract structured information from a function definition node.
511fn extract_function_def(
512    node: &tree_sitter::Node<'_>,
513    content: &str,
514    name: &str,
515    _is_statement: bool,
516) -> Option<FunctionDef> {
517    // For arrow functions inside `const f = ...`, we need the body from the
518    // inner arrow_function node, but the span from the outer statement.
519    let (param_node, body_node) =
520        if node.kind() == "lexical_declaration" || node.kind() == "variable_declaration" {
521            // Find the variable_declarator → value (arrow_function or function_expression)
522            let mut c = node.walk();
523            let mut found_decl: Option<tree_sitter::Node<'_>> = None;
524            if c.goto_first_child() {
525                loop {
526                    let child = c.node();
527                    if child.kind() == "variable_declarator" {
528                        let vname = child
529                            .child_by_field_name("name")
530                            .map(|n| &content[n.start_byte()..n.end_byte()]);
531                        if vname == Some(name) {
532                            found_decl = Some(child);
533                            break;
534                        }
535                    }
536                    if !c.goto_next_sibling() {
537                        break;
538                    }
539                }
540            }
541            let decl = found_decl?;
542            // The function value may be accessed via a named "value" field or as a child.
543            let value = decl.child_by_field_name("value").or_else(|| {
544                // Fallback: find the first arrow/function expression child.
545                let mut cc = decl.walk();
546                let mut found = None;
547                if cc.goto_first_child() {
548                    loop {
549                        let n = cc.node();
550                        if is_arrow_or_func_expr_kind(n.kind()) {
551                            found = Some(n);
552                            break;
553                        }
554                        if !cc.goto_next_sibling() {
555                            break;
556                        }
557                    }
558                }
559                found
560            })?;
561            let params = value
562                .child_by_field_name("parameters")
563                .or_else(|| value.child_by_field_name("formal_parameters"))
564                .or_else(|| find_params_child(&value))?;
565            let body = value
566                .child_by_field_name("body")
567                .or_else(|| find_body_child(&value))?;
568            (params, body)
569        } else {
570            // function_declaration / function_definition / function_item
571            let params = node
572                .child_by_field_name("parameters")
573                .or_else(|| node.child_by_field_name("formal_parameters"))
574                .or_else(|| find_params_child(node))?;
575            let body = node
576                .child_by_field_name("body")
577                .or_else(|| find_body_child(node))?;
578            (params, body)
579        };
580
581    let params = extract_parameter_names(&param_node, content);
582
583    // Determine body start/end (inside the braces, if present).
584    // For arrow functions with expression bodies (no braces), treat the whole
585    // value as the body.
586    let (body_start_byte, body_end_byte) =
587        if body_node.kind() == "statement_block" || body_node.kind() == "block" {
588            // Skip the opening `{` and closing `}`
589            let inner_start = body_node.start_byte() + 1;
590            let inner_end = body_node.end_byte() - 1;
591            (inner_start, inner_end)
592        } else {
593            // Expression body (arrow function without braces): `(x) => x + 1`
594            // The entire body is the expression.
595            (body_node.start_byte(), body_node.end_byte())
596        };
597
598    // Snap def start to line start for clean deletion.
599    let def_start_byte = {
600        let raw = node.start_byte();
601        content[..raw].rfind('\n').map(|i| i + 1).unwrap_or(0)
602    };
603    let def_end_byte = {
604        let raw = node.end_byte();
605        // Include the trailing newline if present.
606        if raw < content.len() && content.as_bytes()[raw] == b'\n' {
607            raw + 1
608        } else {
609            raw
610        }
611    };
612
613    Some(FunctionDef {
614        name: name.to_string(),
615        params,
616        def_start_byte,
617        def_end_byte,
618        body_start_byte,
619        body_end_byte,
620    })
621}
622
623/// Extract positional parameter names from a parameter list node.
624fn extract_parameter_names(params_node: &tree_sitter::Node<'_>, content: &str) -> Vec<String> {
625    let mut names = vec![];
626    let mut c = params_node.walk();
627    if c.goto_first_child() {
628        loop {
629            let child = c.node();
630            let kind = child.kind();
631            // JS/TS: identifier, required_parameter, optional_parameter, rest_parameter,
632            //        assignment_pattern (default param)
633            // Python: identifier, typed_parameter, default_parameter
634            // Rust: pattern (identifier, typed)
635            let param_name = match kind {
636                "identifier" => Some(&content[child.start_byte()..child.end_byte()]),
637                "required_parameter" | "optional_parameter" => child
638                    .child_by_field_name("pattern")
639                    .or_else(|| {
640                        // Fall back to first named child that is an identifier
641                        let mut cc = child.walk();
642                        if cc.goto_first_child() {
643                            loop {
644                                let n = cc.node();
645                                if n.kind() == "identifier" {
646                                    return Some(n);
647                                }
648                                if !cc.goto_next_sibling() {
649                                    break;
650                                }
651                            }
652                        }
653                        None
654                    })
655                    .map(|n| &content[n.start_byte()..n.end_byte()]),
656                "typed_parameter" | "default_parameter" => {
657                    // Python: first child is usually the name identifier
658                    let mut cc = child.walk();
659                    let mut found = None;
660                    if cc.goto_first_child() {
661                        loop {
662                            let n = cc.node();
663                            if n.kind() == "identifier" {
664                                found = Some(&content[n.start_byte()..n.end_byte()]);
665                                break;
666                            }
667                            if !cc.goto_next_sibling() {
668                                break;
669                            }
670                        }
671                    }
672                    found
673                }
674                // Rust: parameter has `pattern` and `type` fields
675                "parameter" => child
676                    .child_by_field_name("pattern")
677                    .map(|n| &content[n.start_byte()..n.end_byte()]),
678                _ => None,
679            };
680            if let Some(n) = param_name
681                && !n.is_empty()
682            {
683                names.push(n.to_string());
684            }
685            if !c.goto_next_sibling() {
686                break;
687            }
688        }
689    }
690    names
691}
692
693// ── Body text extraction and validation ───────────────────────────────
694
695/// Extract the body text from a function definition, stripping surrounding braces
696/// and normalizing indentation. Returns an error if the body is too complex to
697/// inline safely (e.g. multiple `return` statements).
698fn extract_body_text(content: &str, def: &FunctionDef) -> Result<String, String> {
699    let raw_body = &content[def.body_start_byte..def.body_end_byte];
700
701    // Count `return` statements.  A conservative text search is good enough for
702    // a first-pass safety check — if you have `return` in a string or comment,
703    // this will over-count and refuse rather than under-count and generate broken code.
704    // That's the correct conservative behavior.
705    let return_count = count_return_statements(raw_body);
706    if return_count > 1 {
707        return Err(format!(
708            "inline-function: '{}' has {} return statements; inlining would require control-flow analysis — aborting (too complex)",
709            def.name, return_count
710        ));
711    }
712
713    Ok(raw_body.to_string())
714}
715
716/// Count `return` keyword occurrences at word boundaries in `text`.
717fn count_return_statements(text: &str) -> usize {
718    let mut count = 0usize;
719    let mut i = 0usize;
720    let bytes = text.as_bytes();
721    while i + 6 <= bytes.len() {
722        if &bytes[i..i + 6] == b"return" {
723            // Check word boundaries.
724            let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
725            let after = bytes.get(i + 6).copied();
726            let after_ok = after.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_');
727            if before_ok && after_ok {
728                count += 1;
729            }
730        }
731        i += 1;
732    }
733    count
734}
735
736// ── Call site finder ──────────────────────────────────────────────────
737
738fn find_call_sites(
739    root: &tree_sitter::Node<'_>,
740    content: &str,
741    name: &str,
742    _grammar: &str,
743) -> Vec<CallSite> {
744    let mut sites = vec![];
745    let mut cursor = root.walk();
746    find_call_sites_recursive(&mut cursor, *root, content, name, &mut sites);
747    sites
748}
749
750fn find_call_sites_recursive(
751    cursor: &mut tree_sitter::TreeCursor<'_>,
752    node: tree_sitter::Node<'_>,
753    content: &str,
754    name: &str,
755    sites: &mut Vec<CallSite>,
756) {
757    let kind = node.kind();
758
759    if kind == "call_expression" || kind == "call" {
760        // Check callee is our function name (simple identifier, not a.b or a::b).
761        let callee = node
762            .child_by_field_name("function")
763            .or_else(|| node.child_by_field_name("callee"));
764        if let Some(callee_node) = callee {
765            let callee_text = &content[callee_node.start_byte()..callee_node.end_byte()];
766            if callee_text == name {
767                // Extract arguments.
768                let args = extract_call_args(&node, content);
769                let line = byte_to_line(content, node.start_byte());
770
771                sites.push(CallSite {
772                    args,
773                    call_start_byte: node.start_byte(),
774                    call_end_byte: node.end_byte(),
775                    line,
776                });
777            }
778        }
779    }
780
781    if cursor.goto_first_child() {
782        loop {
783            let child = cursor.node();
784            find_call_sites_recursive(cursor, child, content, name, sites);
785            if !cursor.goto_next_sibling() {
786                break;
787            }
788        }
789        cursor.goto_parent();
790    }
791}
792
793/// Extract argument texts from a call_expression node.
794fn extract_call_args(call_node: &tree_sitter::Node<'_>, content: &str) -> Vec<String> {
795    let mut args = vec![];
796    let args_node = call_node.child_by_field_name("arguments").or_else(|| {
797        // Fallback: look for argument_list child (Python)
798        let mut c = call_node.walk();
799        let mut found = None;
800        if c.goto_first_child() {
801            loop {
802                let n = c.node();
803                if matches!(n.kind(), "argument_list" | "arguments") {
804                    found = Some(n);
805                    break;
806                }
807                if !c.goto_next_sibling() {
808                    break;
809                }
810            }
811        }
812        found
813    });
814
815    let Some(args_node) = args_node else {
816        return args;
817    };
818
819    let mut c = args_node.walk();
820    if c.goto_first_child() {
821        loop {
822            let child = c.node();
823            let kind = child.kind();
824            // Skip punctuation nodes (`,`, `(`, `)`)
825            if kind != "," && kind != "(" && kind != ")" && child.is_named() {
826                args.push(content[child.start_byte()..child.end_byte()].to_string());
827            }
828            if !c.goto_next_sibling() {
829                break;
830            }
831        }
832    }
833    args
834}
835
836// ── Substitution ──────────────────────────────────────────────────────
837
838/// Replace the call site with the inlined function body.
839///
840/// Steps:
841/// 1. Strip the body of leading/trailing whitespace
842/// 2. Replace each parameter name with the corresponding argument
843/// 3. Strip the `return` keyword (if present) when the call is in expression position
844/// 4. Replace the call span in `content`
845fn substitute_call(
846    content: &str,
847    def: &FunctionDef,
848    call: &CallSite,
849    body_text: &str,
850) -> Result<String, String> {
851    // Check argument count matches parameter count.
852    if call.args.len() != def.params.len() {
853        return Err(format!(
854            "inline-function: '{}' expects {} arguments but call site provides {} — aborting",
855            def.name,
856            def.params.len(),
857            call.args.len()
858        ));
859    }
860
861    // ── Trim the body ─────────────────────────────────────────────────
862    let trimmed = body_text.trim();
863
864    // ── Strip `return` if present ─────────────────────────────────────
865    let stripped = strip_single_return(trimmed);
866
867    // ── Substitute parameters → arguments ────────────────────────────
868    let mut result = stripped.to_string();
869    for (param, arg) in def.params.iter().zip(call.args.iter()) {
870        result = normalize_edit::replace_all_words(&result, param, arg);
871    }
872
873    // ── Determine what replaces the call site in `content` ────────────
874    //
875    // If the call is the sole expression in an expression_statement, we need to
876    // handle whether to keep the semicolon / statement boundary. In the simple
877    // case we just replace the call expression bytes with the inlined body.
878    //
879    // If the function body contained a statement block, we need to decide whether
880    // to emit the block inline or unwrap it. For now: if the call is a statement
881    // *and* the body looks like a block (contains `;`), keep it as a block.
882    // Otherwise, unwrap to an expression.
883    let replacement = result.trim().to_string();
884
885    // Build new content: replace the call bytes with the replacement.
886    let mut new_content = String::new();
887    new_content.push_str(&content[..call.call_start_byte]);
888    new_content.push_str(&replacement);
889    new_content.push_str(&content[call.call_end_byte..]);
890
891    Ok(new_content)
892}
893
894/// Strip a leading `return ` from an expression if present (single `return`).
895fn strip_single_return(s: &str) -> &str {
896    let s = s.trim_start();
897    if let Some(rest) = s.strip_prefix("return") {
898        // Must be followed by whitespace or end-of-string.
899        let after = rest.trim_start_matches([' ', '\t']);
900        // Strip trailing semicolon if the body was just `return expr;`
901        after.strip_suffix(';').unwrap_or(after).trim()
902    } else {
903        s
904    }
905}
906
907// ── Definition removal ────────────────────────────────────────────────
908
909/// Remove the function definition from `inlined` (which already has the call replaced).
910///
911/// Because the edit we already applied (call replacement) may have shifted byte offsets,
912/// we re-locate the function definition by name in `inlined` using a text-based approach.
913///
914/// `original` is the original file content (used to know the original def span).
915fn remove_function_def(inlined: &str, def: &FunctionDef, original: &str) -> Result<String, String> {
916    // The byte delta introduced by the call replacement.
917    // original length - (call_site length) + replacement length
918    // We don't have the call site bytes here, but we can use a tree-sitter re-parse
919    // of `inlined` to re-find the definition.
920    //
921    // For simplicity: use a grammar-agnostic approach — find the def by locating
922    // the whole-word name on the def's original line in the new content, then
923    // use the Editor's find_symbol.  But since we don't have a path here, we use
924    // the editor's text-only utility.
925    //
926    // Actually, the cleanest approach: re-parse `inlined` to find the def.
927    // Since we don't have the path or grammar here (they're not in FunctionDef),
928    // we use the original line number adjusted for any inserted/removed content
929    // above the definition.
930    //
931    // However, the simplest correct approach is: the call site and the definition
932    // are in different parts of the file. If the call site is AFTER the definition,
933    // the definition bytes haven't moved; we can delete them directly from `inlined`.
934    // If the call site is BEFORE the definition, the definition bytes have shifted.
935    //
936    // We know def_start_byte from the original content.
937    // The call replaced `call_start_byte..call_end_byte` with `replacement`.
938    // The shift in bytes = replacement.len() - (call_end_byte - call_start_byte).
939    //
940    // But we don't have call_start/end here. Let's take the safe path:
941    // diff the original and inlined to compute the shift, then adjust.
942    //
943    // Simplest path: find the function definition text in `inlined` by searching
944    // for the original definition text (which is unchanged since the call was elsewhere).
945
946    let orig_def_text = &original[def.def_start_byte..def.def_end_byte];
947
948    // Find the definition in `inlined` by exact text match.
949    if let Some(pos) = inlined.find(orig_def_text) {
950        let mut result = String::new();
951        result.push_str(&inlined[..pos]);
952        result.push_str(&inlined[pos + orig_def_text.len()..]);
953        // Clean up any double-blank lines introduced by the deletion.
954        Ok(collapse_triple_newlines(result))
955    } else {
956        // Fallback: try to delete by adjusted byte offset.
957        // Compute byte shift: compare lengths before/after the definition.
958        // This handles the case where the call was in the def text itself (recursive),
959        // which is already blocked by our single-use check — but be safe.
960        Err(format!(
961            "inline-function: could not locate definition of '{}' in modified content — aborting",
962            def.name
963        ))
964    }
965}
966
967/// Collapse three or more consecutive newlines into two (one blank line).
968fn collapse_triple_newlines(s: String) -> String {
969    let mut result = s;
970    loop {
971        let before = result.len();
972        result = result.replace("\n\n\n", "\n\n");
973        if result.len() == before {
974            break;
975        }
976    }
977    result
978}