ruby-lsp-refactor 0.1.0 → 0.1.2

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +68 -0
  3. data/README.md +553 -115
  4. data/lib/ruby/lsp/refactor/version.rb +1 -1
  5. data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +54 -17
  6. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/accessor_listener.rb +156 -0
  7. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +2 -2
  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/early_return_listener.rb +105 -0
  11. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/enumerable_listener.rb +90 -0
  12. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_include_file_listener.rb +172 -0
  13. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_predicate_listener.rb +138 -0
  14. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +7 -7
  15. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/logical_operator_listener.rb +70 -0
  16. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +37 -109
  17. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/raise_listener.rb +70 -0
  18. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rescue_listener.rb +85 -0
  19. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rspec_let_listener.rb +61 -0
  20. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_array_listener.rb +88 -0
  21. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_freeze_listener.rb +86 -0
  22. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +2 -2
  23. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/super_listener.rb +95 -0
  24. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/tap_listener.rb +133 -0
  25. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +7 -49
  26. data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +62 -12
  27. data/lib/ruby_lsp/test_helper.rb +5 -5
  28. data/test/ruby_lsp_refactor/accessor_listener_test.rb +91 -0
  29. data/test/ruby_lsp_refactor/constant_listener_test.rb +68 -0
  30. data/test/ruby_lsp_refactor/early_return_listener_test.rb +156 -0
  31. data/test/ruby_lsp_refactor/enumerable_listener_test.rb +80 -0
  32. data/test/ruby_lsp_refactor/extract_include_file_listener_test.rb +189 -0
  33. data/test/ruby_lsp_refactor/extract_predicate_listener_test.rb +113 -0
  34. data/test/ruby_lsp_refactor/logical_operator_listener_test.rb +66 -0
  35. data/test/ruby_lsp_refactor/method_listener_test.rb +3 -52
  36. data/test/ruby_lsp_refactor/raise_listener_test.rb +64 -0
  37. data/test/ruby_lsp_refactor/rescue_listener_test.rb +72 -0
  38. data/test/ruby_lsp_refactor/rspec_let_listener_test.rb +54 -0
  39. data/test/ruby_lsp_refactor/string_array_listener_test.rb +64 -0
  40. data/test/ruby_lsp_refactor/string_freeze_listener_test.rb +52 -0
  41. data/test/ruby_lsp_refactor/string_listener_test.rb +2 -2
  42. data/test/ruby_lsp_refactor/super_listener_test.rb +65 -0
  43. data/test/ruby_lsp_refactor/tap_listener_test.rb +144 -0
  44. data/test/ruby_lsp_refactor/variable_listener_test.rb +0 -23
  45. metadata +42 -13
@@ -12,11 +12,8 @@ module RubyLsp
12
12
  # result = user.calculate → (line deleted)
13
13
  # puts result → puts user.calculate
14
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
15
+ # Note: "Extract local variable" is provided by ruby-lsp upstream as
16
+ # "Refactor: Extract Variable" and is intentionally not duplicated here.
20
17
  class VariableListener
21
18
  include RubyLsp::Requests::Support::Common
22
19
  include Support::NodeHelpers
@@ -40,8 +37,7 @@ module RubyLsp
40
37
  self,
41
38
  :on_local_variable_write_node_enter,
42
39
  :on_local_variable_read_node_enter,
43
- :on_call_node_enter,
44
- :on_program_node_leave,
40
+ :on_program_node_leave
45
41
  )
46
42
  end
47
43
 
@@ -63,15 +59,6 @@ module RubyLsp
63
59
  nil
64
60
  end
65
61
 
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
62
  # After the full AST walk, all read nodes are known — emit inline actions.
76
63
  def on_program_node_leave(_node)
77
64
  @pending_write_nodes.each { |w| emit_inline_variable(w) }
@@ -89,44 +76,15 @@ module RubyLsp
89
76
 
90
77
  @read_nodes[write_node.name].each do |read_node|
91
78
  edits << Interface::TextEdit.new(
92
- range: node_to_lsp_range(read_node),
93
- new_text: rhs_text,
79
+ range: node_to_lsp_range(read_node),
80
+ new_text: rhs_text
94
81
  )
95
82
  end
96
83
 
97
84
  @response_builder << Interface::CodeAction.new(
98
85
  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]),
86
+ kind: Constant::CodeActionKind::REFACTOR_INLINE,
87
+ edit: multi_edit_workspace_edit(edits)
130
88
  )
131
89
  end
132
90
  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
 
@@ -95,6 +95,56 @@ module RubyLsp
95
95
  def indent_for(node)
96
96
  " " * node.location.start_column
97
97
  end
98
+
99
+ # Builds a multi-file WorkspaceEdit using document_changes, which
100
+ # supports both file creation (CreateFile) and text edits across
101
+ # multiple documents (TextDocumentEdit).
102
+ #
103
+ # +document_changes+ is an ordered array of:
104
+ # Interface::CreateFile — create a new empty file
105
+ # Interface::TextDocumentEdit — apply text edits to a file
106
+ #
107
+ # The LSP spec requires CreateFile operations to appear before any
108
+ # TextDocumentEdit that writes into the newly created file.
109
+ #
110
+ # @param document_changes [Array<Interface::CreateFile | Interface::TextDocumentEdit>]
111
+ # @return [Interface::WorkspaceEdit]
112
+ def multi_file_workspace_edit(document_changes)
113
+ Interface::WorkspaceEdit.new(document_changes: document_changes)
114
+ end
115
+
116
+ # Builds a TextDocumentEdit for a given URI and array of TextEdits.
117
+ # Used as one entry inside a multi_file_workspace_edit.
118
+ #
119
+ # @param uri [String] file URI, e.g. "file:///project/lib/foo.rb"
120
+ # @param edits [Array<Interface::TextEdit>]
121
+ # @return [Interface::TextDocumentEdit]
122
+ def text_document_edit(uri, edits)
123
+ Interface::TextDocumentEdit.new(
124
+ text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
125
+ uri: uri,
126
+ version: nil
127
+ ),
128
+ edits: edits
129
+ )
130
+ end
131
+
132
+ # Builds a CreateFile operation for use in a multi_file_workspace_edit.
133
+ # The file is created empty; a subsequent TextDocumentEdit writes its
134
+ # content.
135
+ #
136
+ # @param uri [String] file URI for the new file
137
+ # @return [Interface::CreateFile]
138
+ def create_file_operation(uri)
139
+ Interface::CreateFile.new(
140
+ kind: "create",
141
+ uri: uri,
142
+ options: Interface::CreateFileOptions.new(
143
+ overwrite: false,
144
+ ignore_if_exists: false
145
+ )
146
+ )
147
+ end
98
148
  end
99
149
  end
100
150
  end
@@ -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,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,156 @@
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 EarlyReturnListenerTest < 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
+ # ── core acceptance ────────────────────────────────────────────────────
22
+
23
+ def test_converts_guard_if_to_early_return
24
+ source = <<~RUBY
25
+ def charge_purchase(order)
26
+ if order.fulfilled?
27
+ OrderChargeConfirmation.new(order).create!
28
+ end
29
+ end
30
+ RUBY
31
+
32
+ actions = code_actions_for(source, line: 1)
33
+ action = find_action(actions, "Convert to early return")
34
+ refute_nil action
35
+
36
+ edit = single_edit(action)
37
+ assert_match(/return unless order\.fulfilled\?/, edit.new_text)
38
+ assert_match(/OrderChargeConfirmation\.new\(order\)\.create!/, edit.new_text)
39
+ refute_match(/\bif\b/, edit.new_text)
40
+ refute_match(/\bend\b/, edit.new_text)
41
+ end
42
+
43
+ def test_preserves_multi_statement_body
44
+ source = <<~RUBY
45
+ def process(order)
46
+ if order.valid?
47
+ order.charge!
48
+ order.notify!
49
+ end
50
+ end
51
+ RUBY
52
+
53
+ actions = code_actions_for(source, line: 1)
54
+ action = find_action(actions, "Convert to early return")
55
+ refute_nil action
56
+
57
+ edit = single_edit(action)
58
+ assert_match(/return unless order\.valid\?/, edit.new_text)
59
+ assert_match(/order\.charge!/, edit.new_text)
60
+ assert_match(/order\.notify!/, edit.new_text)
61
+ end
62
+
63
+ def test_preserves_indentation
64
+ source = <<~RUBY
65
+ class Service
66
+ def call(user)
67
+ if user.active?
68
+ user.run!
69
+ end
70
+ end
71
+ end
72
+ RUBY
73
+
74
+ actions = code_actions_for(source, line: 2)
75
+ action = find_action(actions, "Convert to early return")
76
+ refute_nil action
77
+
78
+ edit = single_edit(action)
79
+ # The edit replaces the if node range (start_column=4), so new_text
80
+ # begins with the node's own indentation (4 spaces).
81
+ assert_match(/\A return unless user\.active\?/, edit.new_text)
82
+ assert_match(/user\.run!/, edit.new_text)
83
+ end
84
+
85
+ # ── negative cases ─────────────────────────────────────────────────────
86
+
87
+ def test_does_not_offer_when_if_has_else
88
+ source = <<~RUBY
89
+ def process(order)
90
+ if order.valid?
91
+ order.charge!
92
+ else
93
+ order.reject!
94
+ end
95
+ end
96
+ RUBY
97
+
98
+ actions = code_actions_for(source, line: 1)
99
+ assert_nil find_action(actions, "Convert to early return")
100
+ end
101
+
102
+ def test_does_not_offer_when_if_has_elsif
103
+ source = <<~RUBY
104
+ def process(order)
105
+ if order.paid?
106
+ order.complete!
107
+ elsif order.pending?
108
+ order.charge!
109
+ end
110
+ end
111
+ RUBY
112
+
113
+ actions = code_actions_for(source, line: 1)
114
+ assert_nil find_action(actions, "Convert to early return")
115
+ end
116
+
117
+ def test_does_not_offer_when_if_is_not_first_statement
118
+ source = <<~RUBY
119
+ def process(order)
120
+ order.validate!
121
+ if order.valid?
122
+ order.charge!
123
+ end
124
+ end
125
+ RUBY
126
+
127
+ # Cursor on the if (line 2) — it is not the first statement
128
+ actions = code_actions_for(source, line: 2)
129
+ assert_nil find_action(actions, "Convert to early return")
130
+ end
131
+
132
+ def test_does_not_offer_outside_a_method
133
+ source = <<~RUBY
134
+ if user.active?
135
+ user.run!
136
+ end
137
+ RUBY
138
+
139
+ actions = code_actions_for(source, line: 0)
140
+ assert_nil find_action(actions, "Convert to early return")
141
+ end
142
+
143
+ def test_does_not_offer_for_post_conditional
144
+ source = "user.run! if user.active?\n"
145
+ actions = code_actions_for(source, line: 0)
146
+ assert_nil find_action(actions, "Convert to early return")
147
+ end
148
+
149
+ # ── resilience ─────────────────────────────────────────────────────────
150
+
151
+ def test_does_not_raise_on_empty_source
152
+ assert_silent { code_actions_for("", line: 0) }
153
+ end
154
+ end
155
+ end
156
+ 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