Skip to content

Conversation

@asukaminato0721
Copy link
Contributor

Summary

part of #746

Introduced extract_function_code_actions plus LocalRefactorCodeAction plumbing so the state layer can produce workspace edits for a selected block; it now dedents the selection, synthesizes helper definitions/calls, infers parameters/returns (including augmented assignments), and rejects unsupported selections (returns/breaks/etc.).

Added a visitor-based identifier collector that walks statements/expressions to classify loads, stores, post-selection reads, and synthetic aug-assign loads, which drives the parameter/return heuristics.

Updated the non-wasm LSP server to advertise REFACTOR_EXTRACT, convert LocalRefactorCodeAction edits into URIs, and merge them alongside existing quick fixes.

Test Plan

Added an integration test that selects a block via markers, requests refactor actions, applies the returned edits, and asserts the helper/function call match the expected structure.

@meta-cla meta-cla bot added the cla signed label Dec 7, 2025
@asukaminato0721 asukaminato0721 force-pushed the 746 branch 2 times, most recently from 5e5ecd1 to 41161a7 Compare December 7, 2025 13:55
@asukaminato0721 asukaminato0721 marked this pull request as ready for review December 7, 2025 13:58
Copilot AI review requested due to automatic review settings December 7, 2025 13:58
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the "extract function" refactoring feature as part of issue #746. It adds the ability to select a block of Python code and extract it into a helper function, automatically inferring parameters and return values. The implementation includes a new LocalRefactorCodeAction type, visitor-based identifier collection, and integration with the LSP server to advertise the REFACTOR_EXTRACT capability.

Key Changes:

  • Introduced extract_function_code_actions that analyzes selected code blocks, identifies parameters (from loaded variables) and returns (from stored variables used later), and generates workspace edits to create a helper function and replace the selection with a function call
  • Added visitor-based identifier collection that distinguishes between loads, stores, and synthetic loads from augmented assignments
  • Integrated extract function actions into the LSP server alongside existing quickfix actions

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
pyrefly/lib/test/lsp/lsp_interaction/basic.rs Updated server capabilities test to include "refactor.extract" in advertised code action kinds
pyrefly/lib/test/lsp/code_actions.rs Added test infrastructure (helper functions) and basic integration test for extract function with augmented assignment
pyrefly/lib/state/lsp.rs Core implementation including LocalRefactorCodeAction struct, extract_function_code_actions method, and helper functions for identifier collection, dedenting, indenting, and name generation
pyrefly/lib/lsp/non_wasm/server.rs Integrated extract function actions into the code action handler and registered REFACTOR_EXTRACT capability

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 286 to 340
#[test]
fn extract_function_basic_refactor() {
let code = r#"
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
let (handles, state) =
mk_multi_file_state_assert_no_errors(&[("main", code)], Require::Everything);
let handle = handles.get("main").unwrap();
let transaction = state.transaction();
let module_info = transaction.get_module_info(handle).unwrap();
let selection = find_marked_range(module_info.contents());
let actions = transaction
.extract_function_code_actions(handle, selection)
.unwrap_or_default();
assert!(!actions.is_empty(), "expected extract refactor action");
let updated = apply_refactor_edits_for_module(&module_info, &actions[0].edits);
let expected = r#"
def extracted_function(item, total_sum):
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
return total_sum
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
total_sum = extracted_function(item, total_sum)
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
assert_eq!(expected.trim(), updated.trim());
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage for the extract function feature is limited. Only one basic scenario is tested. Consider adding tests for:

  • Selections with no parameters or return values
  • Multiple return values (tuple unpacking)
  • Edge cases: empty selections, whitespace-only selections
  • Rejection cases: selections containing return/break/continue/raise/function/class definitions
  • Various indentation scenarios
  • Synthetic loads from augmented assignments without regular loads
  • Name collision scenarios for the generated function name

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. please add more tests, even if they are disabled

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some other ones I can think of:

  • function is within a class (but no classes in range), does the indent work?

@asukaminato0721
Copy link
Contributor Author

this aim to be a basic impl, as copilot said, too many edge cases...

@kinto0 kinto0 self-assigned this Dec 9, 2025
Copy link
Contributor

@kinto0 kinto0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome work! this is exciting. a few comments:

  • lsp.rs is much too big. can you put all the new additions in it's own file? maybe in a lsp/quick_fixes folder? (no need to move the other stuff, we can do that later)
  • a few comments about reuse and tests. I don't mind if the feature isn't implemented, I just don't want it crashing because of a crazy situation

Comment on lines 286 to 340
#[test]
fn extract_function_basic_refactor() {
let code = r#"
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
let (handles, state) =
mk_multi_file_state_assert_no_errors(&[("main", code)], Require::Everything);
let handle = handles.get("main").unwrap();
let transaction = state.transaction();
let module_info = transaction.get_module_info(handle).unwrap();
let selection = find_marked_range(module_info.contents());
let actions = transaction
.extract_function_code_actions(handle, selection)
.unwrap_or_default();
assert!(!actions.is_empty(), "expected extract refactor action");
let updated = apply_refactor_edits_for_module(&module_info, &actions[0].edits);
let expected = r#"
def extracted_function(item, total_sum):
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
return total_sum
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
total_sum = extracted_function(item, total_sum)
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
assert_eq!(expected.trim(), updated.trim());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. please add more tests, even if they are disabled

found
}

fn find_enclosing_module_statement_range(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jvansch1 i think you needed something similar for call hierarchy. any way to reuse it?

Comment on lines 286 to 340
#[test]
fn extract_function_basic_refactor() {
let code = r#"
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
let (handles, state) =
mk_multi_file_state_assert_no_errors(&[("main", code)], Require::Everything);
let handle = handles.get("main").unwrap();
let transaction = state.transaction();
let module_info = transaction.get_module_info(handle).unwrap();
let selection = find_marked_range(module_info.contents());
let actions = transaction
.extract_function_code_actions(handle, selection)
.unwrap_or_default();
assert!(!actions.is_empty(), "expected extract refactor action");
let updated = apply_refactor_edits_for_module(&module_info, &actions[0].edits);
let expected = r#"
def extracted_function(item, total_sum):
squared_value = item * item
if squared_value > 100:
print(f"Large value detected: {squared_value}")
total_sum += squared_value
return total_sum
def process_data(data_list):
total_sum = 0
for item in data_list:
# EXTRACT-START
total_sum = extracted_function(item, total_sum)
# EXTRACT-END
return total_sum
if __name__ == "__main__":
data = [1, 5, 12, 8, 15]
result = process_data(data)
print(f"The final sum is: {result}")
"#;
assert_eq!(expected.trim(), updated.trim());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some other ones I can think of:

  • function is within a class (but no classes in range), does the indent work?

test
@meta-codesync
Copy link

meta-codesync bot commented Dec 10, 2025

@kinto0 has imported this pull request. If you are a Meta employee, you can view this in D88799566.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants