Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions lib/tapioca/loaders/gem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should also allow project-local files to be loaded as extensions?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait that's what prerequire is for right? I wonder if we should merge both concepts 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it ends up being the same concept, but with different names. I'm not sure how we should approach it though.

Simply removing prerequire and turning it into gem extensions would be a breaking change. We could also support both for some time or accept the distinct names for a bit until we're ready to make a breaking change.

WDYT?

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... ")
Expand Down
13 changes: 0 additions & 13 deletions lib/tapioca/loaders/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
76 changes: 76 additions & 0 deletions spec/tapioca/cli/gem_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading