diff --git a/lib/ruby_lsp/tapioca/addon.rb b/lib/ruby_lsp/tapioca/addon.rb index b7a035b1a..8be57fd62 100644 --- a/lib/ruby_lsp/tapioca/addon.rb +++ b/lib/ruby_lsp/tapioca/addon.rb @@ -13,6 +13,7 @@ end require "zlib" +require "open3" 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 @@ -45,6 +47,10 @@ def activate(global_state, outgoing_queue) @rails_runner_client = addon.rails_runner_client @outgoing_queue << Notification.window_log_message("Activating Tapioca add-on v#{version}") @rails_runner_client.register_server_addon(File.expand_path("server_addon.rb", __dir__)) + + if git_repo? + lockfile_changed? ? generate_gem_rbis : cleanup_orphaned_rbis + end rescue IncompatibleApiError # The requested version for the Rails add-on no longer matches. We need to upgrade and fix the breaking # changes @@ -127,6 +133,56 @@ def file_updated?(change, path) false end + + sig { returns(T.nilable(T::Boolean)) } + def git_repo? + _, status = Open3.capture2e("git rev-parse --is-inside-work-tree") + + status.success? + end + + sig { returns(T::Boolean) } + def lockfile_changed? + !fetch_lockfile_diff.empty? + end + + sig { returns(String) } + def fetch_lockfile_diff + @lockfile_diff = %x(git diff HEAD Gemfile.lock).strip + end + + sig { void } + def generate_gem_rbis + T.must(@rails_runner_client).delegate_notification( + server_addon_name: "Tapioca", + request_name: "gem", + diff: T.must(@lockfile_diff), + ) + 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) + + untracked_files.each do |file| + File.delete(file) + + T.must(@outgoing_queue) << Notification.window_log_message( + "Deleted untracked RBI: #{file}", + type: Constant::MessageType::INFO, + ) + end + + deleted_files.each do |file| + %x(git checkout -- #{file}) + + T.must(@outgoing_queue) << Notification.window_log_message( + "Restored deleted RBI: #{file}", + type: Constant::MessageType::INFO, + ) + end + end end end end diff --git a/lib/ruby_lsp/tapioca/lockfile_diff_parser.rb b/lib/ruby_lsp/tapioca/lockfile_diff_parser.rb new file mode 100644 index 000000000..efa6c6735 --- /dev/null +++ b/lib/ruby_lsp/tapioca/lockfile_diff_parser.rb @@ -0,0 +1,43 @@ +# typed: true +# frozen_string_literal: true + +module RubyLsp + module Tapioca + class LockfileDiffParser + GEM_NAME_PATTERN = /[\w\-]+/ + DIFF_LINE_PATTERN = /[+-](.*#{GEM_NAME_PATTERN})\s*\(/ + ADDED_LINE_PATTERN = /^\+.*#{GEM_NAME_PATTERN} \(.*\)/ + REMOVED_LINE_PATTERN = /^-.*#{GEM_NAME_PATTERN} \(.*\)/ + + attr_reader :added_or_modified_gems + attr_reader :removed_gems + + def initialize(diff_content) + @diff_content = diff_content.lines + @added_or_modified_gems = parse_added_or_modified_gems + @removed_gems = parse_removed_gems + end + + private + + def parse_added_or_modified_gems + @diff_content + .filter { |line| line.match?(ADDED_LINE_PATTERN) } + .map { |line| extract_gem(line) } + .uniq + end + + def parse_removed_gems + @diff_content + .filter { |line| line.match?(REMOVED_LINE_PATTERN) } + .map { |line| extract_gem(line) } + .reject { |gem| @added_or_modified_gems.include?(gem) } + .uniq + end + + def extract_gem(line) + line.match(DIFF_LINE_PATTERN)[1].strip + end + end + end +end diff --git a/lib/ruby_lsp/tapioca/server_addon.rb b/lib/ruby_lsp/tapioca/server_addon.rb index 0d38cf93d..45219fd27 100644 --- a/lib/ruby_lsp/tapioca/server_addon.rb +++ b/lib/ruby_lsp/tapioca/server_addon.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "tapioca/internal" +require_relative "lockfile_diff_parser" module RubyLsp module Tapioca @@ -16,6 +17,8 @@ def execute(request, params) fork do dsl(params) end + when "gem" + gem(params) end end @@ -25,6 +28,24 @@ def dsl(params) load("tapioca/cli.rb") # Reload the CLI to reset thor defaults between requests ::Tapioca::Cli.start(["dsl", "--lsp_addon", "--workers=1"] + params[:constants]) end + + def gem(params) + gem_changes = LockfileDiffParser.new(params[:diff]) + + removed_gems = gem_changes.removed_gems + added_or_modified_gems = gem_changes.added_or_modified_gems + + if added_or_modified_gems.any? + ::Tapioca::Cli.start([ + "gem", + "--lsp_addon", + *added_or_modified_gems, + ]) + elsif removed_gems.any? + FileUtils.rm_f(Dir.glob("sorbet/rbi/gems/{#{removed_gems.join(",")}}@*.rbi")) + $stdout.puts "Removed RBIs for: #{removed_gems.join(", ")}" + end + end end end end diff --git a/lib/tapioca/cli.rb b/lib/tapioca/cli.rb index 33576f3c5..46699058c 100644 --- a/lib/tapioca/cli.rb +++ b/lib/tapioca/cli.rb @@ -266,6 +266,11 @@ def dsl(*constant_or_paths) type: :boolean, desc: "Halt upon a load error while loading the Rails application", default: true + option :lsp_addon, + type: :boolean, + desc: "Generate Gem RBIs from the LSP addon. Internal to tapioca and not intended for end-users", + default: false, + hide: true def gem(*gems) set_environment(options) @@ -300,6 +305,7 @@ def gem(*gems) dsl_dir: options[:dsl_dir], rbi_formatter: rbi_formatter(options), halt_upon_load_error: options[:halt_upon_load_error], + lsp_addon: options[:lsp_addon], } command = if verify diff --git a/lib/tapioca/commands/abstract_gem.rb b/lib/tapioca/commands/abstract_gem.rb index c4752730d..6ffcfd5b1 100644 --- a/lib/tapioca/commands/abstract_gem.rb +++ b/lib/tapioca/commands/abstract_gem.rb @@ -27,6 +27,7 @@ class AbstractGem < Command dsl_dir: String, rbi_formatter: RBIFormatter, halt_upon_load_error: T::Boolean, + lsp_addon: T.nilable(T::Boolean), ).void end def initialize( @@ -45,7 +46,8 @@ def initialize( auto_strictness: true, dsl_dir: DEFAULT_DSL_DIR, rbi_formatter: DEFAULT_RBI_FORMATTER, - halt_upon_load_error: true + halt_upon_load_error: true, + lsp_addon: false ) @gem_names = gem_names @exclude = exclude @@ -59,6 +61,7 @@ def initialize( @auto_strictness = auto_strictness @dsl_dir = dsl_dir @rbi_formatter = rbi_formatter + @lsp_addon = lsp_addon super() @@ -81,6 +84,8 @@ def gems_to_generate(gem_names) gem = @bundle.gem(gem_name) if gem.nil? + next say("Warning: Cannot find gem '#{gem_name}', skipping", :yellow) if @lsp_addon + raise Thor::Error, set_color("Error: Cannot find gem '#{gem_name}'", :red) end @@ -126,6 +131,7 @@ def compile_gem_rbi(gem) error_handler: ->(error) { say_error(error, :bold, :red) }, + lsp_addon: T.must(@lsp_addon), ).compile end @@ -192,6 +198,7 @@ def perform_additions postrequire: @postrequire, default_command: default_command(:require), halt_upon_load_error: @halt_upon_load_error, + lsp_addon: T.must(@lsp_addon), ) Executor.new(gems, number_of_workers: @number_of_workers).run_in_parallel do |gem_name| diff --git a/lib/tapioca/commands/gem_generate.rb b/lib/tapioca/commands/gem_generate.rb index bd1c740a7..0523a4719 100644 --- a/lib/tapioca/commands/gem_generate.rb +++ b/lib/tapioca/commands/gem_generate.rb @@ -14,6 +14,7 @@ def execute postrequire: @postrequire, default_command: default_command(:require), halt_upon_load_error: @halt_upon_load_error, + lsp_addon: T.must(@lsp_addon), ) gem_queue = gems_to_generate(@gem_names).reject { |gem| @exclude.include?(gem.name) } diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 2b1b655d3..15618e5a4 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -22,19 +22,22 @@ class Pipeline error_handler: T.proc.params(error: String).void, include_doc: T::Boolean, include_loc: T::Boolean, + lsp_addon: T::Boolean, ).void end def initialize( gem, error_handler:, include_doc: false, - include_loc: false + include_loc: false, + lsp_addon: false ) @root = T.let(RBI::Tree.new, RBI::Tree) @gem = gem @seen = T.let(Set.new, T::Set[String]) @alias_namespace = T.let(Set.new, T::Set[String]) @error_handler = error_handler + @lsp_addon = lsp_addon @events = T.let([], T::Array[Gem::Event]) diff --git a/lib/tapioca/loaders/gem.rb b/lib/tapioca/loaders/gem.rb index 150593f1e..0a5cf8728 100644 --- a/lib/tapioca/loaders/gem.rb +++ b/lib/tapioca/loaders/gem.rb @@ -16,9 +16,12 @@ class << self postrequire: String, default_command: String, halt_upon_load_error: T::Boolean, + lsp_addon: T::Boolean, ).void end - def load_application(bundle:, prerequire:, postrequire:, default_command:, halt_upon_load_error:) + def load_application(bundle:, prerequire:, postrequire:, default_command:, halt_upon_load_error:, lsp_addon:) + return if lsp_addon + loader = new( bundle: bundle, prerequire: prerequire, diff --git a/lib/tapioca/loaders/loader.rb b/lib/tapioca/loaders/loader.rb index a1242aefb..9a43bbd6d 100644 --- a/lib/tapioca/loaders/loader.rb +++ b/lib/tapioca/loaders/loader.rb @@ -24,18 +24,21 @@ def load; end initialize_file: T.nilable(String), require_file: T.nilable(String), halt_upon_load_error: T::Boolean, + lsp_addon: T::Boolean, ).void end - def load_bundle(gemfile, initialize_file, require_file, halt_upon_load_error) + def load_bundle(gemfile, initialize_file, require_file, halt_upon_load_error, lsp_addon: false) require_helper(initialize_file) - load_rails_application(halt_upon_load_error: halt_upon_load_error) - gemfile.require_bundle - require_helper(require_file) + load_rails_application( + environment_load: true, + halt_upon_load_error: halt_upon_load_error, + lsp_addon: lsp_addon, + ) - load_rails_engines + require_helper(require_file) end sig do @@ -44,9 +47,12 @@ def load_bundle(gemfile, initialize_file, require_file, halt_upon_load_error) eager_load: T::Boolean, app_root: String, halt_upon_load_error: T::Boolean, + lsp_addon: T::Boolean, ).void end - def load_rails_application(environment_load: false, eager_load: false, app_root: ".", halt_upon_load_error: true) + def load_rails_application(environment_load: false, eager_load: false, app_root: ".", halt_upon_load_error: true, + lsp_addon: false) + return if lsp_addon return unless File.exist?(File.expand_path("config/application.rb", app_root)) load_path = if environment_load @@ -85,116 +91,6 @@ def load_rails_application(environment_load: false, eager_load: false, app_root: say("Continuing RBI generation without loading the Rails application.") end - sig { void } - def load_rails_engines - return if engines.empty? - - with_rails_application do - run_initializers - - if zeitwerk_mode? - load_engines_in_zeitwerk_mode - else - load_engines_in_classic_mode - end - end - end - - def run_initializers - engines.each do |engine| - engine.instance.initializers.tsort_each do |initializer| - initializer.run(Rails.application) - rescue ScriptError, StandardError - nil - end - end - end - - sig { void } - def load_engines_in_zeitwerk_mode - # Collect all the directories that are already managed by all existing Zeitwerk loaders. - managed_dirs = Zeitwerk::Registry.loaders.flat_map(&:dirs).to_set - # We use a fresh loader to load the engine directories, so that we don't interfere with - # any of the existing loaders. - autoloader = Zeitwerk::Loader.new - - engines.each do |engine| - eager_load_paths(engine).each do |path| - # Zeitwerk only accepts existing directories in `push_dir`. - next unless File.directory?(path) - # We should not add directories that are already managed by a Zeitwerk loader. - next if managed_dirs.member?(path) - - autoloader.push_dir(path) - end - end - - autoloader.setup - end - - sig { void } - def load_engines_in_classic_mode - # This is code adapted from `Rails::Engine#eager_load!` in - # https://github.com/rails/rails/blob/d9e188dbab81b412f73dfb7763318d52f360af49/railties/lib/rails/engine.rb#L489-L495 - # - # We can't use `Rails::Engine#eager_load!` directly because it will raise as soon as it encounters - # an error, which is not what we want. We want to try to load as much as we can. - engines.each do |engine| - eager_load_paths(engine).each do |load_path| - Dir.glob("#{load_path}/**/*.rb").sort.each do |file| - require_dependency file - end - rescue ScriptError, StandardError - nil - end - end - end - - sig { returns(T::Boolean) } - def zeitwerk_mode? - Rails.respond_to?(:autoloaders) && - Rails.autoloaders.respond_to?(:zeitwerk_enabled?) && - Rails.autoloaders.zeitwerk_enabled? - end - - sig { params(blk: T.proc.void).void } - def with_rails_application(&blk) - # Store the current Rails.application object so that we can restore it - rails_application = T.unsafe(Rails.application) - - # Create a new Rails::Application object, so that we can load the engines. - # Some engines and the `Rails.autoloaders` call might expect `Rails.application` - # to be set, so we need to create one here. - unless rails_application - Rails.application = Class.new(Rails::Application) - end - - blk.call - ensure - Rails.app_class = Rails.application = rails_application - end - - T::Sig::WithoutRuntime.sig { returns(T::Array[T.class_of(Rails::Engine)]) } - def engines - return [] unless defined?(Rails::Engine) - - safe_require("active_support/core_ext/class/subclasses") - - project_path = Bundler.default_gemfile.parent.expand_path - # We can use `Class#descendants` here, since we know Rails is loaded - Rails::Engine - .descendants - .reject(&:abstract_railtie?) - .reject { |engine| gem_in_app_dir?(project_path, engine.config.root.to_path) } - end - - sig { params(path: String).void } - def safe_require(path) - require path - rescue LoadError - nil - end - sig { void } def eager_load_rails_app application = Rails.application diff --git a/spec/tapioca/cli/gem_spec.rb b/spec/tapioca/cli/gem_spec.rb index ce5276092..e97adcf7c 100644 --- a/spec/tapioca/cli/gem_spec.rb +++ b/spec/tapioca/cli/gem_spec.rb @@ -824,6 +824,22 @@ class Secret; end assert_success_status(result) end + it "skips missing gems and continues with warning when --lsp_addon is used" do + result = @project.tapioca("gem non_existent_gem --lsp_addon") + + assert_stdout_includes(result, "Warning: Cannot find gem 'non_existent_gem', skipping") + + assert_empty_stderr(result) + assert_success_status(result) + end + + it "fails with error when gem cannot be found" do + result = @project.tapioca("gem non_existent_gem") + + assert_stderr_includes(result, "Error: Cannot find gem 'non_existent_gem'") + refute_success_status(result) + end + it "does not crash when the extras gem is loaded" do foo = mock_gem("foo", "0.0.1") do write!("lib/foo.rb", FOO_RB) @@ -1268,6 +1284,19 @@ class Post RB end + @project.write!("config/application.rb", <<~RB) + module Tapioca + class Application < Rails::Application + config.load_defaults(#{ActiveSupport.gem_version.to_s[0..2]}) + end + end + RB + + @project.write!("config/environment.rb", <<~RB) + require_relative "application" + Rails.application.initialize! + RB + @project.require_real_gem("rails", ActiveSupport.gem_version.to_s) @project.require_mock_gem(foo) @project.bundle_install! @@ -1325,6 +1354,11 @@ class Application < Rails::Application end RB + @project.write!("config/environment.rb", <<~RB) + require_relative "application" + Rails.application.initialize! + RB + response = @project.tapioca("gem turbo-rails") assert_includes(response.out, "Compiled turbo-rails") @@ -1356,6 +1390,11 @@ class Application < Rails::Application end RB + @project.write!("config/environment.rb", <<~RB) + require_relative "application" + Rails.application.initialize! + RB + response = @project.tapioca("gem turbo-rails") assert_includes(response.out, "Compiled turbo-rails") diff --git a/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb b/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb new file mode 100644 index 000000000..139dc3fd2 --- /dev/null +++ b/spec/tapioca/ruby_lsp/tapioca/lockfile_diff_parser_spec.rb @@ -0,0 +1,62 @@ +# typed: strict +# frozen_string_literal: true + +require "spec_helper" +require "ruby_lsp/tapioca/lockfile_diff_parser" + +module RubyLsp + module Tapioca + class LockFileDiffParserSpec < Minitest::Spec + describe "#parse_added_or_modified_gems" do + it "parses added or modified gems from git diff" do + diff_output = <<~DIFF + + new_gem (1.0.0) + + updated_gem (2.0.0) + - removed_gem (1.0.0) + DIFF + + lockfile_parser = RubyLsp::Tapioca::LockfileDiffParser.new(diff_output) + assert_equal ["new_gem", "updated_gem"], lockfile_parser.added_or_modified_gems + end + + it "is empty when there is no diff" do + diff_output = "" + + lockfile_parser = RubyLsp::Tapioca::LockfileDiffParser.new(diff_output) + assert_empty lockfile_parser.added_or_modified_gems + end + end + + describe "#parse_removed_gems" do + it "parses removed gems from git diff" do + diff_output = <<~DIFF + + new_gem (1.0.0) + - removed_gem (1.0.0) + - outdated_gem (2.3.4) + DIFF + + lockfile_parser = RubyLsp::Tapioca::LockfileDiffParser.new(diff_output) + assert_equal ["removed_gem", "outdated_gem"], lockfile_parser.removed_gems + end + end + + it "handles gem names with hyphens and underscores" do + diff_output = <<~DIFF + - my-gem_extra2 (1.0.0.beta1) + DIFF + + lockfile_parser = RubyLsp::Tapioca::LockfileDiffParser.new(diff_output) + assert_equal ["my-gem_extra2"], lockfile_parser.removed_gems + end + + it "handles gem names with multiple hyphens" do + diff_output = <<~DIFF + - sorbet-static-and-runtime (0.5.0) + DIFF + + lockfile_parser = RubyLsp::Tapioca::LockfileDiffParser.new(diff_output) + assert_equal ["sorbet-static-and-runtime"], lockfile_parser.removed_gems + end + end + end +end