# 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, options: T.untyped).void } def initialize(project_path, **options) @project_path = project_path @branch = T.let(options[:branch], T.nilable(String)) @experimental = T.let(options[:experimental], T.nilable(T::Boolean)) # 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)) @gemfile_name = T.let(@gemfile&.basename&.to_s || "Gemfile", String) # Custom bundle paths @custom_dir = T.let(Pathname.new(".ruby-lsp").expand_path(Dir.pwd), Pathname) @custom_gemfile = T.let(@custom_dir + @gemfile_name, Pathname) @custom_lockfile = T.let(@custom_dir + (@lockfile&.basename || "Gemfile.lock"), Pathname) @lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname) @last_updated_path = T.let(@custom_dir + "last_updated", 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_name}\", __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 << "--pre" if @experimental command.delete_suffix!(" ") 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...") warn("Ruby LSP> Command: #{command}") 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