Skip to content

Commit 25c57f9

Browse files
committed
feat: make #remove_dependency available for gembytes scripts
Add the `Bundler::GemBytes::Actions.remove_dependency(gem_name)` method so it is available to gembytes scripts.
1 parent b285af5 commit 25c57f9

7 files changed

Lines changed: 397 additions & 5 deletions

File tree

lib/bundler/gem_bytes/actions.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ def add_dependency(dependency_type, gem_name, version_constraint, force: false,
3030
).call(source, path: gemspec)
3131
File.write(gemspec, updated_source)
3232
end
33+
34+
# Removes a dependency from the project's gemspec file
35+
#
36+
# @example
37+
# remove_dependency('rspec')
38+
#
39+
# @param gem_name [String] the name of the gem to add
40+
# @param gemspec [String] the path to the gemspec file
41+
#
42+
# @return [void]
43+
#
44+
# @api public
45+
#
46+
def remove_dependency(gem_name, gemspec: Dir['*.gemspec'].first)
47+
source = File.read(gemspec)
48+
updated_source = Bundler::GemBytes::Gemspec::DeleteDependency.new(gem_name).call(source, path: gemspec)
49+
File.write(gemspec, updated_source)
50+
end
3351
end
3452
end
3553
end

lib/bundler/gem_bytes/gemspec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ module Gemspec; end
77
end
88
end
99

