ruby-lsp-refactor 0.1.0 → 0.1.1

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +68 -0
  3. data/lib/ruby/lsp/refactor/version.rb +1 -1
  4. data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +35 -9
  5. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/accessor_listener.rb +156 -0
  6. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +2 -2
  7. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/block_style_listener.rb +117 -0
  8. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +14 -14
  9. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/constant_listener.rb +118 -0
  10. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/enumerable_listener.rb +90 -0
  11. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +7 -7
  12. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/logical_operator_listener.rb +70 -0
  13. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +34 -34
  14. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/raise_listener.rb +70 -0
  15. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rescue_listener.rb +85 -0
  16. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rspec_let_listener.rb +61 -0
  17. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_array_listener.rb +88 -0
  18. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_freeze_listener.rb +86 -0
  19. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +2 -2
  20. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/super_listener.rb +95 -0
  21. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +11 -11
  22. data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +12 -12
  23. data/lib/ruby_lsp/test_helper.rb +5 -5
  24. data/test/ruby_lsp_refactor/accessor_listener_test.rb +91 -0
  25. data/test/ruby_lsp_refactor/block_style_listener_test.rb +98 -0
  26. data/test/ruby_lsp_refactor/constant_listener_test.rb +68 -0
  27. data/test/ruby_lsp_refactor/enumerable_listener_test.rb +80 -0
  28. data/test/ruby_lsp_refactor/logical_operator_listener_test.rb +66 -0
  29. data/test/ruby_lsp_refactor/raise_listener_test.rb +64 -0
  30. data/test/ruby_lsp_refactor/rescue_listener_test.rb +72 -0
  31. data/test/ruby_lsp_refactor/rspec_let_listener_test.rb +54 -0
  32. data/test/ruby_lsp_refactor/string_array_listener_test.rb +64 -0
  33. data/test/ruby_lsp_refactor/string_freeze_listener_test.rb +52 -0
  34. data/test/ruby_lsp_refactor/string_listener_test.rb +2 -2
  35. data/test/ruby_lsp_refactor/super_listener_test.rb +65 -0
  36. metadata +36 -13
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Wrap in freeze" on any unfrozen string literal, and
8
+ # "Remove freeze" on a string that already calls .freeze.
9
+ #
10
+ # Emitted actions
11
+ # ───────────────
12
+ # 1. Wrap in freeze
13
+ # "hello" → "hello".freeze
14
+ #
15
+ # 2. Remove freeze
16
+ # "hello".freeze → "hello"
17
+ class StringFreezeListener
18
+ include RubyLsp::Requests::Support::Common
19
+ include Support::NodeHelpers
20
+
21
+ def initialize(response_builder, node_context, dispatcher)
22
+ @response_builder = response_builder
23
+ @node_context = node_context
24
+
25
+ # Track start offsets of string nodes that are already receivers of
26
+ # .freeze so on_string_node_enter can skip them. Populated during
27
+ # on_call_node_enter which fires for the outer CallNode first.
28
+ @frozen_string_offsets = {}
29
+
30
+ dispatcher.register(self, :on_call_node_enter, :on_string_node_enter)
31
+ end
32
+
33
+ # Fires for every CallNode — including `.freeze` calls.
34
+ # We register this before on_string_node_enter so the offset set is
35
+ # populated before the inner StringNode callback fires.
36
+ def on_call_node_enter(node)
37
+ return unless node.name == :freeze
38
+ return unless node.receiver.is_a?(Prism::StringNode)
39
+ return unless node.arguments.nil?
40
+
41
+ # Mark the receiver string so on_string_node_enter skips it.
42
+ @frozen_string_offsets[node.receiver.location.start_offset] = true
43
+
44
+ return unless node_covers_cursor?(node)
45
+
46
+ emit_remove_freeze(node)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # Offer "Wrap in freeze" for plain string literals that are not already
52
+ # the receiver of a .freeze call.
53
+ def on_string_node_enter(node)
54
+ return unless node_covers_cursor?(node)
55
+ return if @frozen_string_offsets.key?(node.location.start_offset)
56
+
57
+ emit_wrap_freeze(node)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ def emit_wrap_freeze(node)
65
+ str_src = node.location.slice
66
+ new_text = "#{str_src}.freeze"
67
+
68
+ @response_builder << Interface::CodeAction.new(
69
+ title: "Wrap in freeze",
70
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
71
+ edit: single_edit_workspace_edit(node, new_text)
72
+ )
73
+ end
74
+
75
+ def emit_remove_freeze(node)
76
+ str_src = node.receiver.location.slice
77
+
78
+ @response_builder << Interface::CodeAction.new(
79
+ title: "Remove freeze",
80
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
81
+ edit: single_edit_workspace_edit(node, str_src)
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -52,8 +52,8 @@ module RubyLsp
52
52
 
53
53
  @response_builder << Interface::CodeAction.new(
54
54
  title: "Convert to interpolated string",
55
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
56
- edit: single_edit_workspace_edit(node, new_text),
55
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
56
+ edit: single_edit_workspace_edit(node, new_text)
57
57
  )
58
58
  end
59
59
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Converts a bare `super` (ForwardingSuperNode) inside a def that has
8
+ # parameters into an explicit `super(param1, param2, ...)`.
9
+ #
10
+ # Input (cursor on `super`):
11
+ # def initialize(name, age)
12
+ # super
13
+ # end
14
+ #
15
+ # Output:
16
+ # def initialize(name, age)
17
+ # super(name, age)
18
+ # end
19
+ #
20
+ # Bare `super` forwards all arguments implicitly; making them explicit
21
+ # is safer when the method signature changes over time.
22
+ class SuperListener
23
+ include RubyLsp::Requests::Support::Common
24
+ include Support::NodeHelpers
25
+
26
+ def initialize(response_builder, node_context, dispatcher)
27
+ @response_builder = response_builder
28
+ @node_context = node_context
29
+
30
+ @current_def = nil
31
+
32
+ dispatcher.register(
33
+ self,
34
+ :on_def_node_enter,
35
+ :on_def_node_leave,
36
+ :on_forwarding_super_node_enter
37
+ )
38
+ end
39
+
40
+ def on_def_node_enter(node)
41
+ @current_def = node
42
+ end
43
+
44
+ def on_def_node_leave(_node)
45
+ @current_def = nil
46
+ end
47
+
48
+ def on_forwarding_super_node_enter(node)
49
+ return unless node_covers_cursor?(node)
50
+ return unless @current_def
51
+ return unless has_params?(@current_def)
52
+
53
+ emit_explicit_super(node, @current_def)
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def has_params?(def_node)
61
+ params = def_node.parameters
62
+ return false unless params
63
+
64
+ params.requireds.any? || params.optionals.any? || params.keywords.any?
65
+ end
66
+
67
+ def emit_explicit_super(super_node, def_node)
68
+ param_names = collect_param_names(def_node.parameters)
69
+ new_text = "#{indent_for(super_node)}super(#{param_names.join(", ")})"
70
+
71
+ @response_builder << Interface::CodeAction.new(
72
+ title: "Convert to explicit super(#{param_names.join(", ")})",
73
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
74
+ edit: single_edit_workspace_edit(super_node, new_text)
75
+ )
76
+ end
77
+
78
+ def collect_param_names(params_node)
79
+ names = []
80
+ params_node.requireds.each do |p|
81
+ names << p.name.to_s if p.respond_to?(:name)
82
+ end
83
+ params_node.optionals.each do |p|
84
+ names << p.name.to_s if p.respond_to?(:name)
85
+ end
86
+ params_node.keywords.each do |p|
87
+ # keyword params: `name:` — pass as `name: name`
88
+ kw_name = p.name.to_s.delete_suffix(":")
89
+ names << "#{kw_name}: #{kw_name}"
90
+ end
91
+ names
92
+ end
93
+ end
94
+ end
95
+ end
@@ -41,7 +41,7 @@ module RubyLsp
41
41
  :on_local_variable_write_node_enter,
42
42
  :on_local_variable_read_node_enter,
43
43
  :on_call_node_enter,
44
- :on_program_node_leave,
44
+ :on_program_node_leave
45
45
  )
