class RubyLsp::Requests::CodeActionResolve
textDocument/codeAction response. We can use it for scenarios that require more computation such as refactoring.
request is used to to resolve the edit field for a given code action, if it is not already provided in the
The [code action resolve](microsoft.github.io/language-server-protocol/specification#codeAction_resolve)
def create_attribute_accessor
def create_attribute_accessor source_range = @code_action.dig(:data, :range) node = if source_range[:start] != source_range[:end] @document.locate_first_within_range( @code_action.dig(:data, :range), node_types: CodeActions::INSTANCE_VARIABLE_NODES, ) end if node.nil? node_context = @document.locate_node( source_range[:start], node_types: CodeActions::INSTANCE_VARIABLE_NODES, ) node = node_context.node unless CodeActions::INSTANCE_VARIABLE_NODES.include?(node.class) raise EmptySelectionError, "Invalid selection for refactor" end end node = node #: as Prism::InstanceVariableAndWriteNode | Prism::InstanceVariableOperatorWriteNode | Prism::InstanceVariableOrWriteNode | Prism::InstanceVariableReadNode | Prism::InstanceVariableTargetNode | Prism::InstanceVariableWriteNode node_context = @document.locate_node( { line: node.location.start_line, character: node.location.start_character_column, }, node_types: [ Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, ], ) closest_node = node_context.node if closest_node.nil? raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end attribute_name = node.name[1..] indentation = " " * (closest_node.location.start_column + 2) attribute_accessor_source = case @code_action[:title] when CodeActions::CREATE_ATTRIBUTE_READER "#{indentation}attr_reader :#{attribute_name}\n\n" when CodeActions::CREATE_ATTRIBUTE_WRITER "#{indentation}attr_writer :#{attribute_name}\n\n" when CodeActions::CREATE_ATTRIBUTE_ACCESSOR "#{indentation}attr_accessor :#{attribute_name}\n\n" end #: as !nil target_start_line = closest_node.location.start_line target_range = { start: { line: target_start_line, character: 0 }, end: { line: target_start_line, character: 0 }, } Interface::CodeAction.new( title: @code_action[:title], edit: Interface::WorkspaceEdit.new( document_changes: [ Interface::TextDocumentEdit.new( text_document: Interface::OptionalVersionedTextDocumentIdentifier.new( uri: @code_action.dig(:data, :uri), version: nil, ), edits: [ create_text_edit(target_range, attribute_accessor_source), ], ), ], ), ) end
def create_text_edit(range, new_text)
def create_text_edit(range, new_text) Interface::TextEdit.new( range: Interface::Range.new( start: Interface::Position.new(line: range.dig(:start, :line), character: range.dig(:start, :character)), end: Interface::Position.new(line: range.dig(:end, :line), character: range.dig(:end, :character)), ), new_text: new_text, ) end
def initialize(document, global_state, code_action)
def initialize(document, global_state, code_action) super() @document = document @global_state = global_state @code_action = code_action end
def perform
@override
def perform raise EmptySelectionError, "Invalid selection for refactor" if @document.source.empty? case @code_action[:title] when CodeActions::EXTRACT_TO_VARIABLE_TITLE refactor_variable when CodeActions::EXTRACT_TO_METHOD_TITLE refactor_method when CodeActions::TOGGLE_BLOCK_STYLE_TITLE switch_block_style when CodeActions::CREATE_ATTRIBUTE_READER, CodeActions::CREATE_ATTRIBUTE_WRITER, CodeActions::CREATE_ATTRIBUTE_ACCESSOR create_attribute_accessor else raise UnknownCodeActionError, "Unknown code action: #{@code_action[:title]}" end end
def recursively_switch_nested_block_styles(node, indentation)
def recursively_switch_nested_block_styles(node, indentation) parameters = node.parameters body = node.body # We use the indentation to differentiate between do...end and brace style blocks because only the do...end # style requires the indentation to build the edit. # # If the block is using `do...end` style, we change it to a single line brace block. Newlines are turned into # semi colons, so that the result is valid Ruby code and still a one liner. If the block is using brace style, # we do the opposite and turn it into a `do...end` block, making all semi colons into newlines. source = +"" if indentation source << "do" source << " #{parameters.slice}" if parameters source << "\n#{indentation} " source << switch_block_body(body, indentation) if body source << "\n#{indentation}end" else source << "{ " source << "#{parameters.slice} " if parameters source << switch_block_body(body, nil) if body source << "}" end source end
def refactor_method
def refactor_method source_range = @code_action.dig(:data, :range) raise EmptySelectionError, "Invalid selection for refactor" if source_range[:start] == source_range[:end] start_index, end_index = @document.find_index_by_position(source_range[:start], source_range[:end]) extracted_source = @document.source[start_index...end_index] #: as !nil # Find the closest method declaration node, so that we place the refactor in a valid position node_context = RubyDocument.locate( @document.ast, start_index, node_types: [Prism::DefNode], code_units_cache: @document.code_units_cache, ) closest_node = node_context.node unless closest_node raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end target_range = if closest_node.is_a?(Prism::DefNode) end_keyword_loc = closest_node.end_keyword_loc unless end_keyword_loc raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end end_line = end_keyword_loc.end_line - 1 character = end_keyword_loc.end_column indentation = " " * end_keyword_loc.start_column new_method_source = <<~RUBY.chomp #{indentation}def #{NEW_METHOD_NAME} #{indentation} #{extracted_source} #{indentation}end RUBY { start: { line: end_line, character: character }, end: { line: end_line, character: character }, } else new_method_source = <<~RUBY #{indentation}def #{NEW_METHOD_NAME} #{indentation} #{extracted_source.gsub("\n", "\n ")} #{indentation}end RUBY line = [0, source_range.dig(:start, :line) - 1].max { start: { line: line, character: source_range.dig(:start, :character) }, end: { line: line, character: source_range.dig(:start, :character) }, } end Interface::CodeAction.new( title: CodeActions::EXTRACT_TO_METHOD_TITLE, edit: Interface::WorkspaceEdit.new( document_changes: [ Interface::TextDocumentEdit.new( text_document: Interface::OptionalVersionedTextDocumentIdentifier.new( uri: @code_action.dig(:data, :uri), version: nil, ), edits: [ create_text_edit(target_range, new_method_source), create_text_edit(source_range, NEW_METHOD_NAME), ], ), ], ), ) end
def refactor_variable
def refactor_variable source_range = @code_action.dig(:data, :range) raise EmptySelectionError, "Invalid selection for refactor" if source_range[:start] == source_range[:end] start_index, end_index = @document.find_index_by_position(source_range[:start], source_range[:end]) extracted_source = @document.source[start_index...end_index] #: as !nil # Find the closest statements node, so that we place the refactor in a valid position node_context = RubyDocument .locate(@document.ast, start_index, node_types: [ Prism::StatementsNode, Prism::BlockNode, ], code_units_cache: @document.code_units_cache) closest_statements = node_context.node parent_statements = node_context.parent if closest_statements.nil? || closest_statements.child_nodes.compact.empty? raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end # Find the node with the end line closest to the requested position, so that we can place the refactor # immediately after that closest node closest_node = closest_statements.child_nodes.compact.min_by do |node| distance = source_range.dig(:start, :line) - (node.location.end_line - 1) distance <= 0 ? Float::INFINITY : distance end #: as !nil if closest_node.is_a?(Prism::MissingNode) raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end closest_node_loc = closest_node.location # If the parent expression is a single line block, then we have to extract it inside of the one-line block if parent_statements.is_a?(Prism::BlockNode) && parent_statements.location.start_line == parent_statements.location.end_line variable_source = " #{NEW_VARIABLE_NAME} = #{extracted_source};" character = source_range.dig(:start, :character) - 1 target_range = { start: { line: closest_node_loc.end_line - 1, character: character }, end: { line: closest_node_loc.end_line - 1, character: character }, } else # If the closest node covers the requested location, then we're extracting a statement nested inside of it. In # that case, we want to place the extraction at the start of the closest node (one line above). Otherwise, we # want to place the extract right below the closest node if closest_node_loc.start_line - 1 <= source_range.dig( :start, :line, ) && closest_node_loc.end_line - 1 >= source_range.dig(:end, :line) indentation_line_number = closest_node_loc.start_line - 1 target_line = indentation_line_number else target_line = closest_node_loc.end_line indentation_line_number = closest_node_loc.end_line - 1 end lines = @document.source.lines indentation_line = lines[indentation_line_number] unless indentation_line raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end indentation = indentation_line[/\A */] #: as !nil .size target_range = { start: { line: target_line, character: indentation }, end: { line: target_line, character: indentation }, } line = lines[target_line] unless line raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end variable_source = if line.strip.empty? "\n#{" " * indentation}#{NEW_VARIABLE_NAME} = #{extracted_source}" else "#{NEW_VARIABLE_NAME} = #{extracted_source}\n#{" " * indentation}" end end Interface::CodeAction.new( title: CodeActions::EXTRACT_TO_VARIABLE_TITLE, edit: Interface::WorkspaceEdit.new( document_changes: [ Interface::TextDocumentEdit.new( text_document: Interface::OptionalVersionedTextDocumentIdentifier.new( uri: @code_action.dig(:data, :uri), version: nil, ), edits: [ create_text_edit(source_range, NEW_VARIABLE_NAME), create_text_edit(target_range, variable_source), ], ), ], ), ) end
def switch_block_body(body, indentation)
def switch_block_body(body, indentation) # Check if there are any nested blocks inside of the current block body_loc = body.location nested_block = @document.locate_first_within_range( { start: { line: body_loc.start_line - 1, character: body_loc.start_column }, end: { line: body_loc.end_line - 1, character: body_loc.end_column }, }, node_types: [Prism::BlockNode], ) body_content = body.slice.dup # If there are nested blocks, then we change their style too and we have to mutate the string using the # relative position in respect to the beginning of the body if nested_block.is_a?(Prism::BlockNode) location = nested_block.location correction_start = location.start_offset - body_loc.start_offset correction_end = location.end_offset - body_loc.start_offset next_indentation = indentation ? "#{indentation} " : nil body_content[correction_start...correction_end] = recursively_switch_nested_block_styles(nested_block, next_indentation) end indentation ? body_content.gsub(";", "\n") : "#{body_content.gsub("\n", ";")} " end
def switch_block_style
def switch_block_style source_range = @code_action.dig(:data, :range) raise EmptySelectionError, "Invalid selection for refactor" if source_range[:start] == source_range[:end] target = @document.locate_first_within_range( @code_action.dig(:data, :range), node_types: [Prism::CallNode], ) unless target.is_a?(Prism::CallNode) raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end node = target.block unless node.is_a?(Prism::BlockNode) raise InvalidTargetRangeError, "Couldn't find an appropriate location to place extracted refactor" end indentation = " " * target.location.start_column unless node.opening_loc.slice == "do" Interface::CodeAction.new( title: CodeActions::TOGGLE_BLOCK_STYLE_TITLE, edit: Interface::WorkspaceEdit.new( document_changes: [ Interface::TextDocumentEdit.new( text_document: Interface::OptionalVersionedTextDocumentIdentifier.new( uri: @code_action.dig(:data, :uri), version: nil, ), edits: [ Interface::TextEdit.new( range: range_from_location(node.location), new_text: recursively_switch_nested_block_styles(node, indentation), ), ], ), ], ), ) end