Skip to main content

aft/commands/
extract_function.rs

1//! Handler for the `extract_function` command: extract a range of code into
2//! a new function with auto-detected parameters and return value.
3//!
4//! Follows the edit_symbol.rs pattern: validate → parse → compute →
5//! auto_backup → write_format_validate → respond.
6
7use std::path::Path;
8
9use tree_sitter::Parser;
10
11use crate::context::AppContext;
12use crate::edit;
13use crate::extract::{
14    detect_free_variables, detect_return_value, generate_call_site, generate_extracted_function,
15    ReturnKind,
16};
17use crate::indent::detect_indent;
18use crate::parser::{detect_language, grammar_for, LangId};
19use crate::protocol::{RawRequest, Response};
20
21/// Handle an `extract_function` request.
22///
23/// Params:
24///   - `file` (string, required) — target file path
25///   - `name` (string, required) — name for the new function
26///   - `start_line` (u32, required) — first line of the range to extract (1-based)
27///   - `end_line` (u32, required) — last line (exclusive, 1-based) of the range to extract
28///
29/// Returns on success:
30///   `{ file, name, parameters, return_type, extracted_range, call_site_range, syntax_valid?, backup_id }`
31///
32/// `syntax_valid` is absent when syntax validation could not run.
33///
34/// Error codes:
35///   - `unsupported_language` — file is not TS/JS/TSX/Python
36///   - `this_reference_in_range` — range contains `this`/`self`
37pub fn handle_extract_function(req: &RawRequest, ctx: &AppContext) -> Response {
38    let op_id = crate::backup::new_op_id();
39    // --- Extract params ---
40    let file = match req.params.get("file").and_then(|v| v.as_str()) {
41        Some(f) => f,
42        None => {
43            return Response::error(
44                &req.id,
45                "invalid_request",
46                "extract_function: missing required param 'file'",
47            );
48        }
49    };
50
51    let name = match req.params.get("name").and_then(|v| v.as_str()) {
52        Some(n) => n,
53        None => {
54            return Response::error(
55                &req.id,
56                "invalid_request",
57                "extract_function: missing required param 'name'",
58            );
59        }
60    };
61
62    let start_line_1based = match req.params.get("start_line").and_then(|v| v.as_u64()) {
63        Some(l) if l >= 1 => l as u32,
64        Some(_) => {
65            return Response::error(
66                &req.id,
67                "invalid_request",
68                "extract_function: 'start_line' must be >= 1 (1-based)",
69            );
70        }
71        None => {
72            return Response::error(
73                &req.id,
74                "invalid_request",
75                "extract_function: missing required param 'start_line'",
76            );
77        }
78    };
79    let start_line = start_line_1based - 1;
80
81    let end_line_1based = match req.params.get("end_line").and_then(|v| v.as_u64()) {
82        Some(l) if l >= 1 => l as u32,
83        Some(_) => {
84            return Response::error(
85                &req.id,
86                "invalid_request",
87                "extract_function: 'end_line' must be >= 1 (1-based)",
88            );
89        }
90        None => {
91            return Response::error(
92                &req.id,
93                "invalid_request",
94                "extract_function: missing required param 'end_line'",
95            );
96        }
97    };
98    let end_line = end_line_1based - 1;
99
100    if start_line >= end_line {
101        return Response::error(
102            &req.id,
103            "invalid_request",
104            format!(
105                "extract_function: start_line ({}) must be less than end_line ({})",
106                start_line, end_line
107            ),
108        );
109    }
110
111    // --- Validate file ---
112    let path = match ctx.validate_path(&req.id, Path::new(file)) {
113        Ok(path) => path,
114        Err(resp) => return resp,
115    };
116    if !path.exists() {
117        return Response::error(
118            &req.id,
119            "file_not_found",
120            format!("extract_function: file not found: {}", file),
121        );
122    }
123
124    // --- Language guard (D101) ---
125    let lang = match detect_language(&path) {
126        Some(l) => l,
127        None => {
128            return Response::error(
129                &req.id,
130                "unsupported_language",
131                "extract_function: unsupported file type",
132            );
133        }
134    };
135
136    if !matches!(
137        lang,
138        LangId::TypeScript | LangId::Tsx | LangId::JavaScript | LangId::Python
139    ) {
140        return Response::error(
141            &req.id,
142            "unsupported_language",
143            format!(
144                "extract_function: only TypeScript/JavaScript/Python files are supported, got {:?}",
145                lang
146            ),
147        );
148    }
149
150    // --- Read and parse ---
151    let source = match std::fs::read_to_string(&path) {
152        Ok(s) => s,
153        Err(e) => {
154            return Response::error(
155                &req.id,
156                "file_not_found",
157                format!("extract_function: {}: {}", file, e),
158            );
159        }
160    };
161
162    let grammar = grammar_for(lang);
163    let mut parser = Parser::new();
164    if parser.set_language(&grammar).is_err() {
165        return Response::error(
166            &req.id,
167            "parse_error",
168            "extract_function: failed to initialize parser",
169        );
170    }
171    let tree = match parser.parse(source.as_bytes(), None) {
172        Some(t) => t,
173        None => {
174            return Response::error(
175                &req.id,
176                "parse_error",
177                "extract_function: failed to parse file",
178            );
179        }
180    };
181
182    // --- Convert line range to byte range ---
183    let start_byte = edit::line_col_to_byte(&source, start_line, 0);
184    let end_byte = edit::line_col_to_byte(&source, end_line, 0);
185
186    if start_byte >= source.len() {
187        return Response::error(
188            &req.id,
189            "invalid_request",
190            format!(
191                "extract_function: start_line {} is beyond end of file",
192                start_line
193            ),
194        );
195    }
196
197    // --- Detect free variables ---
198    let free_vars = detect_free_variables(&source, &tree, start_byte, end_byte, lang);
199
200    // Check for this/self
201    if free_vars.has_this_or_self {
202        let keyword = match lang {
203            LangId::Python => "self",
204            _ => "this",
205        };
206        return Response::error(
207            &req.id,
208            "this_reference_in_range",
209            format!(
210                "extract_function: selected range contains '{}' reference. Consider extracting as a method instead, or move the {} usage outside the extracted range.",
211                keyword, keyword
212            ),
213        );
214    }
215
216    // --- Find enclosing function for return value detection ---
217    let root = tree.root_node();
218    let enclosing_fn = find_enclosing_function_node(&root, start_byte, lang);
219    let enclosing_fn_end_byte = enclosing_fn.map(|n| n.end_byte());
220
221    // --- Detect return value ---
222    let return_kind = match detect_return_value(
223        &source,
224        &tree,
225        start_byte,
226        end_byte,
227        enclosing_fn_end_byte,
228        lang,
229    ) {
230        Ok(kind) => kind,
231        Err(error) => {
232            return Response::error(&req.id, "unsupported_return_bindings", error.message);
233        }
234    };
235
236    // --- Detect indentation ---
237    let indent_style = detect_indent(&source, lang);
238
239    // Determine base indent (indentation of the line where the enclosing function starts,
240    // or no indent if at module level)
241    let base_indent = if let Some(fn_node) = enclosing_fn {
242        let fn_start_line = fn_node.start_position().row;
243        get_line_indent(&source, fn_start_line as usize)
244    } else {
245        String::new()
246    };
247
248    // Determine the indent of the extracted range (for the call site)
249    let range_indent = get_line_indent(&source, start_line as usize);
250
251    // --- Extract body text ---
252    let body_text = &source[start_byte..end_byte];
253    let body_text = body_text.trim_end_matches('\n');
254
255    // --- Generate function and call site ---
256    let extracted_fn = generate_extracted_function(
257        name,
258        &free_vars.parameters,
259        &return_kind,
260        body_text,
261        &base_indent,
262        lang,
263        indent_style,
264    );
265
266    let call_site = generate_call_site(
267        name,
268        &free_vars.parameters,
269        &return_kind,
270        &range_indent,
271        lang,
272    );
273
274    // --- Compute new file content ---
275    // Insert the extracted function before the enclosing function (or at the range position
276    // if there's no enclosing function).
277    //
278    // For TS/JS, when the enclosing function is wrapped in `export` (or
279    // `export default`), the parser reports the function_declaration as
280    // a child of an export_statement. If we use fn_node.start_byte() as
281    // the insertion point, the `export` keyword stays attached to the
282    // start of the file content, and the extracted function gets inserted
283    // BETWEEN `export ` and `function`, producing:
284    //   `export function newFn(...) {} \n\n function originalFn(...) {}`
285    // The export keyword silently jumps from the original function to the
286    // extracted one. Fix: if the parent is an export_statement, insert
287    // before the export_statement instead.
288    let insert_pos = if let Some(fn_node) = enclosing_fn {
289        let mut anchor = fn_node;
290        if matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
291            if let Some(parent) = fn_node.parent() {
292                if parent.kind() == "export_statement" {
293                    anchor = parent;
294                }
295            }
296        }
297        anchor.start_byte()
298    } else {
299        start_byte
300    };
301
302    let new_source = build_new_source(
303        &source,
304        insert_pos,
305        start_byte,
306        end_byte,
307        &extracted_fn,
308        &call_site,
309    );
310
311    // --- Return type string for the response ---
312    let return_type = match &return_kind {
313        ReturnKind::Expression(_) => "expression",
314        ReturnKind::Variable(_) => "variable",
315        ReturnKind::Variables(_) => "variables",
316        ReturnKind::Void => "void",
317    };
318
319    // --- Auto-backup before mutation ---
320    let backup_id = match edit::auto_backup(
321        ctx,
322        req.session(),
323        &path,
324        &format!("extract_function: {}", name),
325        Some(&op_id),
326    ) {
327        Ok(id) => id,
328        Err(e) => {
329            return Response::error(&req.id, e.code(), e.to_string());
330        }
331    };
332
333    // --- Write, format, validate ---
334    let mut write_result =
335        match edit::write_format_validate(&path, &new_source, &ctx.config(), &req.params) {
336            Ok(r) => r,
337            Err(e) => {
338                return Response::error(&req.id, e.code(), e.to_string());
339            }
340        };
341
342    // Honesty: write_format_validate reverts the file when the extraction
343    // produced invalid syntax (e.g. extracting a class method emits a bare
344    // `function helper` into a class body). The file is unchanged, so returning
345    // a success response with the new symbol metadata would be a lie. Fail with
346    // the validation errors so the agent retries. (edit_match/batch surface this
347    // via rolled_back already; the refactor handlers did not.)
348    if write_result.rolled_back {
349        return Response::error(
350            &req.id,
351            "generated_invalid_syntax",
352            format!(
353                "extract_function produced invalid syntax; the file was left unchanged. {}",
354                edit::format_validation_errors(&write_result.validation_errors)
355            ),
356        );
357    }
358
359    if let Ok(final_content) = std::fs::read_to_string(&path) {
360        write_result.lsp_outcome = ctx.lsp_post_write(&path, &final_content, &req.params);
361    }
362
363    let param_count = free_vars.parameters.len();
364    log::debug!(
365        "extract_function: {} from {}:{}-{} ({} params)",
366        name,
367        file,
368        start_line,
369        end_line,
370        param_count
371    );
372
373    // --- Build response ---
374    let mut result = serde_json::json!({
375        "file": file,
376        "name": name,
377        "parameters": free_vars.parameters,
378        "return_type": return_type,
379        "formatted": write_result.formatted,
380    });
381
382    if let Some(valid) = write_result.syntax_valid {
383        result["syntax_valid"] = serde_json::json!(valid);
384    }
385
386    if let Some(ref reason) = write_result.format_skipped_reason {
387        result["format_skipped_reason"] = serde_json::json!(reason);
388    }
389
390    if write_result.validate_requested {
391        result["validation_errors"] = serde_json::json!(write_result.validation_errors);
392    }
393    if let Some(ref reason) = write_result.validate_skipped_reason {
394        result["validate_skipped_reason"] = serde_json::json!(reason);
395    }
396
397    if let Some(ref id) = backup_id {
398        result["backup_id"] = serde_json::json!(id);
399    }
400
401    write_result.append_lsp_diagnostics_to(&mut result);
402    write_result.append_reformatted_excerpt_to(&mut result);
403    Response::success(&req.id, result)
404}
405
406/// Find the enclosing function node for a byte position.
407fn find_enclosing_function_node<'a>(
408    root: &'a tree_sitter::Node<'a>,
409    byte_pos: usize,
410    lang: LangId,
411) -> Option<tree_sitter::Node<'a>> {
412    let fn_kinds: &[&str] = match lang {
413        LangId::TypeScript | LangId::Tsx | LangId::JavaScript => &[
414            "function_declaration",
415            "method_definition",
416            "arrow_function",
417            "lexical_declaration",
418        ],
419        LangId::Python => &["function_definition"],
420        _ => &[],
421    };
422
423    find_deepest_ancestor(root, byte_pos, fn_kinds)
424}
425
426/// Find the deepest ancestor node of given kinds containing byte_pos.
427fn find_deepest_ancestor<'a>(
428    node: &tree_sitter::Node<'a>,
429    byte_pos: usize,
430    kinds: &[&str],
431) -> Option<tree_sitter::Node<'a>> {
432    let mut result: Option<tree_sitter::Node<'a>> = None;
433    if kinds.contains(&node.kind()) && node.start_byte() <= byte_pos && byte_pos < node.end_byte() {
434        result = Some(*node);
435    }
436
437    let child_count = node.child_count();
438    for i in 0..child_count {
439        if let Some(child) = node.child(i as u32) {
440            if child.start_byte() <= byte_pos && byte_pos < child.end_byte() {
441                if let Some(deeper) = find_deepest_ancestor(&child, byte_pos, kinds) {
442                    result = Some(deeper);
443                }
444            }
445        }
446    }
447
448    result
449}
450
451/// Get the leading whitespace of a source line.
452fn get_line_indent(source: &str, line: usize) -> String {
453    source
454        .lines()
455        .nth(line)
456        .map(|l| {
457            let trimmed = l.trim_start();
458            l[..l.len() - trimmed.len()].to_string()
459        })
460        .unwrap_or_default()
461}
462
463/// Build the new source with the extracted function inserted and the range replaced.
464fn build_new_source(
465    source: &str,
466    insert_pos: usize,
467    range_start: usize,
468    range_end: usize,
469    extracted_fn: &str,
470    call_site: &str,
471) -> String {
472    let mut result = String::with_capacity(source.len() + extracted_fn.len() + 64);
473
474    // Everything before the insertion point
475    result.push_str(&source[..insert_pos]);
476
477    // The extracted function + blank line
478    result.push_str(extracted_fn);
479    result.push_str("\n\n");
480
481    // Everything between insert point and the range start (the original function
482    // declaration up to where extraction begins)
483    result.push_str(&source[insert_pos..range_start]);
484
485    // The call site replacing the original range
486    result.push_str(call_site);
487    result.push('\n');
488
489    // Everything after the range
490    result.push_str(&source[range_end..]);
491
492    result
493}
494
495// ---------------------------------------------------------------------------
496// Tests
497// ---------------------------------------------------------------------------
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::protocol::RawRequest;
503
504    fn make_request(id: &str, command: &str, params: serde_json::Value) -> RawRequest {
505        RawRequest {
506            id: id.to_string(),
507            command: command.to_string(),
508            params,
509            lsp_hints: None,
510            session_id: None,
511        }
512    }
513
514    // --- Param validation ---
515
516    #[test]
517    fn extract_function_missing_file() {
518        let req = make_request("1", "extract_function", serde_json::json!({}));
519        let ctx = crate::context::AppContext::new(
520            Box::new(crate::parser::TreeSitterProvider::new()),
521            crate::config::Config::default(),
522        );
523        let resp = handle_extract_function(&req, &ctx);
524        let json = serde_json::to_value(&resp).unwrap();
525        assert_eq!(json["success"], false);
526        assert_eq!(json["code"], "invalid_request");
527        let msg = json["message"].as_str().unwrap();
528        assert!(
529            msg.contains("file"),
530            "message should mention 'file': {}",
531            msg
532        );
533    }
534
535    #[test]
536    fn extract_function_missing_name() {
537        let req = make_request(
538            "2",
539            "extract_function",
540            serde_json::json!({"file": "/tmp/test.ts"}),
541        );
542        let ctx = crate::context::AppContext::new(
543            Box::new(crate::parser::TreeSitterProvider::new()),
544            crate::config::Config::default(),
545        );
546        let resp = handle_extract_function(&req, &ctx);
547        let json = serde_json::to_value(&resp).unwrap();
548        assert_eq!(json["success"], false);
549        assert_eq!(json["code"], "invalid_request");
550        let msg = json["message"].as_str().unwrap();
551        assert!(
552            msg.contains("name"),
553            "message should mention 'name': {}",
554            msg
555        );
556    }
557
558    #[test]
559    fn extract_function_missing_start_line() {
560        let req = make_request(
561            "3",
562            "extract_function",
563            serde_json::json!({"file": "/tmp/test.ts", "name": "foo"}),
564        );
565        let ctx = crate::context::AppContext::new(
566            Box::new(crate::parser::TreeSitterProvider::new()),
567            crate::config::Config::default(),
568        );
569        let resp = handle_extract_function(&req, &ctx);
570        let json = serde_json::to_value(&resp).unwrap();
571        assert_eq!(json["success"], false);
572        assert_eq!(json["code"], "invalid_request");
573    }
574
575    #[test]
576    fn extract_function_unsupported_language() {
577        // Create a temp .rs file (Rust is not supported for extract_function)
578        let dir = std::env::temp_dir().join("aft_test_extract");
579        std::fs::create_dir_all(&dir).ok();
580        let file = dir.join("test.rs");
581        std::fs::write(&file, "fn main() {}").unwrap();
582
583        let req = make_request(
584            "4",
585            "extract_function",
586            serde_json::json!({
587                "file": file.display().to_string(),
588                "name": "foo",
589                "start_line": 1,
590                "end_line": 2,
591            }),
592        );
593        let ctx = crate::context::AppContext::new(
594            Box::new(crate::parser::TreeSitterProvider::new()),
595            crate::config::Config::default(),
596        );
597        let resp = handle_extract_function(&req, &ctx);
598        let json = serde_json::to_value(&resp).unwrap();
599        assert_eq!(json["success"], false);
600        assert_eq!(json["code"], "unsupported_language");
601
602        std::fs::remove_dir_all(&dir).ok();
603    }
604
605    #[test]
606    fn extract_function_invalid_line_range() {
607        let dir = std::env::temp_dir().join("aft_test_extract_range");
608        std::fs::create_dir_all(&dir).ok();
609        let file = dir.join("test.ts");
610        std::fs::write(&file, "const x = 1;\n").unwrap();
611
612        let req = make_request(
613            "5",
614            "extract_function",
615            serde_json::json!({
616                "file": file.display().to_string(),
617                "name": "foo",
618                "start_line": 6,
619                "end_line": 4,
620            }),
621        );
622        let ctx = crate::context::AppContext::new(
623            Box::new(crate::parser::TreeSitterProvider::new()),
624            crate::config::Config::default(),
625        );
626        let resp = handle_extract_function(&req, &ctx);
627        let json = serde_json::to_value(&resp).unwrap();
628        assert_eq!(json["success"], false);
629        assert_eq!(json["code"], "invalid_request");
630
631        std::fs::remove_dir_all(&dir).ok();
632    }
633
634    #[test]
635    fn extract_function_this_reference_error() {
636        let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
637            .join("tests/fixtures/extract_function/sample_this.ts");
638
639        let req = make_request(
640            "6",
641            "extract_function",
642            serde_json::json!({
643                "file": fixture.display().to_string(),
644                "name": "extracted",
645                "start_line": 5,
646                "end_line": 8,
647            }),
648        );
649        let ctx = crate::context::AppContext::new(
650            Box::new(crate::parser::TreeSitterProvider::new()),
651            crate::config::Config::default(),
652        );
653        let resp = handle_extract_function(&req, &ctx);
654        let json = serde_json::to_value(&resp).unwrap();
655        assert_eq!(json["success"], false);
656        assert_eq!(json["code"], "this_reference_in_range");
657    }
658}