Skip to content

Commit

Permalink
Use Bundler CLI directly and send errors to telemetry
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Oct 29, 2024
1 parent b7dc6d2 commit be7f01c
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 99 deletions.
86 changes: 58 additions & 28 deletions exe/ruby-lsp-launcher
Original file line number Diff line number Diff line change
Expand Up @@ -16,58 +16,78 @@ headers = $stdin.gets("\r\n\r\n")
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
raw_initialize = $stdin.read(content_length)

bundle_gemfile_file = File.join(".ruby-lsp", "bundle_gemfile")
locked_bundler_version_file = File.join(".ruby-lsp", "locked_bundler_version")

# Compose the Ruby LSP bundle in a forked process so that we can require gems without polluting the main process
# `$LOAD_PATH` and `Gem.loaded_specs`. Windows doesn't support forking, so we need a separate path to support it
pid = if Gem.win_platform?
spawn(Gem.ruby, File.expand_path("../lib/ruby_lsp/scripts/compose_bundle_windows.rb", __dir__), raw_initialize)
# Since we can't fork on Windows and spawn won't carry over the existing load paths, we need to explicitly pass that
# down to the child process or else requiring gems during composing the bundle will fail
load_path = $LOAD_PATH.flat_map do |path|
["-I", File.expand_path(path)]
end

Process.spawn(
Gem.ruby,
*load_path,
File.expand_path("../lib/ruby_lsp/scripts/compose_bundle_windows.rb", __dir__),
raw_initialize,
)
else
fork do
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "ruby_lsp/scripts/compose_bundle"
require_relative "../lib/ruby_lsp/scripts/compose_bundle"
compose(raw_initialize)
end
end

# Wait until the composed Bundle is finished
Process.wait(pid)
begin
# Wait until the composed Bundle is finished
Process.wait(pid)
rescue Errno::ECHILD
# In theory, the child process can finish before we even get to the wait call, but that is not an error
end

begin
bundle_env_path = File.join(".ruby-lsp", "bundle_env")
# We can't require `bundler/setup` because that file prematurely exits the process if setup fails. However, we can't
# simply require bundler either because the version required might conflict with the one locked in the composed
# bundle. We need the composed bundle sub-process to inform us of the locked Bundler version, so that we can then
# activate the right spec and require the exact Bundler version required by the app
if File.exist?(locked_bundler_version_file)
locked_bundler_version = File.read(locked_bundler_version_file)
Gem::Specification.find_by_name("bundler", locked_bundler_version).activate
end
if File.exist?(bundle_env_path)
env = File.readlines(bundle_env_path).to_h { |line| line.chomp.split("=", 2) }
ENV.merge!(env)

require "bundler"
Bundler.ui.level = :silent
if env["BUNDLER_VERSION"]
Gem::Specification.find_by_name("bundler", env["BUNDLER_VERSION"]).activate
end

# The composed bundle logic informs the main process about which Gemfile to setup Bundler with
if File.exist?(bundle_gemfile_file)
ENV["BUNDLE_GEMFILE"] = File.read(bundle_gemfile_file)
require "bundler"
Bundler.ui.level = :silent
Bundler.setup
$stderr.puts("Composed Bundle set up successfully")
end
rescue StandardError => e
# If installing gems failed for any reason, we don't want to exit the process prematurely. We can still provide most
# features in a degraded mode. We simply save the error so that we can report to the user that certain gems might be
# missing, but we respect the LSP life cycle
setup_error = e

# If we failed to set up the bundle, the minimum we need is to have our own dependencies activated so that we can
# require the gems the LSP depends on. If even that fails, then there's no way we can continue to run the language
# server
Gem::Specification.find_by_name("ruby-lsp").activate
$stderr.puts("Failed to set up composed Bundle\n#{e.full_message}")

# If Bundler.setup fails, we need to restore the original $LOAD_PATH so that we can still require the Ruby LSP server
# in degraded mode
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
ensure
require "fileutils"
FileUtils.rm(bundle_env_path) if File.exist?(bundle_env_path)
end

# Now that the bundle is set up, we can begin actually launching the server
error_path = File.join(".ruby-lsp", "install_error")

$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
install_error = if File.exist?(error_path)
Marshal.load(File.read(error_path))
end

# Now that the bundle is set up, we can begin actually launching the server. Note that `Bundler.setup` will have already
# configured the load path using the version of the Ruby LSP present in the composed bundle. Do not push any Ruby LSP
# paths into the load path manually or we may end up requiring the wrong version of the gem
require "ruby_lsp/load_sorbet"
require "ruby_lsp/internal"

