# typed: strict
# frozen_string_literal: true
require "bundler"
require "bundler/cli"
require "bundler/cli/install"
require "bundler/cli/update"
require "fileutils"
require "pathname"
require "digest"
require "time"
require "uri"
# This file is a script that will configure a composed bundle for the Ruby LSP. The composed 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
class BundleNotLocked < StandardError; end
class BundleInstallFailure < StandardError; end
module ThorPatch
#: -> IO
def stdout
$stderr
end
end
FOUR_HOURS = 4 * 60 * 60 #: Integer
#: (String project_path, **untyped options) -> void
def initialize(project_path, **options)
@project_path = project_path
@branch = options[:branch] #: String?
@launcher = options[:launcher] #: bool?
patch_thor_to_print_progress_to_stderr! if @launcher
# Regular bundle paths
@gemfile = begin
Bundler.default_gemfile
rescue Bundler::GemfileNotFound
nil
end #: Pathname?
@lockfile = @gemfile ? Bundler.default_lockfile : nil #: Pathname?
@gemfile_hash = @gemfile ? Digest::SHA256.hexdigest(@gemfile.read) : nil #: String?
@lockfile_hash = @lockfile&.exist? ? Digest::SHA256.hexdigest(@lockfile.read) : nil #: String?
@gemfile_name = @gemfile&.basename&.to_s || "Gemfile" #: String
# Custom bundle paths
@custom_dir = Pathname.new(".ruby-lsp").expand_path(@project_path) #: Pathname
@custom_gemfile = @custom_dir + @gemfile_name #: Pathname
@custom_lockfile = @custom_dir + (@lockfile&.basename || "Gemfile.lock") #: Pathname
@lockfile_hash_path = @custom_dir + "main_lockfile_hash" #: Pathname
@last_updated_path = @custom_dir + "last_updated" #: Pathname
@error_path = @custom_dir + "install_error" #: Pathname
@already_composed_path = @custom_dir + "bundle_is_composed" #: Pathname
dependencies, bundler_version = load_dependencies
@dependencies = dependencies #: Hash[String, untyped]
@bundler_version = bundler_version #: Gem::Version?
@rails_app = rails_app? #: bool
@retry = false #: bool
@needs_update_path = @custom_dir + "needs_update" #: Pathname
end
# Sets up the composed bundle and returns the `BUNDLE_GEMFILE`, `BUNDLE_PATH` and `BUNDLE_APP_CONFIG` that should be
# used for running the server
#: -> Hash[String, String]
def setup!
raise BundleNotLocked if !@launcher && @gemfile&.exist? && !@lockfile&.exist?
# If the bundle was composed ahead of time using our custom `rubyLsp/composeBundle` request, then we can skip the
# entire process and just return the composed environment
if @already_composed_path.exist?
$stderr.puts("Ruby LSP> Composed bundle was set up ahead of time. Skipping...")
@already_composed_path.delete
env = bundler_settings_as_env
env["BUNDLE_GEMFILE"] = @custom_gemfile.exist? ? @custom_gemfile.to_s : @gemfile.to_s
if env["BUNDLE_PATH"]
env["BUNDLE_PATH"] = File.expand_path(env["BUNDLE_PATH"], @project_path)
end
env["BUNDLER_VERSION"] = @bundler_version.to_s if @bundler_version
return env
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?
# Do not set up a composed 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 composed bundle setup since LSP dependencies are already in #{@gemfile}",
)
return run_bundle_install
end
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
if @lockfile_hash && @custom_lockfile.exist? && @lockfile_hash_path.exist? &&
@lockfile_hash_path.read == @lockfile_hash
$stderr.puts(
"Ruby LSP> Skipping composed 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(@lockfile_hash)
run_bundle_install(@custom_gemfile)
end
private
#: -> Hash[String, untyped]
def composed_bundle_dependencies
@composed_bundle_dependencies ||= begin
original_bundle_gemfile = ENV["BUNDLE_GEMFILE"]
if @custom_lockfile.exist?
ENV["BUNDLE_GEMFILE"] = @custom_gemfile.to_s
Bundler::LockfileParser.new(@custom_lockfile.read).dependencies
else
{}
end
ensure
ENV["BUNDLE_GEMFILE"] = original_bundle_gemfile
end #: Hash[String, untyped]?
end
#: -> 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 composed 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? && @lockfile&.exist?
gemfile_path = @gemfile.relative_path_from(@custom_dir.realpath)
parts << "eval_gemfile(File.expand_path(\"#{gemfile_path}\", __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"]
# The `mri` platform excludes Windows. We want to install the debug gem only on MRI for any operating system,
# but that constraint doesn't yet exist in Bundler. On Windows, we are manually checking if the engine is MRI
parts << if Gem.win_platform?
'gem "debug", require: false, group: :development, install_if: -> { RUBY_ENGINE == "ruby" }'
else
'gem "debug", require: false, group: :development, platforms: :mri'
end
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
#: -> [Hash[String, untyped], Gem::Version?]
def load_dependencies
return [{}, nil] 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`
lockfile_parser = Bundler::LockfileParser.new(@lockfile.read)
dependencies = lockfile_parser.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, lockfile_parser.bundler_version]
end
#: (?Pathname? bundle_gemfile) -> 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 composed 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
# Set the specific Bundler version used by the main app. This avoids issues with Bundler restarts, which clean the
# environment and lead to the `ruby-lsp` executable not being found
if @bundler_version
env["BUNDLER_VERSION"] = @bundler_version.to_s
install_bundler_if_needed
end
return run_bundle_install_through_command(env) unless @launcher
begin
run_bundle_install_directly(env)
# If no error occurred, then clear previous errors
@error_path.delete if @error_path.exist?
$stderr.puts("Ruby LSP> Composed bundle installation complete")
rescue Errno::EPIPE
# If the $stderr pipe was closed by the client, for example when closing the editor during running bundle
# install, we don't want to write the error to a file or else we will report to telemetry on the next launch and
# it does not represent an actual error.
#
# This situation may happen because while running bundle install, the server is not yet ready to receive
# shutdown requests and we may continue doing work until the process is killed.
@error_path.delete if @error_path.exist?
rescue => e
# Write the error object to a file so that we can read it from the parent process
@error_path.write(Marshal.dump(e))
end
# If either the Gemfile or the lockfile have been modified during the process of setting up the bundle, retry
# composing the bundle from scratch
if @gemfile&.exist? && @lockfile&.exist?
current_gemfile_hash = Digest::SHA256.hexdigest(@gemfile.read)
current_lockfile_hash = Digest::SHA256.hexdigest(@lockfile.read)
if !@retry && (current_gemfile_hash != @gemfile_hash || current_lockfile_hash != @lockfile_hash)
@gemfile_hash = current_gemfile_hash
@lockfile_hash = current_lockfile_hash
@retry = true
@custom_dir.rmtree
$stderr.puts("Ruby LSP> Bundle was modified during setup. Retrying from scratch...")
return setup!
end
end
env
end
#: (Hash[String, String] env, ?force_install: bool) -> Hash[String, String]
def run_bundle_install_directly(env, force_install: false)
RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
return update(env) if @needs_update_path.exist?
# The ENV can only be merged after checking if an update is required because we depend on the original value of
# ENV["BUNDLE_GEMFILE"], which gets overridden after the merge
FileUtils.touch(@needs_update_path) if should_bundle_update?
ENV.merge!(env)
$stderr.puts("Ruby LSP> Checking if the composed bundle is satisfied...")
missing_gems = bundle_check
unless missing_gems.empty?
$stderr.puts(<<~MESSAGE)
Ruby LSP> Running bundle install because the following gems are not installed:
#{missing_gems.map { |g| "#{g.name}: #{g.version}" }.join("\n")}
MESSAGE
bundle_install
end
$stderr.puts("Ruby LSP> Bundle already satisfied")
env
rescue => e
$stderr.puts("Ruby LSP> Running bundle install because #{e.message}")
bundle_install
env
end
# Essentially the same as bundle check, but simplified
#: -> Array[Gem::Specification]
def bundle_check
definition = Bundler.definition
definition.validate_runtime!
definition.check!
definition.missing_specs
end
#: -> void
def bundle_install
Bundler::CLI::Install.new({ "no-cache" => true }).run
correct_relative_remote_paths if @custom_lockfile.exist?
end
#: (Hash[String, String]) -> Hash[String, String]
def update(env)
# Try to auto upgrade the gems we depend on, unless they are in the Gemfile as that would result in undesired
# source control changes
gems = ["ruby-lsp", "debug", "prism"].reject { |dep| @dependencies[dep] }
gems << "ruby-lsp-rails" if @rails_app && !@dependencies["ruby-lsp-rails"]
Bundler::CLI::Update.new({ conservative: true }, gems).run
correct_relative_remote_paths if @custom_lockfile.exist?
@needs_update_path.delete
@last_updated_path.write(Time.now.iso8601)
env
end
#: (Hash[String, String] env) -> Hash[String, String]
def run_bundle_install_through_command(env)
# 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 composed 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)`
bundler_path = File.join(Gem.default_bindir, "bundle")
base_command = (!Gem.win_platform? && File.exist?(bundler_path) ? "#{Gem.ruby} #{bundler_path}" : "bundle").dup
if env["BUNDLER_VERSION"]
base_command << " _#{env["BUNDLER_VERSION"]}_"
end
command = +"(#{base_command} 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 << " && #{base_command} 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.delete_suffix!(" ")
command << ")"
@last_updated_path.write(Time.now.iso8601)
end
command << " || #{base_command} 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 composed 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 composed 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_gemfile.exist?
@retry = true
@custom_dir.rmtree
$stderr.puts("Ruby LSP> Running bundle install failed. Trying to re-generate the composed 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
#: -> 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
# List of Bundler settings that don't make sense for the composed bundle and are better controlled manually by the
# user
ignored_settings = ["bin", "cache_all", "cache_all_platforms"]
# 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
.reject { |setting| ignored_settings.include?(setting) }
.to_h do |e|
key = settings.key_for(e)
value = Array(settings[e]).join(":").tr(" ", ":")
[key, value]
end
end
#: -> void
def install_bundler_if_needed
# Try to find the bundler version specified in the lockfile in installed gems. If not found, install it
requirement = Gem::Requirement.new(@bundler_version.to_s)
return if Gem::Specification.any? { |s| s.name == "bundler" && requirement =~ s.version }
Gem.install("bundler", @bundler_version.to_s)
end
#: -> bool
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 composed lockfile doesn't include `ruby-lsp`, `ruby-lsp-rails` or `debug`, we need to run bundle
# install before updating
return false if composed_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 composed lockfile doesn't include `ruby-lsp` or `debug`, we need to run bundle install before updating
return false if composed_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
#: -> void
def correct_relative_remote_paths
content = @custom_lockfile.read
content.gsub!(/remote: (.*)/) do |match|
last_match = Regexp.last_match #: as !nil
path = 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 && !URI(path).scheme
bundle_dir = @gemfile #: as !nil
.dirname
"remote: #{File.expand_path(path, bundle_dir)}"
else
match
end
rescue URI::InvalidURIError, URI::InvalidComponentError
# If the path raises an invalid error, it might be a git ssh path, which indeed isn't a URI
match
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`
#: -> bool
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
#: -> void
def patch_thor_to_print_progress_to_stderr!
return unless defined?(Bundler::Thor::Shell::Basic)
Bundler::Thor::Shell::Basic.prepend(ThorPatch)
Bundler.ui.level = :info
end
end
end