Skip to content

Commit

Permalink
Add Tapioca Addon gem RBI generation support
Browse files Browse the repository at this point in the history
To support gem RBI generation, we needed a way to detect changes in
Gemfile.lock. Currently, changes to this file cause the Ruby LSP to
restart, resulting in loss of access to any previous state information.

By running git diff on Gemfile.lock, we can detect changes to the file,
and trigger the gem RBI generation process.

When changes are detected, we parse the output of git diff to identify
added, modified, or removed gems. We then execute `tapioca gem` command
for added, or modified gems, and remove rbi files for removed gems.
  • Loading branch information
alexcrocha committed Jan 24, 2025
1 parent 2bc5c13 commit cc00fed
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 0 deletions.
11 changes: 11 additions & 0 deletions lib/ruby_lsp/tapioca/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
end

require "zlib"
require "ruby_lsp/tapioca/run_gem_rbi_check"

module RubyLsp
module Tapioca
Expand All @@ -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

Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions lib/ruby_lsp/tapioca/run_gem_rbi_check.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions spec/helpers/mock_gem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions spec/spec_with_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
144 changes: 144 additions & 0 deletions spec/tapioca/ruby_lsp/run_gem_rbi_check_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit cc00fed

Please sign in to comment.