diff --git a/lib/ruby_lsp/tapioca/addon.rb b/lib/ruby_lsp/tapioca/addon.rb index b3d17f0c2..d15835dc2 100644 --- a/lib/ruby_lsp/tapioca/addon.rb +++ b/lib/ruby_lsp/tapioca/addon.rb @@ -13,6 +13,7 @@ end require "zlib" +require "ruby_lsp/tapioca/run_gem_rbi_check" module RubyLsp module Tapioca @@ -27,6 +28,7 @@ def initialize @rails_runner_client = T.let(nil, T.nilable(RubyLsp::Rails::RunnerClient)) @index = T.let(nil, T.nilable(RubyIndexer::Index)) @file_checksums = T.let({}, T::Hash[String, String]) + @lockfile_diff = T.let(nil, T.nilable(String)) @outgoing_queue = T.let(nil, T.nilable(Thread::Queue)) end @@ -50,6 +52,15 @@ def activate(global_state, outgoing_queue) request_name: "load_compilers_and_extensions", workspace_path: @global_state.workspace_path, ) + + gem_rbi_check_result = RunGemRbiCheck.new.run + @outgoing_queue << Notification.window_log_message( + gem_rbi_check_result.stdout, + ) unless gem_rbi_check_result.stdout.empty? + @outgoing_queue << Notification.window_log_message( + gem_rbi_check_result.stderr, + type: Constant::MessageType::WARNING, + ) unless gem_rbi_check_result.stderr.empty? rescue IncompatibleApiError # The requested version for the Rails add-on no longer matches. We need to upgrade and fix the breaking # changes diff --git a/lib/ruby_lsp/tapioca/run_gem_rbi_check.rb b/lib/ruby_lsp/tapioca/run_gem_rbi_check.rb new file mode 100644 index 000000000..2d2eb035a --- /dev/null +++ b/lib/ruby_lsp/tapioca/run_gem_rbi_check.rb @@ -0,0 +1,124 @@ +# typed: true +# frozen_string_literal: true + +require "ruby_lsp/tapioca/lockfile_diff_parser" + +module RubyLsp + module Tapioca + class GemRbiCheckResult < T::Struct + prop :stdout, String + prop :stderr, String + prop :status, T.nilable(Process::Status) + end + + class RunGemRbiCheck + extend T::Sig + + sig { void } + def initialize + @result = T.let( + GemRbiCheckResult.new(stdout: "", stderr: "", status: nil), + GemRbiCheckResult, + ) + end + + attr_reader :result + + sig { params(project_path: String).returns(GemRbiCheckResult) } + def run(project_path = ".") + FileUtils.chdir(project_path) do + next log_message("Not a git repository") unless git_repo? + + lockfile_changed? ? generate_gem_rbis : cleanup_orphaned_rbis + end + + @result + end + + private + + sig { returns(T::Boolean) } + def git_repo? + require "open3" + + _, status = Open3.capture2e("git rev-parse --is-inside-work-tree") + T.must(status.success?) + end + + sig { returns(T::Boolean) } + def lockfile_changed? + fetch_lockfile_diff + !@lockfile_diff.empty? + end + + sig { returns(String) } + def fetch_lockfile_diff + @lockfile_diff = File.exist?("Gemfile.lock") ? %x(git diff Gemfile.lock).strip : "" + end + + sig { void } + def generate_gem_rbis + parser = Tapioca::LockfileDiffParser.new(@lockfile_diff) + removed_gems = parser.removed_gems + added_or_modified_gems = parser.added_or_modified_gems + + if added_or_modified_gems.any? + log_message("Identified lockfile changes, attempting to generate gem RBIs...") + execute_tapioca_gem_command(added_or_modified_gems) + elsif removed_gems.any? + remove_rbis(removed_gems) + end + end + + sig { params(gems: T::Array[String]).void } + def execute_tapioca_gem_command(gems) + Bundler.with_unbundled_env do + stdout, stderr, status = T.unsafe(Open3).capture3( + "bundle", + "exec", + "tapioca", + "gem", + "--lsp_addon", + *gems, + ) + + log_message(stdout) unless stdout.empty? + log_message(stderr) unless stderr.empty? + @result.status = status + end + end + + sig { params(gems: T::Array[String]).void } + def remove_rbis(gems) + FileUtils.rm_f(Dir.glob("sorbet/rbi/gems/{#{gems.join(",")}}@*.rbi")) + log_message("Removed RBIs for: #{gems.join(", ")}") + end + + sig { void } + def cleanup_orphaned_rbis + untracked_files = %x(git ls-files --others --exclude-standard sorbet/rbi/gems/).lines.map(&:strip) + deleted_files = %x(git ls-files --deleted sorbet/rbi/gems/).lines.map(&:strip) + + delete_files(untracked_files, "Deleted untracked RBIs") + restore_files(deleted_files, "Restored deleted RBIs") + end + + sig { params(files: T::Array[String], message: String).void } + def delete_files(files, message) + files.each { |file| File.delete(file) } + log_message("#{message}: #{files.join(", ")}") unless files.empty? + end + + sig { params(files: T::Array[String], message: String).void } + def restore_files(files, message) + files.each { |file| %x(git checkout -- #{file}) } + log_message("#{message}: #{files.join(", ")}") unless files.empty? + end + + sig { params(message: String).void } + def log_message(message) + @result.stdout += "#{message}\n" + end + end + end +end diff --git a/spec/helpers/mock_gem.rb b/spec/helpers/mock_gem.rb index 1aa278fdd..2af71fcf4 100644 --- a/spec/helpers/mock_gem.rb +++ b/spec/helpers/mock_gem.rb @@ -66,5 +66,11 @@ def default_gemspec_contents end GEMSPEC end + + sig { params(version: String).void } + def update(version) + @version = version + gemspec(default_gemspec_contents) + end end end diff --git a/spec/spec_with_project.rb b/spec/spec_with_project.rb index 3413914a5..1b4641be9 100644 --- a/spec/spec_with_project.rb +++ b/spec/spec_with_project.rb @@ -84,6 +84,12 @@ def mock_gem(name, version, dependencies: [], path: default_gem_path(name), &blo gem end + sig { params(gem: MockGem, version: String).returns(MockGem) } + def update_mock_gem(gem, version) + gem.update(version) + gem + end + # Spec assertions # Assert that the contents of `path` inside `@project` is equals to `expected` diff --git a/spec/tapioca/ruby_lsp/run_gem_rbi_check_spec.rb b/spec/tapioca/ruby_lsp/run_gem_rbi_check_spec.rb new file mode 100644 index 000000000..cbeb16e74 --- /dev/null +++ b/spec/tapioca/ruby_lsp/run_gem_rbi_check_spec.rb @@ -0,0 +1,144 @@ +# typed: true +# frozen_string_literal: true + +require "spec_helper" +require "ruby_lsp/tapioca/run_gem_rbi_check" + +module Tapioca + module RubyLsp + class RunGemRbiCheckSpec < SpecWithProject + FOO_RB = <<~RUBY + module Foo + end + RUBY + + before(:all) do + @project = mock_project + end + + describe "without git" do + before do + @project.bundle_install! + end + + it "does nothing if there is no git repo" do + foo = mock_gem("foo", "0.0.1") do + write!("lib/foo.rb", FOO_RB) + end + @project.require_mock_gem(foo) + + @project.bundle_install! + check = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check.run(@project.absolute_path) + + assert check.result.stdout.include?("Not a git repository") + end + end + + describe "with git" do + before do + @project.write!("Gemfile", @project.tapioca_gemfile) + @project.bundle_install! + @project.exec("git init") + @project.exec("git add .") + @project.exec("git commit -m 'Initial commit'") + end + + after do + @project.remove!("sorbet/rbi") + @project.remove!(".git") + @project.remove!("Gemfile") + @project.remove!("Gemfile.lock") + end + + it "creates the RBI for a newly added gem" do + foo = mock_gem("foo", "0.0.1") do + write!("lib/foo.rb", FOO_RB) + end + @project.require_mock_gem(foo) + @project.bundle_install! + + check = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check.run(@project.absolute_path) + + assert_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + end + + it "regenerates RBI when a gem version changes" do + foo = mock_gem("foo", "0.0.1") do + write!("lib/foo.rb", FOO_RB) + end + @project.require_mock_gem(foo) + @project.bundle_install! + + check = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check.run(@project.absolute_path) + + assert_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + + # Modify the gem + update_mock_gem foo, "0.0.2" + @project.bundle_install! + + check.run(@project.absolute_path) + + assert_project_file_exist("sorbet/rbi/gems/foo@0.0.2.rbi") + end + + it "removes RBI file when a gem is removed" do + foo = mock_gem("foo", "0.0.1") do + write!("lib/foo.rb", FOO_RB) + end + @project.require_mock_gem(foo) + @project.bundle_install! + + check1 = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check1.run(@project.absolute_path) + + assert_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + + @project.exec("git restore Gemfile Gemfile.lock") + + check2 = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check2.run(@project.absolute_path) + + refute_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + end + + it "deletes untracked RBI files" do + @project.bundle_install! + FileUtils.mkdir_p("#{@project.absolute_path}/sorbet/rbi/gems") + # Create an untracked RBI file + FileUtils.touch("#{@project.absolute_path}/sorbet/rbi/gems/bar@0.0.1.rbi") + + assert_project_file_exist("/sorbet/rbi/gems/bar@0.0.1.rbi") + + check = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check.run(@project.absolute_path) + + refute_project_file_exist("sorbet/rbi/gems/bar@0.0.1.rbi") + end + + it "restores deleted RBI files" do + @project.bundle_install! + FileUtils.mkdir_p("#{@project.absolute_path}/sorbet/rbi/gems") + # Create and delete a tracked RBI file + FileUtils.touch("#{@project.absolute_path}/sorbet/rbi/gems/foo@0.0.1.rbi") + @project.exec("git add sorbet/rbi/gems/foo@0.0.1.rbi") + @project.exec("git commit -m 'Add foo RBI'") + FileUtils.rm("#{@project.absolute_path}/sorbet/rbi/gems/foo@0.0.1.rbi") + + refute_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + + check = ::RubyLsp::Tapioca::RunGemRbiCheck.new + check.run(@project.absolute_path) + + assert_project_file_exist("sorbet/rbi/gems/foo@0.0.1.rbi") + + # Clean-up commit + @project.exec("git reset --hard HEAD^") + end + end + end + end +end