From 664b8cc4e21cc645dce3b424d00188005c294039 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Thu, 24 Oct 2024 14:42:39 -0400 Subject: [PATCH] Add launch mode integration tests (#2766) --- .rubocop.yml | 2 + exe/ruby-lsp-launcher | 28 ++--- lib/ruby_lsp/global_state.rb | 4 +- lib/ruby_lsp/scripts/compose_bundle.rb | 18 +++ .../scripts/compose_bundle_windows.rb | 9 ++ lib/ruby_lsp/server.rb | 2 +- lib/ruby_lsp/setup_bundler.rb | 5 +- project-words | 4 + test/integration_test.rb | 110 ++++++++++++++++++ 9 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 lib/ruby_lsp/scripts/compose_bundle.rb create mode 100644 lib/ruby_lsp/scripts/compose_bundle_windows.rb diff --git a/.rubocop.yml b/.rubocop.yml index 0690e2bbd..2ba910dcb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -43,6 +43,7 @@ Sorbet/TrueSigil: - "lib/ruby_indexer/test/**/*.rb" - "lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb" - "lib/ruby_lsp/load_sorbet.rb" + - "lib/ruby_lsp/scripts/compose_bundle.rb" Exclude: - "**/*.rake" - "lib/**/*.rb" @@ -58,6 +59,7 @@ Sorbet/StrictSigil: - "lib/ruby-lsp.rb" - "lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb" - "lib/ruby_lsp/load_sorbet.rb" + - "lib/ruby_lsp/scripts/compose_bundle.rb" Layout/ClassStructure: Enabled: true diff --git a/exe/ruby-lsp-launcher b/exe/ruby-lsp-launcher index 661be41cb..3e8f49d9f 100755 --- a/exe/ruby-lsp-launcher +++ b/exe/ruby-lsp-launcher @@ -19,24 +19,16 @@ 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") -# Composed 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` -pid = fork do - require_relative "../lib/ruby_lsp/setup_bundler" - require "json" - require "uri" - require "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).setup! - File.write(bundle_gemfile_file, env["BUNDLE_GEMFILE"]) - File.write(locked_bundler_version_file, env["BUNDLER_VERSION"]) if env["BUNDLER_VERSION"] -rescue RubyLsp::SetupBundler::BundleNotLocked - warn("Project contains a Gemfile, but no Gemfile.lock. Run `bundle install` to lock gems and restart the server") +# 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) +else + fork do + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + require "ruby_lsp/scripts/compose_bundle" + compose(raw_initialize) + end end # Wait until the composed Bundle is finished diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 2478dbe80..9f2a32217 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -231,7 +231,9 @@ def dot_rubocop_yml_present sig { returns(T::Array[String]) } def gather_direct_dependencies Bundler.with_original_env { Bundler.default_gemfile } - Bundler.locked_gems.dependencies.keys + gemspec_dependencies + + dependencies = Bundler.locked_gems&.dependencies&.keys || [] + dependencies + gemspec_dependencies rescue Bundler::GemfileNotFound [] end diff --git a/lib/ruby_lsp/scripts/compose_bundle.rb b/lib/ruby_lsp/scripts/compose_bundle.rb new file mode 100644 index 000000000..d54dfd89c --- /dev/null +++ b/lib/ruby_lsp/scripts/compose_bundle.rb @@ -0,0 +1,18 @@ +# typed: true +# frozen_string_literal: true + +def compose(raw_initialize) + require "ruby_lsp/setup_bundler" + require "json" + require "uri" + require "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"] +end diff --git a/lib/ruby_lsp/scripts/compose_bundle_windows.rb b/lib/ruby_lsp/scripts/compose_bundle_windows.rb new file mode 100644 index 000000000..3be82ad01 --- /dev/null +++ b/lib/ruby_lsp/scripts/compose_bundle_windows.rb @@ -0,0 +1,9 @@ +# 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 +# invoke the compose method from inside a forked process +compose(ARGV.first) diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 5ff0d53a0..9fe2172ff 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -1043,7 +1043,7 @@ def workspace_dependencies(message) } end end - rescue Bundler::GemfileNotFound + rescue Bundler::GemfileNotFound, Bundler::GitError [] end diff --git a/lib/ruby_lsp/setup_bundler.rb b/lib/ruby_lsp/setup_bundler.rb index 2841c922e..11e5a6e47 100644 --- a/lib/ruby_lsp/setup_bundler.rb +++ b/lib/ruby_lsp/setup_bundler.rb @@ -27,6 +27,7 @@ class BundleInstallFailure < StandardError; end def initialize(project_path, **options) @project_path = project_path @branch = T.let(options[:branch], T.nilable(String)) + @launcher = T.let(options[:launcher], T.nilable(T::Boolean)) # Regular bundle paths @gemfile = T.let( @@ -59,7 +60,7 @@ def initialize(project_path, **options) # used for running the server sig { returns(T::Hash[String, String]) } def setup! - raise BundleNotLocked if @gemfile&.exist? && !@lockfile&.exist? + raise BundleNotLocked if !@launcher && @gemfile&.exist? && !@lockfile&.exist? # Automatically create and ignore the .ruby-lsp folder for users @custom_dir.mkpath unless @custom_dir.exist? @@ -129,7 +130,7 @@ def write_custom_gemfile # 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? + if @gemfile&.exist? && @lockfile&.exist? parts << "eval_gemfile(File.expand_path(\"../#{@gemfile_name}\", __dir__))" else parts.unshift('source "https://rubygems.org"') diff --git a/project-words b/project-words index 452631a20..f62fbf35c 100644 --- a/project-words +++ b/project-words @@ -4,11 +4,13 @@ autoloaded autorun bigdecimal bindir +binmode binread Bizt Bizw bufnr binstub +bytesize byteslice codepoint codepoints @@ -19,6 +21,7 @@ dont eglot Eglot eruby +exitstatus EXTGLOB FIXEDENCODING Floo @@ -47,6 +50,7 @@ nargs nodoc noreturn nvim +popen qtlzwssomeking quickfixes quxx diff --git a/test/integration_test.rb b/test/integration_test.rb index 37c3c3445..939d2af33 100644 --- a/test/integration_test.rb +++ b/test/integration_test.rb @@ -78,8 +78,118 @@ def test_avoids_bundler_version_if_local_bin_is_in_path end end + def test_launch_mode_with_no_gemfile + skip("CI only") unless ENV["CI"] + + in_temp_dir do |dir| + Bundler.with_unbundled_env do + launch(dir) + end + end + end + + def test_launch_mode_with_missing_lockfile + skip("CI only") unless ENV["CI"] + + in_temp_dir do |dir| + File.write(File.join(dir, "Gemfile"), <<~RUBY) + source "https://rubygems.org" + gem "stringio" + RUBY + + Bundler.with_unbundled_env do + launch(dir) + end + end + end + + def test_launch_mode_with_full_bundle + skip("CI only") unless ENV["CI"] + + in_temp_dir do |dir| + File.write(File.join(dir, "Gemfile"), <<~RUBY) + source "https://rubygems.org" + gem "stringio" + RUBY + + lockfile_contents = <<~LOCKFILE + GEM + remote: https://rubygems.org/ + specs: + stringio (3.1.0) + + PLATFORMS + arm64-darwin-23 + ruby + + DEPENDENCIES + stringio + + BUNDLED WITH + 2.5.7 + LOCKFILE + File.write(File.join(dir, "Gemfile.lock"), lockfile_contents) + + Bundler.with_unbundled_env do + launch(dir) + end + end + end + + def test_launch_mode_with_no_gemfile_and_bundle_path + skip("CI only") unless ENV["CI"] + + in_temp_dir do |dir| + Bundler.with_unbundled_env do + system("bundle config --local path #{File.join("vendor", "bundle")}") + assert_path_exists(File.join(dir, ".bundle", "config")) + + launch(dir) + end + end + end + private + def launch(workspace_path) + initialize_request = { + id: 1, + method: "initialize", + params: { + initializationOptions: {}, + capabilities: { general: { positionEncodings: ["utf-8"] } }, + workspaceFolders: [{ uri: URI::Generic.from_path(path: workspace_path).to_s }], + }, + }.to_json + + $stdin.expects(:gets).with("\r\n\r\n").once.returns("Content-Length: #{initialize_request.bytesize}") + $stdin.expects(:read).with(initialize_request.bytesize).once.returns(initialize_request) + + # Make `new` return a mock that raises so that we don't print to stdout and stop immediately after boot + server_object = mock("server") + server_object.expects(:start).once.raises(StandardError.new("stop")) + RubyLsp::Server.expects(:new).returns(server_object) + + # We load the launcher binary in the same process as the tests are running. We cannot try to re-activate a different + # Bundler version, because that throws an error + if File.exist?(File.join(workspace_path, "Gemfile.lock")) + spec_mock = mock("specification") + spec_mock.expects(:activate).once + Gem::Specification.expects(:find_by_name).with do |name, version| + name == "bundler" && !version.empty? + end.returns(spec_mock) + end + + # Verify that we are setting up the bundle, but there's no actual need to do it + Bundler.expects(:setup).once + + assert_raises(StandardError) do + load(File.expand_path("../exe/ruby-lsp-launcher", __dir__)) + end + + assert_path_exists(File.join(workspace_path, ".ruby-lsp", "bundle_gemfile")) + end + def in_temp_dir Dir.mktmpdir do |dir| Dir.chdir(dir) do