Expand All @@ -91,7 +111,17 @@ $> = $stderr

initialize_request = JSON.parse(raw_initialize, symbolize_names: true) if raw_initialize

RubyLsp::Server.new(
setup_error: setup_error,
initialize_request: initialize_request,
).start
begin
RubyLsp::Server.new(
install_error: install_error,
setup_error: setup_error,
initialize_request: initialize_request,
).start
rescue ArgumentError
# If the launcher is booting an outdated version of the server, then the initializer doesn't accept a keyword splat
# and we already read the initialize request from the stdin pipe. In this case, we need to process the initialize
# request manually and then start the main loop
server = RubyLsp::Server.new
server.process_message(initialize_request)
server.start
end
1 change: 1 addition & 0 deletions lib/ruby_lsp/base_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class BaseServer
def initialize(**options)
@test_mode = T.let(options[:test_mode], T.nilable(T::Boolean))
@setup_error = T.let(options[:setup_error], T.nilable(StandardError))
@install_error = T.let(options[:install_error], T.nilable(StandardError))
@writer = T.let(Transport::Stdio::Writer.new, Transport::Stdio::Writer)
@reader = T.let(Transport::Stdio::Reader.new, Transport::Stdio::Reader)
@incoming_queue = T.let(Thread::Queue.new, Thread::Queue)
Expand Down
15 changes: 13 additions & 2 deletions lib/ruby_lsp/global_state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class GlobalState
sig { returns(TypeInferrer) }
attr_reader :type_inferrer

sig { returns(T.nilable(String)) }
attr_reader :top_level_bundle

sig { void }
def initialize
@workspace_uri = T.let(URI::Generic.from_path(path: Dir.pwd), URI::Generic)
Expand All @@ -46,6 +49,14 @@ def initialize
@addon_settings = T.let({}, T::Hash[String, T.untyped])
@supports_request_delegation = T.let(false, T::Boolean)
@supported_resource_operations = T.let([], T::Array[String])
@top_level_bundle = T.let(
begin
Bundler.with_original_env { Bundler.default_gemfile.to_s }
rescue Bundler::GemfileNotFound, Bundler::GitError
nil
end,
T.nilable(String),
)
end

