diff --git a/Gemfile.lock b/Gemfile.lock index 2a72ef39d..8b50bff1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,8 +20,7 @@ PATH netrc (>= 0.11.0) parallel (>= 1.21.0) rbi (>= 0.3.7) - require-hooks (>= 0.2.2) - rubydex (>= 0.1.0.beta10) + rubydex (>= 0.2.5) sorbet-static-and-runtime (>= 0.6.12698) spoom (>= 1.7.16) thor (>= 1.2.0) @@ -325,7 +324,6 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) - require-hooks (0.4.0) rexml (3.4.4) rubocop (1.84.1) json (~> 2.3) @@ -356,10 +354,11 @@ GEM ruby-lsp-rails (0.4.8) ruby-lsp (>= 0.26.0, < 0.27.0) ruby-progressbar (1.13.0) - rubydex (0.2.3-aarch64-linux) - rubydex (0.2.3-arm64-darwin) - rubydex (0.2.3-x86_64-darwin) - rubydex (0.2.3-x86_64-linux) + rubydex (0.2.5) + rubydex (0.2.5-aarch64-linux) + rubydex (0.2.5-arm64-darwin) + rubydex (0.2.5-x86_64-darwin) + rubydex (0.2.5-x86_64-linux) securerandom (0.4.1) shopify-money (4.1.1) bigdecimal (>= 3.0) @@ -502,7 +501,6 @@ CHECKSUMS benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac cityhash (0.9.0) sha256=1c20843d286524de21d0ecf5d43c7e7f18f5fb0c5866294a717f0be13dc1962d concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab config (5.6.1) sha256=a9f0f0f9ffa6d12d43147a3fa1ab8486fe484c3098a350c6a2e0f32430e0d1cc @@ -589,7 +587,6 @@ CHECKSUMS redis-client (0.29.0) sha256=0c65bf1f8f6dca22063ddb085c0bb2054feef6f03a84869f4161b18a9a15bea3 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 - require-hooks (0.4.0) sha256=005f4c6435b4edae73e358cdbaba48370a4121f9ce893d5d2a3c66fce855677d rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rubocop (1.84.1) sha256=14cc626f355141f5a2ef53c10a68d66b13bb30639b26370a76559096cc6bcc1a rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd @@ -599,10 +596,11 @@ CHECKSUMS ruby-lsp (0.26.9) sha256=33a01c001c00a76b4e821efc04ed7572983430f31ca5d6f3e343d0b6ccab4129 ruby-lsp-rails (0.4.8) sha256=f09d1f926d4063deeb2f3049311925c20dfe6c912371e3bcd04a265a865c44ae ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - rubydex (0.2.3-aarch64-linux) sha256=f666ff383430cc800cb0889d52c77da7457e99165b5eef7c0d45491a5fafea87 - rubydex (0.2.3-arm64-darwin) sha256=997d7895a0208ec3d7ef922c9d29243b62e10c67bc3c2396a9f978d7df117390 - rubydex (0.2.3-x86_64-darwin) sha256=de4890f91bedb59bfefb90c528939e52475da3afa105feaf855594ad527b0fb9 - rubydex (0.2.3-x86_64-linux) sha256=796a54c1af9f8868c87bf92fee5f9ccc39ffd40eb8386e0875058a18beb76b09 + rubydex (0.2.5) sha256=0a2112d62603f6f0ae4dfa9b963cf7617331fffae35740f7856a831cbc7f1580 + rubydex (0.2.5-aarch64-linux) sha256=7cbc1cd8f926bd3138c7cdd5434f5d3140a287394ca7c4f4e9d93b4b900f20ce + rubydex (0.2.5-arm64-darwin) sha256=487cab3df687ccb2f3360c23236300320d402b1042643054cd4d09221e13915a + rubydex (0.2.5-x86_64-darwin) sha256=1888750fd1369353af67e5fe0d3f1b7fc0fbb864f99be2660dddfc729395a55e + rubydex (0.2.5-x86_64-linux) sha256=4b05f0424b867071c41a1b2ac7d91e965829a422c806a532d43ffdb45ec33604 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 shopify-money (4.1.1) sha256=523078e44bfde1920f8b3487ddf9144e0fb6af8cdf67e212bed02025c5c5f423 sidekiq (8.1.5) sha256=19821ff6031100c2317f72a5b8ab32304ca84f5acb5a2ef846ed1ec14144ab02 diff --git a/README.md b/README.md index 159bb6733..e9b827fee 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,7 @@ Tapioca makes it easy to work with [Sorbet](https://sorbet.org) in your codebase * [Using DSL compiler options](#using-dsl-compiler-options) * [Writing custom DSL compilers](#writing-custom-dsl-compilers) * [Writing custom DSL extensions](#writing-custom-dsl-extensions) - * [Rewriting RBS comments to Sorbet signatures](#rewriting-rbs-comments-to-sorbet-signatures) - * [Caching rewrites with Bootsnap](#caching-rewrites-with-bootsnap) - * [Priming the cache from CI](#priming-the-cache-from-ci) + * [Inline RBS comments](#inline-rbs-comments) * [RBI files for missing constants and methods](#rbi-files-for-missing-constants-and-methods) * [Configuration](#configuration) * [Editor Integration](#editor-integration) @@ -492,35 +490,33 @@ Usage: tapioca dsl [constant...] Options: - --out, -o, [--outdir=directory] # The output directory for generated DSL RBI files - # Default: sorbet/rbi/dsl - [--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file - # Default: true - [--only=compiler [compiler ...]] # Only run supplied DSL compiler(s) - [--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s) - [--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date - # Default: false - [--only-bootsnap-rbs-cache], [--no-only-bootsnap-rbs-cache], [--skip-only-bootsnap-rbs-cache] # Only boot the application and load DSL extensions/compilers to populate the bootsnap iseq cache, then exit. Skips compiler execution and RBI generation. Mutually exclusive with --verify and --list-compilers. - # Default: false - -q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output - # Default: false - -w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto) - [--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped - # Default: 120 - -e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs - # Default: development - -l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers - # Default: false - [--app-root=APP_ROOT] # The path to the Rails application - # Default: . - [--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application - # Default: true - [--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s) - [--compiler-options=key:value] # Options to pass to the DSL compilers - -c, [--config=] # Path to the Tapioca configuration file - # Default: sorbet/tapioca/config.yml - -V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes - # Default: false + --out, -o, [--outdir=directory] # The output directory for generated DSL RBI files + # Default: sorbet/rbi/dsl + [--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file + # Default: true + [--only=compiler [compiler ...]] # Only run supplied DSL compiler(s) + [--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s) + [--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date + # Default: false + -q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output + # Default: false + -w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto) + [--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped + # Default: 120 + -e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs + # Default: development + -l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers + # Default: false + [--app-root=APP_ROOT] # The path to the Rails application + # Default: . + [--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application + # Default: true + [--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s) + [--compiler-options=key:value] # Options to pass to the DSL compilers + -c, [--config=] # Path to the Tapioca configuration file + # Default: sorbet/tapioca/config.yml + -V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes + # Default: false Generate RBIs for dynamic methods ``` @@ -841,41 +837,9 @@ In order for DSL extensions to be discovered by Tapioca, they either needs to be For more concrete and advanced examples, take a look at [Tapioca's default DSL extensions](https://github.com/Shopify/tapioca/tree/main/lib/tapioca/dsl/extensions). -### Rewriting RBS comments to Sorbet signatures +### Inline RBS comments -Tapioca translates [RBS comments](https://sorbet.org/docs/rbs-comments) into Sorbet `sig {}` blocks at file load time, so `sorbet-runtime` wraps the methods as if they had been written with native sigs. This is what lets the DSL command introspect signatures that were originally documented as RBS comments. - -The rewriting is automatic on every `tapioca` invocation: [`require-hooks`](https://github.com/Shopify/require-hooks) intercepts `.rb` loads and `Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs` translates the source before Ruby compiles it to bytecode. - -#### Caching rewrites with Bootsnap - -`tapioca dsl` boots the app and eager-loads source files for introspection, so the rewrite runs across the whole codebase. On large applications this adds noticeable overhead. To cache the rewrite output across runs using [bootsnap](https://github.com/Shopify/bootsnap)'s iseq cache, you can set `TAPIOCA_RBS_CACHE=1`: - -```shell -$ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl -``` - -Tapioca configures Bootsnap's iseq cache against a dedicated directory (`tmp/cache/bootsnap-tapioca-rbs` by default; override with `TAPIOCA_BOOTSNAP_CACHE_DIR`). The first run is slower because every file is rewritten and the result is baked into the iseq cache; subsequent runs against the same directory skip the rewrite entirely. - -`Bootsnap.setup` mutates a process-wide singleton, and a second call would overwrite Tapioca's dedicated cache directory and start writing rewritten iseqs into the host's normal cache. Tapioca enforces this under `TAPIOCA_RBS_CACHE=1`: after its own setup runs, any subsequent `Bootsnap.setup` raises a clear error pointing at the fix. Gate your host's `Bootsnap.setup` on the same env var. Rails apps do this in `config/boot.rb`: - -```ruby -# e.g. config/boot.rb -require "bootsnap/setup" unless ENV["TAPIOCA_RBS_CACHE"] == "1" -``` - -#### Priming the cache from CI - -For CI pipelines that want to populate the cache once and have downstream jobs read from a warm copy, use `--only-bootsnap-rbs-cache`. This pattern lets you scope cache writes to a single job (the prime) so PR-side jobs read from it without uploading on every successful build: - -```shell -# Prime: populate the cache. -$ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl --only-bootsnap-rbs-cache - -# Consumer: read from the populated cache. -# BOOTSNAP_READONLY=1 prevents bootsnap from writing back to a read-only mount. -$ TAPIOCA_RBS_CACHE=1 BOOTSNAP_READONLY=1 bin/tapioca dsl -``` +Tapioca understands [inline RBS comments](https://sorbet.org/docs/rbs-comments) natively. While compiling a gem, signatures and class-level annotations written as `#: ...` / `# @abstract` / `# @requires_ancestor: ...` are read directly from the source via a [Rubydex](https://github.com/Shopify/rubydex)-built graph and translated into RBI alongside the runtime reflection that powers Sorbet `sig {}` blocks. There is no require-hook or load-time rewriter: Tapioca parses the source itself, so adding RBS comments to a gem doesn't change how the host application loads. ### RBI files for missing constants and methods @@ -998,7 +962,6 @@ dsl: only: [] exclude: [] verify: false - only_bootsnap_rbs_cache: false quiet: false workers: 1 rbi_max_line_length: 120 diff --git a/lib/tapioca/cli.rb b/lib/tapioca/cli.rb index 977f64165..fc52f39ec 100644 --- a/lib/tapioca/cli.rb +++ b/lib/tapioca/cli.rb @@ -103,10 +103,6 @@ def todo type: :boolean, default: false, desc: "Verifies RBIs are up-to-date" - option :only_bootsnap_rbs_cache, - type: :boolean, - default: false, - desc: "Only boot the application and load DSL extensions/compilers to populate the bootsnap iseq cache, then exit. Skips compiler execution and RBI generation. Mutually exclusive with --verify and --list-compilers." option :quiet, aliases: ["-q"], type: :boolean, @@ -150,12 +146,6 @@ def todo def dsl(*constant_or_paths) set_environment(options) - if options[:only_bootsnap_rbs_cache] && (options[:verify] || options[:list_compilers]) - conflicting = options[:verify] ? "--verify" : "--list-compilers" - raise MalformattedArgumentError, - "Options '--only-bootsnap-rbs-cache' and '#{conflicting}' are mutually exclusive" - end - # Assume anything starting with a capital letter or colon is a class, otherwise a path constants, paths = constant_or_paths.partition { |c| c =~ /\A[A-Z:]/ } @@ -183,7 +173,7 @@ def dsl(*constant_or_paths) elsif options[:list_compilers] Commands::DslCompilerList.new(**command_args) else - Commands::DslGenerate.new(**command_args, only_bootsnap_rbs_cache: options[:only_bootsnap_rbs_cache]) + Commands::DslGenerate.new(**command_args) end command.run diff --git a/lib/tapioca/commands/dsl_generate.rb b/lib/tapioca/commands/dsl_generate.rb index 530cfb067..3114bebd6 100644 --- a/lib/tapioca/commands/dsl_generate.rb +++ b/lib/tapioca/commands/dsl_generate.rb @@ -4,12 +4,6 @@ module Tapioca module Commands class DslGenerate < AbstractDsl - #: (?only_bootsnap_rbs_cache: bool, **untyped) -> void - def initialize(only_bootsnap_rbs_cache: false, **kwargs) - @only_bootsnap_rbs_cache = only_bootsnap_rbs_cache - super(**T.unsafe(kwargs)) - end - private # @override @@ -17,11 +11,6 @@ def initialize(only_bootsnap_rbs_cache: false, **kwargs) def execute load_application - if @only_bootsnap_rbs_cache - say("Bootsnap RBS cache populated, exiting before RBI generation.", :green) - return - end - say("Compiling DSL RBI files...") say("") diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index fbe33fcea..45a9a7350 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -111,32 +111,11 @@ def add_error(error) private # Get the types of each parameter from a method signature - #: ((Method | UnboundMethod) method_def, untyped signature) -> Array[String] + #: ((Method | UnboundMethod) method_def, Tapioca::Runtime::Signature? signature) -> Array[String] def parameters_types_from_signature(method_def, signature) - params = [] #: Array[String] - return method_def.parameters.map { "T.untyped" } unless signature - # parameters types - signature.arg_types.each { |arg_type| params << arg_type[1].to_s } - - # keyword parameters types - signature.kwarg_types.each { |_, kwarg_type| params << kwarg_type.to_s } - - # rest parameter type - rest_type = signature.rest_type - params << rest_type.to_s if rest_type - - # keyrest parameter type - keyrest_type = signature.keyrest_type - params << keyrest_type.to_s if keyrest_type - - # special case `.void` in a proc - unless signature.block_name.nil? - params << signature.block_type.to_s.gsub("returns()", "void") - end - - params + signature.parameter_type_strings end #: (RBI::Scope scope, (Method | UnboundMethod) method_def, ?class_method: bool) -> void @@ -191,8 +170,7 @@ def compile_method_parameters_to_rbi(method_def) #: ((Method | UnboundMethod) method_def) -> String def compile_method_return_type_to_rbi(method_def) signature = signature_of(method_def) - return_type = signature.nil? ? "T.untyped" : name_of_type(signature.return_type) - sanitize_signature_types(return_type) + signature&.return_type_string || "T.untyped" end end end diff --git a/lib/tapioca/dsl/helpers/active_model_type_helper.rb b/lib/tapioca/dsl/helpers/active_model_type_helper.rb index 4fa9bdadf..295a32fef 100644 --- a/lib/tapioca/dsl/helpers/active_model_type_helper.rb +++ b/lib/tapioca/dsl/helpers/active_model_type_helper.rb @@ -12,13 +12,18 @@ class << self def type_for(type_value) return "T.untyped" if Runtime::GenericTypeRegistry.generic_type_instance?(type_value) - type = lookup_tapioca_type(type_value) || - lookup_return_type_of_method(type_value, :deserialize) || - lookup_return_type_of_method(type_value, :cast) || - lookup_return_type_of_method(type_value, :cast_value) || - lookup_arg_type_of_method(type_value, :serialize) || - T.untyped - type.to_s + return_type = lookup_tapioca_type(type_value) + return return_type.to_s if return_type + + [:deserialize, :cast, :cast_value].each do |method| + type = lookup_return_type_of_method(type_value, method) + return type if type + end + + arg_type = lookup_arg_type_of_method(type_value, :serialize) + return arg_type if arg_type + + "T.untyped" end #: (untyped type_value) -> bool @@ -28,44 +33,41 @@ def assume_nilable?(type_value) private - MEANINGLESS_TYPES = [ - T.untyped, - T.noreturn, - T::Private::Types::Void, - T::Private::Types::NotTyped, - ].freeze #: Array[Object] - - #: (untyped type) -> bool - def meaningful_type?(type) - !MEANINGLESS_TYPES.include?(type) - end - #: (untyped obj) -> T::Types::Base? def lookup_tapioca_type(obj) T::Utils.coerce(obj.__tapioca_type) if obj.respond_to?(:__tapioca_type) end - #: (untyped obj, Symbol method) -> T::Types::Base? + # Returns the return type of `method` on `obj` as a string, using + # whichever signature {#lookup_signature_of_method} finds. Returns + # nil when no meaningful type can be discovered. + #: (untyped obj, Symbol method) -> String? def lookup_return_type_of_method(obj, method) - return_type = lookup_signature_of_method(obj, method)&.return_type - return unless return_type && meaningful_type?(return_type) - - return_type + lookup_signature_of_method(obj, method)&.valid_return_type_string end - #: (untyped obj, Symbol method) -> T::Types::Base? + # Returns the first arg's type of `method` on `obj` as a string, + # using whichever signature {#lookup_signature_of_method} finds. + # Returns nil when no meaningful type can be discovered. + #: (untyped obj, Symbol method) -> String? def lookup_arg_type_of_method(obj, method) - # Arg types is an array of [name, type] entries, so we dig into first entry (index 0) - # and then into the type which is the last element (index 1) - first_arg_type = lookup_signature_of_method(obj, method)&.arg_types&.dig(0, 1) - return unless first_arg_type && meaningful_type?(first_arg_type) - - first_arg_type + lookup_signature_of_method(obj, method)&.valid_first_arg_type_string end - #: (untyped obj, Symbol method) -> untyped + # Picks the best signature available for `method` on `obj`, + # preferring the Sorbet runtime sig and falling back to any + # inline RBS sig parsed from source. + #: (untyped obj, Symbol method) -> Tapioca::Runtime::Signature? def lookup_signature_of_method(obj, method) - Runtime::Reflection.signature_of(obj.method(method)) + method_def = lookup_method(obj, method) + return unless method_def + + Runtime::Reflection.signature_of(method_def) + end + + #: (untyped obj, Symbol method) -> Method? + def lookup_method(obj, method) + obj.method(method) rescue NameError nil end diff --git a/lib/tapioca/dsl/helpers/graphql_type_helper.rb b/lib/tapioca/dsl/helpers/graphql_type_helper.rb index a81886b42..de6d5df37 100644 --- a/lib/tapioca/dsl/helpers/graphql_type_helper.rb +++ b/lib/tapioca/dsl/helpers/graphql_type_helper.rb @@ -78,11 +78,11 @@ def type_for(type, ignore_nilable_wrapper: false, prepare_method: nil) when GraphQL::Schema::Scalar.singleton_class method = Runtime::Reflection.method_of(unwrapped_type, :coerce_input) signature = Runtime::Reflection.signature_of(method) - return_type = signature&.return_type + return_type = signature&.valid_return_type_string # Wrap as non-nilable for required arguments. `coerce_input` supports both # required and optional; optional arguments are re-wrapped below based on `type.non_null?` - valid_return_type?(return_type) ? (T::Utils.unwrap_nilable(return_type) || return_type).to_s : "T.untyped" + return_type ? RBIHelper.as_non_nilable_type(return_type) : "T.untyped" when GraphQL::Schema::InputObject.singleton_class type_for_constant(unwrapped_type) when Module @@ -93,10 +93,8 @@ def type_for(type, ignore_nilable_wrapper: false, prepare_method: nil) if prepare_method prepare_signature = Runtime::Reflection.signature_of(prepare_method) - prepare_return_type = prepare_signature&.return_type - if valid_return_type?(prepare_return_type) - parsed_type = prepare_return_type&.to_s - end + prepare_return_type = prepare_signature&.valid_return_type_string + parsed_type = prepare_return_type if prepare_return_type end if type.list? @@ -116,10 +114,10 @@ def type_for(type, ignore_nilable_wrapper: false, prepare_method: nil) def type_for_constant(constant) if constant.instance_methods.include?(:prepare) prepare_method = constant.instance_method(:prepare) - prepare_signature = Runtime::Reflection.signature_of(prepare_method) + prepare_return_type = prepare_signature&.valid_return_type_string - return prepare_signature.return_type&.to_s if valid_return_type?(prepare_signature&.return_type) + return prepare_return_type if prepare_return_type end Runtime::Reflection.qualified_name_of(constant) || "T.untyped" @@ -129,11 +127,6 @@ def type_for_constant(constant) def has_replaceable_default?(argument) !!argument.replace_null_with_default? && !argument.default_value.nil? end - - #: (T::Types::Base? return_type) -> bool - def valid_return_type?(return_type) - !!return_type && !(T::Private::Types::Void === return_type || T::Private::Types::NotTyped === return_type) - end end end end diff --git a/lib/tapioca/gem/events.rb b/lib/tapioca/gem/events.rb index 030ad638a..1a8e6dff9 100644 --- a/lib/tapioca/gem/events.rb +++ b/lib/tapioca/gem/events.rb @@ -95,7 +95,7 @@ class MethodNodeAdded < NodeAdded #: RBI::Method attr_reader :node - #: untyped + #: Tapioca::Runtime::Signature? attr_reader :signature #: Array[[Symbol, String]] @@ -106,7 +106,7 @@ class MethodNodeAdded < NodeAdded #| Module[top] constant, #| UnboundMethod method, #| RBI::Method node, - #| untyped signature, + #| Tapioca::Runtime::Signature? signature, #| Array[[Symbol, String]] parameters #| ) -> void def initialize(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists diff --git a/lib/tapioca/gem/listeners/documentation.rb b/lib/tapioca/gem/listeners/documentation.rb index 8fc616d6d..6abfa0e3a 100644 --- a/lib/tapioca/gem/listeners/documentation.rb +++ b/lib/tapioca/gem/listeners/documentation.rb @@ -69,7 +69,19 @@ def documentation_comments(name, sigs: []) end return [] unless declaration - comments = declaration.definitions.flat_map(&:comments) + # Only pull comments from definitions that live in the gem under + # compilation. The graph also indexes core/stdlib RBS files so that + # references like `Integer` or `String` resolve when fully-qualifying + # inline RBS types — without this filter, reopens of core classes + # would pick up RBS documentation we don't actually want. + gem_definitions = declaration.definitions.select do |d| + @pipeline.gem.contains_path?(d.location.to_file_path) + rescue Rubydex::Location::NotFileUriError + false + end + return [] if gem_definitions.empty? + + comments = gem_definitions.flat_map(&:comments) comments.uniq! return [] if comments.empty? diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 776dd2b61..5f6871bf8 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -17,7 +17,7 @@ def on_scope(event) constant = event.constant node = event.node - compile_method(node, symbol, constant, initialize_method_for(constant)) + compile_method(node, symbol, constant, initialize_method_for(constant), scope_constant: constant) compile_directly_owned_methods(node, symbol, constant) compile_directly_owned_methods(node, symbol, singleton_class_of(constant), attached_class: constant) end @@ -36,6 +36,11 @@ def compile_directly_owned_methods( for_visibility = [:public, :protected, :private], attached_class: nil ) + # For singleton methods (when `attached_class` is set), `mod` is the + # singleton class; the lexical scope used to find RBS comments must be + # the attached class. + scope_constant = attached_class || mod + method_names_by_visibility(mod) .delete_if { |visibility, _method_list| !for_visibility.include?(visibility) } .each do |visibility, method_list| @@ -51,7 +56,7 @@ def compile_directly_owned_methods( else RBI::Public.new end - compile_method(tree, module_name, mod, mod.instance_method(name), vis) + compile_method(tree, module_name, mod, mod.instance_method(name), vis, scope_constant: scope_constant) end end end @@ -61,15 +66,27 @@ def compile_directly_owned_methods( #| String symbol_name, #| Module[top] constant, #| UnboundMethod? method, - #| ?RBI::Visibility visibility + #| ?RBI::Visibility visibility, + #| ?scope_constant: Module[top]? #| ) -> void - def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public.new) + def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public.new, scope_constant: nil) return unless method return unless method_owned_by_constant?(method, constant) begin - signature = signature_of!(method) - method = signature.method if signature #: UnboundMethod + # If no Sorbet runtime sig is attached, fall back to the inline + # RBS comments at the method's declaration. We look the + # declaration up in the gem's Rubydex graph using the lexical + # scope (the attached class for singleton methods, never the + # singleton class itself) and let `SignatureBuilder` do the + # parse-translate-qualify work. + signature = signature_of!(method) do |m| + rbs_signature_for(m, constant, scope_constant) + end + if signature + sig_method = signature.method + method = sig_method.is_a?(Method) ? sig_method.unbind : sig_method + end case @pipeline.method_definition_in_gem(method.name, constant) when Pipeline::MethodUnknown @@ -104,18 +121,16 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public name = if name name.to_s else - # For attr_writer methods, Sorbet signatures have the name - # of the method (without the trailing = sign) as the name of - # the only parameter. So, if the parameter does not have a name - # then the replacement name should be the name of the method - # (minus trailing =) if and only if there is a signature for the - # method and the parameter is required and there is a single - # parameter and the signature also defines a single parameter and - # the name of the method ends with a = character. + # For attr_writer methods, Sorbet signatures (and RBS comments) + # name the only parameter using the attribute name (i.e. the + # method name without the trailing `=`). When we have a + # signature available and we're dealing with a + # single-required-arg writer method, fall back to that + # convention instead of an anonymous `_arg0`. writer_method_with_sig = - signature && type == :req && + signature && + type == :req && parameters.size == 1 && - signature.arg_types.size == 1 && method_name[-1] == "=" if writer_method_with_sig @@ -160,6 +175,38 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public tree << rbi_method end + # Builds an {Tapioca::Runtime::RbsSignature} for `method` from the + # inline RBS comments on its source declaration. Returns nil when no + # declaration is indexed in the gem graph, or when the declaration + # has no `#:` signature comments. + # + # `scope_constant` is the lexical scope used for the declaration + # lookup — attached class for singleton methods, never the + # singleton class itself. For `attr_accessor`, the writer half is + # deliberately skipped so the generated RBI stays in line with + # Sorbet's `sig + attr_accessor` convention (sig on the reader, + # nothing on the writer). + #: ((Method | UnboundMethod) method, Module[top] constant, Module[top]? scope_constant) -> Tapioca::Runtime::RbsSignature? + def rbs_signature_for(method, constant, scope_constant) + return unless scope_constant + + definition_and_kind = @pipeline.rbs_definition_for_method( + scope_constant, + method.name, + is_singleton: constant.singleton_class?, + source_location: method.source_location, + ) + return unless definition_and_kind + + definition, kind = definition_and_kind + + # For `attr_accessor`, Sorbet only attaches a runtime sig to the + # reader; the writer is left bare. + return if kind == :attr_accessor && method.name.to_s.end_with?("=") + + Tapioca::RBS::SignatureBuilder.build(method, definition, kind, @pipeline.gem_graph) + end + # Check whether the method is defined by the constant. # # In most cases, it works to check that the constant is the method owner. However, diff --git a/lib/tapioca/gem/listeners/sorbet_helpers.rb b/lib/tapioca/gem/listeners/sorbet_helpers.rb index 7b0a289c2..ec403b793 100644 --- a/lib/tapioca/gem/listeners/sorbet_helpers.rb +++ b/lib/tapioca/gem/listeners/sorbet_helpers.rb @@ -15,11 +15,44 @@ def on_scope(event) constant = event.constant node = event.node + # Sorbet-runtime tracked helpers (set via `abstract!`, `final!`, + # `sealed!`). abstract_type = abstract_type_of(constant) - node << RBI::Helper.new(abstract_type.to_s) if abstract_type node << RBI::Helper.new("final") if final_module?(constant) node << RBI::Helper.new("sealed") if sealed_module?(constant) + + # Inline RBS `# @abstract`, `# @interface`, `# @sealed`, `# @final` + # annotations. Without the require-hook rewriter we don't get the + # runtime tracking above for these, so we synthesize the helpers + # straight from source. + add_rbs_helpers(event) + end + + #: (ScopeNodeAdded event) -> void + def add_rbs_helpers(event) + rbs_comments = @pipeline.rbs_comments_for_constant(event.constant) + return unless rbs_comments + + existing = event.node.nodes.grep(RBI::Helper).map(&:name).to_set + + rbs_comments.class_annotations.each do |annotation| + helper_name = case annotation.string + when "@abstract" + "abstract" + when "@interface" + "interface" + when "@sealed" + "sealed" + when "@final" + "final" + end + next unless helper_name + next if existing.include?(helper_name) + + event.node << RBI::Helper.new(helper_name) + existing << helper_name + end end # @override diff --git a/lib/tapioca/gem/listeners/sorbet_required_ancestors.rb b/lib/tapioca/gem/listeners/sorbet_required_ancestors.rb index 7033a7ddb..c1fe976d8 100644 --- a/lib/tapioca/gem/listeners/sorbet_required_ancestors.rb +++ b/lib/tapioca/gem/listeners/sorbet_required_ancestors.rb @@ -10,12 +10,45 @@ class SorbetRequiredAncestors < Base # @override #: (ScopeNodeAdded event) -> void def on_scope(event) + # Sorbet-runtime tracked ancestors (set via `requires_ancestor {}`). ancestors = Runtime::Trackers::RequiredAncestor.required_ancestors_by(event.constant) ancestors.each do |ancestor| next unless ancestor # TODO: We should have a way to warn from here event.node << RBI::RequiresAncestor.new(ancestor.to_s) end + + # Inline RBS `# @requires_ancestor: Type` annotations — these are + # picked up from source so we don't need the require-hook rewriter + # to translate them into `requires_ancestor {}` calls at load time. + add_rbs_required_ancestors(event) + end + + #: (ScopeNodeAdded event) -> void + def add_rbs_required_ancestors(event) + rbs_comments = @pipeline.rbs_comments_for_constant(event.constant) + return unless rbs_comments + + qualifier = Tapioca::RBS::TypeQualifier.new( + @pipeline.gem_graph, + event.symbol.delete_prefix("::").split("::").reject(&:empty?), + ) + + rbs_comments.class_annotations.each do |annotation| + string = annotation.string + next unless string.start_with?("@requires_ancestor:") + + type_string = string.delete_prefix("@requires_ancestor:").strip + + begin + srb_type = ::RBS::Parser.parse_type(type_string) + rbi_type = ::RBI::RBS::TypeTranslator.translate(srb_type) + rescue ::RBS::ParsingError, ::RBI::Error + next + end + + event.node << RBI::RequiresAncestor.new(qualifier.visit(rbi_type)) + end end # @override diff --git a/lib/tapioca/gem/listeners/sorbet_signatures.rb b/lib/tapioca/gem/listeners/sorbet_signatures.rb index 88f4f6d72..4aefe8de8 100644 --- a/lib/tapioca/gem/listeners/sorbet_signatures.rb +++ b/lib/tapioca/gem/listeners/sorbet_signatures.rb @@ -5,9 +5,6 @@ module Tapioca module Gem module Listeners class SorbetSignatures < Base - include Runtime::Reflection - include RBIHelper - private # @override @@ -16,60 +13,9 @@ def on_method(event) signature = event.signature return unless signature - event.node.sigs << compile_signature(signature, event.parameters) - end - - #: (untyped signature, Array[[Symbol, String]] parameters) -> RBI::Sig - def compile_signature(signature, parameters) - parameter_types = signature.arg_types.to_h #: Hash[Symbol, T::Types::Base] - parameter_types.merge!(signature.kwarg_types) - rest_type = signature.rest_type - parameter_types[signature.rest_name] = rest_type if rest_type - keyrest_type = signature.keyrest_type - parameter_types[signature.keyrest_name] = keyrest_type if keyrest_type - parameter_types[signature.block_name] = signature.block_type if signature.block_name - - sig = RBI::Sig.new - - parameters.each do |_, name| - type = sanitize_signature_types(parameter_types[name.to_sym].to_s) - @pipeline.push_symbol(type) - sig << RBI::SigParam.new(name, type) - end - - return_type = name_of_type(signature.return_type) - return_type = sanitize_signature_types(return_type) - sig.return_type = return_type - @pipeline.push_symbol(return_type) - - sig.type_params.concat(extract_type_parameters(parameter_types.values.map(&:to_s).append(return_type))) - - case signature.mode - when "abstract" - sig.is_abstract = true - when "override" - sig.is_override = true - when "overridable_override" - sig.is_overridable = true - sig.is_override = true - when "overridable" - sig.is_overridable = true - end - - sig.is_final = signature_final?(signature) - - sig - end - - #: (untyped signature) -> bool - def signature_final?(signature) - modules_with_final = T::Private::Methods.instance_variable_get(:@modules_with_final) - # In https://github.com/sorbet/sorbet/pull/7531, Sorbet changed internal hashes to be compared by identity, - # starting on version 0.5.11155 - final_methods = modules_with_final[signature.owner] || modules_with_final[signature.owner.object_id] - return false unless final_methods - - final_methods.include?(signature.method_name) + event.node.sigs.concat( + signature.compile_to_rbi_sig(event.parameters) { |sym| @pipeline.push_symbol(sym) }, + ) end # @override diff --git a/lib/tapioca/gem/listeners/sorbet_type_variables.rb b/lib/tapioca/gem/listeners/sorbet_type_variables.rb index df456a991..f0b631fb1 100644 --- a/lib/tapioca/gem/listeners/sorbet_type_variables.rb +++ b/lib/tapioca/gem/listeners/sorbet_type_variables.rb @@ -20,6 +20,11 @@ def on_scope(event) sclass = RBI::SingletonClass.new compile_type_variable_declarations(sclass, singleton_class_of(constant)) node << sclass if sclass.nodes.length > 1 + + # Pick up inline RBS class type parameter declarations + # (e.g. `#: [A, B]` on a class) when the runtime didn't track them + # (no `extend T::Generic` / `type_member` calls were made). + add_rbs_type_members(event) end #: (RBI::Tree tree, Module[top] constant) -> void @@ -45,6 +50,82 @@ def compile_type_variable_declarations(tree, constant) tree << RBI::Extend.new("T::Generic") end + # Adds `extend T::Generic` and one `type_member` per RBS type + # parameter when an inline `#: [A, B]` declaration is present on a + # class or module. Does nothing when the runtime already tracked the + # generic via Sorbet's `extend T::Generic` + `type_member` calls, + # since {#compile_type_variable_declarations} already emitted them. + #: (ScopeNodeAdded event) -> void + def add_rbs_type_members(event) + return if event.node.nodes.any?(RBI::TypeMember) + + rbs_comments = @pipeline.rbs_comments_for_constant(event.constant) + return unless rbs_comments + + type_param_signatures = rbs_comments.signatures.select { |s| s.string.start_with?("[") } + return if type_param_signatures.empty? + + qualifier = Tapioca::RBS::TypeQualifier.new( + @pipeline.gem_graph, + event.symbol.delete_prefix("::").split("::").reject(&:empty?), + ) + + added_any = false #: bool + + type_param_signatures.each do |signature| + begin + type_params = ::RBS::Parser.parse_type_params(signature.string) + rescue ::RBS::ParsingError + next + end + next if type_params.empty? + + type_params.each do |type_param| + event.node << build_rbs_type_member(type_param, qualifier) + added_any = true + end + end + + if added_any && !event.node.nodes.any? { |n| n.is_a?(RBI::Extend) && n.names.include?("T::Generic") } + event.node << RBI::Extend.new("T::Generic") + end + end + + # Builds an `RBI::TypeMember` node from an RBS type parameter, + # carrying over variance (`:in` / `:out`), `upper:` bound, and + # `fixed:` default into the standard Sorbet `type_member` block + # form. + #: (untyped type_param, Tapioca::RBS::TypeQualifier qualifier) -> RBI::TypeMember + def build_rbs_type_member(type_param, qualifier) + name = type_param.name.to_s + parts = ["type_member"] + + case type_param.variance + when :covariant + parts << "(:out)" + when :contravariant + parts << "(:in)" + end + + block_parts = [] + + if type_param.upper_bound + rbi_type = ::RBI::RBS::TypeTranslator.translate(type_param.upper_bound) + block_parts << "upper: #{qualifier.visit(rbi_type)}" + end + + if type_param.default_type + rbi_type = ::RBI::RBS::TypeTranslator.translate(type_param.default_type) + block_parts << "fixed: #{qualifier.visit(rbi_type)}" + end + + if block_parts.any? + parts << " { { #{block_parts.join(", ")} } }" + end + + RBI::TypeMember.new(name, parts.join) + end + #: (Tapioca::TypeVariableModule type_variable) -> RBI::Node? def node_from_type_variable(type_variable) case type_variable.type diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index ccf22fe5c..fe04a6e50 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -12,6 +12,10 @@ class Pipeline #: Gemfile::GemSpec attr_reader :gem + # @without_runtime + #: Rubydex::Graph + attr_reader :gem_graph + #: ^(String error) -> void attr_reader :error_handler @@ -32,7 +36,13 @@ def initialize( @payload_symbols = Static::SymbolLoader.payload_symbols #: Set[String] @bootstrap_symbols = load_bootstrap_symbols(@gem) #: Set[String] - gem_graph = Static::SymbolLoader.graph_from_paths(@gem.files) if include_doc + # The graph is built unconditionally because we use it both for inline + # RBS comment parsing (always on) and for documentation extraction + # (only when `include_doc` is true). + @gem_graph = Static::SymbolLoader.graph_from_paths( + @gem.files, + rbi_files: @gem.rbi_stub_files, + ) #: Rubydex::Graph @bootstrap_symbols.each { |symbol| push_symbol(symbol) } @@ -47,7 +57,7 @@ def initialize( @node_listeners << Gem::Listeners::SorbetRequiredAncestors.new(self) @node_listeners << Gem::Listeners::SorbetSignatures.new(self) @node_listeners << Gem::Listeners::Subconstants.new(self) - @node_listeners << Gem::Listeners::Documentation.new(self, gem_graph) if include_doc + @node_listeners << Gem::Listeners::Documentation.new(self, @gem_graph) if include_doc @node_listeners << Gem::Listeners::ForeignConstants.new(self) @node_listeners << Gem::Listeners::SourceLocation.new(self) if include_loc @node_listeners << Gem::Listeners::RemoveEmptyPayloadScopes.new(self) @@ -97,7 +107,7 @@ def push_foreign_scope(symbol, constant, node) #| Module[top] constant, #| UnboundMethod method, #| RBI::Method node, - #| untyped signature, + #| Tapioca::Runtime::Signature? signature, #| Array[[Symbol, String]] parameters #| ) -> void def push_method(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists @@ -174,6 +184,86 @@ def method_definition_in_gem(method_name, owner) MethodInGemWithLocation.new(found) end + # Inline RBS comments + + # Returns the parsed RBS comments attached to the source-level declaration + # of `constant`, if any. Used by listeners to pick up class/module-level + # RBS annotations (e.g. `# @abstract`, `# @requires_ancestor:`, `#: [A, B]`). + #: (Module[top] constant) -> Tapioca::RBS::Comments::Parsed? + def rbs_comments_for_constant(constant) + name = name_of(constant) + return unless name + + declaration = @gem_graph[name] + return unless declaration + + # Pick the definition whose file lives inside the gem under compilation. + definition = declaration.definitions.find do |d| + @gem.contains_path?(d.location.to_file_path) + rescue Rubydex::Location::NotFileUriError + false + end + return unless definition + + parse_rbs_comments(definition) + end + + # Returns the Rubydex definition and the kind of declaration + # (`:method`, `:attr_reader`, `:attr_writer`, `:attr_accessor`) for + # the source-level declaration of `method_name` on `scope_constant`, + # or nil when no matching declaration exists in this gem. + # + # `scope_constant` is the lexical scope (the attached class for + # singleton methods, never the singleton class itself). + # `is_singleton` indicates whether the method is a singleton method. + # When `source_location` is provided, the matching definition is + # selected by file/line; otherwise the first definition is used. + # + # Used by the gem `Methods` listener to feed + # {Tapioca::RBS::SignatureBuilder} when no Sorbet `sig {}` block is + # available at runtime. + #: ( + #| Module[top] scope_constant, + #| Symbol method_name, + #| ?is_singleton: bool, + #| ?source_location: [String, Integer]? + #| ) -> [Rubydex::Definition, Symbol]? + def rbs_definition_for_method(scope_constant, method_name, is_singleton: false, source_location: nil) + scope_name = name_of(scope_constant) + return unless scope_name + + # attr_writer methods (`foo=`) are represented in Rubydex via the + # reader name (`foo()`), so strip the trailing `=`. + lookup_name = method_name.to_s.delete_suffix("=") + + qualified_name = if is_singleton + last_part = scope_name.split("::").last + "#{scope_name}::<#{last_part}>##{lookup_name}()" + else + "#{scope_name}##{lookup_name}()" + end + + declaration = @gem_graph[qualified_name] + # For singleton methods defined via `module_function`/`extend self`, + # Rubydex only indexes the instance form. Fall back to it. + if declaration.nil? && is_singleton + declaration = @gem_graph["#{scope_name}##{lookup_name}()"] + end + return unless declaration + + definition = pick_definition(declaration, source_location) + return unless definition + + kind = case definition + when Rubydex::AttrReaderDefinition then :attr_reader + when Rubydex::AttrWriterDefinition then :attr_writer + when Rubydex::AttrAccessorDefinition then :attr_accessor + else :method + end + + [definition, kind] + end + # Helpers #: (Module[top] constant) -> String? @@ -199,6 +289,55 @@ def load_bootstrap_symbols(gem) gem_symbols.union(engine_symbols) end + # Selects the right `Rubydex::Definition` from a multi-definition + # declaration. When `source_location` (a `[file, line]` 1-indexed tuple as + # returned by `Method#source_location`) is provided, prefers a definition + # whose file matches and whose line is the closest. Otherwise picks the + # first definition belonging to the gem under compilation. + #: (Rubydex::Declaration declaration, [String, Integer]? source_location) -> Rubydex::Definition? + def pick_definition(declaration, source_location) + definitions = declaration.definitions.to_a + + if source_location + file, line = source_location + # `Method#source_location` is 1-indexed, Rubydex is 0-indexed. + target_line = line - 1 + realpath = begin + Pathname.new(file).realpath.to_s + rescue Errno::ENOENT + file + end + + best = definitions.select do |d| + d_path = d.location.to_file_path + d_path == file || d_path == realpath + rescue Rubydex::Location::NotFileUriError + false + end + + if best.any? + return best.min_by { |d| (d.location.start_line - target_line).abs } + end + end + + definitions.find do |d| + @gem.contains_path?(d.location.to_file_path) + rescue Rubydex::Location::NotFileUriError + false + end + end + + # Parses the RBS comments attached to a Rubydex definition. + #: (Rubydex::Definition definition) -> Tapioca::RBS::Comments::Parsed + def parse_rbs_comments(definition) + tuples = definition.comments.map do |comment| + # Rubydex uses 0-indexed lines; convert to 1-indexed to match + # `Method#source_location` and downstream callers. + [comment.string, comment.location.start_line + 1] + end + Tapioca::RBS::Comments.parse(tuples) + end + # Events handling #: -> Gem::Event diff --git a/lib/tapioca/gemfile.rb b/lib/tapioca/gemfile.rb index 6c8413c20..c05f1e5c5 100644 --- a/lib/tapioca/gemfile.rb +++ b/lib/tapioca/gemfile.rb @@ -133,6 +133,14 @@ def spec_lookup_by_file_path #: Array[Pathname] attr_reader :files + # Sibling RBI stub files shipped with the gem (e.g. `rbi/foo.rbi`) + # that aren't loaded by Ruby at runtime but describe the surface + # of the gem's native code. Discovered from the gemspec's full + # file list so we can feed them into static analyzers that need + # to resolve constants only defined in those stubs. + #: Array[Pathname] + attr_reader :rbi_stub_files + #: (Spec spec) -> void def initialize(spec) @spec = spec #: Tapioca::Gemfile::Spec @@ -141,6 +149,7 @@ def initialize(spec) @version = version_string #: String @exported_rbi_files = nil #: Array[String]? @files = collect_files #: Array[Pathname] + @rbi_stub_files = collect_rbi_stub_files #: Array[Pathname] end #: (BasicObject other) -> bool @@ -225,6 +234,15 @@ def collect_files end end + # Returns the `.rbi` files shipped in the gem's `rbi/` directory. + # These RBI stubs describe constants implemented in native code that + # don't have a corresponding `.rb` declaration, so static indexers + # need them to resolve references to those constants. + #: -> Array[Pathname] + def collect_rbi_stub_files + Pathname.glob((Pathname.new(@full_gem_path) / "rbi/**/*.rbi").to_s) + end + #: -> bool? def default_gem? @spec.respond_to?(:default_gem?) && @spec.default_gem? diff --git a/lib/tapioca/internal.rb b/lib/tapioca/internal.rb index ac8f8e592..f309003a7 100644 --- a/lib/tapioca/internal.rb +++ b/lib/tapioca/internal.rb @@ -16,16 +16,14 @@ require "tapioca/sorbet_ext/void_patch" require "tapioca/runtime/generic_type_registry" -# The rewriter needs to be loaded very early so RBS comments within Tapioca itself are rewritten +# Make `sig {}` blocks available to every class/module without requiring an +# explicit `extend T::Sig`. Gems and applications that rely on bare `sig` +# in their classes used to get this behavior from the load-time RBS +# rewriter; we now install the include directly so that the same +# convention keeps working after the rewriter was removed. +Module.include(T::Sig) + require "spoom" -# Eager load all the autoloads at this point, so that we don't enter into -# a weird loop when the autoloads get triggered and we try to require the file. -# This is especially important since Prism has a few autoloaded constants that -# should NOT be rewritten (since they are needed for the rewriting itself), so -# should be loaded as early as possible. -Tapioca::Runtime::Trackers::Autoload.eager_load_all! -require "tapioca/rbs/rewriter" -# ^ Do not change the order of these requires require "benchmark" require "bundler" @@ -45,11 +43,18 @@ require "rubydex" require "prism" +require "tapioca/rbs/comments" +require "tapioca/rbs/type_qualifier" + require "tapioca/helpers/gem_helper" require "tapioca/helpers/git_attributes" require "tapioca/helpers/sorbet_helper" require "tapioca/helpers/rbi_helper" +require "tapioca/runtime/signature" +require "tapioca/rbs/signature_builder" +require "tapioca/rbs/dsl_signatures" + require "tapioca/helpers/package_url" require "tapioca/helpers/cli_helper" require "tapioca/helpers/config_helper" diff --git a/lib/tapioca/rbs/comments.rb b/lib/tapioca/rbs/comments.rb new file mode 100644 index 000000000..63cbcb142 --- /dev/null +++ b/lib/tapioca/rbs/comments.rb @@ -0,0 +1,105 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module RBS + # Parses RBS comments (e.g. `#: -> void`, `#| continuation`, `# @abstract`, + # `# @requires_ancestor: Kernel`, `#: [A, B]`) out of a stream of raw comment + # strings that immediately precede a Ruby construct (method, attr, class). + # + # The result is a {Comments::Parsed} object exposing parsed signatures + # and annotations, classified into class-level and method-level annotations. + # + # This implementation mirrors the logic in `Spoom::RBS::ExtractRBSComments`, + # but operates on plain `[comment_string, line]` tuples (as obtained from + # Rubydex or any other comment provider) rather than Prism nodes, so it can + # be used without re-parsing source files. + module Comments + Signature = Struct.new(:string, :line) + Annotation = Struct.new(:string, :line) + + CLASS_ANNOTATION_PATTERN = /\A(@abstract|@interface|@sealed|@final|@requires_ancestor:)/ #: Regexp + METHOD_ANNOTATION_NAMES = [ + "@abstract", + "@final", + "@override", + "@override(allow_incompatible: true)", + "@override(allow_incompatible: :visibility)", + "@overridable", + "@without_runtime", + ].freeze #: Array[String] + private_constant :CLASS_ANNOTATION_PATTERN, :METHOD_ANNOTATION_NAMES + + class Parsed + #: Array[Signature] + attr_reader :signatures + + #: Array[Annotation] + attr_reader :annotations + + #: -> void + def initialize + @signatures = [] #: Array[Signature] + @annotations = [] #: Array[Annotation] + end + + #: -> bool + def empty? + @signatures.empty? && @annotations.empty? + end + + #: -> Array[Annotation] + def class_annotations + @annotations.select { |a| a.string.match?(CLASS_ANNOTATION_PATTERN) } + end + + #: -> Array[Annotation] + def method_annotations + @annotations.select { |a| METHOD_ANNOTATION_NAMES.include?(a.string) } + end + end + + class << self + # Parses a list of `[comment_string, line]` tuples (ordered by line, top to + # bottom) into a {Parsed} object. + # + # The tuples must be the contiguous block of comments that immediately + # precedes the construct of interest; callers are responsible for + # selecting the right block. + #: (Array[[String, Integer]] comments) -> Parsed + def parse(comments) + result = Parsed.new + + continuation_comments = [] #: Array[[String, Integer]] + + comments.reverse_each do |string, line| + if string.start_with?("# @") + annotation = string.delete_prefix("#").strip + result.annotations.unshift(Annotation.new(annotation, line)) + elsif string.start_with?("#: ") || string == "#:" + sig_string = string.delete_prefix("#:").strip + + # Continuation comments are accumulated by pushing while we walk + # source comments in reverse order (so they sit in + # last-line-first order in the array). Walking the array in + # reverse here puts them back in forward source order before + # we append them to the signature string. + continuation_comments.reverse_each do |cont_string, _cont_line| + sig_string = "#{sig_string}#{cont_string.delete_prefix("#|")}" + end + continuation_comments.clear + + result.signatures.unshift(Signature.new(sig_string, line)) + elsif string.start_with?("#|") + continuation_comments << [string, line] + else + continuation_comments.clear + end + end + + result + end + end + end + end +end diff --git a/lib/tapioca/rbs/dsl_signatures.rb b/lib/tapioca/rbs/dsl_signatures.rb new file mode 100644 index 000000000..59a794b55 --- /dev/null +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -0,0 +1,334 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module RBS + # Resolves inline RBS signatures for runtime methods encountered during a + # `tapioca dsl` run. + # + # The DSL command doesn't compile a specific gem — it inspects whichever + # constants the user's app exposes — so we build a Rubydex graph of the + # entire host workspace (plus core/stdlib RBS) and consult it whenever a + # DSL compiler asks for the signature of a method that has no Sorbet + # runtime sig. This mirrors what the `gem` pipeline does on a per-gem + # graph and lets DSL compilers see RBS-only sigs without relying on the + # require-hook rewriter. + module DslSignatures + class << self + # Returns a {Tapioca::Runtime::RbsSignature} for the inline RBS + # comments next to `method_def`'s source declaration. Types in + # the signature are fully qualified through the host-app graph, + # and method-level annotations (`# @abstract`, `# @override`, + # `# @without_runtime`, ...) are carried over so callers can + # apply them when emitting the final `RBI::Sig`. Returns nil + # when no RBS info is available or the signature can't be + # parsed. + #: ((Method | UnboundMethod) method_def) -> Tapioca::Runtime::RbsSignature? + def build(method_def) + location = method_def.source_location + return unless location + + file, line = location + declaration_and_kind = find_declaration(method_def, file, line) + return unless declaration_and_kind + + declaration, kind = declaration_and_kind + definition = pick_definition(declaration, file, line) + return unless definition + + SignatureBuilder.build(method_def, definition, kind, graph) + end + + # Returns the per-process Rubydex graph used to look up declarations + # and resolve constants. Built lazily on first access. On every call + # we also incrementally index any new `$LOADED_FEATURES` entries we + # haven't seen yet — this matters for test suites that `require` + # fresh fixture files between tests, where the cached graph would + # otherwise miss the new source. + # + # Parallel DSL workers (forked by `Parallel.map`) get their own copy + # the first time a compiler asks for a sig — the graph is not + # Marshal-friendly (Rust-backed) so we can't share across the fork + # boundary cleanly. + #: -> Rubydex::Graph + def graph + @graph ||= build_graph #: Rubydex::Graph? + refresh_graph(@graph) + end + + # Drops the cached graph. Test-only escape hatch. + #: -> void + def reset! + @graph = nil #: Rubydex::Graph? + @indexed_paths = nil #: Set[String]? + end + + private + + #: -> Rubydex::Graph + def build_graph + paths = workspace_source_paths + graph = Rubydex::Graph.new + graph.index_all(paths) + graph.resolve + @indexed_paths = Set.new(paths) #: Set[String]? + graph + end + + # Indexes any new `$LOADED_FEATURES` files that have appeared since + # the graph was last built/refreshed. Returns `graph` for chaining. + #: (Rubydex::Graph graph) -> Rubydex::Graph + def refresh_graph(graph) + indexed = (@indexed_paths ||= Set.new) #: Set[String] + new_paths = extra_loaded_features.reject { |p| indexed.include?(p) } + return graph if new_paths.empty? + + graph.index_all(new_paths) + graph.resolve + indexed.merge(new_paths) + graph + end + + # Source paths to index for the host app: the user's own code under + # `Dir.pwd` (excluding common artifact directories like `.git`, + # `tmp`, `node_modules`, `vendor`, etc.), every `.rb` file already + # loaded into the process via `$LOADED_FEATURES` (so we cover code + # loaded from temp dirs, scripts outside `Dir.pwd`, etc.), and the + # latest installed core/stdlib RBS so basic constant resolution + # still works. + # + # We deliberately skip Bundler-managed dependencies that live under + # `Gem.path`, because indexing every gem has been seen to make + # Rubydex's resolver panic on Rails apps and there's nothing we'd + # do with the resolved declarations anyway — we only need to + # resolve constants the user references from their own inline RBS + # sigs. + #: -> Array[String] + def workspace_source_paths + paths = workspace_top_level_paths + paths.concat(extra_loaded_features) + paths.concat(Static::SymbolLoader.core_rbs_definition_paths) + paths.uniq! + paths + rescue StandardError + # Last-ditch fallback if anything blows up while probing — at + # least we still get core RBS resolution. + Static::SymbolLoader.core_rbs_definition_paths.dup + end + + # Walk `Dir.pwd`'s top level, returning the subdirectories and + # top-level `.rb` files that should feed the graph. + #: -> Array[String] + def workspace_top_level_paths + workspace = begin + Dir.pwd + rescue StandardError + "." + end + + paths = [] + Dir.each_child(workspace) do |entry| + next if IGNORED_WORKSPACE_DIRS.include?(entry) + + full_path = File.join(workspace, entry) + if File.directory?(full_path) + paths << full_path + elsif File.extname(entry) == ".rb" + paths << full_path + end + end + paths + end + + # Returns the absolute paths of every Ruby source file already + # loaded into the process that lives *outside* the workspace, + # gem path, and any Ruby runtime/standard library directory we can + # detect. This captures host-app code that lives in unusual places + # (most notably the `tmp_path` directories used by the spec suite) + # without dragging every gem into the graph. + #: -> Array[String] + def extra_loaded_features + workspace_prefix = begin + "#{Dir.pwd}/" + rescue StandardError + nil + end + gem_prefixes = ::Gem.path.map { |p| "#{p}/" } + ruby_lib_prefix = "#{RbConfig::CONFIG["rubylibdir"]}/" + site_dir_prefix = "#{RbConfig::CONFIG["sitelibdir"]}/" if RbConfig::CONFIG["sitelibdir"] + + $LOADED_FEATURES.select do |feature| + next false unless feature.end_with?(".rb") + next false unless feature.start_with?("/") # absolute path + next false if workspace_prefix && feature.start_with?(workspace_prefix) + next false if gem_prefixes.any? { |gp| feature.start_with?(gp) } + next false if feature.start_with?(ruby_lib_prefix) + next false if site_dir_prefix && feature.start_with?(site_dir_prefix) + + true + end + end + + IGNORED_WORKSPACE_DIRS = [ + ".bundle", + ".git", + ".github", + ".ruby-lsp", + ".vscode", + "log", + "node_modules", + "sorbet", + "tmp", + "vendor", + ].freeze #: Array[String] + private_constant :IGNORED_WORKSPACE_DIRS + + # Finds the Rubydex declaration that owns `method_def`. Tries + # several lookup shapes in order, falling back to a file/line scan + # when the owner has no name (anonymous classes built with + # `Class.new`). + # + # The `line` argument is the 1-indexed runtime + # `method.source_location` line, used to disambiguate between + # multiple declarations with the same name (e.g. when a spec file + # creates anonymous classes in each test block). + #: ((Method | UnboundMethod) method_def, String file, Integer line) -> [Rubydex::Declaration, Symbol]? + def find_declaration(method_def, file, line) + owner = method_def.owner + owner_name = Runtime::Reflection.name_of(owner) + method_name = method_def.name.to_s + + if owner_name + # Singleton methods live on the singleton class; we surface them + # under their attached class with the `` marker Rubydex uses. + if owner.singleton_class? + # Singleton classes are always `Class`, but the `Module#owner` + # accessor types as `Module`. Refine the type here so the + # downstream `attached_class_of` call lines up. + singleton = owner #: as Class[top] + result = lookup_singleton_declaration(singleton, method_name) + return result if result + end + + lookup_name = method_name.delete_suffix("=") + qualified = "#{owner_name}##{lookup_name}()" + result = lookup_with_kind(qualified) + return result if result + end + + # Owner has no name (anonymous class) or qualified lookup failed. + # Fall back to scanning the file for a method declaration with the + # right name and a definition closest to `line`. + find_declaration_by_location(method_def, file, line) + end + + # Scans the graph for a method declaration whose name matches, + # which has a definition in `file`, and whose definition line is + # the closest match to `line` (1-indexed runtime source location). + # Used when the owner is anonymous and can't be looked up by + # qualified name. + #: ((Method | UnboundMethod) method_def, String file, Integer line) -> [Rubydex::Declaration, Symbol]? + def find_declaration_by_location(method_def, file, line) + method_name = method_def.name.to_s + lookup_name = method_name.delete_suffix("=") + target_line = line - 1 # Rubydex is 0-indexed + realpath = begin + Pathname.new(file).realpath.to_s + rescue Errno::ENOENT + file + end + + best_declaration = nil #: Rubydex::Declaration? + best_distance = nil #: Integer? + + graph.declarations.each do |declaration| + next unless declaration.is_a?(Rubydex::Method) + next unless declaration.unqualified_name == "#{lookup_name}()" + + declaration.definitions.each do |defn| + path = begin + defn.location.to_file_path + rescue Rubydex::Location::NotFileUriError + next + end + next unless path == file || path == realpath + + distance = (defn.location.start_line - target_line).abs + if best_distance.nil? || distance < best_distance + best_distance = distance + best_declaration = declaration + end + end + end + + return unless best_declaration + + kind = case best_declaration.definitions.first + when Rubydex::AttrReaderDefinition then :attr_reader + when Rubydex::AttrWriterDefinition then :attr_writer + when Rubydex::AttrAccessorDefinition then :attr_accessor + else :method + end + [best_declaration, kind] + end + + # Looks up a singleton method declaration on `owner` (which is + # expected to be a singleton class) by walking up to the attached + # class and using Rubydex's `Foo::#method()` form. + #: (Class[top] owner, String method_name) -> [Rubydex::Declaration, Symbol]? + def lookup_singleton_declaration(owner, method_name) + attached = Runtime::Reflection.attached_class_of(owner) + return unless attached + + attached_name = Runtime::Reflection.name_of(attached) + return unless attached_name + + last_part = attached_name.split("::").last + qualified = "#{attached_name}::<#{last_part}>##{method_name.delete_suffix("=")}()" + lookup_with_kind(qualified) + end + + #: (String qualified) -> [Rubydex::Declaration, Symbol]? + def lookup_with_kind(qualified) + declaration = graph[qualified] + return unless declaration + + kind = declaration.definitions.first&.then do |d| + case d + when Rubydex::AttrReaderDefinition then :attr_reader + when Rubydex::AttrWriterDefinition then :attr_writer + when Rubydex::AttrAccessorDefinition then :attr_accessor + else :method + end + end || :method + + [declaration, kind] + end + + # Selects the definition matching `file` and `line` (1-indexed, + # i.e. `method.source_location` form). Rubydex itself uses + # 0-indexed lines, so we offset by one. + #: (Rubydex::Declaration declaration, String file, Integer line) -> Rubydex::Definition? + def pick_definition(declaration, file, line) + target_line = line - 1 + realpath = begin + Pathname.new(file).realpath.to_s + rescue Errno::ENOENT + file + end + + matching = declaration.definitions.select do |d| + path = d.location.to_file_path + path == file || path == realpath + rescue Rubydex::Location::NotFileUriError + false + end + + return matching.min_by { |d| (d.location.start_line - target_line).abs } if matching.any? + + declaration.definitions.first + end + end + end + end +end diff --git a/lib/tapioca/rbs/rewriter.rb b/lib/tapioca/rbs/rewriter.rb deleted file mode 100644 index ac5bb1974..000000000 --- a/lib/tapioca/rbs/rewriter.rb +++ /dev/null @@ -1,110 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -# This code rewrites RBS comments back into Sorbet's signatures as the files are being loaded. -# This will allow `sorbet-runtime` to wrap the methods as if they were originally written with the `sig{}` blocks. -# This will in turn allow Tapioca to use this signatures to generate typed RBI files. - -module Tapioca - module RBS - class HostBootsnapSetupError < StandardError; end - - # Raises when the host calls `Bootsnap.setup` after tapioca's setup. Host's call - # would overwrite tapioca's cache directory, so rewritten iseqs would end up in - # the host's regular cache. - module BootsnapGuard - extend T::Sig - - sig { params(_kwargs: T.untyped).void } - def setup(**_kwargs) - Kernel.raise HostBootsnapSetupError, <<~MSG - Bootsnap.setup was called while TAPIOCA_RBS_CACHE=1 is set. Tapioca already - configured bootsnap with a dedicated cache directory; re-running setup - would overwrite that config and start writing rewritten iseqs into your - host's cache. - - Gate your host's Bootsnap.setup on the env var, e.g. in config/boot.rb: - - require "bootsnap/setup" unless ENV["TAPIOCA_RBS_CACHE"] == "1" - MSG - end - end - end -end - -# When TAPIOCA_RBS_CACHE=1, set up bootsnap with a dedicated cache directory -# and load require-hooks so the RBS-rewritten iseqs get cached. Subsequent -# runs read the rewritten iseq directly and skip the rewrite. -# -# After our setup, BootsnapGuard is prepended so the host application can't -# replace our cache directory. -if ENV["TAPIOCA_RBS_CACHE"] == "1" - begin - require "bootsnap" - # Respect BOOTSNAP_READONLY for consumers reading a pre-populated cache - # (e.g. a CI prime step). - readonly = !["0", "false", false].include?(ENV.fetch("BOOTSNAP_READONLY") { false }) - Bootsnap.setup( - cache_dir: ENV.fetch("TAPIOCA_BOOTSNAP_CACHE_DIR", File.join(Dir.pwd, "tmp/cache/bootsnap-tapioca-rbs")), - development_mode: true, - load_path_cache: true, - compile_cache_iseq: true, - compile_cache_yaml: true, - readonly: readonly, - revalidation: true, - ) - Bootsnap.log_stats! - Bootsnap.singleton_class.prepend(Tapioca::RBS::BootsnapGuard) - rescue LoadError - # Bootsnap is not in the bundle, skip iseq caching. - end - - require "require-hooks/setup" -else - require "require-hooks/setup" - - begin - # Disable Bootsnap's iseq cache unless TAPIOCA_RBS_CACHE=1 enabled the separate cache above. - # - # This is necessary because host apps can call Bootsnap.setup after tapioca loads this file. When that happens, - # Bootsnap installs `load_iseq` and serves files from its cache, which bypasses RequireHooks.source_transform. - # Preloading bootsnap's iseq support lets us override `load_iseq` before setup installs it, preserving the default - # RBS rewrite behavior at the cost of slower app boot. - require "bootsnap" - require "bootsnap/compile_cache/iseq" - - module Bootsnap - module CompileCache - module ISeq - module InstructionSequenceMixin - #: (String) -> RubyVM::InstructionSequence - def load_iseq(path) - super if defined?(super) # Disable Bootsnap's hook, but trigger any others. - end - end - end - end - end - rescue LoadError - # Bootsnap is not in the bundle, we don't need to do anything. - end -end - -# 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) - -# Trigger the source transformation for each Ruby file being loaded. -RequireHooks.source_transform(patterns: ["**/*.rb"]) do |path, source| - # The source is most likely nil since no `source_transform` hook was triggered before this one. - source ||= File.read(path, encoding: "UTF-8") - - # For performance reasons, we only rewrite files that use Sorbet. - if source =~ /^\s*#\s*typed: (ignore|false|true|strict|strong|__STDLIB_INTERNAL)/ - # Sorbet runtime only supports one signature per method, so keep the last overload. - Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs(source, file: path, overloads_strategy: :translate_last) - end -rescue Spoom::Sorbet::Translate::Error - # If we can't translate the RBS comments back into Sorbet's signatures, we just skip the file. - source -end diff --git a/lib/tapioca/rbs/signature_builder.rb b/lib/tapioca/rbs/signature_builder.rb new file mode 100644 index 000000000..e23d19563 --- /dev/null +++ b/lib/tapioca/rbs/signature_builder.rb @@ -0,0 +1,193 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module RBS + # Builds a {Tapioca::Runtime::RbsSignature} from a Rubydex method + # definition. Both the gem-RBI pipeline and the DSL signature lookup + # need the same translate-and-qualify dance — parse the `#:` comment + # strings, translate them through `RBI::RBS::*Translator`, and qualify + # every constant reference against a Rubydex graph for the surrounding + # lexical scope. The two call sites differ only in *which* graph they + # qualify against (gem-scoped vs workspace-scoped); the rest is the + # same work. + module SignatureBuilder + class << self + # Reads the inline `#:` comments on `definition`, parses each + # signature line as RBS, translates to `RBI::Sig`, and qualifies + # every constant against `graph` using the definition's lexical + # nesting. Returns nil when the definition has no RBS signatures + # or none of them parse. + # + # The resulting {Tapioca::Runtime::RbsSignature} owns N overload + # sigs (one per `#:` line) plus the method-level annotations + # (`@abstract`, `@override`, `@without_runtime`, ...) ready for + # {Tapioca::Runtime::RbsSignature#compile_to_rbi_sig} to apply. + #: ( + #| (Method | UnboundMethod) method, + #| Rubydex::Definition definition, + #| Symbol kind, + #| Rubydex::Graph graph + #| ) -> Tapioca::Runtime::RbsSignature? + def build(method, definition, kind, graph) + parsed = parse_rbs_comments(definition) + return if parsed.signatures.empty? + + qualifier = TypeQualifier.new(graph, nesting_for(definition)) + rbi_method = build_rbi_method(method) + + sigs = parsed.signatures.filter_map do |signature| + sig = build_sig(signature.string, kind, rbi_method, method) + next unless sig + + qualify_sig!(sig, qualifier) + sig + end + return if sigs.empty? + + Tapioca::Runtime::RbsSignature.new( + method, + sigs, + annotations: parsed.method_annotations.map(&:string), + ) + rescue ::RBS::ParsingError, ::RBI::Error + nil + end + + private + + #: (Rubydex::Definition definition) -> Tapioca::RBS::Comments::Parsed + def parse_rbs_comments(definition) + tuples = definition.comments.map do |comment| + # Rubydex uses 0-indexed lines; we present 1-indexed lines to + # match `Method#source_location` and downstream callers. + [comment.string, comment.location.start_line + 1] + end + Tapioca::RBS::Comments.parse(tuples) + end + + #: (String signature_string, Symbol kind, RBI::Method rbi_method, (Method | UnboundMethod) method) -> RBI::Sig? + def build_sig(signature_string, kind, rbi_method, method) + case kind + when :attr_reader, :attr_accessor + build_attr_sig(signature_string, attr_name_from(method), writer: false) + when :attr_writer + build_attr_sig(signature_string, attr_name_from(method), writer: true) + else + method_type = ::RBS::Parser.parse_method_type(signature_string) + ::RBI::RBS::MethodTypeTranslator.translate(rbi_method, method_type) + end + rescue ::RBS::ParsingError, ::RBI::Error + nil + end + + #: (String signature_string, String attr_name, writer: bool) -> RBI::Sig + def build_attr_sig(signature_string, attr_name, writer:) + attr_type = ::RBS::Parser.parse_type(signature_string) + translated = ::RBI::RBS::TypeTranslator.translate(attr_type) + + sig = ::RBI::Sig.new + sig.params << ::RBI::SigParam.new(attr_name, translated) if writer + sig.return_type = translated + sig + end + + #: ((Method | UnboundMethod) method) -> RBI::Method + def build_rbi_method(method) + rbi = RBI::Method.new(method.name.to_s) + method.parameters.each_with_index do |(type, name), index| + rbi_name = name ? name.to_s : "_arg#{index}" + case type + when :req + rbi << RBI::ReqParam.new(rbi_name) + when :opt + rbi << RBI::OptParam.new(rbi_name, "T.unsafe(nil)") + when :rest + rbi << RBI::RestParam.new(rbi_name) + when :keyreq + rbi << RBI::KwParam.new(rbi_name) + when :key + rbi << RBI::KwOptParam.new(rbi_name, "T.unsafe(nil)") + when :keyrest + rbi << RBI::KwRestParam.new(rbi_name) + when :block + rbi << RBI::BlockParam.new(rbi_name) + end + end + rbi + end + + #: ((Method | UnboundMethod) method) -> String + def attr_name_from(method) + method.name.to_s.delete_suffix("=") + end + + #: (RBI::Sig sig, TypeQualifier qualifier) -> void + def qualify_sig!(sig, qualifier) + new_params = sig.params.map do |param| + type = param.type + new_type = type.is_a?(::RBI::Type) ? qualifier.visit(type) : type.to_s + ::RBI::SigParam.new(param.name, new_type) + end + sig.params.replace(new_params) + + return_type = sig.return_type + sig.return_type = qualifier.visit(return_type) if return_type.is_a?(::RBI::Type) + end + + # Lexical nesting at the definition's source position, expressed + # in the shape Rubydex's `Graph#resolve_constant` expects: short + # names, outermost first. See {Tapioca::RBS::DslSignatures} for + # the historical context. + # + # The translation from `Definition#lexical_nesting` (deepest + # first, giving each scope's short name + qualified declaration + # name) accounts for three source shapes: + # + # - **Plain nesting** (`module Foo; class Bar; ...`): each inner + # scope is contributed as its short name (`["Foo", "Bar"]`). + # - **Compound-path opening** (`class Foo::Bar; ...`): the + # outermost scope is contributed as its fully-qualified + # declaration name (`["Foo::Bar"]`). + # - **Absolute-path opening** (`class Foo; module ::Bar; ...`): + # the inner scope is contributed as its declaration name with + # a leading `::` (`["Foo", "::Bar"]`), which is the marker + # Rubydex uses for "this is a top-level reference, restart the + # walk." + # + # Anonymous classes (`Class.new do ... end`) show up as entries + # in `Definition#lexical_nesting` but their declaration name is + # the synthetic `…` form Rubydex uses, which is + # useless for constant resolution. We drop those frames so the + # surrounding named scopes still get picked up correctly. + #: (Rubydex::Definition definition) -> Array[String] + def nesting_for(definition) + scopes = definition.lexical_nesting.reject do |s| + declaration = s.declaration + declaration.nil? || declaration.name.include?("") + end + + result = [] #: Array[String] + parent_decl_name = nil #: String? + scopes.reverse_each do |scope| + declaration = scope.declaration #: as !nil + decl_name = declaration.name + + entry = if parent_decl_name.nil? + decl_name + elsif decl_name == "#{parent_decl_name}::#{scope.name}" + scope.name + else + "::#{decl_name}" + end + + result << entry + parent_decl_name = decl_name + end + + result + end + end + end + end +end diff --git a/lib/tapioca/rbs/type_qualifier.rb b/lib/tapioca/rbs/type_qualifier.rb new file mode 100644 index 000000000..2297a7c28 --- /dev/null +++ b/lib/tapioca/rbs/type_qualifier.rb @@ -0,0 +1,156 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module RBS + # Translates {RBI::Type} trees into the same fully-qualified string form + # Tapioca uses elsewhere when emitting RBI: every constant reference + # (user-defined as well as Sorbet's own `T.*` and `T::*`) is prefixed + # with `::`. Bare names from RBS like `Integer` or `Bar` are first + # resolved through a {Rubydex::Graph} using a lexical `nesting` so the + # output reflects the actual fully-qualified constant name (`::Integer`, + # `::Foo::Bar`, ...). + # + # We deliberately produce strings instead of constructing transformed + # {RBI::Type} instances because we want a single shared serialization + # convention that matches Tapioca's existing output — every type lives + # under the global namespace, including `::T`. + class TypeQualifier + # @without_runtime + #: Rubydex::Graph + attr_reader :graph + + #: Array[String] + attr_reader :nesting + + #: (Rubydex::Graph graph, Array[String] nesting) -> void + def initialize(graph, nesting) + @graph = graph + @nesting = nesting + end + + # Converts an {RBI::Type} tree into a fully-qualified string. Both + # user-defined constants and Sorbet's `T` helpers are emitted with a + # leading `::` (e.g. `::String`, `::T.nilable(::Integer)`, `::T::Array[::String]`). + #: (RBI::Type type) -> String + def visit(type) + case type + when RBI::Type::Simple + qualify(type.name) + when RBI::Type::Generic + "#{qualify_generic(type.name)}[#{type.params.map { |t| visit(t) }.join(", ")}]" + when RBI::Type::Class + "::T::Class[#{visit(type.type)}]" + when RBI::Type::Module + "::T::Module[#{visit(type.type)}]" + when RBI::Type::ClassOf + inner = type.type_parameter + if inner + "::T.class_of(#{visit(type.type)})[#{visit(inner)}]" + else + "::T.class_of(#{visit(type.type)})" + end + when RBI::Type::Nilable + "::T.nilable(#{visit(type.type)})" + when RBI::Type::All + "::T.all(#{type.types.map { |t| visit(t) }.join(", ")})" + when RBI::Type::Any + "::T.any(#{type.types.map { |t| visit(t) }.join(", ")})" + when RBI::Type::Tuple + "[#{type.types.map { |t| visit(t) }.join(", ")}]" + when RBI::Type::Shape + fields = type.types.map { |name, t| "#{name.inspect} => #{visit(t)}" } + "{#{fields.join(", ")}}" + when RBI::Type::TypeAlias + qualify(type.name) + when RBI::Type::TypeParameter + "::T.type_parameter(#{type.name.inspect})" + when RBI::Type::Proc + render_proc(type) + when RBI::Type::Anything + "::T.anything" + when RBI::Type::AttachedClass + "::T.attached_class" + when RBI::Type::Boolean + "::T::Boolean" + when RBI::Type::NoReturn + "::T.noreturn" + when RBI::Type::SelfType + "::T.self_type" + when RBI::Type::Untyped + "::T.untyped" + when RBI::Type::Void + "void" + else + # Unknown subclass — fall back to RBI's own serializer. + type.to_rbi + end + end + + private + + #: (RBI::Type::Proc type) -> String + def render_proc(type) + result = +"::T.proc" + + bind = type.proc_bind + result << ".bind(#{visit(bind)})" if bind + + unless type.proc_params.empty? + result << ".params(" + result << type.proc_params.map { |name, t| "#{name}: #{visit(t)}" }.join(", ") + result << ")" + end + + returns = type.proc_returns + result << if returns.is_a?(RBI::Type::Void) + ".void" + else + ".returns(#{visit(returns)})" + end + + result + end + + # Fully-qualifies a constant name, returning `::Foo::Bar` when the + # name resolves through the graph in the current nesting. Names + # already prefixed with `::` are returned as-is. Names the graph + # can't find fall back to a top-level (`::Name`) qualification. + #: (String name) -> String + def qualify(name) + return name if name.start_with?("::") + + resolved = @graph.resolve_constant(name, @nesting) + return "::#{resolved.name}" if resolved + + "::#{name}" + end + + # Same as {#qualify}, but specialized for `Generic` names. + # + # `RBI::Type::Generic` covers both Sorbet's builtin parametric + # generics (`T::Array[X]`, `T::Hash[K, V]`, ...) and user-defined + # generic classes that extend `T::Generic`. RBI's `TypeTranslator` + # already prefixes the Sorbet builtins with `::T::`, so those pass + # through unchanged. + # + # User-defined generics are a different beast: Sorbet's runtime + # `T::Types::TypedGenericType#name` emits them *without* a leading + # `::`, while their type parameters keep the standard `::Foo` + # qualification. We match that convention here so generated RBI + # stays consistent with the runtime-driven path — resolve the name + # through Rubydex (so `ValueType` becomes + # `Tapioca::Dsl::Helpers::ActiveModelTypeHelperSpec::ValueType`) but + # don't prepend `::`. + #: (String name) -> String + def qualify_generic(name) + return name if name.start_with?("::T::") + + resolved = @graph.resolve_constant(name, @nesting) + return resolved.name if resolved + + name + end + end + end +end diff --git a/lib/tapioca/runtime/reflection.rb b/lib/tapioca/runtime/reflection.rb index cb0c4350b..9912ee817 100644 --- a/lib/tapioca/runtime/reflection.rb +++ b/lib/tapioca/runtime/reflection.rb @@ -123,16 +123,34 @@ def qualified_name_of(constant) SignatureBlockError = Class.new(Tapioca::Error) - #: ((UnboundMethod | Method) method) -> untyped - def signature_of!(method) - T::Utils.signature_for_method(method) + # Returns a polymorphic {Signature} for `method`. Prefers a Sorbet + # runtime sig when one is registered; otherwise falls back to an + # inline RBS lookup. + # + # By default the RBS lookup walks the host workspace's Rubydex + # graph via {Tapioca::RBS::DslSignatures.build}. Callers that need + # a different scope (the gem-RBI pipeline uses its own gem-scoped + # graph) can pass a block; when given, the block replaces the + # default RBS lookup entirely. Its return value becomes the + # function's result. + # + # Raises {SignatureBlockError} when loading the Sorbet sig blows up + # (e.g. its block references an unresolvable constant); callers + # that want a non-raising version use {#signature_of}. + #: ((UnboundMethod | Method) method) ?{ ((Method | UnboundMethod) method) -> Signature? } -> Signature? + def signature_of!(method, &rbs_lookup) + sorbet_signature = T::Utils.signature_for_method(method) + return SorbetSignature.new(sorbet_signature) if sorbet_signature + + rbs_lookup ||= ->(m) { Tapioca::RBS::DslSignatures.build(m) } + rbs_lookup.call(method) rescue LoadError, StandardError Kernel.raise SignatureBlockError end - #: ((UnboundMethod | Method) method) -> untyped - def signature_of(method) - signature_of!(method) + #: ((UnboundMethod | Method) method) ?{ ((Method | UnboundMethod) method) -> Signature? } -> Signature? + def signature_of(method, &rbs_lookup) + signature_of!(method, &rbs_lookup) rescue SignatureBlockError nil end diff --git a/lib/tapioca/runtime/signature.rb b/lib/tapioca/runtime/signature.rb new file mode 100644 index 000000000..1e388a056 --- /dev/null +++ b/lib/tapioca/runtime/signature.rb @@ -0,0 +1,371 @@ +# typed: strict +# frozen_string_literal: true + +module Tapioca + module Runtime + # Polymorphic wrapper around a method signature. + # + # The runtime side of Tapioca needs to talk about "the signature of a + # method" in a few different places (gem RBI generation, DSL compilers, + # type-aware helpers) without leaking the underlying representation. At + # the moment that representation is always a Sorbet + # `T::Private::Methods::Signature`, but the same surface needs to grow to + # cover inline RBS signatures parsed from source. This abstract class is + # the place callers depend on; concrete subclasses encapsulate the + # backend-specific work. + # + # The public surface is deliberately small. We never expose raw + # `arg_types` / `kwarg_types` / `rest_type` / etc. — those are internal + # to whichever backend produced the signature. Callers ask high-level + # questions ("compile yourself into an RBI sig", "give me your return + # type as a string") and the signature answers. + # + # @abstract + class Signature + # Type strings (post-sanitization) that don't carry useful information + # for downstream callers asking "what's the type of …?". Both the + # `ActiveModelTypeHelper` and the `GraphqlTypeHelper` filter on this + # set when deciding whether the signature actually says something. + MEANINGLESS_TYPE_STRINGS = [ + "T.untyped", + "::T.untyped", + "T.noreturn", + "::T.noreturn", + "void", + "", + "", + ].to_set.freeze #: Set[String] + + # The method this signature was attached to. Sorbet's runtime wraps + # methods with sigs in a layer that points back to the original + # method via `signature.method`; callers that introspect parameter + # names / source locations want that wrapped method, not the + # surface one. + # @abstract + #: -> (Method | UnboundMethod) + def method = raise NotImplementedError, "Abstract method called" + + # Parameter type strings in positional source order, ready to feed + # into `RBI::TypedParam` constructors. Encapsulates the + # arg/kwarg/rest/keyrest/block plumbing. + # @abstract + #: -> Array[String] + def parameter_type_strings = raise NotImplementedError, "Abstract method called" + + # The signature's return type as a sanitized string (no `` / + # `` artifacts). + # @abstract + #: -> String + def return_type_string = raise NotImplementedError, "Abstract method called" + + # Same as {#return_type_string}, but returns `nil` when the + # underlying type is one of {MEANINGLESS_TYPE_STRINGS} (`void`, + # `T.untyped`, `T.noreturn`, etc.). Callers that want to ignore + # "no useful info" sigs use this to short-circuit. + #: -> String? + def valid_return_type_string + type_string = return_type_string + return if MEANINGLESS_TYPE_STRINGS.include?(type_string) + + type_string + end + + # The first positional argument's type as a sanitized string, or + # `nil` when the signature has no positional arguments or its first + # arg type is meaningless. Used by helpers that infer custom types + # from a method's lone "value" parameter (e.g. + # `ActiveModelTypeHelper#lookup_arg_type_of_method`). + # @abstract + #: -> String? + def valid_first_arg_type_string = raise NotImplementedError, "Abstract method called" + + # Compiles this signature into a list of `RBI::Sig`s. Sorbet + # runtime sigs always produce a single-element array; inline RBS + # signatures can carry multiple overloads (`#: (String) -> ...` + # and `#: (Symbol) -> ...` on the same method) and produce one + # entry per overload, in source order. + # + # `parameters` is the sanitized `[type, name]` list the caller has + # already prepared from the underlying method. The block receives + # every constant symbol the signature references, so callers (the + # gem pipeline, typically) can feed them back into their symbol + # tracker. + # @abstract + #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> Array[RBI::Sig] + def compile_to_rbi_sig(parameters, &push_symbol) = raise NotImplementedError, "Abstract method called" + end + + # Concrete {Signature} backed by Sorbet's runtime + # `T::Private::Methods::Signature`. This is what + # `Runtime::Reflection.signature_of` returns today; the wrapper hides + # Sorbet's internal layout so callers never have to touch + # `arg_types`/`kwarg_types`/`rest_type`/etc. directly. + class SorbetSignature < Signature + include Reflection + include RBIHelper + + # Sorbet-specific "meaningless" runtime type sentinels. These are + # the runtime-level equivalents of {MEANINGLESS_TYPE_STRINGS} and + # only matter to {SorbetSignature}; the string filter on the + # parent class is the canonical user-facing answer. + MEANINGLESS_TYPES = [ + T.untyped, + T.noreturn, + T::Private::Types::Void, + T::Private::Types::NotTyped, + ].freeze #: Array[Object] + private_constant :MEANINGLESS_TYPES + + #: (untyped signature) -> void + def initialize(signature) + super() + @signature = signature + end + + # @override + #: -> UnboundMethod + def method + @signature.method + end + + # @override + #: -> Array[String] + def parameter_type_strings + parameter_types.values.map { |type| sanitize_signature_types(type.to_s) } + end + + # @override + #: -> String + def return_type_string + sanitize_signature_types(name_of_type(@signature.return_type)) + end + + # @override + #: -> String? + def valid_first_arg_type_string + first_arg_type = @signature.arg_types.dig(0, 1) + return unless first_arg_type + return unless meaningful_runtime_type?(first_arg_type) + + type_string = sanitize_signature_types(first_arg_type.to_s) + return if MEANINGLESS_TYPE_STRINGS.include?(type_string) + + type_string + end + + # @override + #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> Array[RBI::Sig] + def compile_to_rbi_sig(parameters, &push_symbol) + types_by_name = parameter_types + sig = RBI::Sig.new + + parameters.each do |_, name| + type = sanitize_signature_types(types_by_name[name.to_sym].to_s) + push_symbol.call(type) + sig << RBI::SigParam.new(name, type) + end + + return_type = return_type_string + sig.return_type = return_type + push_symbol.call(return_type) + + sig.type_params.concat( + extract_type_parameters(types_by_name.values.map(&:to_s).append(return_type)), + ) + + apply_mode!(sig) + sig.is_final = final? + + [sig] + end + + private + + # Builds the ordered `{ name => type }` mapping of every parameter the + # signature describes (positional, keyword, rest, keyrest, block). + # Used by both {#parameter_type_strings} and {#compile_to_rbi_sig}. + #: -> Hash[Symbol, untyped] + def parameter_types + parameter_types = @signature.arg_types.to_h #: Hash[Symbol, untyped] + parameter_types.merge!(@signature.kwarg_types) + + rest_type = @signature.rest_type + parameter_types[@signature.rest_name] = rest_type if rest_type + + keyrest_type = @signature.keyrest_type + parameter_types[@signature.keyrest_name] = keyrest_type if keyrest_type + + if @signature.block_name + parameter_types[@signature.block_name] = @signature.block_type + end + + parameter_types + end + + #: (RBI::Sig sig) -> void + def apply_mode!(sig) + case @signature.mode + when "abstract" + sig.is_abstract = true + when "override" + sig.is_override = true + when "overridable_override" + sig.is_overridable = true + sig.is_override = true + when "overridable" + sig.is_overridable = true + end + end + + #: -> bool + def final? + modules_with_final = T::Private::Methods.instance_variable_get(:@modules_with_final) + # In https://github.com/sorbet/sorbet/pull/7531, Sorbet changed + # internal hashes to be compared by identity, starting on version + # 0.5.11155, so we have to look both ways. + final_methods = modules_with_final[@signature.owner] || modules_with_final[@signature.owner.object_id] + return false unless final_methods + + final_methods.include?(@signature.method_name) + end + + #: (untyped type) -> bool + def meaningful_runtime_type?(type) + !MEANINGLESS_TYPES.include?(type) + end + end + + # Concrete {Signature} backed by inline RBS comments that have already + # been translated and qualified into `RBI::Sig` instances by + # {Tapioca::RBS::SignatureBuilder}. RBS allows multiple `#:` lines on + # the same method (overloads); the wrapper carries all of them, along + # with the method-level annotations (`# @abstract`, `# @override`, + # `# @without_runtime`, ...) so {#compile_to_rbi_sig} can apply them + # to every emitted sig. + # + # The single-sig accessors ({#parameter_type_strings}, + # {#return_type_string}, {#valid_first_arg_type_string}) pick the + # last overload, mirroring the convention the require-hook RBS + # rewriter used: Sorbet's runtime can only attach one signature per + # method anyway. + class RbsSignature < Signature + #: ( + #| (Method | UnboundMethod) method, + #| Array[RBI::Sig] sigs, + #| ?annotations: Array[String] + #| ) -> void + def initialize(method, sigs, annotations: []) + super() + @method = method + @sigs = sigs + @annotations = annotations + end + + # @override + #: -> (Method | UnboundMethod) + def method # rubocop:disable Style/TrivialAccessors + @method + end + + # @override + #: -> Array[String] + def parameter_type_strings + last_sig.params.map { |param| param.type.to_s } + end + + # @override + #: -> String + def return_type_string + last_sig.return_type.to_s + end + + # @override + #: -> String? + def valid_first_arg_type_string + first_param = last_sig.params.first + return unless first_param + + type_string = first_param.type.to_s + return if MEANINGLESS_TYPE_STRINGS.include?(type_string) + + type_string + end + + # @override + #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> Array[RBI::Sig] + def compile_to_rbi_sig(parameters, &push_symbol) + @sigs.map do |sig| + out = clone_sig(sig) + apply_method_annotations(out) + + # Feed every type the sig references back to the caller's symbol + # tracker so downstream symbol-resolution still sees those + # constants — same contract as `SorbetSignature#compile_to_rbi_sig`. + out.params.each { |param| push_symbol.call(param.type.to_s) } + push_symbol.call(out.return_type.to_s) + + out + end + end + + private + + #: -> RBI::Sig + def last_sig + @sigs.last #: as !nil + end + + # Produces a shallow copy of `sig` so {#compile_to_rbi_sig} can mutate + # the copy (applying annotations, setting `without_runtime` on + # `method_added`/`singleton_method_added`) without disturbing the + # original we built at construction. + #: (RBI::Sig sig) -> RBI::Sig + def clone_sig(sig) + new_sig = RBI::Sig.new + sig.params.each { |param| new_sig.params << RBI::SigParam.new(param.name, param.type) } + new_sig.return_type = sig.return_type + new_sig.type_params.concat(sig.type_params) + new_sig.is_abstract = sig.is_abstract + new_sig.is_override = sig.is_override + new_sig.is_overridable = sig.is_overridable + new_sig.is_final = sig.is_final + new_sig.allow_incompatible_override = sig.allow_incompatible_override + new_sig.allow_incompatible_override_visibility = sig.allow_incompatible_override_visibility + new_sig.without_runtime = sig.without_runtime + new_sig.checked = sig.checked + new_sig + end + + #: (RBI::Sig sig) -> void + def apply_method_annotations(sig) + @annotations.each do |annotation| + case annotation + when "@abstract" + sig.is_abstract = true + when "@final" + sig.is_final = true + when "@override" + sig.is_override = true + when "@override(allow_incompatible: true)" + sig.is_override = true + sig.allow_incompatible_override = true + when "@override(allow_incompatible: :visibility)" + sig.is_override = true + sig.allow_incompatible_override_visibility = true + when "@overridable" + sig.is_overridable = true + when "@without_runtime" + sig.without_runtime = true + end + end + + # `method_added` and `singleton_method_added` can never carry a + # runtime sig — Sorbet wraps these hooks itself, so any sig we + # emit for them must be marked `without_runtime`. + if @method.name == :method_added || @method.name == :singleton_method_added + sig.without_runtime = true + end + end + end + end +end diff --git a/lib/tapioca/static/symbol_loader.rb b/lib/tapioca/static/symbol_loader.rb index 7f0c08b09..aa1ecb1c6 100644 --- a/lib/tapioca/static/symbol_loader.rb +++ b/lib/tapioca/static/symbol_loader.rb @@ -18,14 +18,62 @@ def payload_symbols T.must(@payload_symbols) end - #: (Array[Pathname] paths) -> Rubydex::Graph - def graph_from_paths(paths) + # Builds a Rubydex graph from `paths` (regular Ruby/RBS source files) + # and optional `rbi_files` (Sorbet RBI stubs shipped alongside the + # gem in `rbi/`). Rubydex's `index_all` ignores `.rbi` extensions, + # so we feed those files through `index_source` after retitling + # their URIs to a `.rb` extension — RBI is plain Ruby, so the + # indexer is happy with the content once it can see it. + # + # The graph also indexes the latest installed `rbs` gem's core + # and stdlib RBS definitions so that bare references like + # `Integer` or `String` resolve. + #: (Array[Pathname] paths, ?rbi_files: Array[Pathname]) -> Rubydex::Graph + def graph_from_paths(paths, rbi_files: []) graph = Rubydex::Graph.new - graph.index_all(paths.map(&:to_s)) + paths_to_index = paths.map(&:to_s) + # Include core/stdlib RBS so that references like `Integer`, `String`, + # etc. resolve when we fully-qualify types extracted from inline RBS + # signatures. + paths_to_index.concat(core_rbs_definition_paths) + graph.index_all(paths_to_index) + + rbi_files.each do |rbi_path| + content = begin + rbi_path.read(encoding: "UTF-8") + rescue Errno::ENOENT, Errno::EACCES + next + end + # Pretend the file has a `.rb` extension so Rubydex's source + # registration doesn't reject it; the underlying syntax is plain + # Ruby. + uri = "file://#{rbi_path}.rb" + graph.index_source(uri, content, "ruby") + end + graph.resolve graph end + # Returns the filesystem paths to the latest installation of the + # `rbs` gem's `core` and `stdlib` RBS definition directories, or an + # empty list if no such installation exists. Used to seed the Rubydex + # graph so it can resolve references to builtin constants such as + # `Integer`, `String`, etc. + #: -> Array[String] + def core_rbs_definition_paths + rbs_gem_path = ::Gem.path + .flat_map { |path| Dir.glob(File.join(path, "gems", "rbs-[0-9]*/")) } + .max_by { |path| ::Gem::Version.new(File.basename(path).delete_prefix("rbs-")) } + + return [] unless rbs_gem_path + + [ + File.join(rbs_gem_path, "core"), + File.join(rbs_gem_path, "stdlib"), + ] + end + #: (Gemfile::GemSpec gem) -> Set[String] def gem_symbols(gem) symbols_from_paths(gem.files) diff --git a/sorbet/rbi/gems/require-hooks@0.4.0.rbi b/sorbet/rbi/gems/require-hooks@0.4.0.rbi deleted file mode 100644 index 1e536b20a..000000000 --- a/sorbet/rbi/gems/require-hooks@0.4.0.rbi +++ /dev/null @@ -1,152 +0,0 @@ -# typed: true - -# DO NOT EDIT MANUALLY -# This is an autogenerated file for types exported from the `require-hooks` gem. -# Please instead update this file by running `bin/tapioca gem require-hooks`. - - -# pkg:gem/require-hooks#lib/require-hooks/api.rb:3 -module RequireHooks - class << self - # Define a block to wrap the code loading. - # The return value MUST be a result of calling the passed block. - # For example, you can use such hooks for instrumentation, debugging purposes. - # - # RequireHooks.around_load do |path, &block| - # puts "Loading #{path}" - # block.call.tap { puts "Loaded #{path}" } - # end - # - # pkg:gem/require-hooks#lib/require-hooks/api.rb:103 - def around_load(patterns: T.unsafe(nil), exclude_patterns: T.unsafe(nil), &block); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:139 - def context_for(path); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:168 - def contexts; end - - # This hook should be used to manually compile byte code to be loaded by the VM. - # The arguments are (path, source = nil), where source is only defined if transformations took place. - # Otherwise, you MUST read the source code from the file yourself. - # - # The return value MUST be either nil (continue to the next hook or default behavior) or a platform-specific bytecode object (e.g., RubyVM::InstructionSequence). - # - # RequireHooks.hijack_load do |path, source| - # source ||= File.read(path) - # if defined?(RubyVM::InstructionSequence) - # RubyVM::InstructionSequence.compile(source) - # elsif defined?(JRUBY_VERSION) - # JRuby.compile(source) - # end - # end - # - # pkg:gem/require-hooks#lib/require-hooks/api.rb:135 - def hijack_load(patterns: T.unsafe(nil), exclude_patterns: T.unsafe(nil), &block); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:93 - def print_warnings; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:93 - def print_warnings=(_arg0); end - - # Hack to enable coverage for hooked files. - # Requires eval coverage to be on. - # See https://bugs.ruby-lang.org/issues/22018 (https://github.com/ruby/ruby/pull/16805) - # - # pkg:gem/require-hooks#lib/require-hooks/api.rb:160 - def setup_path_coverage(path, contents = T.unsafe(nil)); end - - # Define hooks to perform source-to-source transformations. - # The return value MUST be either String (new source code) or nil (indicating that no transformations were performed). - # - # NOTE: The second argument (`source`) MAY be nil, indicating that no transformer tried to transform the source code. - # - # - # RequireHooks.source_transform do |path, source| - # end - # - # pkg:gem/require-hooks#lib/require-hooks/api.rb:117 - def source_transform(patterns: T.unsafe(nil), exclude_patterns: T.unsafe(nil), &block); end - - private - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:184 - def eval_coverage_enabled?; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:174 - def register_hook(type, block, patterns: T.unsafe(nil), exclude_patterns: T.unsafe(nil)); end - end -end - -# pkg:gem/require-hooks#lib/require-hooks/api.rb:4 -class RequireHooks::Context - # pkg:gem/require-hooks#lib/require-hooks/api.rb:8 - def initialize(patterns: T.unsafe(nil), exclude_patterns: T.unsafe(nil)); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:5 - def around_load; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:30 - def empty?; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:5 - def exclude_patterns; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:45 - def hijack?; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:5 - def hijack_load; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:24 - def match?(path); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:81 - def merge!(another_ctx); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:5 - def patterns; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:59 - def perform_source_transform(path); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:35 - def readonly?; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:49 - def run_around_load_callbacks(path); end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:5 - def source_transform; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:41 - def source_transform?; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:20 - def to_key; end - - # pkg:gem/require-hooks#lib/require-hooks/api.rb:71 - def try_hijack_load(path, source); end -end - -# pkg:gem/require-hooks#lib/require-hooks/mode/load_iseq.rb:6 -RequireHooks::EMPTY_ISEQ = T.let(T.unsafe(nil), RubyVM::InstructionSequence) - -# pkg:gem/require-hooks#lib/require-hooks/iseq.rb:4 -module RequireHooks::Iseq - class << self - # pkg:gem/require-hooks#lib/require-hooks/iseq.rb:6 - def compile_with_coverage(ctx, path); end - end -end - -# pkg:gem/require-hooks#lib/require-hooks/mode/load_iseq.rb:8 -module RequireHooks::LoadIseq - # pkg:gem/require-hooks#lib/require-hooks/mode/load_iseq.rb:9 - def load_iseq(path); end -end - -class RubyVM::InstructionSequence - extend ::RequireHooks::LoadIseq -end diff --git a/sorbet/rbi/gems/rubydex@0.2.3.rbi b/sorbet/rbi/gems/rubydex@0.2.5.rbi similarity index 58% rename from sorbet/rbi/gems/rubydex@0.2.3.rbi rename to sorbet/rbi/gems/rubydex@0.2.5.rbi index e259e8e12..3981c09a2 100644 --- a/sorbet/rbi/gems/rubydex@0.2.3.rbi +++ b/sorbet/rbi/gems/rubydex@0.2.5.rbi @@ -11,78 +11,101 @@ # pkg:gem/rubydex#lib/rubydex/version.rb:3 module Rubydex; end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrAccessorDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrReaderDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrWriterDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:23 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Class < ::Rubydex::Namespace include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::ConstantReference)) } def superclass; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassVariableDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/comment.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Comment # pkg:gem/rubydex#lib/rubydex/comment.rb:12 - sig { params(string: String, location: Rubydex::Location).void } + sig { params(string: ::String, location: ::Rubydex::Location).void } def initialize(string:, location:); end # pkg:gem/rubydex#lib/rubydex/comment.rb:9 - sig { returns(Rubydex::Location) } + sig { returns(::Rubydex::Location) } def location; end # pkg:gem/rubydex#lib/rubydex/comment.rb:6 - sig { returns(String) } + sig { returns(::String) } def string; end end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:31 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Constant < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:35 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantAlias < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def target; end + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantAliasDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantDefinition < ::Rubydex::Definition; end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantReference < ::Rubydex::Reference abstract! + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end @@ -91,29 +114,35 @@ class Rubydex::ConstantReference < ::Rubydex::Reference end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:15 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Declaration abstract! + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Declaration) } def owner; end # @abstract # # pkg:gem/rubydex#lib/rubydex/declaration.rb:18 - sig { returns(T::Enumerable[Rubydex::Reference]) } + sig { abstract.returns(::T::Enumerable[::Rubydex::Reference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def unqualified_name; end @@ -125,26 +154,42 @@ class Rubydex::Declaration end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Definition abstract! + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Comment]) } def comments; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def declaration; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Boolean) } def deprecated?; end + # pkg:gem/rubydex#lib/rubydex.rb:11 + sig { returns(T::Array[Rubydex::Definition]) } + def lexical_nesting; end + + # pkg:gem/rubydex#lib/rubydex.rb:11 + sig { returns(T.nilable(Rubydex::Definition)) } + def lexical_owner; end + + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Location)) } def name_location; end @@ -156,22 +201,22 @@ class Rubydex::Definition end end -# pkg:gem/rubydex#lib/rubydex/diagnostic.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Diagnostic # pkg:gem/rubydex#lib/rubydex/diagnostic.rb:15 - sig { params(rule: Symbol, message: String, location: Rubydex::Location).void } + sig { params(rule: ::Symbol, message: ::String, location: ::Rubydex::Location).void } def initialize(rule:, message:, location:); end # pkg:gem/rubydex#lib/rubydex/diagnostic.rb:12 - sig { returns(Rubydex::Location) } + sig { returns(::Rubydex::Location) } def location; end # pkg:gem/rubydex#lib/rubydex/diagnostic.rb:9 - sig { returns(String) } + sig { returns(::String) } def message; end # pkg:gem/rubydex#lib/rubydex/diagnostic.rb:6 - sig { returns(Symbol) } + sig { returns(::Symbol) } def rule; end end @@ -183,26 +228,30 @@ class Rubydex::DisplayLocation < ::Rubydex::Location # Normalize to zero-based for comparison with Location # # pkg:gem/rubydex#lib/rubydex/location.rb:81 - sig { returns([String, Integer, Integer, Integer, Integer]) } + sig { returns([::String, ::Integer, ::Integer, ::Integer, ::Integer]) } def comparable_values; end # Returns itself # # pkg:gem/rubydex#lib/rubydex/location.rb:74 - sig { returns(Rubydex::DisplayLocation) } + sig { returns(::Rubydex::DisplayLocation) } def to_display; end # pkg:gem/rubydex#lib/rubydex/location.rb:86 - sig { returns(String) } + sig { returns(::String) } def to_s; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Document + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def uri; end @@ -224,118 +273,177 @@ class Rubydex::Extend < ::Rubydex::Mixin; end # pkg:gem/rubydex#lib/rubydex/failures.rb:4 class Rubydex::Failure # pkg:gem/rubydex#lib/rubydex/failures.rb:9 - sig { params(message: String).void } + sig { params(message: ::String).void } def initialize(message); end # pkg:gem/rubydex#lib/rubydex/failures.rb:6 - sig { returns(String) } + sig { returns(::String) } def message; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::GlobalVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::GlobalVariableAliasDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::GlobalVariableDefinition < ::Rubydex::Definition; end # The global graph representing all declarations and their relationships for the workspace # # Note: this class is partially defined in C to integrate with the Rust backend # -# pkg:gem/rubydex#lib/rubydex/graph.rb:7 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Graph - # pkg:gem/rubydex#lib/rubydex/graph.rb:24 - sig { params(workspace_path: T.nilable(String)).void } + # pkg:gem/rubydex#lib/rubydex/graph.rb:26 + sig { params(workspace_path: ::String).void } def initialize(workspace_path: T.unsafe(nil)); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(fully_qualified_name: String).returns(T.nilable(Rubydex::Declaration)) } def [](fully_qualified_name); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Failure]) } def check_integrity; end + # Returns completion candidates for an expression context. This includes all keywords, constants, methods, instance + # variables, class variables and global variables reachable from the current lexical scope and self type. + # + # The nesting array represents the lexical scope stack. The optional `self_receiver` keyword argument overrides the + # self type independently of the lexical scope (e.g., `"Foo::"` for `def Foo.bar`). This distinction is important + # because constants and class variables are always attached to the lexical scope. Meanwhile, methods and instance + # variables are attached to the type of `self` and those don't always match. + # + # pkg:gem/rubydex#lib/rubydex.rb:11 def complete_expression(*_arg0); end + + # Returns completion candidates inside a method call's argument list (e.g., `foo.bar(|)`). This includes everything + # that expression completion provides plus keyword argument names of the method being called. + # + # See `complete_expression` for the semantics of `nesting` and `self_receiver`. + # + # pkg:gem/rubydex#lib/rubydex.rb:11 def complete_method_argument(*_arg0); end + + # Returns completion candidates after a method call operator (e.g., `foo.`). This includes all methods that exist on + # the type of the receiver and its ancestors. + # + # The optional `self_receiver` kwarg is the caller's runtime self type. It's used for visibility checks for `private` + # and `protected` methods. Pass `nil` (the default) for top-level/script scope. + # + # pkg:gem/rubydex#lib/rubydex.rb:11 def complete_method_call(*_arg0); end + + # Returns completion candidates after a namespace access operator (e.g., `Foo::`). This includes all constants and + # singleton methods for the namespace and its ancestors. + # + # The optional `self_receiver` kwarg is the caller's runtime self type. It's used to filter visibility-restricted + # singleton methods (e.g., `private_class_method`). Pass `nil` (the default) for top-level/script scope. + # + # pkg:gem/rubydex#lib/rubydex.rb:11 def complete_namespace_access(*_arg0); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def constant_references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def declarations; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } def delete_document(uri); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Diagnostic]) } def diagnostics; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } def document(uri); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Document]) } def documents; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(encoding: String).void } def encoding=(encoding); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(paths: T::Array[String]).void } def exclude_paths(paths); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[String]) } def excluded_paths; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def fuzzy_search(query); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(file_paths: T::Array[String]).returns(T::Array[String]) } def index_all(file_paths); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(uri: String, source: String, language_id: String).void } def index_source(uri, source, language_id); end + # Index all files and dependencies of the workspace that exists in `@workspace_path` # Index all files and dependencies of the workspace that exists in `@workspace_path` # - # pkg:gem/rubydex#lib/rubydex/graph.rb:32 - sig { returns(T::Array[String]) } + # pkg:gem/rubydex#lib/rubydex/graph.rb:34 + sig { returns(::T::Array[::String]) } def index_workspace; end + # pkg:gem/rubydex#lib/rubydex.rb:11 def keyword(_arg0); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def method_references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(load_paths: T::Array[String]).returns(T::Array[String]) } def require_paths(load_paths); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.self_type) } def resolve; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(name: String, nesting: T::Array[String]).returns(T.nilable(Rubydex::Declaration)) } def resolve_constant(name, nesting); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(require_path: String, load_paths: T::Array[String]).returns(T.nilable(Rubydex::Document)) } def resolve_require_path(require_path, load_paths); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def search(query); end - # pkg:gem/rubydex#lib/rubydex/graph.rb:21 - sig { returns(String) } + # pkg:gem/rubydex#lib/rubydex/graph.rb:23 + sig { returns(::String) } def workspace_path; end - # pkg:gem/rubydex#lib/rubydex/graph.rb:21 + # pkg:gem/rubydex#lib/rubydex/graph.rb:23 sig { params(workspace_path: String).returns(String) } def workspace_path=(workspace_path); end # Returns all workspace paths that should be indexed, excluding directories that we don't need to descend into such # as `.git`, `node_modules`. Also includes any top level Ruby files # - # pkg:gem/rubydex#lib/rubydex/graph.rb:40 - sig { returns(T::Array[String]) } + # pkg:gem/rubydex#lib/rubydex/graph.rb:42 + sig { returns(::T::Array[::String]) } def workspace_paths; end private @@ -344,149 +452,175 @@ class Rubydex::Graph # to the list of paths. This method does not require `rbs` to be a part of the bundle. It searches for whatever # latest installation of `rbs` exists in the system and fails silently if we can't find one # - # pkg:gem/rubydex#lib/rubydex/graph.rb:87 - sig { params(paths: T::Array[String]).void } + # pkg:gem/rubydex#lib/rubydex/graph.rb:89 + sig { params(paths: ::T::Array[::String]).void } def add_core_rbs_definition_paths(paths); end # Gathers the paths we have to index for all workspace dependencies # - # pkg:gem/rubydex#lib/rubydex/graph.rb:63 - sig { params(paths: T::Array[String]).void } + # pkg:gem/rubydex#lib/rubydex/graph.rb:65 + sig { params(paths: ::T::Array[::String]).void } def add_workspace_dependency_paths(paths); end end # pkg:gem/rubydex#lib/rubydex/graph.rb:8 Rubydex::Graph::IGNORED_DIRECTORIES = T.let(T.unsafe(nil), Array) +# pkg:gem/rubydex#lib/rubydex/graph.rb:20 +Rubydex::Graph::INDEXABLE_EXTENSIONS = T.let(T.unsafe(nil), Array) + # Represents `include SomeModule` # # pkg:gem/rubydex#lib/rubydex/mixin.rb:15 class Rubydex::Include < ::Rubydex::Mixin; end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::InstanceVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::InstanceVariableDefinition < ::Rubydex::Definition; end # pkg:gem/rubydex#lib/rubydex/failures.rb:14 class Rubydex::IntegrityFailure < ::Rubydex::Failure; end -# pkg:gem/rubydex#lib/rubydex/keyword.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Keyword # pkg:gem/rubydex#lib/rubydex/keyword.rb:12 - sig { params(name: String, documentation: String).void } + sig { params(name: ::String, documentation: ::String).void } def initialize(name, documentation); end # pkg:gem/rubydex#lib/rubydex/keyword.rb:9 - sig { returns(String) } + sig { returns(::String) } def documentation; end # pkg:gem/rubydex#lib/rubydex/keyword.rb:6 - sig { returns(String) } + sig { returns(::String) } def name; end end -# pkg:gem/rubydex#lib/rubydex/keyword_parameter.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::KeywordParameter # pkg:gem/rubydex#lib/rubydex/keyword_parameter.rb:9 - sig { params(name: String).void } + sig { params(name: ::String).void } def initialize(name); end # pkg:gem/rubydex#lib/rubydex/keyword_parameter.rb:6 - sig { returns(String) } + sig { returns(::String) } def name; end end # A zero based internal location. Intended to be used for tool-to-tool communication, such as a language server # communicating with an editor. # -# pkg:gem/rubydex#lib/rubydex/location.rb:6 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Location include ::Comparable # pkg:gem/rubydex#lib/rubydex/location.rb:18 - sig { params(uri: String, start_line: Integer, end_line: Integer, start_column: Integer, end_column: Integer).void } + sig do + params( + uri: ::String, + start_line: ::Integer, + end_line: ::Integer, + start_column: ::Integer, + end_column: ::Integer + ).void + end def initialize(uri:, start_line:, end_line:, start_column:, end_column:); end # pkg:gem/rubydex#lib/rubydex/location.rb:38 - sig { params(other: T.untyped).returns(T.nilable(Integer)) } + sig { params(other: ::BasicObject).returns(::Integer) } def <=>(other); end # pkg:gem/rubydex#lib/rubydex/location.rb:45 - sig { returns([String, Integer, Integer, Integer, Integer]) } + sig { returns([::String, ::Integer, ::Integer, ::Integer, ::Integer]) } def comparable_values; end # pkg:gem/rubydex#lib/rubydex/location.rb:15 - sig { returns(Integer) } + sig { returns(::Integer) } def end_column; end # pkg:gem/rubydex#lib/rubydex/location.rb:15 - sig { returns(Integer) } + sig { returns(::Integer) } def end_line; end # pkg:gem/rubydex#lib/rubydex/location.rb:15 - sig { returns(Integer) } + sig { returns(::Integer) } def start_column; end # pkg:gem/rubydex#lib/rubydex/location.rb:15 - sig { returns(Integer) } + sig { returns(::Integer) } def start_line; end # Turns this zero based location into a one based location for display purposes. # # pkg:gem/rubydex#lib/rubydex/location.rb:52 - sig { returns(Rubydex::DisplayLocation) } + sig { returns(::Rubydex::DisplayLocation) } def to_display; end # pkg:gem/rubydex#lib/rubydex/location.rb:27 - sig { returns(String) } + sig { returns(::String) } def to_file_path; end # pkg:gem/rubydex#lib/rubydex/location.rb:63 - sig { returns(String) } + sig { returns(::String) } def to_s; end # pkg:gem/rubydex#lib/rubydex/location.rb:12 - sig { returns(String) } + sig { returns(::String) } def uri; end end # pkg:gem/rubydex#lib/rubydex/location.rb:7 class Rubydex::Location::NotFileUriError < ::StandardError; end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:39 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Method < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodAliasDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:11 def signatures; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:11 def signatures; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodReference < ::Rubydex::Reference + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def receiver; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodVisibilityDefinition < ::Rubydex::Definition; end # pkg:gem/rubydex#lib/rubydex/mixin.rb:4 @@ -494,45 +628,61 @@ class Rubydex::Mixin abstract! # pkg:gem/rubydex#lib/rubydex/mixin.rb:9 - sig { params(constant_reference: Rubydex::ConstantReference).void } + sig { params(constant_reference: ::Rubydex::ConstantReference).void } def initialize(constant_reference); end # pkg:gem/rubydex#lib/rubydex/mixin.rb:6 + sig { returns(::Rubydex::ConstantReference) } def constant_reference; end end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:27 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Module < ::Rubydex::Namespace include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ModuleDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Namespace < ::Rubydex::Declaration abstract! + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def ancestors; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def descendants; end + # pkg:gem/rubydex#lib/rubydex.rb:11 def find_member(*_arg0); end + # pkg:gem/rubydex#lib/rubydex/declaration.rb:25 + sig { params(ancestor_names: ::String).returns(::T::Boolean) } + def has_ancestor?(*ancestor_names); end + + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } def member(name); end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def members; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::SingletonClass)) } def singleton_class; end end @@ -542,13 +692,15 @@ end # pkg:gem/rubydex#lib/rubydex/mixin.rb:18 class Rubydex::Prepend < ::Rubydex::Mixin; end -# pkg:gem/rubydex#lib/rubydex/reference.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Reference abstract! + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end # pkg:gem/rubydex#lib/rubydex/reference.rb:6 + sig { returns(::Rubydex::Location) } def location; end class << self @@ -559,105 +711,135 @@ class Rubydex::Reference end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ResolvedConstantReference < ::Rubydex::ConstantReference + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Declaration) } def declaration; end end -# pkg:gem/rubydex#lib/rubydex/signature.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature # pkg:gem/rubydex#lib/rubydex/signature.rb:33 + sig { params(parameters: ::T::Array[::Rubydex::Signature::Parameter]).void } def initialize(parameters); end # pkg:gem/rubydex#lib/rubydex/signature.rb:128 + sig { returns(::T.nilable(::Rubydex::Signature::BlockParameter)) } def block_parameter; end # pkg:gem/rubydex#lib/rubydex/signature.rb:38 + sig do + returns([::T::Array[::Rubydex::Signature::PositionalParameter], ::T::Array[::Rubydex::Signature::OptionalPositionalParameter], ::T.nilable(::Rubydex::Signature::RestPositionalParameter), ::T::Array[::Rubydex::Signature::PostParameter], ::T::Array[::Rubydex::Signature::KeywordParameter], ::T::Array[::Rubydex::Signature::OptionalKeywordParameter], ::T.nilable(::Rubydex::Signature::RestKeywordParameter), ::T.nilable(::Rubydex::Signature::ForwardParameter), ::T.nilable(::Rubydex::Signature::BlockParameter)]) + end def deconstruct; end # pkg:gem/rubydex#lib/rubydex/signature.rb:80 + sig { params(keys: ::T.nilable(::T::Array[::Symbol])).returns(::T::Hash[::Symbol, ::T.untyped]) } def deconstruct_keys(keys); end # pkg:gem/rubydex#lib/rubydex/signature.rb:125 + sig { returns(::T.nilable(::Rubydex::Signature::ForwardParameter)) } def forward_parameter; end # pkg:gem/rubydex#lib/rubydex/signature.rb:116 + sig { returns(::T::Array[::Rubydex::Signature::KeywordParameter]) } def keyword_parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:119 + sig { returns(::T::Array[::Rubydex::Signature::OptionalKeywordParameter]) } def optional_keyword_parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:107 + sig { returns(::T::Array[::Rubydex::Signature::OptionalPositionalParameter]) } def optional_positional_parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:30 + sig { returns(::T::Array[::Rubydex::Signature::Parameter]) } def parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:104 + sig { returns(::T::Array[::Rubydex::Signature::PositionalParameter]) } def positional_parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:113 + sig { returns(::T::Array[::Rubydex::Signature::PostParameter]) } def post_parameters; end # pkg:gem/rubydex#lib/rubydex/signature.rb:122 + sig { returns(::T.nilable(::Rubydex::Signature::RestKeywordParameter)) } def rest_keyword_parameter; end # pkg:gem/rubydex#lib/rubydex/signature.rb:110 + sig { returns(::T.nilable(::Rubydex::Signature::RestPositionalParameter)) } def rest_positional_parameter; end end -# pkg:gem/rubydex#lib/rubydex/signature.rb:27 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::BlockParameter < ::Rubydex::Signature::Parameter; end # pkg:gem/rubydex#lib/rubydex/signature.rb:66 Rubydex::Signature::DECONSTRUCT_KEYS = T.let(T.unsafe(nil), Array) -# pkg:gem/rubydex#lib/rubydex/signature.rb:26 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::ForwardParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:23 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::KeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:24 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::OptionalKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:20 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::OptionalPositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:5 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::Parameter # pkg:gem/rubydex#lib/rubydex/signature.rb:13 + sig { params(name: ::Symbol, location: ::Rubydex::Location).void } def initialize(name, location); end # pkg:gem/rubydex#lib/rubydex/signature.rb:10 + sig { returns(::Rubydex::Location) } def location; end # pkg:gem/rubydex#lib/rubydex/signature.rb:7 + sig { returns(::Symbol) } def name; end end -# pkg:gem/rubydex#lib/rubydex/signature.rb:19 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::PositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:22 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::PostParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:25 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::RestKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:21 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::RestPositionalParameter < ::Rubydex::Signature::Parameter; end -class Rubydex::SingletonClass < ::Rubydex::Namespace; end +# pkg:gem/rubydex#lib/rubydex.rb:11 +class Rubydex::SingletonClass < ::Rubydex::Namespace + # pkg:gem/rubydex#lib/rubydex/declaration.rb:40 + sig { returns(Rubydex::Declaration) } + def attached_class; end +end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::SingletonClassDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Todo < ::Rubydex::Namespace; end +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::UnresolvedConstantReference < ::Rubydex::ConstantReference + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end end @@ -668,11 +850,14 @@ Rubydex::VERSION = T.let(T.unsafe(nil), String) # pkg:gem/rubydex#lib/rubydex/declaration.rb:4 module Rubydex::Visibility # pkg:gem/rubydex#lib/rubydex/declaration.rb:9 + sig { returns(::T::Boolean) } def private?; end # pkg:gem/rubydex#lib/rubydex/declaration.rb:12 + sig { returns(::T::Boolean) } def protected?; end # pkg:gem/rubydex#lib/rubydex/declaration.rb:6 + sig { returns(::T::Boolean) } def public?; end end diff --git a/sorbet/rbi/shims/bootsnap.rbi b/sorbet/rbi/shims/bootsnap.rbi deleted file mode 100644 index f2336da32..000000000 --- a/sorbet/rbi/shims/bootsnap.rbi +++ /dev/null @@ -1,9 +0,0 @@ -# typed: true - -# Bootsnap is loaded conditionally in `lib/tapioca/rbs/rewriter.rb` when -# `TAPIOCA_RBS_CACHE=1`. It isn't in the Gemfile, so this shim declares the -# minimal surface used there. -module Bootsnap - def self.setup(**); end - def self.log_stats!; end -end diff --git a/spec/tapioca/cli/dsl_spec.rb b/spec/tapioca/cli/dsl_spec.rb index 9eb4e871a..784d19043 100644 --- a/spec/tapioca/cli/dsl_spec.rb +++ b/spec/tapioca/cli/dsl_spec.rb @@ -659,33 +659,7 @@ class Post assert_success_status(result) end - it "exits before RBI generation when --only-bootsnap-rbs-cache is set" do - @project.write!("lib/post.rb", <<~RB) - require "smart_properties" - - class Post - include SmartProperties - property :title, accepts: String - end - RB - - result = @project.tapioca("dsl --only-bootsnap-rbs-cache Post") - - assert_stdout_includes(result, <<~OUT) - Bootsnap RBS cache populated, exiting before RBI generation. - OUT - - assert_empty_stderr(result) - refute_project_file_exist("sorbet/rbi/dsl/post.rbi") - assert_success_status(result) - end - - it "preserves RBS comment rewriting when the host sets up Bootsnap without TAPIOCA_RBS_CACHE" do - @project.write!("lib/00_bootsnap.rb", <<~RB) - require "bootsnap" - Bootsnap.setup(cache_dir: File.join(Dir.pwd, "tmp/cache/host-bootsnap")) - RB - + it "exposes inline RBS method signatures to DSL compilers" do @project.write!("lib/post.rb", <<~RB) # typed: strict @@ -739,7 +713,7 @@ def title; end assert_success_status(result) end - it "uses the last overload when rewriting RBS comments" do + it "uses the last overload when generating RBI from inline RBS overloads" do @project.write!("lib/post.rb", <<~RB) # typed: strict @@ -794,27 +768,6 @@ def find(value); end assert_success_status(result) end - it "raises when the host calls Bootsnap.setup under TAPIOCA_RBS_CACHE=1" do - @project.write!("lib/post.rb", <<~RB) - require "bootsnap" - Bootsnap.setup(cache_dir: File.join(Dir.pwd, "tmp/cache/host-bootsnap")) - require "smart_properties" - - class Post - include SmartProperties - property :title, accepts: String - end - RB - - result = @project.tapioca("dsl Post", env: { "TAPIOCA_RBS_CACHE" => "1" }) - - assert_stderr_includes( - result, - "Bootsnap.setup was called while TAPIOCA_RBS_CACHE=1 is set", - ) - refute_success_status(result) - end - it "generates RBI files without header" do @project.write!("lib/post.rb", <<~RB) require "smart_properties" @@ -2081,26 +2034,6 @@ def perform(foo, bar) assert_success_status(result) end - it "rejects --only-bootsnap-rbs-cache combined with --verify" do - result = @project.tapioca("dsl --verify --only-bootsnap-rbs-cache") - - assert_stderr_includes( - result, - "Options '--only-bootsnap-rbs-cache' and '--verify' are mutually exclusive", - ) - refute_success_status(result) - end - - it "rejects --only-bootsnap-rbs-cache combined with --list-compilers" do - result = @project.tapioca("dsl --list-compilers --only-bootsnap-rbs-cache") - - assert_stderr_includes( - result, - "Options '--only-bootsnap-rbs-cache' and '--list-compilers' are mutually exclusive", - ) - refute_success_status(result) - end - it "advises of removed file(s) and returns exit status 1 when files are excluded" do @project.tapioca("dsl") result = @project.tapioca("dsl --verify --exclude SmartProperties") @@ -2801,7 +2734,7 @@ class Post OUT err = %r{ - tapioca/tests/dsl_spec/project/sorbet/tapioca/extensions/test\.rb:2:in\s['`]
':\s + tapioca/tests/dsl_spec/project/sorbet/tapioca/extensions/test\.rb:2:in\s['`]<(?:main|top\s\(required\))>':\s Raising\sfrom\stest\sextension\s\(RuntimeError\) }x assert_stderr_includes_pattern(result, err) diff --git a/spec/tapioca/dsl/compiler_spec.rb b/spec/tapioca/dsl/compiler_spec.rb index aed145616..a9c89ab80 100644 --- a/spec/tapioca/dsl/compiler_spec.rb +++ b/spec/tapioca/dsl/compiler_spec.rb @@ -11,11 +11,10 @@ class CompilerSpec < Minitest::Spec describe "Tapioca::Dsl::Compiler" do before do add_ruby_file("post_compiler.rb", <<~RUBY) + #: [ConstantType = singleton(::Post)] class PostCompiler < Tapioca::Dsl::Compiler extend T::Sig - ConstantType = type_member { { fixed: T.class_of(Post) } } - sig { override.void } def decorate methods = constant.instance_methods(false) @@ -177,11 +176,10 @@ class Post; end describe "Tapioca::Dsl::Compiler with invalid syntax" do before do add_ruby_file("post_compiler.rb", <<~RUBY) + #: [ConstantType = singleton(::Post)] class PostCompiler < Tapioca::Dsl::Compiler extend T::Sig - ConstantType = type_member { { fixed: T.class_of(Post) } } - sig { override.void } def decorate methods = constant.instance_methods(false) diff --git a/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index d9354b4fe..f17f08fc2 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -4704,10 +4704,12 @@ def bar(a, b:); end def foo; end def foo=(_arg0); end + + T::Sig::WithoutRuntime.sig { returns(::NotExisting) } def qux; end class << self - sig { returns(T.proc.params(arg0: ::String).void) } + sig { returns(::T.proc.params(arg0: ::String).void) } def baz; end sig { void } @@ -4790,9 +4792,9 @@ def bar; end output = template(<<~RBI) class Foo - requires_ancestor { Kernel } + requires_ancestor { ::Kernel } - sig { returns(T::Array[::String]) } + sig { returns(::T::Array[::String]) } def bar; end # :comment: diff --git a/spec/tapioca/runtime/reflection_spec.rb b/spec/tapioca/runtime/reflection_spec.rb index 95edd51c3..0626c58ef 100644 --- a/spec/tapioca/runtime/reflection_spec.rb +++ b/spec/tapioca/runtime/reflection_spec.rb @@ -62,7 +62,9 @@ def equal?(other) end class SignatureFoo - #: -> String + extend T::Sig + + sig { returns(String) } def good_method "Thank you." end diff --git a/tapioca.gemspec b/tapioca.gemspec index b5def864f..425399803 100644 --- a/tapioca.gemspec +++ b/tapioca.gemspec @@ -27,8 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency("bundler", ">= 2.2.25") spec.add_dependency("netrc", ">= 0.11.0") spec.add_dependency("parallel", ">= 1.21.0") - spec.add_dependency("require-hooks", ">= 0.2.2") - spec.add_dependency("rubydex", ">= 0.1.0.beta10") + spec.add_dependency("rubydex", ">= 0.2.5") spec.add_dependency("sorbet-static-and-runtime", ">= 0.6.12698") spec.add_dependency("thor", ">= 1.2.0")