46
46
  end
47
47
 
@@ -89,15 +89,15 @@ module RubyLsp
89
89
 
90
90
  @read_nodes[write_node.name].each do |read_node|
91
91
  edits << Interface::TextEdit.new(
92
- range: node_to_lsp_range(read_node),
93
- new_text: rhs_text,
92
+ range: node_to_lsp_range(read_node),
93
+ new_text: rhs_text
94
94
  )
95
95
  end
96
96
 
97
97
  @response_builder << Interface::CodeAction.new(
98
98
  title: "Inline variable '#{write_node.name}'",
99
- kind: Constant::CodeActionKind::REFACTOR_INLINE,
100
- edit: multi_edit_workspace_edit(edits),
99
+ kind: Constant::CodeActionKind::REFACTOR_INLINE,
100
+ edit: multi_edit_workspace_edit(edits)
101
101
  )
102
102
  end
103
103
 
@@ -113,20 +113,20 @@ module RubyLsp
113
113
  insert_edit = Interface::TextEdit.new(
114
114
  range: Interface::Range.new(
115
115
  start: Interface::Position.new(line: insert_line, character: 0),
116
- end: Interface::Position.new(line: insert_line, character: 0),
116
+ end: Interface::Position.new(line: insert_line, character: 0)
117
117
  ),
118
- new_text: "#{indent}variable = #{expr_src}\n",
118
+ new_text: "#{indent}variable = #{expr_src}\n"
119
119
  )