sig { params(addon_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
Expand Down Expand Up @@ -240,15 +251,15 @@ def gather_direct_dependencies

sig { returns(T::Array[String]) }
def gemspec_dependencies
Bundler.locked_gems.sources
(Bundler.locked_gems&.sources || [])
.grep(Bundler::Source::Gemspec)
.flat_map { _1.gemspec&.dependencies&.map(&:name) }
end

sig { returns(T::Array[String]) }
def gather_direct_and_indirect_dependencies
Bundler.with_original_env { Bundler.default_gemfile }
Bundler.locked_gems.specs.map(&:name)
Bundler.locked_gems&.specs&.map(&:name) || []
rescue Bundler::GemfileNotFound
[]
end
Expand Down
12 changes: 11 additions & 1 deletion lib/ruby_lsp/requests/support/rubocop_runner.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
# typed: strict
# frozen_string_literal: true

# If there's no top level Gemfile, don't load RuboCop from a global installation
begin
Bundler.with_original_env { Bundler.default_gemfile }
rescue Bundler::GemfileNotFound
return
end

# Ensure that RuboCop is available
begin
require "rubocop"
rescue LoadError
return
end

# Ensure that RuboCop is at least version 1.4.0
begin
gem("rubocop", ">= 1.4.0")
rescue LoadError
raise StandardError, "Incompatible RuboCop version. Ruby LSP requires >= 1.4.0"
$stderr.puts "Incompatible RuboCop version. Ruby LSP requires >= 1.4.0"
return
end

if RuboCop.const_defined?(:LSP) # This condition will be removed when requiring RuboCop >= 1.61.
Expand Down
10 changes: 6 additions & 4 deletions lib/ruby_lsp/scripts/compose_bundle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@
# frozen_string_literal: true

def compose(raw_initialize)
require "ruby_lsp/setup_bundler"
require_relative "../setup_bundler"
require "json"
require "uri"
require "core_ext/uri"
require_relative "../../core_ext/uri"

initialize_request = JSON.parse(raw_initialize, symbolize_names: true)
workspace_uri = initialize_request.dig(:params, :workspaceFolders, 0, :uri)
workspace_path = workspace_uri && URI(workspace_uri).to_standardized_path
workspace_path ||= Dir.pwd

env = RubyLsp::SetupBundler.new(workspace_path, launcher: true).setup!
File.write(File.join(".ruby-lsp", "bundle_gemfile"), env["BUNDLE_GEMFILE"])
File.write(File.join(".ruby-lsp", "locked_bundler_version"), env["BUNDLER_VERSION"]) if env["BUNDLER_VERSION"]
File.write(
File.join(".ruby-lsp", "bundle_env"),
env.map { |k, v| "#{k}=#{v}" }.join("\n"),
)
end
1 change: 0 additions & 1 deletion lib/ruby_lsp/scripts/compose_bundle_windows.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# typed: strict
# frozen_string_literal: true

$LOAD_PATH.unshift(File.expand_path("../../../lib", __dir__))
require_relative "compose_bundle"

# When this is invoked on Windows, we pass the raw initialize as an argument to this script. On other platforms, we
Expand Down
60 changes: 37 additions & 23 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ def process_message(message)

sig { params(include_project_addons: T::Boolean).void }
def load_addons(include_project_addons: true)
# If invoking Bundler.setup failed, then the load path will not be configured properly and trying to load add-ons
# with Gem.find_files will find every single version installed of an add-on, leading to requiring several
# different versions of the same files. We cannot load add-ons if Bundler.setup failed
return if @setup_error

errors = Addon.load_addons(@global_state, @outgoing_queue, include_project_addons: include_project_addons)

if errors.any?
Expand Down Expand Up @@ -292,15 +297,21 @@ def run_initialize(message)
global_state_notifications.each { |notification| send_message(notification) }

if @setup_error
message = <<~MESSAGE
An error occurred while setting up Bundler. This may be due to a failure when installing dependencies.
The Ruby LSP will continue to run, but features related to the missing dependencies will be limited.
Error:
#{@setup_error.full_message}
MESSAGE
send_message(Notification.telemetry(
type: "error",
errorMessage: @setup_error.message,
errorClass: @setup_error.class,
stack: @setup_error.backtrace&.join("\n"),
))
end

send_message(Notification.window_log_message(message, type: Constant::MessageType::ERROR))
if @install_error
send_message(Notification.telemetry(
type: "error",
errorMessage: @install_error.message,
errorClass: @install_error.class,
stack: @install_error.backtrace&.join("\n"),
))
end
end

Expand All @@ -309,20 +320,22 @@ def run_initialized
load_addons
RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)

if defined?(Requests::Support::RuboCopFormatter)
begin
@global_state.register_formatter("rubocop", Requests::Support::RuboCopFormatter.new)
rescue RuboCop::Error => e
# The user may have provided unknown config switches in .rubocop or
# is trying to load a non-existent config file.
send_message(Notification.window_show_message(
"RuboCop configuration error: #{e.message}. Formatting will not be available.",
type: Constant::MessageType::ERROR,
))
unless @setup_error
if defined?(Requests::Support::RuboCopFormatter)
begin
@global_state.register_formatter("rubocop", Requests::Support::RuboCopFormatter.new)
rescue RuboCop::Error => e
# The user may have provided unknown config switches in .rubocop or
# is trying to load a non-existent config file.
send_message(Notification.window_show_message(
"RuboCop configuration error: #{e.message}. Formatting will not be available.",
type: Constant::MessageType::ERROR,
))
end
end
if defined?(Requests::Support::SyntaxTreeFormatter)
@global_state.register_formatter("syntax_tree", Requests::Support::SyntaxTreeFormatter.new)
end
end
if defined?(Requests::Support::SyntaxTreeFormatter)
@global_state.register_formatter("syntax_tree", Requests::Support::SyntaxTreeFormatter.new)
end

perform_initial_indexing
Expand Down Expand Up @@ -1029,7 +1042,7 @@ def type_hierarchy_subtypes(message)

sig { params(message: T::Hash[Symbol, T.untyped]).void }
def workspace_dependencies(message)
response = begin
response = if @global_state.top_level_bundle
Bundler.with_original_env do
definition = Bundler.definition
dep_keys = definition.locked_deps.keys.to_set
Expand All @@ -1043,7 +1056,7 @@ def workspace_dependencies(message)
}
end
end
rescue Bundler::GemfileNotFound, Bundler::GitError
else
[]
end

Expand Down Expand Up @@ -1150,6 +1163,7 @@ def end_progress(id)

sig { void }
def check_formatter_is_available
return if @setup_error
# Warn of an unavailable `formatter` setting, e.g. `rubocop` on a project which doesn't have RuboCop.
# Syntax Tree will always be available via Ruby LSP so we don't need to check for it.
return unless @global_state.formatter == "rubocop"
Expand Down
Loading

0 comments on commit be7f01c

Please sign in to comment.