diff --git a/README.md b/README.md index 904755733..708f33218 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Tapioca makes it easy to work with [Sorbet](https://sorbet.org) in your codebase * [Changing the strictness level of the RBI for a gem](#changing-the-strictness-level-of-the-rbi-for-a-gem) * [Keeping RBI files for gems up-to-date](#keeping-rbi-files-for-gems-up-to-date) * [Importing hand written signatures from gem's `rbi/` folder](#importing-hand-written-signatures-from-gems-rbi-folder) + * [Writing custom gem extensions](#writing-custom-gem-extensions) * [Pulling RBI annotations from remote sources](#pulling-rbi-annotations-from-remote-sources) * [Basic authentication](#basic-authentication) * [Using a .netrc file](#using-a-netrc-file) @@ -345,6 +346,52 @@ This option can be used in CI to make sure the RBI files are *up-to-date* and en Tapioca will import any signatures found in the `rbi/` folder of a given gem and combine them with the RBIs it generates. This is useful when a gem doesn't want to depend on `sorbet-runtime` but still wants to provide type safety to users during static checks. Note that the `rbi/` folder needs to be included in the gem release using the `.gemspec` file. Applications can choose not to import these signatures using the `--no-exported-gem-rbis` flag. +#### Writing custom gem extensions + +Similar to [DSL extensions](#writing-custom-dsl-extensions), it is sometimes useful to patch and modify certain behavior +from gems to ensure that RBIs are generated correctly. Gem RBIs are generated by reflecting over what the gem defines at +runtime, which means that anything the gem never really defines is invisible to Tapioca. A common example is methods +handled by `method_missing`: + +```ruby +# my_gem/lib/my_gem/settings.rb + +module MyGem + class Settings + SETTINGS = [:host, :port, :timeout] + + def method_missing(name, *args) + SETTINGS.include?(name) ? @config[name] : super + end + + def respond_to_missing?(name, include_private = false) + SETTINGS.include?(name) || super + end + end +end +``` + +Since `host`, `port` and `timeout` are never defined as real methods, they will be missing from the generated RBI. An +extension can define them, so that Tapioca is able to see them: + +```ruby +# my_gem/lib/tapioca/gem/extensions/my_gem.rb + +require "my_gem" + +module MyGem + class Settings + SETTINGS.each do |name| + define_method(name) { @config[name] } + end + end +end +``` + +Extensions are only loaded during RBI generation, so they never change the behavior of the gem for applications using +it. They are also loaded before the bundle is required, which is why the extension has to require what it wants to +patch. + ### Pulling RBI annotations from remote sources Since Tapioca does not perform any type inference, the RBI files generated for the gems do not contain any type signatures. Instead, Tapioca relies on the community to provide high-quality, manually written RBI annotations for public gems. diff --git a/lib/tapioca/loaders/gem.rb b/lib/tapioca/loaders/gem.rb index 67fbc45dd..e9d31bc83 100644 --- a/lib/tapioca/loaders/gem.rb +++ b/lib/tapioca/loaders/gem.rb @@ -49,6 +49,33 @@ def initialize(bundle:, prerequire:, postrequire:, default_command:, halt_upon_l @halt_upon_load_error = halt_upon_load_error end + #: -> void + def load_gem_extensions + say("Loading gem extension classes... ") + + # Extensions are loaded before the bundle is required so that they can patch the gems + # they apply to as those gems are being loaded. + ::Gem.find_files("tapioca/gem/extensions/*.rb").each do |extension| + require File.expand_path(extension) + end + + say("Done", :green) + end + + #: (Tapioca::Gemfile gemfile, String? initialize_file, String? require_file, bool halt_upon_load_error) -> void + def load_bundle(gemfile, initialize_file, require_file, halt_upon_load_error) + require_helper(initialize_file) + load_gem_extensions + + load_rails_application(halt_upon_load_error: halt_upon_load_error) + + gemfile.require_bundle + + require_helper(require_file) + + load_rails_engines + end + #: -> void def require_gem_file say("Requiring all gems to prepare for compiling... ") diff --git a/lib/tapioca/loaders/loader.rb b/lib/tapioca/loaders/loader.rb index 23a3fa845..eabefc050 100644 --- a/lib/tapioca/loaders/loader.rb +++ b/lib/tapioca/loaders/loader.rb @@ -15,19 +15,6 @@ def load = raise NotImplementedError, "Abstract method called" private - #: (Tapioca::Gemfile gemfile, String? initialize_file, String? require_file, bool halt_upon_load_error) -> void - def load_bundle(gemfile, initialize_file, require_file, halt_upon_load_error) - require_helper(initialize_file) - - load_rails_application(halt_upon_load_error: halt_upon_load_error) - - gemfile.require_bundle - - require_helper(require_file) - - load_rails_engines - end - #: (?environment_load: bool, ?eager_load: bool, ?app_root: String, ?halt_upon_load_error: bool) -> void def load_rails_application(environment_load: false, eager_load: false, app_root: ".", halt_upon_load_error: true) return unless File.exist?(File.expand_path("config/application.rb", app_root)) diff --git a/spec/tapioca/cli/gem_spec.rb b/spec/tapioca/cli/gem_spec.rb index 261207ac8..544649052 100644 --- a/spec/tapioca/cli/gem_spec.rb +++ b/spec/tapioca/cli/gem_spec.rb @@ -2298,6 +2298,82 @@ class Application < Rails::Application assert_success_status(result) end end + + describe "custom extensions" do + after do + project.write_gemfile!(project.tapioca_gemfile) + @project.require_default_gems + @project.remove!("sorbet/rbi") + @project.remove!("../gems") + end + + it "loads extensions" do + foo = mock_gem("foo", "0.0.1") do + write!("lib/foo.rb", <<~RUBY) + module Patch + def [](*types) + self + end + end + + class Foo + extend T::Generic + Value = type_member + extend Patch + + sig do + type_parameters(:Value). + params( + block: T.proc.returns(T.type_parameter(:Value)) + ).returns(Foo[T.type_parameter(:Value)]) + end + def something(&block); end + end + RUBY + + write!("lib/tapioca/gem/extensions/foo.rb", <<~RUBY) + require "foo" + + module Patch + def [](*types) + super + end + end + RUBY + end + + @project.require_mock_gem(foo) + @project.bundle_install! + + result = @project.tapioca("gem foo") + + assert_stdout_includes(result, "Loading gem extension classes... Done") + + assert_project_file_includes("sorbet/rbi/gems/foo@0.0.1.rbi", <<~RBI) + class Foo + extend T::Generic + extend ::Patch + + Value = type_member + + sig do + type_parameters(:Value) + .params( + block: T.proc.returns(T.type_parameter(:Value)) + ).returns(Foo[T.type_parameter(:Value)]) + end + def something(&block); end + end + + module Patch + def [](*types); end + end + RBI + + assert_empty_stderr(result) + assert_success_status(result) + end + end end end end