Skip to content

Commit 724bb8e

Browse files
authored
Merge pull request #981 from dduugg/deadcode-fix-orphaned-private-constant
Remove orphaned `private_constant` when removing a constant
2 parents e489af0 + f1704e2 commit 724bb8e

4 files changed

Lines changed: 126 additions & 0 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ PLATFORMS
131131
arm64-darwin-21
132132
arm64-darwin-23
133133
arm64-darwin-24
134+
arm64-darwin-25
134135
universal-darwin-22
135136
x86_64-linux
136137

lib/spoom/deadcode/remover.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def delete_constant_assignment(context)
117117
Prism::ConstantPathAndWriteNode, Prism::ConstantPathOrWriteNode
118118
# Nesting node is an assign, it means only one constant is assigned on the line
119119
# so we can remove the whole assign
120+
remove_constant_visibility_call(context)
120121
delete_node_and_comments_and_sigs(context)
121122
return
122123
end
@@ -127,6 +128,7 @@ def delete_constant_assignment(context)
127128
if parent_node.is_a?(Prism::ConstantWriteNode)
128129
# Nesting node is an assign, it means only one constant is assigned on the line
129130
# so we can remove the whole assign
131+
remove_constant_visibility_call(parent_context)
130132
delete_node_and_comments_and_sigs(parent_context)
131133
return
132134
elsif parent_node.is_a?(Prism::MultiWriteNode) && parent_node.lefts.size == 1
@@ -190,6 +192,64 @@ def delete_constant_assignment(context)
190192
end
191193
end
192194

195+
# A dead constant is often followed by a `private_constant`/`public_constant` call naming it.
196+
# That call references the now-removed constant (a load-time `NameError` if left behind), so
197+
# remove the reference too: delete the whole call when the constant is its only argument, or
198+
# drop just that symbol when the call lists several constants.
199+
#: (NodeContext context) -> void
200+
def remove_constant_visibility_call(context)
201+
node = context.node
202+
return unless node.is_a?(Prism::ConstantWriteNode)
203+
204+
name = node.name
205+
call = context.next_nodes.find { |sibling| constant_visibility_call?(sibling, name) }
206+
return unless call.is_a?(Prism::CallNode)
207+
208+
call_context = NodeContext.new(@old_source, @node_context.comments, call, context.nesting)
209+
arguments = call.arguments&.arguments #: Array[Prism::Node]?
210+
if arguments && arguments.size > 1
211+
delete_symbol_argument(call_context, name)
212+
else
213+
delete_node_and_comments_and_sigs(call_context)
214+
end
215+
end
216+
217+
# Whether `node` is a bare `private_constant`/`public_constant` call listing `name`.
218+
#: (Prism::Node node, Symbol name) -> bool
219+
def constant_visibility_call?(node, name)
220+
return false unless node.is_a?(Prism::CallNode)
221+
return false unless node.receiver.nil?
222+
return false unless node.name == :private_constant || node.name == :public_constant
223+
224+
arguments = node.arguments&.arguments
225+
return false unless arguments
226+
227+
arguments.any? { |argument| argument.is_a?(Prism::SymbolNode) && argument.value == name.to_s }
228+
end
229+
230+
# Drop the `:name` symbol from a `private_constant`/`public_constant` call that lists several
231+
# constants, keeping the call and the other names intact.
232+
#: (NodeContext context, Symbol name) -> void
233+
def delete_symbol_argument(context, name)
234+
arguments = T.cast(context.node, Prism::CallNode).arguments&.arguments
235+
return unless arguments
236+
237+
index = arguments.index { |argument| argument.is_a?(Prism::SymbolNode) && argument.value == name.to_s }
238+
return unless index
239+
240+
argument = arguments.fetch(index)
241+
prev_argument = arguments[index - 1] if index.positive?
242+
next_argument = arguments[index + 1]
243+
244+
if prev_argument && next_argument
245+
replace_chars(prev_argument.location.end_offset, next_argument.location.start_offset, ", ")
246+
elsif prev_argument
247+
delete_chars(prev_argument.location.end_offset, argument.location.end_offset)
248+
elsif next_argument
249+
delete_chars(argument.location.start_offset, next_argument.location.start_offset)
250+
end
251+
end
252+
193253
#: (NodeContext context) -> void
194254
def delete_attr_accessor(context)
195255
args_context = context.parent_context

rbi/spoom.rbi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,9 @@ class Spoom::Deadcode::Remover::NodeRemover
14971497

14981498
private
14991499

1500+
sig { params(node: ::Prism::Node, name: ::Symbol).returns(T::Boolean) }
1501+
def constant_visibility_call?(node, name); end
1502+
15001503
sig { params(context: ::Spoom::Deadcode::Remover::NodeContext).void }
15011504
def delete_attr_accessor(context); end
15021505

@@ -1512,6 +1515,9 @@ class Spoom::Deadcode::Remover::NodeRemover
15121515
sig { params(context: ::Spoom::Deadcode::Remover::NodeContext).void }
15131516
def delete_node_and_comments_and_sigs(context); end
15141517

1518+
sig { params(context: ::Spoom::Deadcode::Remover::NodeContext, name: ::Symbol).void }
1519+
def delete_symbol_argument(context, name); end
1520+
15151521
sig do
15161522
params(
15171523
node: ::Prism::Node,
@@ -1524,6 +1530,9 @@ class Spoom::Deadcode::Remover::NodeRemover
15241530
sig { params(def_node: ::Prism::DefNode).returns(T.nilable(::Spoom::Deadcode::Remover::NodeContext)) }
15251531
def modifier_call_context(def_node); end
15261532

1533+
sig { params(context: ::Spoom::Deadcode::Remover::NodeContext).void }
1534+
def remove_constant_visibility_call(context); end
1535+
15271536
sig { params(start_char: ::Integer, end_char: ::Integer, replacement: ::String).void }
15281537
def replace_chars(start_char, end_char, replacement); end
15291538

test/spoom/deadcode/remover_test.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,62 @@ class Foo
170170
RB
171171
end
172172

173+
def test_removes_constant_and_its_private_constant
174+
res = remove(<<~RB, "BAR")
175+
class Foo
176+
FOO = 1
177+
178+
BAR = 2
179+
private_constant :BAR
180+
181+
BAZ = 3
182+
end
183+
RB
184+
185+
assert_equal(<<~RB, res)
186+
class Foo
187+
FOO = 1
188+
189+
BAZ = 3
190+
end
191+
RB
192+
end
193+
194+
def test_removes_constant_and_its_public_constant
195+
res = remove(<<~RB, "BAR")
196+
class Foo
197+
BAR = 2
198+
public_constant :BAR
199+
BAZ = 3
200+
end
201+
RB
202+
203+
assert_equal(<<~RB, res)
204+
class Foo
205+
BAZ = 3
206+
end
207+
RB
208+
end
209+
210+
def test_removes_constant_referenced_by_a_multi_private_constant
211+
res = remove(<<~RB, "BAR")
212+
class Foo
213+
FOO = 1
214+
BAR = 2
215+
BAZ = 3
216+
private_constant :FOO, :BAR, :BAZ
217+
end
218+
RB
219+
220+
assert_equal(<<~RB, res)
221+
class Foo
222+
FOO = 1
223+
BAZ = 3
224+
private_constant :FOO, :BAZ
225+
end
226+
RB
227+
end
228+
173229
def test_removes_multiline_const
174230
res = remove(<<~RB, "BAR")
175231
class Foo

0 commit comments

Comments
 (0)