ruby-lsp-refactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +327 -0
- data/Rakefile +16 -0
- data/lib/ruby/lsp/refactor/version.rb +9 -0
- data/lib/ruby/lsp/refactor.rb +12 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +65 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +250 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +316 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +61 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +134 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +101 -0
- data/lib/ruby_lsp/test_helper.rb +50 -0
- data/sig/ruby/lsp/refactor.rbs +8 -0
- data/test/ruby_lsp_refactor/array_listener_test.rb +82 -0
- data/test/ruby_lsp_refactor/conditional_listener_test.rb +307 -0
- data/test/ruby_lsp_refactor/hash_listener_test.rb +72 -0
- data/test/ruby_lsp_refactor/method_listener_test.rb +193 -0
- data/test/ruby_lsp_refactor/string_listener_test.rb +70 -0
- data/test/ruby_lsp_refactor/variable_listener_test.rb +97 -0
- metadata +143 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Emits a "Convert to interpolated string" code action when the cursor is
|
|
8
|
+
# on a single-quoted StringNode.
|
|
9
|
+
#
|
|
10
|
+
# Input: 'hello world'
|
|
11
|
+
# Output: "hello world"
|
|
12
|
+
#
|
|
13
|
+
# The action only upgrades the delimiters; the developer can then type #{}
|
|
14
|
+
# to add interpolation. Backslash-escape sequences that are meaningful in
|
|
15
|
+
# double-quoted strings but literal in single-quoted strings (e.g. \n, \t)
|
|
16
|
+
# are left as-is — the developer is expected to review them.
|
|
17
|
+
class StringListener
|
|
18
|
+
include RubyLsp::Requests::Support::Common
|
|
19
|
+
include Support::NodeHelpers
|
|
20
|
+
|
|
21
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
22
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
23
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
24
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
25
|
+
@response_builder = response_builder
|
|
26
|
+
@node_context = node_context
|
|
27
|
+
|
|
28
|
+
dispatcher.register(self, :on_string_node_enter)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def on_string_node_enter(node)
|
|
32
|
+
return unless node_covers_cursor?(node)
|
|
33
|
+
return unless single_quoted?(node)
|
|
34
|
+
|
|
35
|
+
emit_convert_to_interpolated(node)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Returns true when the string literal uses single-quote delimiters.
|
|
43
|
+
def single_quoted?(node)
|
|
44
|
+
node.opening_loc&.slice == "'"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def emit_convert_to_interpolated(node)
|
|
48
|
+
# Escape any bare double-quotes inside the string content so the
|
|
49
|
+
# resulting double-quoted literal remains valid.
|
|
50
|
+
content = node.unescaped.gsub('"', '\\"')
|
|
51
|
+
new_text = "\"#{content}\""
|
|
52
|
+
|
|
53
|
+
@response_builder << Interface::CodeAction.new(
|
|
54
|
+
title: "Convert to interpolated string",
|
|
55
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
56
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Handles variable-related code actions.
|
|
8
|
+
#
|
|
9
|
+
# Emitted actions
|
|
10
|
+
# ───────────────
|
|
11
|
+
# 1. Inline variable
|
|
12
|
+
# result = user.calculate → (line deleted)
|
|
13
|
+
# puts result → puts user.calculate
|
|
14
|
+
#
|
|
15
|
+
# 2. Extract local variable
|
|
16
|
+
# Cursor on any expression; wraps it in a new variable assignment
|
|
17
|
+
# inserted on the line above.
|
|
18
|
+
# user.full_name.upcase → name = user.full_name.upcase
|
|
19
|
+
# name
|
|
20
|
+
class VariableListener
|
|
21
|
+
include RubyLsp::Requests::Support::Common
|
|
22
|
+
include Support::NodeHelpers
|
|
23
|
+
|
|
24
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
25
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
26
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
27
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
28
|
+
@response_builder = response_builder
|
|
29
|
+
@node_context = node_context
|
|
30
|
+
|
|
31
|
+
# Collect all read nodes keyed by variable name so we can cross-reference
|
|
32
|
+
# them when we encounter a matching write node.
|
|
33
|
+
@read_nodes = Hash.new { |h, k| h[k] = [] } # { name => [ReadNode, ...] }
|
|
34
|
+
|
|
35
|
+
# Defer inline-variable actions until after the full walk so that all
|
|
36
|
+
# read nodes are collected before we build the edits.
|
|
37
|
+
@pending_write_nodes = []
|
|
38
|
+
|
|
39
|
+
dispatcher.register(
|
|
40
|
+
self,
|
|
41
|
+
:on_local_variable_write_node_enter,
|
|
42
|
+
:on_local_variable_read_node_enter,
|
|
43
|
+
:on_call_node_enter,
|
|
44
|
+
:on_program_node_leave,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ── dispatcher callbacks ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def on_local_variable_read_node_enter(node)
|
|
51
|
+
@read_nodes[node.name] << node
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def on_local_variable_write_node_enter(node)
|
|
57
|
+
return unless node_covers_cursor?(node)
|
|
58
|
+
return unless node.value
|
|
59
|
+
|
|
60
|
+
# Defer: we need all reads collected before building the edit.
|
|
61
|
+
@pending_write_nodes << node
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Offer "Extract local variable" for any call expression under the cursor.
|
|
67
|
+
def on_call_node_enter(node)
|
|
68
|
+
return unless node_covers_cursor?(node)
|
|
69
|
+
|
|
70
|
+
emit_extract_local_variable(node)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# After the full AST walk, all read nodes are known — emit inline actions.
|
|
76
|
+
def on_program_node_leave(_node)
|
|
77
|
+
@pending_write_nodes.each { |w| emit_inline_variable(w) }
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# ── inline variable ─────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def emit_inline_variable(write_node)
|
|
87
|
+
rhs_text = write_node.value.location.slice.strip
|
|
88
|
+
edits = [delete_line_edit(write_node)]
|
|
89
|
+
|
|
90
|
+
@read_nodes[write_node.name].each do |read_node|
|
|
91
|
+
edits << Interface::TextEdit.new(
|
|
92
|
+
range: node_to_lsp_range(read_node),
|
|
93
|
+
new_text: rhs_text,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@response_builder << Interface::CodeAction.new(
|
|
98
|
+
title: "Inline variable '#{write_node.name}'",
|
|
99
|
+
kind: Constant::CodeActionKind::REFACTOR_INLINE,
|
|
100
|
+
edit: multi_edit_workspace_edit(edits),
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ── extract local variable ───────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def emit_extract_local_variable(node)
|
|
107
|
+
expr_src = node.location.slice.strip
|
|
108
|
+
indent = " " * node.location.start_column
|
|
109
|
+
insert_line = node.location.start_line - 1
|
|
110
|
+
|
|
111
|
+
# Insert `variable = <expr>` on the line above, then replace the
|
|
112
|
+
# expression in-place with the variable name.
|
|
113
|
+
insert_edit = Interface::TextEdit.new(
|
|
114
|
+
range: Interface::Range.new(
|
|
115
|
+
start: Interface::Position.new(line: insert_line, character: 0),
|
|
116
|
+
end: Interface::Position.new(line: insert_line, character: 0),
|
|
117
|
+
),
|
|
118
|
+
new_text: "#{indent}variable = #{expr_src}\n",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
replace_edit = Interface::TextEdit.new(
|
|
122
|
+
range: node_to_lsp_range(node),
|
|
123
|
+
new_text: "variable",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@response_builder << Interface::CodeAction.new(
|
|
127
|
+
title: "Extract local variable",
|
|
128
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
129
|
+
edit: multi_edit_workspace_edit([insert_edit, replace_edit]),
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLsp
|
|
4
|
+
module Refactor
|
|
5
|
+
module Support
|
|
6
|
+
# Shared helpers mixed into every listener.
|
|
7
|
+
#
|
|
8
|
+
# Provides:
|
|
9
|
+
# - node_to_lsp_range(node) – Prism location → Interface::Range
|
|
10
|
+
# - node_covers_cursor?(node) – overlap check against @node_context.range
|
|
11
|
+
# - single_edit_workspace_edit(…) – convenience WorkspaceEdit factory
|
|
12
|
+
module NodeHelpers
|
|
13
|
+
# Converts a Prism node's location to an LSP Interface::Range.
|
|
14
|
+
#
|
|
15
|
+
# @param node [Prism::Node]
|
|
16
|
+
# @return [Interface::Range]
|
|
17
|
+
def node_to_lsp_range(node)
|
|
18
|
+
loc = node.location
|
|
19
|
+
Interface::Range.new(
|
|
20
|
+
start: Interface::Position.new(
|
|
21
|
+
line: loc.start_line - 1,
|
|
22
|
+
character: loc.start_column,
|
|
23
|
+
),
|
|
24
|
+
end: Interface::Position.new(
|
|
25
|
+
line: loc.end_line - 1,
|
|
26
|
+
character: loc.end_column,
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns true when the node's source range overlaps the cursor/selection
|
|
32
|
+
# range provided by the LSP client via @node_context.
|
|
33
|
+
#
|
|
34
|
+
# @param node [Prism::Node]
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def node_covers_cursor?(node)
|
|
37
|
+
cursor = @node_context.range
|
|
38
|
+
return true unless cursor
|
|
39
|
+
|
|
40
|
+
node_start = node.location.start_line - 1
|
|
41
|
+
node_end = node.location.end_line - 1
|
|
42
|
+
|
|
43
|
+
cursor_start = cursor.start.line
|
|
44
|
+
cursor_end = cursor.end.line
|
|
45
|
+
|
|
46
|
+
node_start <= cursor_end && node_end >= cursor_start
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Builds a WorkspaceEdit containing a single TextEdit that replaces the
|
|
50
|
+
# entire range of +node+ with +new_text+.
|
|
51
|
+
#
|
|
52
|
+
# @param node [Prism::Node]
|
|
53
|
+
# @param new_text [String]
|
|
54
|
+
# @return [Interface::WorkspaceEdit]
|
|
55
|
+
def single_edit_workspace_edit(node, new_text)
|
|
56
|
+
Interface::WorkspaceEdit.new(
|
|
57
|
+
changes: {
|
|
58
|
+
@node_context.uri => [
|
|
59
|
+
Interface::TextEdit.new(range: node_to_lsp_range(node), new_text: new_text),
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Builds a WorkspaceEdit from an arbitrary array of TextEdit objects.
|
|
66
|
+
#
|
|
67
|
+
# @param edits [Array<Interface::TextEdit>]
|
|
68
|
+
# @return [Interface::WorkspaceEdit]
|
|
69
|
+
def multi_edit_workspace_edit(edits)
|
|
70
|
+
Interface::WorkspaceEdit.new(
|
|
71
|
+
changes: { @node_context.uri => edits },
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Produces a TextEdit that deletes the full source line of +node+,
|
|
76
|
+
# including its trailing newline so no blank line is left behind.
|
|
77
|
+
#
|
|
78
|
+
# @param node [Prism::Node]
|
|
79
|
+
# @return [Interface::TextEdit]
|
|
80
|
+
def delete_line_edit(node)
|
|
81
|
+
line = node.location.start_line - 1
|
|
82
|
+
Interface::TextEdit.new(
|
|
83
|
+
range: Interface::Range.new(
|
|
84
|
+
start: Interface::Position.new(line: line, character: 0),
|
|
85
|
+
end: Interface::Position.new(line: line + 1, character: 0),
|
|
86
|
+
),
|
|
87
|
+
new_text: "",
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Leading whitespace for the line that contains +node+.
|
|
92
|
+
#
|
|
93
|
+
# @param node [Prism::Node]
|
|
94
|
+
# @return [String]
|
|
95
|
+
def indent_for(node)
|
|
96
|
+
" " * node.location.start_column
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_lsp/internal"
|
|
4
|
+
require "ruby_lsp/ruby_lsp_refactor/addon"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
# TestHelper provides lightweight integration scaffolding for testing
|
|
9
|
+
# ruby-lsp-refactor listeners without spinning up a full LSP server.
|
|
10
|
+
#
|
|
11
|
+
# Usage in a Minitest test class:
|
|
12
|
+
#
|
|
13
|
+
# class MyTest < Minitest::Test
|
|
14
|
+
# include RubyLsp::Refactor::TestHelper
|
|
15
|
+
#
|
|
16
|
+
# def test_something
|
|
17
|
+
# actions = code_actions_for(source, line: 0)
|
|
18
|
+
# assert_includes actions.map(&:title), "Convert to post-conditional"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
module TestHelper
|
|
22
|
+
# Parses +source+, runs the full listener pipeline via
|
|
23
|
+
# Addon.refactor_actions_for, and returns the resulting code actions.
|
|
24
|
+
# This exercises exactly the same path that runs inside the real LSP server.
|
|
25
|
+
#
|
|
26
|
+
# @param source [String] Ruby source code to analyse.
|
|
27
|
+
# @param line [Integer] Zero-based line the cursor is on.
|
|
28
|
+
# @param char [Integer] Zero-based character offset (column).
|
|
29
|
+
# @return [Array<Interface::CodeAction>]
|
|
30
|
+
def code_actions_for(source, line: 0, char: 0)
|
|
31
|
+
uri = URI::Generic.from_path(path: "/test/fixture.rb")
|
|
32
|
+
global_state = RubyLsp::GlobalState.new
|
|
33
|
+
document = RubyLsp::RubyDocument.new(
|
|
34
|
+
source: source,
|
|
35
|
+
version: 1,
|
|
36
|
+
uri: uri,
|
|
37
|
+
global_state: global_state,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# LSP range hash — same shape the real server passes to CodeActions.
|
|
41
|
+
range = {
|
|
42
|
+
start: { line: line, character: char },
|
|
43
|
+
end: { line: line, character: char },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
RubyLsp::Refactor::Addon.refactor_actions_for(document, range)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "ruby_lsp/test_helper"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
class ArrayListenerTest < Minitest::Test
|
|
9
|
+
include RubyLsp::Refactor::TestHelper
|
|
10
|
+
|
|
11
|
+
def find_action(actions, title)
|
|
12
|
+
actions.find { |a| a.title == title }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def single_edit(action)
|
|
16
|
+
edits = action.edit.changes.values.flatten
|
|
17
|
+
assert_equal 1, edits.size
|
|
18
|
+
edits.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Core acceptance
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def test_converts_symbol_array_to_percent_i
|
|
26
|
+
source = "[:foo, :bar, :baz]\n"
|
|
27
|
+
actions = code_actions_for(source, line: 0)
|
|
28
|
+
action = find_action(actions, "Convert to symbol array")
|
|
29
|
+
refute_nil action
|
|
30
|
+
|
|
31
|
+
edit = single_edit(action)
|
|
32
|
+
assert_equal "%i[foo bar baz]", edit.new_text
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_converts_single_element_symbol_array
|
|
36
|
+
source = "[:only]\n"
|
|
37
|
+
actions = code_actions_for(source, line: 0)
|
|
38
|
+
action = find_action(actions, "Convert to symbol array")
|
|
39
|
+
refute_nil action
|
|
40
|
+
|
|
41
|
+
edit = single_edit(action)
|
|
42
|
+
assert_equal "%i[only]", edit.new_text
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Negative cases
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def test_does_not_offer_action_for_mixed_array
|
|
50
|
+
source = "[:foo, 'bar']\n"
|
|
51
|
+
actions = code_actions_for(source, line: 0)
|
|
52
|
+
assert_nil find_action(actions, "Convert to symbol array")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_does_not_offer_action_for_empty_array
|
|
56
|
+
source = "[]\n"
|
|
57
|
+
actions = code_actions_for(source, line: 0)
|
|
58
|
+
assert_nil find_action(actions, "Convert to symbol array")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_does_not_offer_action_for_already_percent_i
|
|
62
|
+
source = "%i[foo bar]\n"
|
|
63
|
+
actions = code_actions_for(source, line: 0)
|
|
64
|
+
assert_nil find_action(actions, "Convert to symbol array")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_does_not_offer_action_for_integer_array
|
|
68
|
+
source = "[1, 2, 3]\n"
|
|
69
|
+
actions = code_actions_for(source, line: 0)
|
|
70
|
+
assert_nil find_action(actions, "Convert to symbol array")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Resilience
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def test_does_not_raise_on_empty_source
|
|
78
|
+
assert_silent { code_actions_for("", line: 0) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|