10+
require_relative 'gemspec/delete_dependency'
1011
require_relative 'gemspec/upsert_dependency'
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# frozen_string_literal: true
2+
3+
require 'parser/current'
4+
require 'rubocop-ast'
5+
require 'active_support/core_ext/object'
6+
7+
module Bundler
8+
module GemBytes
9+
module Gemspec
10+
# Delete a dependency in a gemspec file
11+
#
12+
# This class works by parsing the gemspec file into an AST and then walking the
13+
# AST to find the Gem::Specification block (via #on_block). Once the block is
14+
# found, AST within that block is walked to locate the dependency declarations
15+
# (via #on_send). Any dependency declaration that matches the given gem name is
16+
# collected into the found_dependencies array.
17+
#
18+
# Once the Gem::Specification block is fully processed, any dependencies on the
19+
# given gem are deleted from the gemspec source.
20+
#
21+
# If the dependency is not found, the gemspec source is returned unmodified.
22+
#
23+
# @example
24+
# require 'bundler/gem_bytes'
25+
#
26+
# delete_dependency = Bundler::GemBytes::Gemspec::DeleteDependency.new('test_tool')
27+
#
28+
# gemspec_file = 'foo.gemspec'
29+
# gemspec = File.read(gemspec_file)
30+
# updated_gemspec = delete_dependency.call(gemspec, path: gemspec_file)
31+
# File.write(gemspec_file, updated_gemspec)
32+
#
33+
# @!attribute [r] gem_name
34+
# The name of the gem to add a dependency on (i.e. 'rubocop')
35+
# @return [String]
36+
# @api private
37+
#
38+
# @!attribute [r] receiver_name
39+
# The name of the receiver for the Gem::Specification block
40+
#
41+
# i.e. 'spec' in `spec.add_dependency 'rubocop', '~> 1.0'`
42+
#
43+
# @return [Symbol]
44+
# @api private
45+
#
46+
# @!attribute [r] found_gemspec_block
47+
# Whether the Gem::Specification block was found in the gemspec file
48+
#
49+
# Only valid after calling `#call`.
50+
# @return [Boolean]
51+
# @api private
52+
#
53+
# @!attribute [r] found_dependencies
54+
# The dependencies found in the gemspec file
55+
#
56+
# Only valid after calling `#call`.
57+
# @return [Array<Hash>]
58+
# @api private
59+
#
60+
# @api public
61+
class DeleteDependency < Parser::TreeRewriter
62+
# Create a new instance of a dependency upserter
63+
# @example
64+
# command = Bundler::GemBytes::Gemspec::DeleteDependency.new('my_gem')
65+
# @param gem_name [String] The name of the gem to add a dependency on
66+
def initialize(gem_name)
67+
super()
68+
69+
@gem_name = gem_name
70+
71+
@found_dependencies = []
72+
end
73+
74+
# Returns the content of the gemspec file with the dependency deleted
75+
#
76+
# @param code [String] The content of the gemspec file
77+
# @param path [String] This should be the path to the gemspspec file
78+
#
79+
# path is used to generate error messages only
80+
#
81+
# @return [String] The updated gemspec content with the dependency deleted
82+
#
83+
# @raise [ArgumentError] if the Gem Specification block is not found in the given gemspec
84+
#
85+
# @example
86+
# code = File.read('project.gemspec')
87+
# command = Bundler::GemBytes::DeleteDependency.new('my_gem')
88+
# updated_code = command.call(code)
89+
# puts updated_code
90+
#
91+
def call(code, path: '(string)')
92+
@found_gemspec_block = false
93+
rewrite(*parse(code, path)).tap do |_result|
94+
raise ArgumentError, 'Gem::Specification block not found' unless found_gemspec_block
95+
end
96+
end
97+
98+
attr_reader :gem_name, :receiver_name, :found_gemspec_block, :found_dependencies
99+
100+
# Handles block nodes within the AST to locate the Gem Specification block
101+
#
102+
# @param node [Parser::AST::Node] The block node within the AST
103+
# @return [void]
104+
# @api private
105+
def on_block(node)
106+
return if receiver_name # already processing the Gem Specification block
107+
108+
@found_gemspec_block = true
109+
@receiver_name = gem_specification_pattern.match(node)
110+
111+
return unless receiver_name
112+
113+
super # process the children of this node to find the existing dependencies
114+
115+
delete_dependencies
116+
117+
@receiver_name = nil
118+
end
119+
120+
# Handles `send` nodes within the AST to locate dependency calls
121+
#
122+
# If receiver_name is not present then we are not in a Gem Specification block.
123+
#
124+
# @param node [Parser::AST::Node] The `send` node to check for dependency patterns
125+
# @return [void]
126+
# @api private
127+
def on_send(node)
128+
return unless receiver_name.present?
129+
return unless (match = dependency_pattern.match(node))
130+
131+
found_dependencies << { node:, match: }
132+
end
133+
134+
private
135+
136+
# Parses the given code into an AST
137+
# @param code [String] The code to parse
138+
# @param path [String] The path to the file being parsed (used for error messages only)
139+
# @return [Array<Parser::AST::Node, Parser::Source::Buffer>] The AST and buffer
140+
# @api private
141+
def parse(code, path)
142+
buffer = Parser::Source::Buffer.new(path, source: code)
143+
processed_source = RuboCop::AST::ProcessedSource.new(code, ruby_version, path)
144+
unless processed_source.valid_syntax?
145+
raise "Invalid syntax in #{path}\n#{processed_source.diagnostics.map(&:render).join("\n")}"
146+
end
147+
148+
ast = processed_source.ast
149+
[buffer, ast]
150+
end
151+
152+
# Deletes any dependency on the given gem within the Gem::Specification block
153+
# @param node [Parser::AST::Node] The block node within the AST
154+
# @return [void]
155+
# @api private
156+
def delete_dependencies
157+
found_dependencies.each do |found_dependency|
158+
dependency_node = found_dependency[:node]
159+
remove(range_including_leading_spaces(dependency_node))
160+
end
161+
end
162+
163+
# Returns the range of the dependency node including any leading spaces & newline
164+
# @param node [Parser::AST::Node] The node
165+
# @return [Parser::Source::Range] The range of the dependency node
166+
# @api private
167+
def range_including_leading_spaces(node)
168+
leading_spaces = leading_whitespace_count(node)
169+
range = node.loc.expression
170+
range.with(begin_pos: range.begin_pos - leading_spaces - 1, end_pos: range.end_pos)
171+
end
172+
173+
# Returns the # of leading whitespace chars in the source line before the node
174+
# @param node [Parser::AST::Node] The node
175+
# @return [Integer] The number of leading whitespace characters
176+
# @api private
177+
def leading_whitespace_count(node)
178+
match_data = node.loc.expression.source_line.match(/^\s*/)
179+
match_data ? match_data[0].size : 0
180+
end
181+
182+
# Returns the Ruby version in use as a float (MAJOR.MINOR only)
183+
# @return [Float] The Ruby version number, e.g., 3.0
184+
# @api private
185+
def ruby_version = RUBY_VERSION.match(/^(?<version>\d+\.\d+)/)['version'].to_f
186+
187+
# The pattern to match a dependency declaration in the AST
188+
# @return [RuboCop::AST::NodePattern] The dependency pattern
189+
# @api private
190+
def dependency_pattern
191+
# :nocov: JRuby give false positive for this line being uncovered by tests
192+
@dependency_pattern ||=
193+
RuboCop::AST::NodePattern.new(<<~PATTERN)
194+
(send
195+
{ (send _ :#{receiver_name}) | (lvar :#{receiver_name}) }
196+
${ :add_dependency :add_runtime_dependency :add_development_dependency }
197+
(str #{gem_name ? "$\"#{gem_name}\"" : '$_gem_name'})
198+
<(str $_version_constraint) ...>
199+
)
200+
PATTERN
201+
# :nocov:
202+
end
203+
204+
# The pattern to match the Gem::Specification block in the AST
205+
# @return [RuboCop::AST::NodePattern] The Gem::Specification pattern
206+
# @api private
207+
def gem_specification_pattern
208+
@gem_specification_pattern ||=
209+
RuboCop::AST::NodePattern.new(<<~PATTERN)
210+
(block (send (const (const nil? :Gem) :Specification) :new)(args (arg $_)) ...)
211+
PATTERN
212+
end
213+
end
214+
end
215+
end
216+
end

lib/bundler/gem_bytes/gemspec/upsert_dependency.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def dependency_type_method
304304
dependency_type == :development ? :add_development_dependency : :add_dependency
305305
end
306306

307-
# The patter to match a dependency declaration in the AST
307+
# The pattern to match a dependency declaration in the AST
308308
# @return [RuboCop::AST::NodePattern] The dependency pattern
309309
# @api private
310310
def dependency_pattern

spec/features/add_dependency.feature

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
Feature: Add or update a dependency to the project's gemspec
1+
Feature: Runs a gembytes script
22

3-
Scenario: Add a dependency
3+
Scenario: Add and remove a dependency
44

55
Given a gem project named "foo" with the bundler-gem_bytes plugin installed
66
And the project has a gemspec containing:
77
"""
88
Gem::Specification.new do |spec|
99
spec.name = 'foo'
1010
spec.version = '1.0'
11+
spec.add_dependency 'bar', '>= 0.9'
1112
end
1213
"""
1314
And a gem-bytes script "gem_bytes_script" containing:
1415
"""
15-
add_dependency :runtime, 'foo', '>= 1.0'
16+
remove_dependency 'bar'
17+
add_dependency :runtime, 'baz', '>= 1.0'
1618
"""
1719
When I run "bundle gem-bytes gem_bytes_script"
1820
Then the command should have succeeded
@@ -21,7 +23,7 @@ Feature: Add or update a dependency to the project's gemspec
2123
Gem::Specification.new do |spec|
2224
spec.name = 'foo'
2325
spec.version = '1.0'
24-
spec.add_dependency 'foo', '>= 1.0'
26+
spec.add_dependency 'baz', '>= 1.0'
2527
end
2628
"""
2729

spec/lib/bundler/gem_bytes/actions_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,36 @@
4848
end
4949
end
5050
end
51+
52+
describe '.remove_dependency' do
53+
subject { instance.remove_dependency(gem_name) }
54+
55+
let(:gem_name) { 'rspec' }
56+
57+
it 'remove the dependency from the gemspec' do
58+
# Make a temporary directory to work in
59+
Dir.mktmpdir do |temp_dir|
60+
Dir.chdir(temp_dir) do
61+
# Create a new gemspec file
62+
gemspec_file = 'my_gem.gemspec'
63+
File.write(gemspec_file, <<~GEMSPEC)
64+
Gem::Specification.new do |spec|
65+
spec.name = 'my_gem'
66+
spec.version = '0.1.0'
67+
spec.add_development_dependency 'rspec', '~> 3.13'
68+
end
69+
GEMSPEC
70+
71+
# remove the dependency
72+
instance.remove_dependency(gem_name)
73+
74+
# Read the gemspec file
75+
gemspec_content = File.read(gemspec_file)
76+
77+
# Check that the dependency was removed
78+
expect(gemspec_content).not_to include("spec.add_development_dependency 'rspec', '~> 3.13'")
79+
end
80+
end
81+
end
82+
end
5183
end

0 commit comments

Comments
 (0)