120
120
 
121
121
  replace_edit = Interface::TextEdit.new(
122
- range: node_to_lsp_range(node),
123
- new_text: "variable",
122
+ range: node_to_lsp_range(node),
123
+ new_text: "variable"
124
124
  )
125
125
 
126
126
  @response_builder << Interface::CodeAction.new(
127
127
  title: "Extract local variable",
128
- kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
129
- edit: multi_edit_workspace_edit([insert_edit, replace_edit]),
128
+ kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
129
+ edit: multi_edit_workspace_edit([insert_edit, replace_edit])
130
130
  )
131
131
  end
132
132
  end
@@ -18,13 +18,13 @@ module RubyLsp
18
18
  loc = node.location
19
19
  Interface::Range.new(
20
20
  start: Interface::Position.new(
21
- line: loc.start_line - 1,
22
- character: loc.start_column,
21
+ line: loc.start_line - 1,
22
+ character: loc.start_column
23
23
  ),
24
24
  end: Interface::Position.new(
25
- line: loc.end_line - 1,
26
- character: loc.end_column,
27
- ),
25
+ line: loc.end_line - 1,
26
+ character: loc.end_column
27
+ )
28
28
  )
29
29
  end
30
30
 
@@ -56,9 +56,9 @@ module RubyLsp
56
56
  Interface::WorkspaceEdit.new(
57
57
  changes: {
58
58
  @node_context.uri => [
59
- Interface::TextEdit.new(range: node_to_lsp_range(node), new_text: new_text),
60
- ],
61
- },
59
+ Interface::TextEdit.new(range: node_to_lsp_range(node), new_text: new_text)
60
+ ]
61
+ }
62
62
  )
63
63
  end
64
64
 
@@ -68,7 +68,7 @@ module RubyLsp
68
68
  # @return [Interface::WorkspaceEdit]
69
69
  def multi_edit_workspace_edit(edits)
70
70
  Interface::WorkspaceEdit.new(
71
- changes: { @node_context.uri => edits },
71
+ changes: { @node_context.uri => edits }
72
72
  )
