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.
@@ -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,8 @@
1
+ module Ruby
2
+ module Lsp
3
+ module Refactor
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ 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