Skip to content

Commit f902a2d

Browse files
committed
Add Tapioca Addon gem RBI generation support
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.
1 parent 2bc5c13 commit f902a2d

File tree

4 files changed

+281
-0
lines changed

4 files changed

+281
-0
lines changed

lib/ruby_lsp/tapioca/addon.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
end
1414

1515
require "zlib"
16+
require "ruby_lsp/tapioca/run_gem_rbi_check"
1617

1718
module RubyLsp
1819
module Tapioca
@@ -27,6 +28,7 @@ def initialize
2728
@rails_runner_client = T.let(nil, T.nilable(RubyLsp::Rails::RunnerClient))
2829
@index = T.let(nil, T.nilable(RubyIndexer::Index))
2930
@file_checksums = T.let({}, T::Hash[String, String])
31+
@lockfile_diff = T.let(nil, T.nilable(String))
3032
@outgoing_queue = T.let(nil, T.nilable(Thread::Queue))
3133
end
3234

@@ -50,6 +52,8 @@ def activate(global_state, outgoing_queue)
5052
request_name: "load_compilers_and_extensions",
5153
workspace_path: @global_state.workspace_path,
5254
)
55+
56+
run_gem_rbi_check
5357
rescue IncompatibleApiError
5458
# The requested version for the Rails add-on no longer matches. We need to upgrade and fix the breaking
5559
# changes
@@ -132,6 +136,20 @@ def file_updated?(change, path)
132136

133137
false
134138
end
139+
140+
sig { void }
141+
def run_gem_rbi_check
142+
gem_rbi_check = RunGemRbiCheck.new
143+
gem_rbi_check.run
144+
145+
T.must(@outgoing_queue) << Notification.window_log_message(
146+
gem_rbi_check.stdout,
147+
) unless gem_rbi_check.stdout.empty?
148+
T.must(@outgoing_queue) << Notification.window_log_message(
149+
gem_rbi_check.stderr,
150+
type: Constant::MessageType::WARNING,
151+
) unless gem_rbi_check.stderr.empty?
152+
end
135153
end
136154
end
137155
end
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "open3"
5+
require "ruby_lsp/tapioca/lockfile_diff_parser"
6+
7+
module RubyLsp
8+
module Tapioca
9+
class RunGemRbiCheck
10+
extend T::Sig
11+
12+
attr_reader :stdout
13+
attr_reader :stderr
14+
attr_reader :status
15+
16+
sig { void }
17+
def initialize
18+
@stdout = T.let("", String)
19+
@stderr = T.let("", String)
20+
@status = T.let(nil, T.nilable(Process::Status))
21+
end
22+
23+
sig { params(project_path: String).void }
24+
def run(project_path = ".")
25+
FileUtils.chdir(project_path) do
26+
return log_message("Not a git repository") unless git_repo?
27+
28+
lockfile_changed? ? generate_gem_rbis : cleanup_orphaned_rbis
29+
end
30+
end
31+
32+
private
33+
34+
sig { returns(T::Boolean) }
35+
def git_repo?
36+
_, status = Open3.capture2e("git rev-parse --is-inside-work-tree")
37+
T.must(status.success?)
38+
end
39+
40+
sig { returns(T::Boolean) }
41+
def lockfile_changed?
42+
fetch_lockfile_diff
43+
!@lockfile_diff.empty?
44+
end
45+
46+
sig { returns(String) }
47+
def fetch_lockfile_diff
48+
@lockfile_diff = File.exist?("Gemfile.lock") ? %x(git diff Gemfile.lock).strip : ""
49+
end
50+
51+
sig { void }
52+
def generate_gem_rbis
53+
parser = Tapioca::LockfileDiffParser.new(@lockfile_diff)
54+
removed_gems = parser.removed_gems
55+
added_or_modified_gems = parser.added_or_modified_gems
56+
57+
if added_or_modified_gems.any?
58+
log_message("Identified lockfile changes, attempting to generate gem RBIs...")
59+
execute_tapioca_gem_command(added_or_modified_gems)
60+
elsif removed_gems.any?
61+
remove_rbis(removed_gems)
62+
end
63+
end
64+
65+
sig { params(gems: T::Array[String]).void }
66+
def execute_tapioca_gem_command(gems)
67+
Bundler.with_unbundled_env do
68+
stdout, stderr, status = T.unsafe(Open3).capture3(
69+
"bundle",
70+
"exec",
71+
"tapioca",
72+
"gem",
73+
"--lsp_addon",
74+
*gems,
75+
)
76+
77+
log_message(stdout) unless stdout.empty?
78+
@stderr = stderr unless stderr.empty?
79+
@status = status
80+
end
81+
end
82+
83+
sig { params(gems: T::Array[String]).void }
84+
def remove_rbis(gems)
85+
FileUtils.rm_f(Dir.glob("sorbet/rbi/gems/{#{gems.join(",")}}@*.rbi"))
86+
log_message("Removed RBIs for: #{gems.join(", ")}")
87+
end
88+
89+
sig { void }
90+
def cleanup_orphaned_rbis
91+
untracked_files = %x(git ls-files --others --exclude-standard sorbet/rbi/gems/).lines.map(&:strip)
92+
deleted_files = %x(git ls-files --deleted sorbet/rbi/gems/).lines.map(&:strip)
93+
94+
delete_files(untracked_files, "Deleted untracked RBIs")
95+
restore_files(deleted_files, "Restored deleted RBIs")
96+
end
97+
98+
sig { params(files: T::Array[String], message: String).void }
99+
def delete_files(files, message)
100+
files.each { |file| File.delete(file) }
101+
log_message("#{message}: #{files.join(", ")}") unless files.empty?
102+
end
103+
104+
sig { params(files: T::Array[String], message: String).void }
105+
def restore_files(files, message)
106+
files.each { |file| %x(git checkout -- #{file}) }
107+
log_message("#{message}: #{files.join(", ")}") unless files.empty?
108+
end
109+
110+
sig { params(message: String).void }
111+
def log_message(message)
112+
@stdout += "#{message}\n"
113+
end
114+
end
115+
end
116+
end

spec/helpers/mock_gem.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,11 @@ def default_gemspec_contents
6666
end
6767
GEMSPEC
6868
end
69+
70+
sig { params(version: String).void }
71+
def update(version)
72+
@version = version
73+
gemspec(default_gemspec_contents)
74+
end
6975
end
7076
end
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "spec_helper"
5+
require "ruby_lsp/tapioca/run_gem_rbi_check"
6+
7+
module Tapioca
8+
module RubyLsp
9+
class RunGemRbiCheckSpec < SpecWithProject
10+
FOO_RB = "module Foo; end"
11+
12+
before(:all) do
13+
@project = mock_project
14+
end
15+
16+
describe "without git" do
17+
before do
18+
@project.bundle_install!
19+
end
20+
21+
it "does nothing if there is no git repo" do
22+
foo = mock_gem("foo", "0.0.1") do
23+
write!("lib/foo.rb", FOO_RB)
24+
end
25+
@project.require_mock_gem(foo)
26+
27+
@project.bundle_install!
28+
check = ::RubyLsp::Tapioca::RunGemRbiCheck.new
29+
check.run(@project.absolute_path)
30+
31+
assert check.stdout.include?("Not a git repository")
32+
end
33+
end
34+
35+
describe "with git" do
36+
before do
37+
@project.write!("Gemfile", @project.tapioca_gemfile)
38+
@project.bundle_install!
39+
@project.exec("git init")
40+
@project.exec("git add .")
41+
@project.exec("git commit -m 'Initial commit'")
42+
end
43+
44+
after do
45+
@project.remove!("sorbet/rbi")
46+
@project.remove!(".git")
47+
@project.remove!("Gemfile")
48+
@project.remove!("Gemfile.lock")
49+
end
50+
51+
it "creates the RBI for a newly added gem" do
52+
foo = mock_gem("foo", "0.0.1") do
53+
write!("lib/foo.rb", FOO_RB)
54+
end
55+
@project.require_mock_gem(foo)
56+
@project.bundle_install!
57+
58+
check = ::RubyLsp::Tapioca::RunGemRbiCheck.new
59+
check.run(@project.absolute_path)
60+
61+
assert_project_file_exist("sorbet/rbi/gems/[email protected]")
62+
end
63+
64+
it "regenerates RBI when a gem version changes" do
65+
foo = mock_gem("foo", "0.0.1") do
66+
write!("lib/foo.rb", FOO_RB)
67+
end
68+
@project.require_mock_gem(foo)
69+
@project.bundle_install!
70+
71+
check = ::RubyLsp::Tapioca::RunGemRbiCheck.new
72+
check.run(@project.absolute_path)
73+
74+
assert_project_file_exist("sorbet/rbi/gems/[email protected]")
75+
76+
# Modify the gem
77+
foo.update("0.0.2")
78+
@project.bundle_install!
79+
80+
check.run(@project.absolute_path)
81+
82+
assert_project_file_exist("sorbet/rbi/gems/[email protected]")
83+
end
84+
85+
it "removes RBI file when a gem is removed" do
86+
foo = mock_gem("foo", "0.0.1") do
87+
write!("lib/foo.rb", FOO_RB)
88+
end
89+
@project.require_mock_gem(foo)
90+
@project.bundle_install!
91+
92+
check1 = ::RubyLsp::Tapioca::RunGemRbiCheck.new
93+
check1.run(@project.absolute_path)
94+
95+
assert_project_file_exist("sorbet/rbi/gems/[email protected]")
96+
97+
@project.exec("git restore Gemfile Gemfile.lock")
98+
99+
check2 = ::RubyLsp::Tapioca::RunGemRbiCheck.new
100+
check2.run(@project.absolute_path)
101+
102+
refute_project_file_exist("sorbet/rbi/gems/[email protected]")
103+
end
104+
105+
it "deletes untracked RBI files" do
106+
@project.bundle_install!
107+
FileUtils.mkdir_p("#{@project.absolute_path}/sorbet/rbi/gems")
108+
# Create an untracked RBI file
109+
FileUtils.touch("#{@project.absolute_path}/sorbet/rbi/gems/[email protected]")
110+
111+
assert_project_file_exist("/sorbet/rbi/gems/[email protected]")
112+
113+
check = ::RubyLsp::Tapioca::RunGemRbiCheck.new
114+
check.run(@project.absolute_path)
115+
116+
refute_project_file_exist("sorbet/rbi/gems/[email protected]")
117+
end
118+
119+
it "restores deleted RBI files" do
120+
@project.bundle_install!
121+
FileUtils.mkdir_p("#{@project.absolute_path}/sorbet/rbi/gems")
122+
# Create and delete a tracked RBI file
123+
FileUtils.touch("#{@project.absolute_path}/sorbet/rbi/gems/[email protected]")
124+
@project.exec("git add sorbet/rbi/gems/[email protected]")
125+
@project.exec("git commit -m 'Add foo RBI'")
126+
FileUtils.rm("#{@project.absolute_path}/sorbet/rbi/gems/[email protected]")
127+
128+
refute_project_file_exist("sorbet/rbi/gems/[email protected]")
129+
130+
check = ::RubyLsp::Tapioca::RunGemRbiCheck.new
131+
check.run(@project.absolute_path)
132+
133+
assert_project_file_exist("sorbet/rbi/gems/[email protected]")
134+
135+
# Clean-up commit
136+
@project.exec("git reset --hard HEAD^")
137+
end
138+
end
139+
end
140+
end
141+
end

0 commit comments

Comments
 (0)