-
Notifications
You must be signed in to change notification settings - Fork 175
/
Copy pathsetup_bundler.rb
223 lines (181 loc) · 9.38 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
# 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.
module RubyLsp
class SetupBundler
extend T::Sig
class BundleNotLocked < StandardError; end
FOUR_HOURS = T.let(4 * 60 * 60, Integer)
sig { params(project_path: String, branch: T.nilable(String)).void }
def initialize(project_path, branch: nil)
@project_path = project_path
@branch = branch
# Custom bundle paths
@custom_dir = T.let(Pathname.new(".ruby-lsp").expand_path(Dir.pwd), Pathname)
@custom_gemfile = T.let(@custom_dir + "Gemfile", Pathname)
@custom_lockfile = T.let(@custom_dir + "Gemfile.lock", Pathname)
@lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname)
@last_updated_path = T.let(@custom_dir + "last_updated", Pathname)
# 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))
@dependencies = T.let(load_dependencies, T::Hash[String, T.untyped])
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([String, T.nilable(String), T.nilable(String)]) }
def setup!
raise BundleNotLocked if @gemfile&.exist? && !@lockfile&.exist?
# Do not setup a custom bundle if both `ruby-lsp` and `debug` are already in the Gemfile
if @dependencies["ruby-lsp"] && @dependencies["debug"]
warn("Ruby LSP> Skipping custom bundle setup since both `ruby-lsp` and `debug` are already in #{@gemfile}")
# If the user decided to add the `ruby-lsp` and `debug` 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?
warn("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
warn("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)
@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\", __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
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([String, T.nilable(String), T.nilable(String)]) }
def run_bundle_install(bundle_gemfile = @gemfile)
# 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`
path = Bundler.settings["path"]
expanded_path = File.expand_path(path, Dir.pwd) if path
# Use the absolute `BUNDLE_PATH` to prevent accidentally creating unwanted folders under `.ruby-lsp`
env = {}
env["BUNDLE_GEMFILE"] = bundle_gemfile.to_s
env["BUNDLE_PATH"] = expanded_path if expanded_path
local_config_path = File.join(Dir.pwd, ".bundle")
env["BUNDLE_APP_CONFIG"] = local_config_path if Dir.exist?(local_config_path)
# If both `ruby-lsp` and `debug` 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 either `ruby-lsp` 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 both 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 ruby-lsp 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 << ")"
@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
warn("Ruby LSP> Running bundle install for the custom bundle. This may take a while...")
system(env, command)
[bundle_gemfile.to_s, expanded_path, env["BUNDLE_APP_CONFIG"]]
end
sig { returns(T::Boolean) }
def should_bundle_update?
# If both `ruby-lsp` and `debug` are in the Gemfile, then we shouldn't try to upgrade them or else it will produce
# version control changes
return false if @dependencies["ruby-lsp"] && @dependencies["debug"]
# If the custom lockfile doesn't include either the `ruby-lsp` or `debug`, we need to run bundle install before
# updating
return false if custom_bundle_dependencies["ruby-lsp"].nil? || custom_bundle_dependencies["debug"].nil?
# 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
end
end