-
Notifications
You must be signed in to change notification settings - Fork 179
/
Copy pathsetup_bundler.rb
313 lines (258 loc) · 13.1 KB
/
setup_bundler.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# typed: strict
# frozen_string_literal: true
require "sorbet-runtime"
require "bundler"
require "fileutils"
require "pathname"
require "digest"
require "time"
# This file is a script that will configure a custom bundle for the Ruby LSP. The custom bundle allows developers to use
# the Ruby LSP without including the gem in their application's Gemfile while at the same time giving us access to the
# exact locked versions of dependencies.
Bundler.ui.level = :silent
module RubyLsp
class SetupBundler
extend T::Sig
class BundleNotLocked < StandardError; end
class BundleInstallFailure < StandardError; end
FOUR_HOURS = T.let(4 * 60 * 60, Integer)
sig { params(project_path: String, options: T.untyped).void }
def initialize(project_path, **options)
@project_path = project_path
@branch = T.let(options[:branch], T.nilable(String))
@experimental = T.let(options[:experimental], T.nilable(T::Boolean))
# Regular bundle paths
@gemfile = T.let(
begin
Bundler.default_gemfile
rescue Bundler::GemfileNotFound
nil
end,
T.nilable(Pathname),
)
@lockfile = T.let(@gemfile ? Bundler.default_lockfile : nil, T.nilable(Pathname))
@gemfile_name = T.let(@gemfile&.basename&.to_s || "Gemfile", String)
# Custom bundle paths
@custom_dir = T.let(Pathname.new(".ruby-lsp").expand_path(@project_path), Pathname)
@custom_gemfile = T.let(@custom_dir + @gemfile_name, Pathname)
@custom_lockfile = T.let(@custom_dir + (@lockfile&.basename || "Gemfile.lock"), Pathname)
@lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname)
@last_updated_path = T.let(@custom_dir + "last_updated", Pathname)
@dependencies = T.let(load_dependencies, T::Hash[String, T.untyped])
@rails_app = T.let(rails_app?, T::Boolean)
@retry = T.let(false, T::Boolean)
end
# Sets up the custom bundle and returns the `BUNDLE_GEMFILE`, `BUNDLE_PATH` and `BUNDLE_APP_CONFIG` that should be
# used for running the server
sig { returns(T::Hash[String, String]) }
def setup!
raise BundleNotLocked if @gemfile&.exist? && !@lockfile&.exist?
# Do not set up a custom bundle if LSP dependencies are already in the Gemfile
if @dependencies["ruby-lsp"] &&
@dependencies["debug"] &&
(@rails_app ? @dependencies["ruby-lsp-rails"] : true)
$stderr.puts(
"Ruby LSP> Skipping custom bundle setup since LSP dependencies are already in #{@gemfile}",
)
# If the user decided to add `ruby-lsp` and `debug` (and potentially `ruby-lsp-rails`) to their Gemfile after
# having already run the Ruby LSP, then we need to remove the `.ruby-lsp` folder, otherwise we will run `bundle
# install` for the top level and try to execute the Ruby LSP using the custom bundle, which will fail since the
# gems are not installed there
@custom_dir.rmtree if @custom_dir.exist?
return run_bundle_install
end
# Automatically create and ignore the .ruby-lsp folder for users
@custom_dir.mkpath unless @custom_dir.exist?
ignore_file = @custom_dir + ".gitignore"
ignore_file.write("*") unless ignore_file.exist?
write_custom_gemfile
unless @gemfile&.exist? && @lockfile&.exist?
$stderr.puts("Ruby LSP> Skipping lockfile copies because there's no top level bundle")
return run_bundle_install(@custom_gemfile)
end
lockfile_contents = @lockfile.read
current_lockfile_hash = Digest::SHA256.hexdigest(lockfile_contents)
if @custom_lockfile.exist? && @lockfile_hash_path.exist? && @lockfile_hash_path.read == current_lockfile_hash
$stderr.puts(
"Ruby LSP> Skipping custom bundle setup since #{@custom_lockfile} already exists and is up to date",
)
return run_bundle_install(@custom_gemfile)
end
FileUtils.cp(@lockfile.to_s, @custom_lockfile.to_s)
correct_relative_remote_paths
@lockfile_hash_path.write(current_lockfile_hash)
run_bundle_install(@custom_gemfile)
end
private
sig { returns(T::Hash[String, T.untyped]) }
def custom_bundle_dependencies
@custom_bundle_dependencies ||= T.let(
begin
if @custom_lockfile.exist?
ENV["BUNDLE_GEMFILE"] = @custom_gemfile.to_s
Bundler::LockfileParser.new(@custom_lockfile.read).dependencies
else
{}
end
end,
T.nilable(T::Hash[String, T.untyped]),
)
ensure
ENV.delete("BUNDLE_GEMFILE")
end
sig { void }
def write_custom_gemfile
parts = [
"# This custom gemfile is automatically generated by the Ruby LSP.",
"# It should be automatically git ignored, but in any case: do not commit it to your repository.",
"",
]
# If there's a top level Gemfile, we want to evaluate from the custom bundle. We get the source from the top level
# Gemfile, so if there isn't one we need to add a default source
if @gemfile&.exist?
parts << "eval_gemfile(File.expand_path(\"../#{@gemfile_name}\", __dir__))"
else
parts.unshift('source "https://rubygems.org"')
end
unless @dependencies["ruby-lsp"]
ruby_lsp_entry = +'gem "ruby-lsp", require: false, group: :development'
ruby_lsp_entry << ", github: \"Shopify/ruby-lsp\", branch: \"#{@branch}\"" if @branch
parts << ruby_lsp_entry
end
unless @dependencies["debug"]
parts << 'gem "debug", require: false, group: :development, platforms: :mri'
end
if @rails_app && !@dependencies["ruby-lsp-rails"]
parts << 'gem "ruby-lsp-rails", require: false, group: :development'
end
content = parts.join("\n")
@custom_gemfile.write(content) unless @custom_gemfile.exist? && @custom_gemfile.read == content
end
sig { returns(T::Hash[String, T.untyped]) }
def load_dependencies
return {} unless @lockfile&.exist?
# We need to parse the Gemfile.lock manually here. If we try to do `bundler/setup` to use something more
# convenient, we may end up with issues when the globally installed `ruby-lsp` version mismatches the one included
# in the `Gemfile`
dependencies = Bundler::LockfileParser.new(@lockfile.read).dependencies
# When working on a gem, the `ruby-lsp` might be listed as a dependency in the gemspec. We need to make sure we
# check those as well or else we may get version mismatch errors. Notice that bundler allows more than one
# gemspec, so we need to make sure we go through all of them
Dir.glob("{,*}.gemspec").each do |path|
dependencies.merge!(Bundler.load_gemspec(path).dependencies.to_h { |dep| [dep.name, dep] })
end
dependencies
end
sig { params(bundle_gemfile: T.nilable(Pathname)).returns(T::Hash[String, String]) }
def run_bundle_install(bundle_gemfile = @gemfile)
env = bundler_settings_as_env
env["BUNDLE_GEMFILE"] = bundle_gemfile.to_s
# If the user has a custom bundle path configured, we need to ensure that we will use the absolute and not
# relative version of it when running `bundle install`. This is necessary to avoid installing the gems under the
# `.ruby-lsp` folder, which is not the user's intention. For example, if the path is configured as `vendor`, we
# want to install it in the top level `vendor` and not `.ruby-lsp/vendor`
if env["BUNDLE_PATH"]
env["BUNDLE_PATH"] = File.expand_path(env["BUNDLE_PATH"], @project_path)
end
# If `ruby-lsp` and `debug` (and potentially `ruby-lsp-rails`) are already in the Gemfile, then we shouldn't try
# to upgrade them or else we'll produce undesired source control changes. If the custom bundle was just created
# and any of `ruby-lsp`, `ruby-lsp-rails` or `debug` weren't a part of the Gemfile, then we need to run `bundle
# install` for the first time to generate the Gemfile.lock with them included or else Bundler will complain that
# they're missing. We can only update if the custom `.ruby-lsp/Gemfile.lock` already exists and includes all gems
# When not updating, we run `(bundle check || bundle install)`
# When updating, we run `((bundle check && bundle update ruby-lsp debug) || bundle install)`
command = +"(bundle check"
if should_bundle_update?
# If any of `ruby-lsp`, `ruby-lsp-rails` or `debug` are not in the Gemfile, try to update them to the latest
# version
command.prepend("(")
command << " && bundle update "
command << "ruby-lsp " unless @dependencies["ruby-lsp"]
command << "debug " unless @dependencies["debug"]
command << "ruby-lsp-rails " if @rails_app && !@dependencies["ruby-lsp-rails"]
command << "--pre" if @experimental
command.delete_suffix!(" ")
command << ")"
@last_updated_path.write(Time.now.iso8601)
end
command << " || bundle install) "
# Redirect stdout to stderr to prevent going into an infinite loop. The extension might confuse stdout output with
# responses
command << "1>&2"
# Add bundle update
$stderr.puts("Ruby LSP> Running bundle install for the custom bundle. This may take a while...")
$stderr.puts("Ruby LSP> Command: #{command}")
# Try to run the bundle install or update command. If that fails, it normally means that the custom lockfile is in
# a bad state that no longer reflects the top level one. In that case, we can remove the whole directory, try
# another time and give up if it fails again
if !system(env, command) && !@retry && @custom_dir.exist?
@retry = true
@custom_dir.rmtree
$stderr.puts("Ruby LSP> Running bundle install failed. Trying to re-generate the custom bundle from scratch")
return setup!
end
env
end
# Gather all Bundler settings (global and local) and return them as a hash that can be used as the environment
sig { returns(T::Hash[String, String]) }
def bundler_settings_as_env
local_config_path = File.join(@project_path, ".bundle")
# If there's no Gemfile or if the local config path does not exist, we return an empty setting set (which has the
# global settings included). Otherwise, we also load the local settings
settings = begin
Dir.exist?(local_config_path) ? Bundler::Settings.new(local_config_path) : Bundler::Settings.new
rescue Bundler::GemfileNotFound
Bundler::Settings.new
end
# Map all settings to their environment variable names with `key_for` and their values. For example, the if the
# setting name `e` is `path` with a value of `vendor/bundle`, then it will return `"BUNDLE_PATH" =>
# "vendor/bundle"`
settings.all.to_h do |e|
key = Bundler::Settings.key_for(e)
[key, settings[e].to_s]
end
end
sig { returns(T::Boolean) }
def should_bundle_update?
# If `ruby-lsp`, `ruby-lsp-rails` and `debug` are in the Gemfile, then we shouldn't try to upgrade them or else it
# will produce version control changes
if @rails_app
return false if @dependencies.values_at("ruby-lsp", "ruby-lsp-rails", "debug").all?
# If the custom lockfile doesn't include `ruby-lsp`, `ruby-lsp-rails` or `debug`, we need to run bundle install
# before updating
return false if custom_bundle_dependencies.values_at("ruby-lsp", "debug", "ruby-lsp-rails").any?(&:nil?)
else
return false if @dependencies.values_at("ruby-lsp", "debug").all?
# If the custom lockfile doesn't include `ruby-lsp` or `debug`, we need to run bundle install before updating
return false if custom_bundle_dependencies.values_at("ruby-lsp", "debug").any?(&:nil?)
end
# If the last updated file doesn't exist or was updated more than 4 hours ago, we should update
!@last_updated_path.exist? || Time.parse(@last_updated_path.read) < (Time.now - FOUR_HOURS)
end
# When a lockfile has remote references based on relative file paths, we need to ensure that they are pointing to
# the correct place since after copying the relative path is no longer valid
sig { void }
def correct_relative_remote_paths
content = @custom_lockfile.read
content.gsub!(/remote: (.*)/) do |match|
path = T.must(Regexp.last_match)[1]
# We should only apply the correction if the remote is a relative path. It might also be a URI, like
# `https://rubygems.org` or an absolute path, in which case we shouldn't do anything
if path&.start_with?(".")
"remote: #{File.expand_path(path, T.must(@gemfile).dirname)}"
else
match
end
end
@custom_lockfile.write(content)
end
# Detects if the project is a Rails app by looking if the superclass of the main class is `Rails::Application`
sig { returns(T::Boolean) }
def rails_app?
config = Pathname.new("config/application.rb").expand_path
application_contents = config.read(external_encoding: Encoding::UTF_8) if config.exist?
return false unless application_contents
/class .* < (::)?Rails::Application/.match?(application_contents)
end
end
end