73
73
  end
74
74
 
@@ -81,10 +81,10 @@ module RubyLsp
81
81
  line = node.location.start_line - 1
82
82
  Interface::TextEdit.new(
83
83
  range: Interface::Range.new(
84
- start: Interface::Position.new(line: line, character: 0),
85
- end: Interface::Position.new(line: line + 1, character: 0),
84
+ start: Interface::Position.new(line: line, character: 0),
85
+ end: Interface::Position.new(line: line + 1, character: 0)
86
86
  ),
87
- new_text: "",
87
+ new_text: ""
88
88
  )
89
89
  end
90
90
 
@@ -31,16 +31,16 @@ module RubyLsp
31
31
  uri = URI::Generic.from_path(path: "/test/fixture.rb")
32
32
  global_state = RubyLsp::GlobalState.new
33
33
  document = RubyLsp::RubyDocument.new(
34
- source: source,
35
- version: 1,
36
- uri: uri,
37
- global_state: global_state,
34
+ source: source,
35
+ version: 1,
36
+ uri: uri,
37
+ global_state: global_state
38
38
  )
39
39
 
40
40
  # LSP range hash — same shape the real server passes to CodeActions.
41
41
  range = {
42
42
  start: { line: line, character: char },
43
- end: { line: line, character: char },
43
+ end: { line: line, character: char }
44
44
  }
45
45
 
46
46
  RubyLsp::Refactor::Addon.refactor_actions_for(document, range)
