Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for RBS signature comments #2236

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open

Add support for RBS signature comments #2236

wants to merge 10 commits into from

Conversation

Morriar
Copy link
Collaborator

@Morriar Morriar commented Mar 14, 2025

Motivation

Before this PR, when generating RBI files for gems using RBS comments, Tapioca was unable to generate signatures.

This PR introduce a preprocessing of all loaded files that rewrites the RBS comments into actual Sorbet signatures blocks.

Thus a file looking like this:

class SomeGem
  #: -> void
  def foo; end
end

Will be translated into this at load time:

class SomeGem
  sig { void }
  def foo; end
end

This will enable sorbet-runtime to properly see the signature and wrap the method as it would do normally.

Then Tapioca will be able to pick up the signature and generate properly types RBIs for gems using RBS comment.

Implementation

We rely on ruby-next for the rewriting. While I not convinced by the gem itself, it does work. We could potentially rewrite the mechanism with an ad-hoc implementation later.

Tests

See automated tests.

Morriar and others added 6 commits March 13, 2025 16:09
Signed-off-by: Alexandre Terrasa <[email protected]>
Signed-off-by: Alexandre Terrasa <[email protected]>
Signed-off-by: Alexandre Terrasa <[email protected]>
Signed-off-by: Alexandre Terrasa <[email protected]>
Signed-off-by: Alexandre Terrasa <[email protected]>
Co-authored-by: Ufuk Kayserilioglu <[email protected]>
Signed-off-by: Alexandre Terrasa <[email protected]>
Co-authored-by: Kaan Ozkan <[email protected]>
@Morriar Morriar self-assigned this Mar 14, 2025
@Morriar Morriar requested a review from a team as a code owner March 14, 2025 14:18
@@ -1,6 +1,11 @@
# typed: strict
# frozen_string_literal: true

# The rewriter needs to be loaded very early so RBS comments within Tapioca itself are rewritten
require "spoom"
require "tapioca/rbs/rewriter"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We need this to happen super early so we also rewrite the RBS comments we find inside Tapioca

@@ -160,7 +160,7 @@ def with_rails_application(&blk)
Rails.app_class = Rails.application = rails_application
end

#: -> Array[singleton(Rails::Engine)]
T::Sig::WithoutRuntime.sig { returns(T::Array[T.class_of(Rails::Engine)]) }
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Now that we re-execute the signatures, having a reference to Rails is a problem. For now I changed them back to a T::Sig::WithoutRuntime. We can add a @without-runtime annotation later.


# We need to include `T::Sig` very early to make sure that the `sig` method is available since gems using RBS comments
# are unlikely to include `T::Sig` in their own classes.
Module.include(T::Sig)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@KaanOzkan Is there any adverse effect to do this in the context of the LSP addon?

Copy link
Contributor

Choose a reason for hiding this comment

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

No it should be fine. Rewriting itself could be an issue with the addon, I'll test it.

Copy link
Contributor

Choose a reason for hiding this comment

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

It seems okay on a first glance, not blocking.


RubyNext::Language.include_patterns.clear
RubyNext::Language.include_patterns << "**/*.rb"
RubyNext::Language.rewriters = [Tapioca::RBS::Rewriter]
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note that we're replacing any list of rewriters that may have been set

#: (String source) -> String
def rewrite(source)
# Do not try to parse files that don't have RBS comments
return source unless source =~ /^\s*#\s*typed: (ignore|false|true|strict|strong|__STDLIB_INTERNAL)/
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Parsing all the files is a major slow down. We escape early if the file is not likely to contain Sorbet signatures.

Note that I'd like for us to move away from having typed: X in our files, so maybe we should be matching on #: instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The more I'm thinking about it and the more I think it's a mistake. I already got confused while writing tests because I forgot the sigil. Matching on #: seems safer and future proof 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

We would gain significant performance improvement for tapioca gem with arguments if we could rewrite only the gems we are generating RBIs for. It would help with the performance loss of the early return when we match #: instead.

sig { returns(::String) }
def foo; end

def foo=(_arg0); end
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note that writer were already untyped before this PR.

@Morriar Morriar added enhancement New feature or request and removed feature labels Mar 14, 2025
@Morriar Morriar requested review from paracycle and KaanOzkan March 14, 2025 14:24
@Morriar Morriar force-pushed the at-rbs-rewriter branch 2 times, most recently from 2b9454e to c116258 Compare March 14, 2025 17:18
@@ -37,7 +37,7 @@ def override_require_false(exclude:, &blk)
end
end

#: -> untyped
sig { returns(T.untyped).checked(:never) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are some comments now sigs? Is it so that tapioca's own RBIs are generated correctly?

Will it error in CI if someone wrote a RBS comment that was supposed to be a sig?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's only for the checked(:never) and Sig::WithoutRuntime because now that we re-inject them they get executed. This will only be required for the sigs in Tapioca we do not want to run at runtime.

#2236 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, I missed the checked(:never) calls. I think this is fine for now, we can look at speeding it up later.

Locally I reverted some of these sigs to comments but I don't get any test failures. I'm wary that we'll forget to use sigs in this case and only encounter the issue after we release/merge. Is there a way we can run into this failure in CI and create a check?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CI should fail if any of this method gets reverted to one checked at runtime. Try with the BasicObject test for example: bin/test -n "/can handle Basic/"

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh yeah. I tested it with RBI generation which strangely succeeds but CI fails. Thanks.


# We need to include `T::Sig` very early to make sure that the `sig` method is available since gems using RBS comments
# are unlikely to include `T::Sig` in their own classes.
Module.include(T::Sig)
Copy link
Contributor

Choose a reason for hiding this comment

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

No it should be fine. Rewriting itself could be an issue with the addon, I'll test it.

#: (String source) -> String
def rewrite(source)
# Do not try to parse files that don't have RBS comments
return source unless source =~ /^\s*#\s*typed: (ignore|false|true|strict|strong|__STDLIB_INTERNAL)/
Copy link
Contributor

Choose a reason for hiding this comment

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

We would gain significant performance improvement for tapioca gem with arguments if we could rewrite only the gems we are generating RBIs for. It would help with the performance loss of the early return when we match #: instead.

@Morriar
Copy link
Collaborator Author

Morriar commented Mar 14, 2025

We would gain significant performance improvement for tapioca gem with arguments if we could rewrite only the gems we are generating RBIs for. It would help with the performance loss of the early return when we match #: instead.

Yeah it's a good idea, though we would need to register the rewriter much later so we can have access to the list of gems which means no signatures for Tapioca itself.

If we want to go this route I think it would mean exporting the RBI for Tapioca itself with spoom srb sigs export like we do for rbi and spoom.

This would also solve the issue of the signatures I had to revert to sig { ...checked(:never) }.

@Verseth
Copy link

Verseth commented Mar 17, 2025

This would be great. I am incredibly excited to see RBS comments fully embraced by sorbet/tapioca.

My team is closely following your progress in that matter.
We've already started experimenting with RBS and we fell in love with it.

Good luck and cheers 👍

Copy link
Member

@vinistock vinistock left a comment

Choose a reason for hiding this comment

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

The approach we're taking here is to do a full rewriter pass to translate RBS -> RBI and then letting Tapioca do its thing with no functional changes.

Would it be possible to change Tapioca to handle the RBS comments directly instead? For example, when we find a method, we could search its comments, find an RBS signature and then build a signature entry for it.

If this suggestion is feasible, I think (without actually benchmarking anything) we may be able to achieve better performance on top of avoiding the ruby-next dependency.

What do you think? Am I missing context?

@Morriar
Copy link
Collaborator Author

Morriar commented Mar 17, 2025

Would it be possible to change Tapioca to handle the RBS comments directly instead? For example, when we find a method, we could search its comments, find an RBS signature and then build a signature entry for it.

This was the idea with our first prototype to solve this issue.

First we need to access the comments which we can't from a pure runtime perspective and we need to rely on reparsing the file (indexer, yard, whatever).

Once we have the comment there is the issue of resolving constants. Consider this:

module Foo
  class Bar; end

  class Baz
    #: -> Bar
    def foo; end
  end
end

The way Tapioca is currently built, it creates a flattened view of classes so if we were to just translate the sig the result would look like this:

module Foo; end

class Foo::Bar; end

class Foo::Baz
  sig { returns(Bar) } # error: Unresolved constant `Bar`
  def foo; end
end

Since the code is already loaded, we can use the runtime to resolve the constants, something like this would work for example:

const = eval <<~RB
  module Foo
    class Baz
      Bar
    end
  end
RB

Finally with this information we can write the final sig. And that would work but it wouldn't solve the issue of DSL compilers that look for the method signature at runtime.

So what we could do at this point is use the same approach but instead of generating the sig as a RBI string, we could call into sorbet-runtime sig builder to instantiate the signature at runtime and re-wrap the method. Which in turn comes with a bunch of other issues since we reach into sorbet-runtime internals. And at this point we're doing almost the same thing than the approach used in this PR.

One interesting thing about the load time rewriter we use here is that we could in the future extract it as a standalone feature to re-enable runtime type checks in tests for example. Imagine using RBS comments, you could just require sorbet-runtime/rbs and get back runtime checks.

Copy link
Member

@vinistock vinistock left a comment

Choose a reason for hiding this comment

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

Thanks for explaining the other considerations. I think using the indexer to detect the RBS signatures would be nice, but it seems like it would be a much more involved change that expands simply adopting it.

I would rather us move forward with solving the problem and then iterate on improving the performance / designing a longer term solution

Comment on lines +32 to +33
RubyNext::Language.include_patterns.clear
RubyNext::Language.include_patterns << "**/*.rb"
Copy link
Member

Choose a reason for hiding this comment

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

Why do these need to be cleared first? Does it include something other than Ruby files by default?

We could do

Suggested change
RubyNext::Language.include_patterns.clear
RubyNext::Language.include_patterns << "**/*.rb"
RubyNext::Language.include_patterns = ["**/*.rb"]

or just append if it's already empty.

module RBS
# Transpiles RBS comments back into Sorbet's `sig{}` blocks
class Rewriter < RubyNext::Language::Rewriters::Text
NAME = "rbs_rewriter"
Copy link
Member

Choose a reason for hiding this comment

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

Out of curiosity, is this used by ruby-next?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants