1use 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
21pub fn handle_extract_function(req: &RawRequest, ctx: &AppContext) -> Response {
38 let op_id = crate::backup::new_op_id();
39 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 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 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 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 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 let free_vars = detect_free_variables(&source, &tree, start_byte, end_byte, lang);
199
200 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 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 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 let indent_style = detect_indent(&source, lang);
238
239 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 let range_indent = get_line_indent(&source, start_line as usize);
250
251 let body_text = &source[start_byte..end_byte];
253 let body_text = body_text.trim_end_matches('\n');
254
255 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 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 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 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 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 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 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
406fn 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
426fn 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
451fn 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
463fn 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 result.push_str(&source[..insert_pos]);
476
477 result.push_str(extracted_fn);
479 result.push_str("\n\n");
480
481 result.push_str(&source[insert_pos..range_start]);
484
485 result.push_str(call_site);
487 result.push('\n');
488
489 result.push_str(&source[range_end..]);
491
492 result
493}
494
495#[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 #[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 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}