@@ -0,0 +1,91 @@
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 AccessorListenerTest < Minitest::Test
9
+ include RubyLsp::Refactor::TestHelper
10
+
11
+ def find_action(actions, title_pattern)
12
+ actions.find { |a| a.title.match?(title_pattern) }
13
+ end
14
+
15
+ def test_collapses_attr_reader_and_writer_into_attr_accessor
16
+ source = <<~RUBY
17
+ class User
18
+ attr_reader :name
19
+
20
+ def name=(val)
21
+ @name = val
22
+ end
23
+ end
24
+ RUBY
25
+
26
+ # Cursor on the attr_reader line
27
+ actions = code_actions_for(source, line: 1)
28
+ action = find_action(actions, /attr_accessor :name/)
29
+ refute_nil action
30
+
31
+ edits = action.edit.changes.values.flatten
32
+ assert_equal 2, edits.size
33
+
34
+ replace_edit = edits.find { |e| e.new_text.include?("attr_accessor") }
35
+ delete_edit = edits.find { |e| e.new_text == "" }
36
+
37
+ refute_nil replace_edit
38
+ refute_nil delete_edit
39
+ assert_match(/attr_accessor :name/, replace_edit.new_text)
40
+ end
41
+
42
+ def test_offers_action_from_writer_def_line_too
43
+ source = <<~RUBY
44
+ class User
45
+ attr_reader :name
46
+
47
+ def name=(val)
48
+ @name = val
49
+ end
50
+ end
51
+ RUBY
52
+
53
+ # Cursor on the def name= line
54
+ actions = code_actions_for(source, line: 3)
55
+ action = find_action(actions, /attr_accessor :name/)
56
+ refute_nil action
57
+ end
58
+
59
+ def test_does_not_offer_when_no_matching_writer
60
+ source = <<~RUBY
61
+ class User
62
+ attr_reader :name
63
+ end
64
+ RUBY
65
+
66
+ actions = code_actions_for(source, line: 1)
67
+ assert_nil find_action(actions, /attr_accessor/)
68
+ end
69
+
70
+ def test_does_not_offer_for_non_canonical_writer
71
+ # Writer has extra logic — not a simple passthrough
72
+ source = <<~RUBY
73
+ class User
74
+ attr_reader :name
75
+
76
+ def name=(val)
77
+ @name = val.strip
78
+ end
79
+ end
80
+ RUBY
81
+
82
+ actions = code_actions_for(source, line: 1)
83
+ assert_nil find_action(actions, /attr_accessor/)
84
+ end
85
+
86
+ def test_does_not_raise_on_empty_source
87
+ assert_silent { code_actions_for("", line: 0) }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,98 @@
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 BlockStyleListenerTest < 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
+ # ── brace → do…end ────────────────────────────────────────────────────
22
+
23
+ def test_converts_brace_block_to_do_end
24
+ source = "users.each { |u| u.activate! }\n"
25
+ actions = code_actions_for(source, line: 0)
26
+ action = find_action(actions, "Convert to do…end block")
27
+ refute_nil action
28
+
29
+ edit = single_edit(action)
30
+ assert_match(/do \|u\|/, edit.new_text)
31
+ assert_match(/u\.activate!/, edit.new_text)
32
+ assert_match(/end/, edit.new_text)
33
+ end
34
+
35
+ def test_brace_to_do_end_preserves_indentation
36
+ source = " users.each { |u| u.activate! }\n"
37
+ actions = code_actions_for(source, line: 0)
38
+ action = find_action(actions, "Convert to do…end block")
39
+ refute_nil action
40
+
41
+ edit = single_edit(action)
42
+ # The edit replaces the node range which starts at column 2, so
43
+ # new_text begins at the call itself (no leading spaces).
44
+ assert_match(/\Ausers\.each do \|u\|/, edit.new_text)
45
+ # Body is indented 2 (node column) + 2 (block body) = 4 spaces.
46
+ assert_match(/^ u\.activate!/, edit.new_text)
47
+ # Closing end is at the node's own indentation level (2 spaces).
48
+ assert_match(/^ end$/, edit.new_text)
49
+ end
50
+
51
+ def test_brace_to_do_end_without_params
52
+ source = "3.times { puts 'hi' }\n"
53
+ actions = code_actions_for(source, line: 0)
54
+ action = find_action(actions, "Convert to do…end block")
55
+ refute_nil action
56
+
57
+ edit = single_edit(action)
58
+ assert_match(/do\n/, edit.new_text)
59
+ refute_match(/\|/, edit.new_text)
60
+ end
61
+
62
+ # ── do…end → brace ────────────────────────────────────────────────────
63
+
64
+ def test_converts_do_end_block_to_brace
65
+ source = <<~RUBY
66
+ users.each do |u|
67
+ u.activate!
68
+ end
69
+ RUBY
70
+
71
+ actions = code_actions_for(source, line: 0)
72
+ action = find_action(actions, "Convert to brace block")
73
+ refute_nil action
74
+
75
+ edit = single_edit(action)
76
+ assert_match(/\{ \|u\| u\.activate! \}/, edit.new_text)
77
+ end
78
+
79
+ def test_does_not_offer_brace_for_multi_statement_do_end
80
+ source = <<~RUBY
81
+ users.each do |u|
82
+ u.activate!
83
+ u.notify!
84
+ end
85
+ RUBY
86
+
87
+ actions = code_actions_for(source, line: 0)
88
+ assert_nil find_action(actions, "Convert to brace block")
89
+ end
90
+
91
+ # ── resilience ─────────────────────────────────────────────────────────
92
+
93
+ def test_does_not_raise_on_empty_source
94
+ assert_silent { code_actions_for("", line: 0) }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,68 @@
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 ConstantListenerTest < 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 test_extracts_integer_literal_to_constant
16
+ source = <<~RUBY
17
+ class Processor
18
+ def run
19
+ items.first(100)
20
+ end
21
+ end
22
+ RUBY
23
+
24
+ actions = code_actions_for(source, line: 2)
25
+ action = find_action(actions, "Extract constant")
26
+ refute_nil action
27
+
28
+ edits = action.edit.changes.values.flatten
29
+ assert_equal 2, edits.size
30
+
31
+ insert_edit = edits.find { |e| e.new_text.include?("EXTRACTED_CONSTANT") && e.new_text.include?("=") }
32
+ replace_edit = edits.find { |e| e.new_text == "EXTRACTED_CONSTANT" }
33
+
34
+ refute_nil insert_edit
35
+ refute_nil replace_edit
36
+ assert_match(/EXTRACTED_CONSTANT = 100/, insert_edit.new_text)
37
+ end
38
+
39
+ def test_extracts_string_literal_to_constant
40
+ source = <<~RUBY
41
+ class Mailer
42
+ def subject
43
+ "Welcome to the app"
44
+ end
45
+ end
46
+ RUBY
47
+
48
+ actions = code_actions_for(source, line: 2)
49
+ action = find_action(actions, "Extract constant")
50
+ refute_nil action
51
+
52
+ edits = action.edit.changes.values.flatten
53
+ insert_edit = edits.find { |e| e.new_text.include?("EXTRACTED_CONSTANT") && e.new_text.include?("=") }
54
+ assert_match(/EXTRACTED_CONSTANT = "Welcome to the app"/, insert_edit.new_text)
55
+ end
56
+
57
+ def test_does_not_offer_outside_class
58
+ source = "items.first(100)\n"
59
+ actions = code_actions_for(source, line: 0)
60
+ assert_nil find_action(actions, "Extract constant")
61
+ end
62
+
63
+ def test_does_not_raise_on_empty_source
64
+ assert_silent { code_actions_for("", line: 0) }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,80 @@
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 EnumerableListenerTest < 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
+ # ── map + flatten → flat_map ───────────────────────────────────────────
22
+
23
+ def test_converts_map_flatten_1_to_flat_map
24
+ source = "items.map { |i| i.tags }.flatten(1)\n"
25
+ actions = code_actions_for(source, line: 0)
26
+ action = find_action(actions, "Convert to .flat_map")
27
+ refute_nil action
28
+
29
+ edit = single_edit(action)
30
+ assert_equal "items.flat_map { |i| i.tags }", edit.new_text.strip
31
+ end
32
+
33
+ def test_converts_map_flatten_no_arg_to_flat_map
34
+ source = "items.map { |i| i.tags }.flatten\n"
35
+ actions = code_actions_for(source, line: 0)
36
+ action = find_action(actions, "Convert to .flat_map")
37
+ refute_nil action
38
+
39
+ edit = single_edit(action)
40
+ assert_equal "items.flat_map { |i| i.tags }", edit.new_text.strip
41
+ end
42
+
43
+ def test_does_not_offer_flat_map_for_flatten_2
44
+ source = "items.map { |i| i.tags }.flatten(2)\n"
45
+ actions = code_actions_for(source, line: 0)
46
+ assert_nil find_action(actions, "Convert to .flat_map")
47
+ end
48
+
49
+ # ── select + first → find ─────────────────────────────────────────────
50
+
51
+ def test_converts_select_first_to_find
52
+ source = "users.select { |u| u.admin? }.first\n"
53
+ actions = code_actions_for(source, line: 0)
54
+ action = find_action(actions, "Convert to .find")
55
+ refute_nil action
56
+
57
+ edit = single_edit(action)
58
+ assert_equal "users.find { |u| u.admin? }", edit.new_text.strip
59
+ end
60
+
61
+ # ── map + compact → filter_map ────────────────────────────────────────
62
+
63
+ def test_converts_map_compact_to_filter_map
64
+ source = "items.map { |i| i.value }.compact\n"
65
+ actions = code_actions_for(source, line: 0)
66
+ action = find_action(actions, "Convert to .filter_map")
67
+ refute_nil action
68
+
69
+ edit = single_edit(action)
70
+ assert_equal "items.filter_map { |i| i.value }", edit.new_text.strip
71
+ end
72
+
73
+ # ── resilience ─────────────────────────────────────────────────────────
74
+
75
+ def test_does_not_raise_on_empty_source
76
+ assert_silent { code_actions_for("", line: 0) }
77
+ end
78
+ end
79
+ end
80
+ end