Skip to content

Commit b4e5b27

Browse files
authored
Merge pull request #847 from Shopify/vs/improve_custom_bundle
Use pathnames and bundler methods in custom bundle logic
2 parents 5b6b46f + 92cb22a commit b4e5b27

File tree

3 files changed

+167
-74
lines changed

3 files changed

+167
-74
lines changed

exe/ruby-lsp

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,21 @@
11
#!/usr/bin/env ruby
22
# frozen_string_literal: true
33

4-
# We should make sure that, if we're running on a bundler project, that it has a Gemfile.lock
5-
if File.exist?("Gemfile") && !File.exist?("Gemfile.lock")
6-
warn("Project contains a Gemfile, but no Gemfile.lock. Run `bundle install` to lock gems and restart the server")
7-
exit(78)
8-
end
9-
104
# When we're running without bundler, then we need to make sure the custom bundle is fully configured and re-execute
115
# using `BUNDLE_GEMFILE=.ruby-lsp/Gemfile bundle exec ruby-lsp` so that we have access to the gems that are a part of
126
# the application's bundle
13-
if ENV["BUNDLE_GEMFILE"].nil? && File.exist?("Gemfile.lock")
7+
if ENV["BUNDLE_GEMFILE"].nil?
148
require_relative "../lib/ruby_lsp/setup_bundler"
15-
RubyLsp::SetupBundler.new(Dir.pwd).setup!
16-
17-
# In some cases, like when the `ruby-lsp` is already a part of the bundle, we don't generate `.ruby-lsp/Gemfile`.
18-
# However, we still want to run the server with `bundle exec`. We need to make sure we're pointing to the right
19-
# `Gemfile`
20-
bundle_gemfile = File.exist?(".ruby-lsp/Gemfile") ? ".ruby-lsp/Gemfile" : "Gemfile"
219

22-
# In addition to BUNDLE_GEMFILE, we also need to make sure that BUNDLE_PATH is absolute and not relative. For example,
23-
# if BUNDLE_PATH is `vendor/bundle`, we want the top level `vendor/bundle` and not `.ruby-lsp/vendor/bundle`.
24-
# Expanding to get the absolute path ensures we're pointing to the correct folder, which is the same one we use in
25-
# SetupBundler to install the gems
26-
path = Bundler.settings["path"]
10+
begin
11+
bundle_gemfile, bundle_path = RubyLsp::SetupBundler.new(Dir.pwd).setup!
12+
rescue RubyLsp::SetupBundler::BundleNotLocked
13+
warn("Project contains a Gemfile, but no Gemfile.lock. Run `bundle install` to lock gems and restart the server")
14+
exit(78)
15+
end
2716

2817
env = { "BUNDLE_GEMFILE" => bundle_gemfile }
29-
env["BUNDLE_PATH"] = File.expand_path(path, Dir.pwd) if path
18+
env["BUNDLE_PATH"] = bundle_path if bundle_path
3019
exit exec(env, "bundle exec ruby-lsp #{ARGV.join(" ")}")
3120
end
3221

lib/ruby_lsp/setup_bundler.rb

Lines changed: 74 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "sorbet-runtime"
55
require "bundler"
66
require "fileutils"
7+
require "pathname"
78

89
# This file is a script that will configure a custom bundle for the Ruby LSP. The custom bundle allows developers to use
910
# the Ruby LSP without including the gem in their application's Gemfile while at the same time giving us access to the
@@ -13,107 +14,133 @@ module RubyLsp
1314
class SetupBundler
1415
extend T::Sig
1516

17+
class BundleNotLocked < StandardError; end
18+
1619
sig { params(project_path: String).void }
1720
def initialize(project_path)
1821
@project_path = project_path
19-
@dependencies = T.let(load_dependencies, T::Hash[String, T.untyped])
20-
@custom_bundle_dependencies = T.let(
21-
if File.exist?(".ruby-lsp/Gemfile.lock")
22-
Bundler::LockfileParser.new(Bundler.read_file(".ruby-lsp/Gemfile.lock")).dependencies
23-
else
24-
{}
22+
23+
# Custom bundle paths
24+
@custom_dir = T.let(Pathname.new(".ruby-lsp").expand_path(Dir.pwd), Pathname)
25+
@custom_gemfile = T.let(@custom_dir + "Gemfile", Pathname)
26+
@custom_lockfile = T.let(@custom_dir + "Gemfile.lock", Pathname)
27+
28+
# Regular bundle paths
29+
@gemfile = T.let(
30+
begin
31+
Bundler.default_gemfile
32+
rescue Bundler::GemfileNotFound
33+
nil
2534
end,
26-
T::Hash[String, T.untyped],
35+
T.nilable(Pathname),
2736
)
37+
@lockfile = T.let(@gemfile ? Bundler.default_lockfile : nil, T.nilable(Pathname))
38+
39+
@dependencies = T.let(load_dependencies, T::Hash[String, T.untyped])
40+
@custom_bundle_dependencies = T.let(custom_bundle_dependencies, T::Hash[String, T.untyped])
2841
end
2942

30-
sig { void }
43+
# Setups up the custom bundle and returns the `BUNDLE_GEMFILE` and `BUNDLE_PATH` that should be used for running the
44+
# server
45+
sig { returns([String, T.nilable(String)]) }
3146
def setup!
32-
# Do not setup a custom bundle if we're working on the Ruby LSP, since it's already included by default
33-
if File.basename(@project_path) == "ruby-lsp"
34-
warn("Ruby LSP> Skipping custom bundle setup since we're working on the Ruby LSP itself")
35-
run_bundle_install
36-
return
37-
end
47+
raise BundleNotLocked if @gemfile&.exist? && !@lockfile&.exist?
3848

3949
# Do not setup a custom bundle if both `ruby-lsp` and `debug` are already in the Gemfile
4050
if @dependencies["ruby-lsp"] && @dependencies["debug"]
41-
warn("Ruby LSP> Skipping custom bundle setup since both `ruby-lsp` and `debug` are already in the Gemfile")
51+
warn("Ruby LSP> Skipping custom bundle setup since both `ruby-lsp` and `debug` are already in #{@gemfile}")
4252

4353
# If the user decided to add the `ruby-lsp` and `debug` to their Gemfile after having already run the Ruby LSP,
4454
# then we need to remove the `.ruby-lsp` folder, otherwise we will run `bundle install` for the top level and
4555
# try to execute the Ruby LSP using the custom bundle, which will fail since the gems are not installed there
46-
FileUtils.rm_r(".ruby-lsp") if Dir.exist?(".ruby-lsp")
47-
run_bundle_install
48-
return
56+
@custom_dir.rmtree if @custom_dir.exist?
57+
return run_bundle_install
4958
end
5059

5160
# Automatically create and ignore the .ruby-lsp folder for users
52-
FileUtils.mkdir(".ruby-lsp") unless Dir.exist?(".ruby-lsp")
53-
File.write(".ruby-lsp/.gitignore", "*") unless File.exist?(".ruby-lsp/.gitignore")
61+
@custom_dir.mkpath unless @custom_dir.exist?
62+
ignore_file = @custom_dir + ".gitignore"
63+
ignore_file.write("*") unless ignore_file.exist?
5464

55-
# Write the custom `.ruby-lsp/Gemfile` if it doesn't exist or if the content doesn't match
56-
content = custom_gemfile_content
65+
write_custom_gemfile
5766

58-
unless File.exist?(".ruby-lsp/Gemfile") && File.read(".ruby-lsp/Gemfile") == content
59-
File.write(".ruby-lsp/Gemfile", content)
67+
unless @gemfile&.exist? && @lockfile&.exist?
68+
warn("Ruby LSP> Skipping lockfile copies because there's no top level bundle")
69+
return run_bundle_install(@custom_gemfile)
6070
end
6171

6272
# If .ruby-lsp/Gemfile.lock already exists and the top level Gemfile.lock hasn't been modified since it was last
6373
# updated, then we're ready to boot the server
64-
if File.exist?(".ruby-lsp/Gemfile.lock") &&
65-
File.stat(".ruby-lsp/Gemfile.lock").mtime > File.stat("Gemfile.lock").mtime
66-
warn("Ruby LSP> Skipping custom bundle setup since .ruby-lsp/Gemfile.lock already exists and is up to date")
67-
run_bundle_install(".ruby-lsp/Gemfile")
68-
return
74+
if @custom_lockfile.exist? && @custom_lockfile.stat.mtime > @lockfile.stat.mtime
75+
warn("Ruby LSP> Skipping custom bundle setup since #{@custom_lockfile} already exists and is up to date")
76+
return run_bundle_install(@custom_gemfile)
6977
end
7078

71-
FileUtils.cp("Gemfile.lock", ".ruby-lsp/Gemfile.lock")
72-
run_bundle_install(".ruby-lsp/Gemfile")
79+
FileUtils.cp(@lockfile.to_s, @custom_lockfile.to_s)
80+
run_bundle_install(@custom_gemfile)
7381
end
7482

7583
private
7684

77-
sig { returns(String) }
78-
def custom_gemfile_content
85+
sig { returns(T::Hash[String, T.untyped]) }
86+
def custom_bundle_dependencies
87+
return {} unless @custom_lockfile.exist?
88+
89+
ENV["BUNDLE_GEMFILE"] = @custom_gemfile.to_s
90+
Bundler::LockfileParser.new(@custom_lockfile.read).dependencies
91+
ensure
92+
ENV.delete("BUNDLE_GEMFILE")
93+
end
94+
95+
sig { void }
96+
def write_custom_gemfile
7997
parts = [
8098
"# This custom gemfile is automatically generated by the Ruby LSP.",
8199
"# It should be automatically git ignored, but in any case: do not commit it to your repository.",
82100
"",
83-
"eval_gemfile(File.expand_path(\"../Gemfile\", __dir__))",
84101
]
85102

103+
# If there's a top level Gemfile, we want to evaluate from the custom bundle. We get the source from the top level
104+
# Gemfile, so if there isn't one we need to add a default source
105+
if @gemfile&.exist?
106+
parts << "eval_gemfile(File.expand_path(\"../Gemfile\", __dir__))"
107+
else
108+
parts.unshift('source "https://rubygems.org"')
109+
end
110+
86111
unless @dependencies["ruby-lsp"]
87-
parts << 'gem "ruby-lsp", require: false, group: :development, source: "https://rubygems.org"'
112+
parts << 'gem "ruby-lsp", require: false, group: :development'
88113
end
89114

90115
unless @dependencies["debug"]
91-
parts << 'gem "debug", require: false, group: :development, platforms: :mri, source: "https://rubygems.org"'
116+
parts << 'gem "debug", require: false, group: :development, platforms: :mri'
92117
end
93118

94-
parts.join("\n")
119+
content = parts.join("\n")
120+
@custom_gemfile.write(content) unless @custom_gemfile.exist? && @custom_gemfile.read == content
95121
end
96122

97123
sig { returns(T::Hash[String, T.untyped]) }
98124
def load_dependencies
125+
return {} unless @lockfile&.exist?
126+
99127
# We need to parse the Gemfile.lock manually here. If we try to do `bundler/setup` to use something more
100128
# convenient, we may end up with issues when the globally installed `ruby-lsp` version mismatches the one included
101129
# in the `Gemfile`
102-
dependencies = Bundler::LockfileParser.new(Bundler.read_file("Gemfile.lock")).dependencies
130+
dependencies = Bundler::LockfileParser.new(@lockfile.read).dependencies
103131

104132
# When working on a gem, the `ruby-lsp` might be listed as a dependency in the gemspec. We need to make sure we
105-
# check those as well or else we may get version mismatch errors
106-
gemspec_path = Dir.glob("*.gemspec").first
107-
if gemspec_path
108-
gemspec_dependencies = Bundler.load_gemspec(gemspec_path).dependencies.to_h { |dep| [dep.name, dep] }
109-
dependencies.merge!(gemspec_dependencies)
133+
# check those as well or else we may get version mismatch errors. Notice that bundler allows more than one
134+
# gemspec, so we need to make sure we go through all of them
135+
Dir.glob("{,*}.gemspec").each do |path|
136+
dependencies.merge!(Bundler.load_gemspec(path).dependencies.to_h { |dep| [dep.name, dep] })
110137
end
111138

112139
dependencies
113140
end
114141

115-
sig { params(bundle_gemfile: T.untyped).void }
116-
def run_bundle_install(bundle_gemfile = nil)
142+
sig { params(bundle_gemfile: T.nilable(Pathname)).returns([String, T.nilable(String)]) }
143+
def run_bundle_install(bundle_gemfile = @gemfile)
117144
# If the user has a custom bundle path configured, we need to ensure that we will use the absolute and not
118145
# relative version of it when running `bundle install`. This is necessary to avoid installing the gems under the
119146
# `.ruby-lsp` folder, which is not the user's intention. For example, if the path is configured as `vendor`, we
@@ -122,7 +149,7 @@ def run_bundle_install(bundle_gemfile = nil)
122149

123150
# Use the absolute `BUNDLE_PATH` to prevent accidentally creating unwanted folders under `.ruby-lsp`
124151
env = {}
125-
env["BUNDLE_GEMFILE"] = bundle_gemfile if bundle_gemfile
152+
env["BUNDLE_GEMFILE"] = bundle_gemfile.to_s
126153
env["BUNDLE_PATH"] = File.expand_path(path, Dir.pwd) if path
127154

128155
# If both `ruby-lsp` and `debug` are already in the Gemfile, then we shouldn't try to upgrade them or else we'll
@@ -150,6 +177,7 @@ def run_bundle_install(bundle_gemfile = nil)
150177
# Add bundle update
151178
warn("Ruby LSP> Running bundle install for the custom bundle. This may take a while...")
152179
system(env, command)
180+
[bundle_gemfile.to_s, path]
153181
end
154182
end
155183
end

test/setup_bundler_test.rb

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,6 @@
55
require "ruby_lsp/setup_bundler"
66

77
class SetupBundlerTest < Minitest::Test
8-
def test_does_nothing_when_running_in_the_ruby_lsp
9-
Object.any_instance.expects(:system).with(bundle_env, "bundle install 1>&2")
10-
run_script("/some/path/ruby-lsp")
11-
refute_path_exists(".ruby-lsp")
12-
end
13-
148
def test_does_nothing_if_both_ruby_lsp_and_debug_are_in_the_bundle
159
Object.any_instance.expects(:system).with(bundle_env, "bundle install 1>&2")
1610
Bundler::LockfileParser.any_instance.expects(:dependencies).returns({ "ruby-lsp" => true, "debug" => true })
@@ -51,7 +45,10 @@ def test_copies_gemfile_lock_when_modified
5145
sleep(0.05)
5246
FileUtils.touch("Gemfile.lock")
5347

54-
FileUtils.expects(:cp).with("Gemfile.lock", ".ruby-lsp/Gemfile.lock")
48+
FileUtils.expects(:cp).with(
49+
File.expand_path("Gemfile.lock", Dir.pwd),
50+
File.expand_path(".ruby-lsp/Gemfile.lock", Dir.pwd),
51+
)
5552

5653
run_script
5754
ensure
@@ -80,6 +77,83 @@ def test_uses_absolute_bundle_path_for_bundle_install
8077
FileUtils.rm_r(".ruby-lsp")
8178
end
8279

80+
def test_creates_custom_bundle_if_no_gemfile
81+
# Create a temporary directory with no Gemfile or Gemfile.lock
82+
Dir.mktmpdir do |dir|
83+
Dir.chdir(dir) do
84+
bundle_gemfile = Pathname.new(".ruby-lsp").expand_path(Dir.pwd) + "Gemfile"
85+
Object.any_instance.expects(:system).with(bundle_env(bundle_gemfile.to_s), "bundle install 1>&2")
86+
87+
Bundler.with_unbundled_env do
88+
run_script
89+
end
90+
91+
assert_path_exists(".ruby-lsp")
92+
assert_path_exists(".ruby-lsp/Gemfile")
93+
assert_match("ruby-lsp", File.read(".ruby-lsp/Gemfile"))
94+
assert_match("debug", File.read(".ruby-lsp/Gemfile"))
95+
end
96+
end
97+
end
98+
99+
def test_raises_if_bundle_is_not_locked
100+
# Create a temporary directory with no Gemfile or Gemfile.lock
101+
Dir.mktmpdir do |dir|
102+
Dir.chdir(dir) do
103+
FileUtils.touch("Gemfile")
104+
105+
Bundler.with_unbundled_env do
106+
assert_raises(RubyLsp::SetupBundler::BundleNotLocked) do
107+
run_script
108+
end
109+
end
110+
end
111+
end
112+
end
113+
114+
def test_does_nothing_if_both_ruby_lsp_and_debug_are_gemspec_dependencies
115+
Dir.mktmpdir do |dir|
116+
Dir.chdir(dir) do
117+
# Write a fake Gemfile and gemspec
118+
File.write(File.join(dir, "Gemfile"), <<~GEMFILE)
119+
source "https://rubygems.org"
120+
gemspec
121+
GEMFILE
122+
123+
File.write(File.join(dir, "fake.gemspec"), <<~GEMSPEC)
124+
Gem::Specification.new do |s|
125+
s.name = "fake"
126+
s.version = "0.1.0"
127+
s.authors = ["Dev"]
128+
s.email = ["[email protected]"]
129+
s.metadata["allowed_push_host"] = "https://rubygems.org"
130+
131+
s.summary = "A fake gem"
132+
s.description = "A fake gem"
133+
s.homepage = "https://github.com/fake/gem"
134+
s.license = "MIT"
135+
s.files = Dir.glob("lib/**/*.rb") + ["README.md", "VERSION", "LICENSE.txt"]
136+
s.bindir = "exe"
137+
s.require_paths = ["lib"]
138+
139+
s.add_dependency("ruby-lsp")
140+
s.add_dependency("debug")
141+
end
142+
GEMSPEC
143+
144+
FileUtils.touch(File.join(dir, "Gemfile.lock"))
145+
146+
Bundler.with_unbundled_env do
147+
Object.any_instance.expects(:system).with(bundle_env, "bundle install 1>&2")
148+
Bundler::LockfileParser.any_instance.expects(:dependencies).returns({})
149+
run_script
150+
end
151+
152+
refute_path_exists(".ruby-lsp")
153+
end
154+
end
155+
end
156+
83157
private
84158

85159
# This method runs the script and then immediately unloads it. This allows us to make assertions against the effects
@@ -88,12 +162,14 @@ def run_script(path = "/fake/project/path")
88162
RubyLsp::SetupBundler.new(path).setup!
89163
end
90164

91-
def bundle_env(bundle_gemfile = nil)
165+
def bundle_env(bundle_gemfile = "Gemfile")
166+
bundle_gemfile_path = Pathname.new(bundle_gemfile)
92167
path = Bundler.settings["path"]
93168

94169
env = {}
95170
env["BUNDLE_PATH"] = File.expand_path(path, Dir.pwd) if path
96-
env["BUNDLE_GEMFILE"] = bundle_gemfile if bundle_gemfile
171+
env["BUNDLE_GEMFILE"] =
172+
bundle_gemfile_path.absolute? ? bundle_gemfile_path.to_s : bundle_gemfile_path.expand_path(Dir.pwd).to_s
97173
env
98174
end
99175
end

0 commit comments

Comments
 (0)