From e6407d9af18bdf9700969b27d90144f05b259423 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 28 May 2026 21:56:45 +0300 Subject: [PATCH 01/11] Process inline RBS comments natively without the require-hook rewriter Tapioca used to discover method signatures purely from Sorbet's runtime reflection. To support inline `#:` / `# @...` RBS comments, it shipped a require-hook (`lib/tapioca/rbs/rewriter.rb`) that, at every load, rewrote sources into `sig {}` blocks so `sorbet-runtime` would track them. That detour required `require-hooks`, made boot slower, and forced a separate bootsnap cache to be remotely usable on large apps. Read RBS straight from source instead. The gem pipeline always builds a `Rubydex::Graph` (with core/stdlib RBS seeded for constant resolution), and the listeners that needed a runtime signature now also accept inline RBS comments. A matching path on the DSL side picks up `#:` sigs for arbitrary host-app methods so DSL compilers see the same signatures they used to see through the rewriter. Highlights: - New `Tapioca::RBS::Comments`, `Tapioca::RBS::TypeQualifier`, and `Tapioca::RBS::DslSignatures` modules handle parsing, fully-qualified type rendering, and DSL-side lookup. - `Gem::Pipeline` exposes `gem_graph`, `rbs_comments_for_constant`, and `rbs_comments_for_method`. Listeners (`SorbetSignatures`, `SorbetHelpers`, `SorbetRequiredAncestors`, `SorbetTypeVariables`) surface `#: ...`, `# @abstract`, `# @requires_ancestor:`, and `#: [A, B]` from source. - `Dsl::Compiler#compile_method_*_to_rbi` and `ActiveModelTypeHelper.type_for` fall back to `DslSignatures.build` when no Sorbet runtime sig exists. - All `T::` and `T.*` constants are emitted fully qualified (`::T::Array`, `::T.proc`, ...). User-defined constants are resolved through Rubydex so relative references like `Bar` inside `Foo::Bar` become `::Foo::Bar`. Lexical nesting for anonymous classes is recovered from source via a small Prism visitor. - Removes `lib/tapioca/rbs/rewriter.rb`, the `require-hooks` dependency, the bootsnap shim, `dsl --only-bootsnap-rbs-cache`, and the `TAPIOCA_RBS_CACHE` README section. --- Gemfile.lock | 4 - README.md | 43 +- lib/tapioca/cli.rb | 12 +- lib/tapioca/commands/dsl_generate.rb | 11 - lib/tapioca/dsl/compiler.rb | 43 +- .../dsl/helpers/active_model_type_helper.rb | 99 +++- lib/tapioca/gem/events.rb | 14 +- lib/tapioca/gem/listeners/documentation.rb | 14 +- lib/tapioca/gem/listeners/methods.rb | 65 ++- lib/tapioca/gem/listeners/sorbet_helpers.rb | 35 +- .../listeners/sorbet_required_ancestors.rb | 33 ++ .../gem/listeners/sorbet_signatures.rb | 145 +++++- .../gem/listeners/sorbet_type_variables.rb | 81 +++ lib/tapioca/gem/pipeline.rb | 167 +++++- lib/tapioca/internal.rb | 20 +- lib/tapioca/rbs/comments.rb | 105 ++++ lib/tapioca/rbs/dsl_signatures.rb | 475 ++++++++++++++++++ lib/tapioca/rbs/rewriter.rb | 110 ---- lib/tapioca/rbs/type_qualifier.rb | 156 ++++++ lib/tapioca/static/symbol_loader.rb | 26 +- sorbet/rbi/gems/require-hooks@0.4.0.rbi | 152 ------ sorbet/rbi/shims/bootsnap.rbi | 9 - spec/tapioca/cli/dsl_spec.rb | 73 +-- spec/tapioca/gem/pipeline_spec.rb | 6 +- spec/tapioca/runtime/reflection_spec.rb | 4 +- tapioca.gemspec | 1 - 26 files changed, 1432 insertions(+), 471 deletions(-) create mode 100644 lib/tapioca/rbs/comments.rb create mode 100644 lib/tapioca/rbs/dsl_signatures.rb delete mode 100644 lib/tapioca/rbs/rewriter.rb create mode 100644 lib/tapioca/rbs/type_qualifier.rb delete mode 100644 sorbet/rbi/gems/require-hooks@0.4.0.rbi delete mode 100644 sorbet/rbi/shims/bootsnap.rbi diff --git a/Gemfile.lock b/Gemfile.lock index 2a72ef39d..9b376ae15 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,7 +20,6 @@ PATH netrc (>= 0.11.0) parallel (>= 1.21.0) rbi (>= 0.3.7) - require-hooks (>= 0.2.2) rubydex (>= 0.1.0.beta10) sorbet-static-and-runtime (>= 0.6.12698) spoom (>= 1.7.16) @@ -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) @@ -502,7 +500,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 +586,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 diff --git a/README.md b/README.md index 159bb6733..ee2e1eaec 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) @@ -500,8 +498,6 @@ Options: [--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) @@ -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 - -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. +### Inline RBS comments -`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..574682a7d 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -6,10 +6,13 @@ module Dsl # @abstract #: [ConstantType < Module[top]] class Compiler + extend T::Generic include RBIHelper include Runtime::Reflection extend Runtime::Reflection + ConstantType = type_member { { upper: Module } } + #: ConstantType attr_reader :constant @@ -156,7 +159,14 @@ def create_method_from_def(scope, method_def, class_method: false) def compile_method_parameters_to_rbi(method_def) signature = signature_of(method_def) method_def = signature.nil? ? method_def : signature.method - method_types = parameters_types_from_signature(method_def, signature) + method_types = if signature + parameters_types_from_signature(method_def, signature) + else + # No runtime sig — fall back to inline RBS comments parsed straight + # from source. Returns nil when no RBS info is available, in which + # case we use `T.untyped` for every parameter. + rbs_parameter_types_for(method_def) || method_def.parameters.map { "T.untyped" } + end parameters = method_def.parameters #: Array[[Symbol, Symbol?]] @@ -191,8 +201,35 @@ 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) + return sanitize_signature_types(name_of_type(signature.return_type)) if signature + + rbs_return = rbs_return_type_for(method_def) + return sanitize_signature_types(rbs_return) if rbs_return + + "T.untyped" + end + + # Looks up inline RBS comments for `method_def` via the host app's + # Rubydex graph and returns the parameter types as strings, in the + # same order as `method_def.parameters`. Returns nil when there's no + # RBS info attached to the method declaration. + #: ((Method | UnboundMethod) method_def) -> Array[String]? + def rbs_parameter_types_for(method_def) + sig = Tapioca::RBS::DslSignatures.build(method_def) + return unless sig + + sig.params.map { |param| param.type.to_s } + end + + # Looks up inline RBS comments for `method_def` via the host app's + # Rubydex graph and returns the return type as a string. Returns nil + # when there's no RBS info attached to the method declaration. + #: ((Method | UnboundMethod) method_def) -> String? + def rbs_return_type_for(method_def) + sig = Tapioca::RBS::DslSignatures.build(method_def) + return unless sig + + sig.return_type.to_s 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..d2f80ce82 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 @@ -35,37 +40,91 @@ def assume_nilable?(type_value) T::Private::Types::NotTyped, ].freeze #: Array[Object] + MEANINGLESS_TYPE_STRINGS = [ + "T.untyped", + "::T.untyped", + "T.noreturn", + "::T.noreturn", + "void", + "", + "", + ].to_set.freeze #: Set[String] + #: (untyped type) -> bool def meaningful_type?(type) !MEANINGLESS_TYPES.include?(type) end + #: (String type_string) -> bool + def meaningful_type_string?(type_string) + !MEANINGLESS_TYPE_STRINGS.include?(type_string) + 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 + # the Sorbet runtime signature when one is registered and falling + # back to inline RBS comments otherwise. 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) + method_def = lookup_method(obj, method) + return unless method_def + + signature = Runtime::Reflection.signature_of(method_def) + if signature + return_type = signature.return_type + + return return_type.to_s if return_type && meaningful_type?(return_type) + + return + end - return_type + rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) + return unless rbs_sig + + type_string = rbs_sig.return_type.to_s + return unless meaningful_type_string?(type_string) + + type_string end - #: (untyped obj, Symbol method) -> T::Types::Base? + # Returns the first arg's type of `method` on `obj` as a string, + # using the Sorbet runtime signature when one is registered and + # falling back to inline RBS comments otherwise. 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) + method_def = lookup_method(obj, method) + return unless method_def + + signature = Runtime::Reflection.signature_of(method_def) + if signature + first_arg_type = signature.arg_types.dig(0, 1) + + return first_arg_type.to_s if first_arg_type && meaningful_type?(first_arg_type) + + return + end + + rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) + return unless rbs_sig + + first_param = rbs_sig.params.first + return unless first_param + + type_string = first_param.type.to_s + return unless meaningful_type_string?(type_string) - first_arg_type + type_string end - #: (untyped obj, Symbol method) -> untyped - def lookup_signature_of_method(obj, method) - Runtime::Reflection.signature_of(obj.method(method)) + #: (untyped obj, Symbol method) -> Method? + def lookup_method(obj, method) + obj.method(method) rescue NameError nil end diff --git a/lib/tapioca/gem/events.rb b/lib/tapioca/gem/events.rb index 030ad638a..15bb9e950 100644 --- a/lib/tapioca/gem/events.rb +++ b/lib/tapioca/gem/events.rb @@ -101,20 +101,30 @@ class MethodNodeAdded < NodeAdded #: Array[[Symbol, String]] attr_reader :parameters + # Inline RBS lookup for the method's source declaration, when the method + # has no Sorbet runtime signature. Used by the `SorbetSignatures` + # listener to synthesize a `sig {}` directly from `#: -> ...` style + # comments, carrying along the kind (regular def vs. attr_*) so the + # listener can interpret the signature correctly. + #: Gem::Pipeline::RBSMethodLookup? + attr_reader :rbs_lookup + #: ( #| String symbol, #| Module[top] constant, #| UnboundMethod method, #| RBI::Method node, #| untyped signature, - #| Array[[Symbol, String]] parameters + #| Array[[Symbol, String]] parameters, + #| ?rbs_lookup: Gem::Pipeline::RBSMethodLookup? #| ) -> void - def initialize(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists + def initialize(symbol, constant, method, node, signature, parameters, rbs_lookup: nil) # rubocop:disable Metrics/ParameterLists super(symbol, constant) @node = node @method = method @signature = signature @parameters = parameters + @rbs_lookup = rbs_lookup end end end 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..470f45f32 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,9 +66,10 @@ 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) @@ -91,6 +97,28 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public signature = nil end + # When no Sorbet runtime signature is registered, look for inline RBS + # comments in source. This is the path Tapioca uses to surface + # `#: -> ...` style signatures without needing the require-hook + # rewriter. + rbs_lookup = nil #: Pipeline::RBSMethodLookup? + if signature.nil? && scope_constant + rbs_lookup = @pipeline.rbs_comments_for_method( + scope_constant, + method.name, + is_singleton: constant.singleton_class?, + source_location: method.source_location, + ) + + # For `attr_accessor`, Sorbet only attaches a runtime sig to the + # reader; the writer is left bare. We match that convention here so + # the generated RBI stays in line with the existing + # `sig + attr_accessor` output. + if rbs_lookup && rbs_lookup.kind == :attr_accessor && method.name.to_s.end_with?("=") + rbs_lookup = nil + end + end + method_name = method.name.to_s return unless valid_method_name?(method_name) return if struct_method?(constant, method_name) @@ -104,18 +132,17 @@ 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 any kind + # of signature available — Sorbet runtime or RBS — 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 || rbs_lookup&.comments&.signatures&.any?) && + type == :req && parameters.size == 1 && - signature.arg_types.size == 1 && + (signature.nil? || signature.arg_types.size == 1) && method_name[-1] == "=" if writer_method_with_sig @@ -156,7 +183,15 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public end end - @pipeline.push_method(symbol_name, constant, method, rbi_method, signature, sanitized_parameters) + @pipeline.push_method( + symbol_name, + constant, + method, + rbi_method, + signature, + sanitized_parameters, + rbs_lookup: rbs_lookup, + ) tree << rbi_method end 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..f87ab8169 100644 --- a/lib/tapioca/gem/listeners/sorbet_signatures.rb +++ b/lib/tapioca/gem/listeners/sorbet_signatures.rb @@ -14,9 +14,150 @@ class SorbetSignatures < Base #: (MethodNodeAdded event) -> void def on_method(event) signature = event.signature - return unless signature + if signature + event.node.sigs << compile_signature(signature, event.parameters) + return + end + + rbs_lookup = event.rbs_lookup + return unless rbs_lookup + return if rbs_lookup.comments.signatures.empty? + + compile_rbs_lookup(event, rbs_lookup) + end + + # Builds RBI sigs for `node` from a set of inline `#: ...` RBS comments + # captured on the method's source declaration. Translates Spoom/RBS + # method types (via the `rbi` gem's `MethodTypeTranslator` for plain + # methods, or `TypeTranslator` for attr_* methods), fully-qualifies + # every constant reference using the pipeline's Rubydex graph, and + # applies any `# @abstract`, `# @override`, etc. annotations that go + # with them. + # + # When the RBS sig carries a `@without_runtime` annotation we skip + # emitting the sig entirely — this matches the existing rewriter-based + # behavior, where `T::Sig::WithoutRuntime.sig` blocks are not picked up + # by Sorbet's runtime and therefore never made it into the generated + # RBI. + #: (MethodNodeAdded event, Pipeline::RBSMethodLookup rbs_lookup) -> void + def compile_rbs_lookup(event, rbs_lookup) + method_annotations = rbs_lookup.comments.method_annotations + return if method_annotations.any? { |a| a.string == "@without_runtime" } + + qualifier = Tapioca::RBS::TypeQualifier.new(@pipeline.gem_graph, nesting_for(event)) + node = event.node + + rbs_lookup.comments.signatures.each do |signature| + sig = build_rbi_sig(node, signature.string, rbs_lookup.kind, qualifier) + next unless sig + + apply_method_annotations(sig, method_annotations) + + # Sorbet runtime doesn't support `sig` on `method_added` or + # `singleton_method_added`, so we always tag those with + # `without_runtime`. + if node.name == "method_added" || node.name == "singleton_method_added" + sig.without_runtime = true + end + + push_sig_symbols(sig) + node.sigs << sig + end + end + + # Parses a single RBS signature string and translates it into an + # {RBI::Sig} with fully-qualified type strings. For regular methods + # the string is parsed as an `RBS::MethodType`; for attr_* methods it + # is parsed as a plain `RBS::Type`, then wrapped into a getter or + # setter sig depending on the kind of attr method. + #: (RBI::Method node, String signature_string, Symbol kind, Tapioca::RBS::TypeQualifier qualifier) -> RBI::Sig? + def build_rbi_sig(node, signature_string, kind, qualifier) + case kind + when :attr_reader, :attr_accessor + attr_type = ::RBS::Parser.parse_type(signature_string) + sig = ::RBI::Sig.new + sig.return_type = qualifier.visit(::RBI::RBS::TypeTranslator.translate(attr_type)) + sig + when :attr_writer + attr_type = ::RBS::Parser.parse_type(signature_string) + sig = ::RBI::Sig.new + translated = qualifier.visit(::RBI::RBS::TypeTranslator.translate(attr_type)) + attr_name = node.name.to_s.delete_suffix("=") + sig.params << ::RBI::SigParam.new(attr_name, translated) + sig.return_type = translated + sig + else + method_type = ::RBS::Parser.parse_method_type(signature_string) + rbi_sig = ::RBI::RBS::MethodTypeTranslator.translate(node, method_type) + qualify_sig(rbi_sig, qualifier) + rbi_sig + end + rescue ::RBS::ParsingError, ::RBI::Error + nil + end + + # Walks an `RBI::Sig`, replacing each `Type` param and return type + # with its fully-qualified string form (so the printer emits the + # already-qualified text verbatim and never recurses back into the + # default RBI serializer). + #: (RBI::Sig sig, Tapioca::RBS::TypeQualifier qualifier) -> void + def qualify_sig(sig, qualifier) + new_params = sig.params.map do |param| + param_type = param.type + new_type = param_type.is_a?(::RBI::Type) ? qualifier.visit(param_type) : param_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 + + #: (RBI::Sig sig, Array[Tapioca::RBS::Comments::Annotation] annotations) -> void + def apply_method_annotations(sig, annotations) + annotations.each do |annotation| + case annotation.string + 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 + end + + # Pushes every type symbol referenced by an RBI sig into the pipeline so + # downstream symbol resolution still sees those constants. + #: (RBI::Sig sig) -> void + def push_sig_symbols(sig) + sig.params.each do |param| + push_type_symbols(param.type.to_s) + end + push_type_symbols(sig.return_type.to_s) + end + + #: (String type_string) -> void + def push_type_symbols(type_string) + @pipeline.push_symbol(sanitize_signature_types(type_string)) + end - event.node.sigs << compile_signature(signature, event.parameters) + # Lexical nesting (e.g. `["Foo", "Bar"]`) for a method defined under + # `Foo::Bar`. For singleton methods the nesting is the attached class + # path, since RBS comments are written against the actual class scope. + #: (MethodNodeAdded event) -> Array[String] + def nesting_for(event) + event.symbol.delete_prefix("::").split("::").reject(&:empty?) end #: (untyped signature, Array[[Symbol, String]] parameters) -> RBI::Sig diff --git a/lib/tapioca/gem/listeners/sorbet_type_variables.rb b/lib/tapioca/gem/listeners/sorbet_type_variables.rb index df456a991..32cfebe94 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 + + 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.name == "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..5e0547d48 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,10 @@ 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) #: Rubydex::Graph @bootstrap_symbols.each { |symbol| push_symbol(symbol) } @@ -47,7 +54,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) @@ -98,10 +105,19 @@ def push_foreign_scope(symbol, constant, node) #| UnboundMethod method, #| RBI::Method node, #| untyped signature, - #| Array[[Symbol, String]] parameters + #| Array[[Symbol, String]] parameters, + #| ?rbs_lookup: RBSMethodLookup? #| ) -> void - def push_method(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists - @events << Gem::MethodNodeAdded.new(symbol, constant, method, node, signature, parameters) + def push_method(symbol, constant, method, node, signature, parameters, rbs_lookup: nil) # rubocop:disable Metrics/ParameterLists + @events << Gem::MethodNodeAdded.new( + symbol, + constant, + method, + node, + signature, + parameters, + rbs_lookup: rbs_lookup, + ) end # Constants and properties filtering @@ -174,6 +190,98 @@ 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 + + # Result of an inline RBS lookup for a method declaration: the parsed + # comments and the kind of method definition found (regular `def`, + # `attr_reader`, `attr_writer`, or `attr_accessor`). + class RBSMethodLookup + #: Tapioca::RBS::Comments::Parsed + attr_reader :comments + + #: Symbol + attr_reader :kind # :method, :attr_reader, :attr_writer, :attr_accessor + + #: (Tapioca::RBS::Comments::Parsed comments, Symbol kind) -> void + def initialize(comments, kind) + @comments = comments + @kind = kind + end + end + + # Returns the parsed RBS comments attached to the source-level declaration + # of a method `method_name` on `scope_constant`. Used by listeners to + # pick up method-level RBS signatures and annotations when no Sorbet + # `sig {}` block is available at runtime. + # + # `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 in this gem is used. + #: (Module[top] scope_constant, Symbol method_name, ?is_singleton: bool, ?source_location: [String, Integer]?) -> RBSMethodLookup? + def rbs_comments_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 + + comments = parse_rbs_comments(definition) + return if comments.empty? + + kind = case definition + when Rubydex::AttrReaderDefinition then :attr_reader + when Rubydex::AttrWriterDefinition then :attr_writer + when Rubydex::AttrAccessorDefinition then :attr_accessor + else :method + end + + RBSMethodLookup.new(comments, kind) + end + # Helpers #: (Module[top] constant) -> String? @@ -199,6 +307,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/internal.rb b/lib/tapioca/internal.rb index ac8f8e592..5b4a7e93c 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,6 +43,10 @@ require "rubydex" require "prism" +require "tapioca/rbs/comments" +require "tapioca/rbs/type_qualifier" +require "tapioca/rbs/dsl_signatures" + require "tapioca/helpers/gem_helper" require "tapioca/helpers/git_attributes" require "tapioca/helpers/sorbet_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..8c7feea68 --- /dev/null +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -0,0 +1,475 @@ +# 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 the {RBI::Sig} extracted from inline RBS comments next to + # `method_def`'s source declaration, fully qualified through the + # host-app graph. Returns nil when no RBS info is available or the + # signature can't be parsed. + #: ((Method | UnboundMethod) method_def) -> RBI::Sig? + def build(method_def) + location = method_def.source_location + return unless location + + file, line = location + declaration, kind = find_declaration(method_def, file, line) + return unless declaration + + definition = pick_definition(declaration, file, line) + return unless definition + + parsed = parse_rbs_comments(definition) + return if parsed.signatures.empty? + + # When the source carries multiple `#: ... -> ...` overload lines, + # Sorbet's runtime can only attach one signature to a given method + # anyway, so we pick the last overload — same convention the + # spoom-based rewriter used. + signature_string = parsed.signatures.last&.string #: as !nil + + rbi_method = build_rbi_method(method_def) + sig = case kind + when :attr_reader, :attr_accessor + build_attr_sig(signature_string, attr_name_from(method_def), writer: false) + when :attr_writer + build_attr_sig(signature_string, attr_name_from(method_def), writer: true) + else + build_method_sig(signature_string, rbi_method) + end + return unless sig + + qualifier = TypeQualifier.new(graph, nesting_for(method_def)) + qualify_sig!(sig, qualifier) + sig + rescue ::RBS::ParsingError, ::RBI::Error + nil + end + + # The Rubydex graph used to look up declarations and resolve + # constants. Built lazily on first access and shared per process. + # 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? + end + + # Drops the cached graph. Test-only escape hatch. + #: -> void + def reset! + @graph = nil + end + + private + + #: -> Rubydex::Graph + def build_graph + graph = Rubydex::Graph.new + graph.index_all(workspace_source_paths) + graph.resolve + 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 File.absolute_path?(feature) + 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. + result = lookup_singleton_declaration(owner, method_name) if owner.singleton_class? + return result if result + + 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. + #: (Module[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 + + #: (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 + + # Lexical nesting (e.g. `["Foo", "Bar"]`) of the method's owning + # constant, used so {TypeQualifier} can resolve relative references + # like `Bar` from inside `Foo::Bar`. Falls back to a source-based + # scan of the file when the owner is anonymous (e.g. created with + # `Class.new`). + #: ((Method | UnboundMethod) method_def) -> Array[String] + def nesting_for(method_def) + owner = method_def.owner + name = if owner.singleton_class? + attached = Runtime::Reflection.attached_class_of(owner) + attached && Runtime::Reflection.name_of(attached) + else + Runtime::Reflection.name_of(owner) + end + + return name.split("::").reject(&:empty?) if name + + location = method_def.source_location + return [] unless location + + source_nesting_for(location[0], location[1]) + end + + # Returns the lexical class/module nesting at `line` in `file`, by + # parsing the file with Prism and recording the path of `class` + # and `module` nodes whose source range contains `line`. + #: (String file, Integer line) -> Array[String] + def source_nesting_for(file, line) + source = File.read(file, encoding: "UTF-8") + result = Prism.parse(source) + return [] unless result.success? + + visitor = NestingVisitor.new(line) + visitor.visit(result.value) + visitor.nesting + rescue Errno::ENOENT, Errno::EACCES + [] + end + + # Walks a Prism AST and records the chain of `class`/`module` + # `constant_path` slices that lexically enclose `line`. Visiting is + # short-circuited once we descend into a scope that does not + # contain `line`. + class NestingVisitor < Prism::Visitor + #: Array[String] + attr_reader :nesting + + #: (Integer line) -> void + def initialize(line) + super() + @target_line = line + @nesting = [] #: Array[String] + end + + # @override + #: (Prism::ClassNode node) -> void + def visit_class_node(node) + return unless contains_target?(node) + + @nesting.push(node.constant_path.slice) + super + end + + # @override + #: (Prism::ModuleNode node) -> void + def visit_module_node(node) + return unless contains_target?(node) + + @nesting.push(node.constant_path.slice) + super + end + + private + + #: (Prism::Node node) -> bool + def contains_target?(node) + node.location.start_line <= @target_line && + @target_line <= node.location.end_line + end + end + + #: ((Method | UnboundMethod) method_def) -> RBI::Method + def build_rbi_method(method_def) + rbi = RBI::Method.new(method_def.name.to_s) + method_def.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_def) -> String + def attr_name_from(method_def) + method_def.name.to_s.delete_suffix("=") + end + + #: (String signature_string, RBI::Method rbi_method) -> RBI::Sig? + def build_method_sig(signature_string, rbi_method) + method_type = ::RBS::Parser.parse_method_type(signature_string) + ::RBI::RBS::MethodTypeTranslator.translate(rbi_method, method_type) + 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 + + #: (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 + 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/type_qualifier.rb b/lib/tapioca/rbs/type_qualifier.rb new file mode 100644 index 000000000..be22b3c19 --- /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 in the current nesting. Names already prefixed with `::` are + # returned as-is. Names that can't be resolved through the graph fall + # back to a top-level (`::`) 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/static/symbol_loader.rb b/lib/tapioca/static/symbol_loader.rb index 7f0c08b09..0723da37b 100644 --- a/lib/tapioca/static/symbol_loader.rb +++ b/lib/tapioca/static/symbol_loader.rb @@ -21,11 +21,35 @@ def payload_symbols #: (Array[Pathname] paths) -> Rubydex::Graph def graph_from_paths(paths) 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) 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/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/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index d9354b4fe..8691eece1 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -4707,7 +4707,7 @@ def foo=(_arg0); end 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 +4790,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..5a9ef14b0 100644 --- a/tapioca.gemspec +++ b/tapioca.gemspec @@ -27,7 +27,6 @@ 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("sorbet-static-and-runtime", ">= 0.6.12698") spec.add_dependency("thor", ">= 1.2.0") From 9d4d3d840ecb3873a56b7ffaf206524edfaa98f5 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Thu, 28 May 2026 22:52:15 +0300 Subject: [PATCH 02/11] Refresh the DSL Rubydex graph when new source files get loaded The DSL pipeline memoizes its per-process Rubydex graph and snapshots the list of `$LOADED_FEATURES` paths it was indexed against. Test suites that don't fork between tests (e.g. `DslSpec`) end up sharing one graph across tests, but each test `require`s its own freshly-written fixture file under a different `tmp_path/lib/...`. The cached graph never picks those up, so `DslSignatures.build` returns nil for any method defined in the new file and the DSL compiler falls back to `T.untyped`. Track which paths the graph has already indexed and, on each `graph` call, incrementally index whatever showed up in `$LOADED_FEATURES` since last time. Rubydex's `Graph#index_all` + `Graph#resolve` is incremental, so this is cheap: the no-new-files path is one set diff and an early return. --- lib/tapioca/rbs/dsl_signatures.rb | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/tapioca/rbs/dsl_signatures.rb b/lib/tapioca/rbs/dsl_signatures.rb index 8c7feea68..4791a13a7 100644 --- a/lib/tapioca/rbs/dsl_signatures.rb +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -64,27 +64,60 @@ def build(method_def) # 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. + # 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? + if @graph + refresh_graph(@graph) + @graph + else + @graph = build_graph #: Rubydex::Graph? + end end # Drops the cached graph. Test-only escape hatch. #: -> void def reset! @graph = nil + @indexed_paths = nil end private #: -> Rubydex::Graph def build_graph + paths = workspace_source_paths graph = Rubydex::Graph.new - graph.index_all(workspace_source_paths) + 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. No-ops when there's nothing + # new. + #: (Rubydex::Graph graph) -> void + def refresh_graph(graph) + indexed = (@indexed_paths ||= Set.new) #: Set[String] + new_paths = extra_loaded_features.reject { |p| indexed.include?(p) } + return if new_paths.empty? + + graph.index_all(new_paths) + graph.resolve + indexed.merge(new_paths) + 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 From 2452b61485e541e6d5591800f8083c1f82dec0b2 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 00:42:46 +0300 Subject: [PATCH 03/11] Use Rubydex's lexical_nesting API and index gem RBI stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that simplify and tighten DSL-side RBS resolution now that Rubydex (on the `expose-definition-lexical-nesting` branch) ships `Definition#lexical_owner` and `Definition#lexical_nesting`: - `Tapioca::RBS::DslSignatures.nesting_for` reads the lexical nesting straight off the matching `Rubydex::Definition` instead of parsing the source again with Prism. The transformation into the shape `Graph#resolve_constant` wants — short names, outermost first, with `::Foo` markers for compound or absolute openings — is done in one place and covers plain nesting, `class Foo::Bar` compound paths, and `module ::Bar` absolute paths uniformly. The Prism-based `NestingVisitor` is gone. - `Static::SymbolLoader.graph_from_paths` now also accepts the gem's `.rbi` stub files (collected from `rbi/` in the gem directory) and feeds them in through `Rubydex::Graph#index_source`. RBI is plain Ruby, so the indexer just needs to see the content under a `.rb` URI. This recovers constants that only exist in a gem's native-code shim (e.g. `Rubydex::ConstantReference`), which used to resolve through runtime reflection under the old rewriter path and would otherwise produce unresolvable references in the generated RBI. Also picks up the new `Definition#lexical_owner`/`lexical_nesting` RBI surface in `sorbet/rbi/gems/rubydex@*.rbi`, points the Gemfile at the in-flight Rubydex branch, fixes a few Sorbet errors my earlier commits introduced (`added_any` typing, nilable `Definition#declaration`, `RBI::Extend#names` vs `name`, the `Module` upper bound on `Dsl::Compiler::ConstantType`), and switches the `T.must` cast in `DslSignatures.graph` to an `#: as !nil` inline RBS so we stop using `T.xxx` calls in the new code paths. --- Gemfile | 5 + Gemfile.lock | 17 +- lib/tapioca/dsl/compiler.rb | 2 +- .../gem/listeners/sorbet_type_variables.rb | 4 +- lib/tapioca/gem/pipeline.rb | 5 +- lib/tapioca/gemfile.rb | 18 + lib/tapioca/rbs/dsl_signatures.rb | 174 ++++----- lib/tapioca/rbs/type_qualifier.rb | 8 +- lib/tapioca/static/symbol_loader.rb | 28 +- ...57657f26545a7dd051c909efc2fb7ea7bfd80.rbi} | 329 ++++++++++++++---- 10 files changed, 401 insertions(+), 189 deletions(-) rename sorbet/rbi/gems/{rubydex@0.2.3.rbi => rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi} (58%) diff --git a/Gemfile b/Gemfile index 43b343c3b..836cbb4c3 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,11 @@ source "https://rubygems.org" gemspec +# Pull Rubydex from the branch that exposes `Definition#lexical_owner` and +# `Definition#lexical_nesting` (Shopify/rubydex#832). Drop this override +# once that lands in a release. +gem "rubydex", github: "Shopify/rubydex", branch: "expose-definition-lexical-nesting" + CURRENT_RAILS_VERSION = "8.1" rails_version = ENV.fetch("RAILS_VERSION", CURRENT_RAILS_VERSION) diff --git a/Gemfile.lock b/Gemfile.lock index 9b376ae15..3043f877b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/Shopify/rubydex.git + revision: db757657f26545a7dd051c909efc2fb7ea7bfd80 + branch: expose-definition-lexical-nesting + specs: + rubydex (0.2.4) + GIT remote: https://github.com/paracycle/json_api_client.git revision: 606196035e27f172e686194b583bf4704296a00f @@ -354,10 +361,6 @@ 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) securerandom (0.4.1) shopify-money (4.1.1) bigdecimal (>= 3.0) @@ -462,6 +465,7 @@ DEPENDENCIES rubocop-sorbet (>= 0.4.1) ruby-lsp (>= 0.23.1) ruby-lsp-rails (>= 0.4) + rubydex! shopify-money sidekiq smart_properties @@ -595,10 +599,7 @@ 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.4) securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 shopify-money (4.1.1) sha256=523078e44bfde1920f8b3487ddf9144e0fb6af8cdf67e212bed02025c5c5f423 sidekiq (8.1.5) sha256=19821ff6031100c2317f72a5b8ab32304ca84f5acb5a2ef846ed1ec14144ab02 diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index 574682a7d..06c90188f 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -11,7 +11,7 @@ class Compiler include Runtime::Reflection extend Runtime::Reflection - ConstantType = type_member { { upper: Module } } + ConstantType = type_member { { upper: T::Module[T.anything] } } #: ConstantType attr_reader :constant diff --git a/lib/tapioca/gem/listeners/sorbet_type_variables.rb b/lib/tapioca/gem/listeners/sorbet_type_variables.rb index 32cfebe94..f0b631fb1 100644 --- a/lib/tapioca/gem/listeners/sorbet_type_variables.rb +++ b/lib/tapioca/gem/listeners/sorbet_type_variables.rb @@ -70,7 +70,7 @@ def add_rbs_type_members(event) event.symbol.delete_prefix("::").split("::").reject(&:empty?), ) - added_any = false + added_any = false #: bool type_param_signatures.each do |signature| begin @@ -86,7 +86,7 @@ def add_rbs_type_members(event) end end - if added_any && !event.node.nodes.any? { |n| n.is_a?(RBI::Extend) && n.name == "T::Generic" } + 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 diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 5e0547d48..9753471a9 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -39,7 +39,10 @@ def initialize( # 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) #: Rubydex::Graph + @gem_graph = Static::SymbolLoader.graph_from_paths( + @gem.files, + rbi_files: @gem.rbi_stub_files, + ) #: Rubydex::Graph @bootstrap_symbols.each { |symbol| push_symbol(symbol) } 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/rbs/dsl_signatures.rb b/lib/tapioca/rbs/dsl_signatures.rb index 4791a13a7..fef152e8c 100644 --- a/lib/tapioca/rbs/dsl_signatures.rb +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -37,8 +37,9 @@ def build(method_def) # When the source carries multiple `#: ... -> ...` overload lines, # Sorbet's runtime can only attach one signature to a given method # anyway, so we pick the last overload — same convention the - # spoom-based rewriter used. - signature_string = parsed.signatures.last&.string #: as !nil + # spoom-based rewriter used. We checked `signatures.empty?` above, + # so `last` is non-nil here. + signature_string = parsed.signatures.last&.string rbi_method = build_rbi_method(method_def) sig = case kind @@ -51,19 +52,13 @@ def build(method_def) end return unless sig - qualifier = TypeQualifier.new(graph, nesting_for(method_def)) + qualifier = TypeQualifier.new(graph, nesting_for(definition)) qualify_sig!(sig, qualifier) sig rescue ::RBS::ParsingError, ::RBI::Error nil end - # The Rubydex graph used to look up declarations and resolve - # constants. Built lazily on first access and shared per process. - # 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. # 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 @@ -77,19 +72,15 @@ def build(method_def) # boundary cleanly. #: -> Rubydex::Graph def graph - if @graph - refresh_graph(@graph) - @graph - else - @graph = build_graph #: Rubydex::Graph? - end + @graph ||= build_graph #: Rubydex::Graph? + refresh_graph(@graph) end # Drops the cached graph. Test-only escape hatch. #: -> void def reset! - @graph = nil - @indexed_paths = nil + @graph = nil #: Rubydex::Graph? + @indexed_paths = nil #: Set[String]? end private @@ -105,17 +96,17 @@ def build_graph end # Indexes any new `$LOADED_FEATURES` files that have appeared since - # the graph was last built/refreshed. No-ops when there's nothing - # new. - #: (Rubydex::Graph graph) -> void + # 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 if new_paths.empty? + 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 @@ -188,7 +179,7 @@ def extra_loaded_features $LOADED_FEATURES.select do |feature| next false unless feature.end_with?(".rb") - next false unless File.absolute_path?(feature) + 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) @@ -230,8 +221,14 @@ def find_declaration(method_def, file, line) if owner_name # Singleton methods live on the singleton class; we surface them # under their attached class with the `` marker Rubydex uses. - result = lookup_singleton_declaration(owner, method_name) if owner.singleton_class? - return result if result + 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}()" @@ -298,7 +295,7 @@ def find_declaration_by_location(method_def, file, line) # 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. - #: (Module[top] owner, String method_name) -> [Rubydex::Declaration, Symbol]? + #: (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 @@ -362,85 +359,64 @@ def parse_rbs_comments(definition) Tapioca::RBS::Comments.parse(tuples) end - # Lexical nesting (e.g. `["Foo", "Bar"]`) of the method's owning - # constant, used so {TypeQualifier} can resolve relative references - # like `Bar` from inside `Foo::Bar`. Falls back to a source-based - # scan of the file when the owner is anonymous (e.g. created with - # `Class.new`). - #: ((Method | UnboundMethod) method_def) -> Array[String] - def nesting_for(method_def) - owner = method_def.owner - name = if owner.singleton_class? - attached = Runtime::Reflection.attached_class_of(owner) - attached && Runtime::Reflection.name_of(attached) - else - Runtime::Reflection.name_of(owner) - end - - return name.split("::").reject(&:empty?) if name - - location = method_def.source_location - return [] unless location - - source_nesting_for(location[0], location[1]) - end - - # Returns the lexical class/module nesting at `line` in `file`, by - # parsing the file with Prism and recording the path of `class` - # and `module` nodes whose source range contains `line`. - #: (String file, Integer line) -> Array[String] - def source_nesting_for(file, line) - source = File.read(file, encoding: "UTF-8") - result = Prism.parse(source) - return [] unless result.success? - - visitor = NestingVisitor.new(line) - visitor.visit(result.value) - visitor.nesting - rescue Errno::ENOENT, Errno::EACCES - [] - end - - # Walks a Prism AST and records the chain of `class`/`module` - # `constant_path` slices that lexically enclose `line`. Visiting is - # short-circuited once we descend into a scope that does not - # contain `line`. - class NestingVisitor < Prism::Visitor - #: Array[String] - attr_reader :nesting - - #: (Integer line) -> void - def initialize(line) - super() - @target_line = line - @nesting = [] #: Array[String] - end - - # @override - #: (Prism::ClassNode node) -> void - def visit_class_node(node) - return unless contains_target?(node) - - @nesting.push(node.constant_path.slice) - super + # Lexical nesting at the definition's source position, expressed in + # the shape Rubydex's `Graph#resolve_constant` expects: short names, + # outermost first. + # + # The translation from `Definition#lexical_nesting` (which is + # deepest first and gives 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 - # @override - #: (Prism::ModuleNode node) -> void - def visit_module_node(node) - return unless contains_target?(node) + # Walk outermost-first so we can compare each scope against the + # one above it in the lexical chain. + 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? + # Outermost: always use the fully-qualified declaration name + # so compound-path openings (`class Foo::Bar; ...`) keep + # their full identity. + decl_name + elsif decl_name == "#{parent_decl_name}::#{scope.name}" + # Naturally nested: short name is fine, Rubydex walks into + # the parent. + scope.name + else + # Compound or absolute-path opening: mark this entry as + # absolute so Rubydex restarts the walk from top-level. + "::#{decl_name}" + end - @nesting.push(node.constant_path.slice) - super + result << entry + parent_decl_name = decl_name end - private - - #: (Prism::Node node) -> bool - def contains_target?(node) - node.location.start_line <= @target_line && - @target_line <= node.location.end_line - end + result end #: ((Method | UnboundMethod) method_def) -> RBI::Method diff --git a/lib/tapioca/rbs/type_qualifier.rb b/lib/tapioca/rbs/type_qualifier.rb index be22b3c19..2297a7c28 100644 --- a/lib/tapioca/rbs/type_qualifier.rb +++ b/lib/tapioca/rbs/type_qualifier.rb @@ -112,10 +112,10 @@ def render_proc(type) result end - # Fully-qualifies a constant name, returning `::Foo::Bar` when the name - # resolves in the current nesting. Names already prefixed with `::` are - # returned as-is. Names that can't be resolved through the graph fall - # back to a top-level (`::`) qualification. + # 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?("::") diff --git a/lib/tapioca/static/symbol_loader.rb b/lib/tapioca/static/symbol_loader.rb index 0723da37b..aa1ecb1c6 100644 --- a/lib/tapioca/static/symbol_loader.rb +++ b/lib/tapioca/static/symbol_loader.rb @@ -18,8 +18,18 @@ 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 paths_to_index = paths.map(&:to_s) # Include core/stdlib RBS so that references like `Integer`, `String`, @@ -27,6 +37,20 @@ def graph_from_paths(paths) # 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 diff --git a/sorbet/rbi/gems/rubydex@0.2.3.rbi b/sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi similarity index 58% rename from sorbet/rbi/gems/rubydex@0.2.3.rbi rename to sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi index e259e8e12..fa99464cf 100644 --- a/sorbet/rbi/gems/rubydex@0.2.3.rbi +++ b/sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi @@ -11,78 +11,101 @@ # pkg:gem/rubydex#lib/rubydex/version.rb:3 module Rubydex; end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::AttrAccessorDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::AttrReaderDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::AttrWriterDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:23 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Class < ::Rubydex::Namespace include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:14 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ClassDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::ConstantReference)) } def superclass; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ClassVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ClassVariableDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/comment.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 class Rubydex::Constant < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 def visibility; end end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:35 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ConstantAlias < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::Declaration)) } def target; end + # pkg:gem/rubydex#lib/rubydex.rb:14 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ConstantAliasDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ConstantDefinition < ::Rubydex::Definition; end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ConstantReference < ::Rubydex::Reference abstract! + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(Rubydex::Location) } def location; end @@ -91,87 +114,109 @@ class Rubydex::ConstantReference < ::Rubydex::Reference end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex/declaration.rb:15 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Declaration abstract! + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:14 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:14 sig { returns(String) } def unqualified_name; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:11 + # pkg:gem/rubydex#lib/rubydex.rb:14 def new(*args); end end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Definition abstract! + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[Rubydex::Comment]) } def comments; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::Declaration)) } def declaration; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Boolean) } def deprecated?; end + # pkg:gem/rubydex#lib/rubydex.rb:14 + sig { returns(T::Array[Rubydex::Definition]) } + def lexical_nesting; end + + # pkg:gem/rubydex#lib/rubydex.rb:14 + sig { returns(T.nilable(Rubydex::Definition)) } + def lexical_owner; end + + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(Rubydex::Location) } def location; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::Location)) } def name_location; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:11 + # pkg:gem/rubydex#lib/rubydex.rb:14 def new(*args); end end end -# pkg:gem/rubydex#lib/rubydex/diagnostic.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:14 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,33 +228,37 @@ 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:14 class Rubydex::Document + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(String) } def uri; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:11 + # pkg:gem/rubydex#lib/rubydex.rb:14 def new(*args); end end 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:14 class Rubydex::GlobalVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::GlobalVariableAliasDefinition < ::Rubydex::Definition; end + +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 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:14 sig { params(fully_qualified_name: String).returns(T.nilable(Rubydex::Declaration)) } def [](fully_qualified_name); end + # pkg:gem/rubydex#lib/rubydex.rb:14 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:14 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:14 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:14 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:14 def complete_namespace_access(*_arg0); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def constant_references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Declaration]) } def declarations; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } def delete_document(uri); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[Rubydex::Diagnostic]) } def diagnostics; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(uri: String).returns(T.nilable(Rubydex::Document)) } def document(uri); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Document]) } def documents; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(encoding: String).void } def encoding=(encoding); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(paths: T::Array[String]).void } def exclude_paths(paths); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[String]) } def excluded_paths; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def fuzzy_search(query); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(file_paths: T::Array[String]).returns(T::Array[String]) } def index_all(file_paths); end + # pkg:gem/rubydex#lib/rubydex.rb:14 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:14 def keyword(_arg0); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def method_references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(load_paths: T::Array[String]).returns(T::Array[String]) } def require_paths(load_paths); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.self_type) } def resolve; end + # pkg:gem/rubydex#lib/rubydex.rb:14 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:14 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:14 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:14 class Rubydex::InstanceVariable < ::Rubydex::Declaration + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[T.untyped]) } def references; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 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:14 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:14 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:14 class Rubydex::Method < ::Rubydex::Declaration include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::MethodAliasDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:14 def signatures; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::MethodDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:14 def signatures; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::MethodReference < ::Rubydex::Reference + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(Rubydex::Location) } def location; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(String) } def name; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::Declaration)) } def receiver; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 class Rubydex::Module < ::Rubydex::Namespace include ::Rubydex::Visibility + # pkg:gem/rubydex#lib/rubydex.rb:14 def visibility; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ModuleDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Namespace < ::Rubydex::Declaration abstract! + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Namespace]) } def ancestors; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Namespace]) } def descendants; end + # pkg:gem/rubydex#lib/rubydex.rb:14 def find_member(*_arg0); end + # pkg:gem/rubydex#lib/rubydex/declaration.rb:25 + sig { params(ancestor_name: ::String).returns(::T::Boolean) } + def has_ancestor?(ancestor_name); end + + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } def member(name); end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::Declaration]) } def members; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T.nilable(Rubydex::SingletonClass)) } def singleton_class; end end @@ -542,122 +692,154 @@ 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:14 class Rubydex::Reference abstract! + # pkg:gem/rubydex#lib/rubydex.rb:14 def initialize(_arg0, _arg1); end # pkg:gem/rubydex#lib/rubydex/reference.rb:6 + sig { returns(::Rubydex::Location) } def location; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:11 + # pkg:gem/rubydex#lib/rubydex.rb:14 def new(*args); end end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::ResolvedConstantReference < ::Rubydex::ConstantReference + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(Rubydex::Declaration) } def declaration; end end -# pkg:gem/rubydex#lib/rubydex/signature.rb:4 +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 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:14 class Rubydex::Signature::ForwardParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:23 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::KeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:24 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::OptionalKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:20 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::OptionalPositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:5 +# pkg:gem/rubydex#lib/rubydex.rb:14 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:14 class Rubydex::Signature::PositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:22 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::PostParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:25 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::RestKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex/signature.rb:21 +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Signature::RestPositionalParameter < ::Rubydex::Signature::Parameter; end -class Rubydex::SingletonClass < ::Rubydex::Namespace; end +# pkg:gem/rubydex#lib/rubydex.rb:14 +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:14 class Rubydex::SingletonClassDefinition < ::Rubydex::Definition + # pkg:gem/rubydex#lib/rubydex.rb:14 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::Todo < ::Rubydex::Namespace; end +# pkg:gem/rubydex#lib/rubydex.rb:14 class Rubydex::UnresolvedConstantReference < ::Rubydex::ConstantReference + # pkg:gem/rubydex#lib/rubydex.rb:14 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 From 074de6ba3e1070dbfb5c2c6363857cdaf97cbaec Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 00:47:42 +0300 Subject: [PATCH 04/11] Point Rubydex at main now that the lexical-nesting branch is merged Shopify/rubydex#832 has landed on main, so the dedicated branch is gone and the lexical-nesting API ships from main going forward. Also re-runs `tapioca gem rubydex` to refresh the gem RBI against the post-merge commit. --- Gemfile | 7 +++---- Gemfile.lock | 8 ++++---- ...ex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi} | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) rename sorbet/rbi/gems/{rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi => rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi} (99%) diff --git a/Gemfile b/Gemfile index 836cbb4c3..80be20162 100644 --- a/Gemfile +++ b/Gemfile @@ -4,10 +4,9 @@ source "https://rubygems.org" gemspec -# Pull Rubydex from the branch that exposes `Definition#lexical_owner` and -# `Definition#lexical_nesting` (Shopify/rubydex#832). Drop this override -# once that lands in a release. -gem "rubydex", github: "Shopify/rubydex", branch: "expose-definition-lexical-nesting" +# Pull Rubydex from main until the version that ships +# `Definition#lexical_owner` / `Definition#lexical_nesting` is released. +gem "rubydex", github: "Shopify/rubydex", branch: "main" CURRENT_RAILS_VERSION = "8.1" rails_version = ENV.fetch("RAILS_VERSION", CURRENT_RAILS_VERSION) diff --git a/Gemfile.lock b/Gemfile.lock index 3043f877b..c3bac701d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/Shopify/rubydex.git - revision: db757657f26545a7dd051c909efc2fb7ea7bfd80 - branch: expose-definition-lexical-nesting + revision: de4039036a04ce55ff2927994a7961aebb579da8 + branch: main specs: - rubydex (0.2.4) + rubydex (0.2.5) GIT remote: https://github.com/paracycle/json_api_client.git @@ -599,7 +599,7 @@ 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.4) + rubydex (0.2.5) securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 shopify-money (4.1.1) sha256=523078e44bfde1920f8b3487ddf9144e0fb6af8cdf67e212bed02025c5c5f423 sidekiq (8.1.5) sha256=19821ff6031100c2317f72a5b8ab32304ca84f5acb5a2ef846ed1ec14144ab02 diff --git a/sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi b/sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi similarity index 99% rename from sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi rename to sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi index fa99464cf..e79d1dd07 100644 --- a/sorbet/rbi/gems/rubydex@0.2.4-db757657f26545a7dd051c909efc2fb7ea7bfd80.rbi +++ b/sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi @@ -667,8 +667,8 @@ class Rubydex::Namespace < ::Rubydex::Declaration def find_member(*_arg0); end # pkg:gem/rubydex#lib/rubydex/declaration.rb:25 - sig { params(ancestor_name: ::String).returns(::T::Boolean) } - def has_ancestor?(ancestor_name); end + sig { params(ancestor_names: ::String).returns(::T::Boolean) } + def has_ancestor?(*ancestor_names); end # pkg:gem/rubydex#lib/rubydex.rb:14 sig { params(name: String).returns(T.nilable(Rubydex::Declaration)) } From 1896741876fa6ed86dd3394855e46d5fb6f3d48e Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 01:05:24 +0300 Subject: [PATCH 05/11] Drop runtime T::Generic boilerplate from Dsl::Compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline RBS \`#: [ConstantType < Module[top]]\` annotation on \`Tapioca::Dsl::Compiler\` is the source of truth for the class's generic shape; the explicit \`extend T::Generic\` / \`ConstantType = type_member\` lines I previously added are redundant duplication of the same statement in a different idiom. DSL compiler subclasses that need a refined \`ConstantType\` either keep using the inline \`#: [ConstantType = ...]\` form (no runtime \`type_member\` needed — \`constant\` is a plain instance variable) or, if they prefer the explicit runtime form, declare \`extend T::Generic\` and \`ConstantType = type_member { { fixed: ... } }\` themselves. Both styles work. Updates the \`compiler_spec.rb\` fixtures to use the inline RBS form (they relied on the parent being generic at runtime, which is now no longer the case) and adds the same Rubydex pin to \`MockProject#tapioca_gemfile\` so subprocess test runs pick up the \`Definition#lexical_owner\`/\`lexical_nesting\` API. --- lib/tapioca/dsl/compiler.rb | 3 --- spec/helpers/mock_project.rb | 6 ++++++ spec/tapioca/dsl/compiler_spec.rb | 6 ++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index 06c90188f..81ba05366 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -6,13 +6,10 @@ module Dsl # @abstract #: [ConstantType < Module[top]] class Compiler - extend T::Generic include RBIHelper include Runtime::Reflection extend Runtime::Reflection - ConstantType = type_member { { upper: T::Module[T.anything] } } - #: ConstantType attr_reader :constant diff --git a/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index d9afa5453..ad86047dc 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -44,6 +44,12 @@ def tapioca_gemfile source("https://rubygems.org") gemspec name: "tapioca", path: "#{TAPIOCA_PATH}" + + # Mirror the dev Gemfile's pin so subprocess test runs pick up the + # Rubydex version that exposes `Definition#lexical_owner` and + # `Definition#lexical_nesting`. Drop once tapioca.gemspec is bumped + # to require a released Rubydex with that API. + gem "rubydex", github: "Shopify/rubydex", branch: "main" GEMFILE end 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) From 4fb2ba4d9d6f978fc0db795e60f3fa7a8fdc44bf Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 01:27:08 +0300 Subject: [PATCH 06/11] Wrap method signatures in a polymorphic Tapioca::Runtime::Signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`Runtime::Reflection.signature_of\` used to leak Sorbet's raw \`T::Private::Methods::Signature\` to every caller. That meant \`SorbetSignatures#compile_signature\`, \`Dsl::Compiler\`, \`ActiveModelTypeHelper\`, and \`GraphqlTypeHelper\` all reached into the same set of internals — \`arg_types\` / \`kwarg_types\` / \`rest_type\` / \`block_name\` / \`mode\` / \`owner\` / \`method_name\` — and reimplemented the same \"build a positional type list\", \"sanitize the return type\", \"is this signature final?\" logic in slightly different shapes. Introduces \`Tapioca::Runtime::Signature\` as a small abstract type with one initial concrete impl, \`SorbetSignature\`, that wraps the old object. The interface is deliberately high-level and exposes only what callers actually need: - \`method\` — the canonical \`UnboundMethod\` the sig is attached to. - \`parameter_type_strings\` — positional type strings, post-sanitization. Encapsulates the arg/kwarg/rest/keyrest/block plumbing that used to live in both \`compile_signature\` and \`Dsl::Compiler#parameters_types_from_signature\`. - \`return_type_string\` — sanitized return type. - \`valid_return_type_string\` — same, but \`nil\` when the type string is meaningless (\`void\`, \`T.untyped\`, \`T.noreturn\`, \`\`, ...). - \`valid_first_arg_type_string\` — first positional argument's sanitized type, or \`nil\` when meaningless. Replaces the only surviving \`arg_types.dig(0, 1)\` consumer. - \`compile_to_rbi_sig(parameters, &push_symbol)\` — emits an \`RBI::Sig\`. The body of the old \`SorbetSignatures#compile_signature\` plus the final-method lookup lifted onto the type itself. Caller migrations: - \`SorbetSignatures#on_method\` collapses into a single \`signature.compile_to_rbi_sig(event.parameters) { |sym| @pipeline.push_symbol(sym) }\` call. \`compile_signature\` and \`signature_final?\` are gone. - \`Methods#compile_method\`'s writer-method detection no longer inspects \`signature.arg_types.size\` — it was a redundant cross-check against \`method.parameters.size\`, which we already test. - \`Dsl::Compiler#parameters_types_from_signature\` keeps the same public shape and delegates to \`signature.parameter_type_strings\`. \`compile_method_return_type_to_rbi\` delegates to \`signature.return_type_string\`. - \`ActiveModelTypeHelper#lookup_return_type_of_method\` becomes \`signature.valid_return_type_string\`. \`lookup_arg_type_of_method\` becomes \`signature.valid_first_arg_type_string\`. The \`MEANINGLESS_TYPES\` / \`MEANINGLESS_TYPE_STRINGS\` filtering moves onto \`Signature\` (\`MEANINGLESS_TYPE_STRINGS\` is now a shared constant; the runtime-type sentinels stay private to \`SorbetSignature\`). - \`GraphqlTypeHelper\` swaps \`signature&.return_type\` + \`valid_return_type?\` checks for \`signature&.valid_return_type_string\`. The Scalar branch's \`T::Utils.unwrap_nilable\` becomes \`RBIHelper.as_non_nilable_type\` on the resulting string, which is the same transformation but expressed at string level. Types tightened: \`MethodNodeAdded#signature\` and \`Pipeline#push_method\`'s \`signature\` parameter both move from \`untyped\` to \`Tapioca::Runtime::Signature?\`. No behavioural change for downstream callers; this is purely a refactor that prepares the ground for an \`RbsSignature\` implementation to land in a follow-up. --- lib/tapioca/dsl/compiler.rb | 27 +- .../dsl/helpers/active_model_type_helper.rb | 43 +--- .../dsl/helpers/graphql_type_helper.rb | 19 +- lib/tapioca/gem/events.rb | 4 +- lib/tapioca/gem/listeners/methods.rb | 1 - .../gem/listeners/sorbet_signatures.rb | 55 +---- lib/tapioca/gem/pipeline.rb | 2 +- lib/tapioca/internal.rb | 2 + lib/tapioca/runtime/reflection.rb | 7 +- lib/tapioca/runtime/signature.rb | 232 ++++++++++++++++++ 10 files changed, 255 insertions(+), 137 deletions(-) create mode 100644 lib/tapioca/runtime/signature.rb diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index 81ba05366..ad4e363c5 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 @@ -198,7 +177,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 sanitize_signature_types(name_of_type(signature.return_type)) if signature + return signature.return_type_string if signature rbs_return = rbs_return_type_for(method_def) return sanitize_signature_types(rbs_return) if rbs_return diff --git a/lib/tapioca/dsl/helpers/active_model_type_helper.rb b/lib/tapioca/dsl/helpers/active_model_type_helper.rb index d2f80ce82..2781490bc 100644 --- a/lib/tapioca/dsl/helpers/active_model_type_helper.rb +++ b/lib/tapioca/dsl/helpers/active_model_type_helper.rb @@ -33,33 +33,6 @@ def assume_nilable?(type_value) private - MEANINGLESS_TYPES = [ - T.untyped, - T.noreturn, - T::Private::Types::Void, - T::Private::Types::NotTyped, - ].freeze #: Array[Object] - - MEANINGLESS_TYPE_STRINGS = [ - "T.untyped", - "::T.untyped", - "T.noreturn", - "::T.noreturn", - "void", - "", - "", - ].to_set.freeze #: Set[String] - - #: (untyped type) -> bool - def meaningful_type?(type) - !MEANINGLESS_TYPES.include?(type) - end - - #: (String type_string) -> bool - def meaningful_type_string?(type_string) - !MEANINGLESS_TYPE_STRINGS.include?(type_string) - end - #: (untyped obj) -> T::Types::Base? def lookup_tapioca_type(obj) T::Utils.coerce(obj.__tapioca_type) if obj.respond_to?(:__tapioca_type) @@ -76,18 +49,14 @@ def lookup_return_type_of_method(obj, method) signature = Runtime::Reflection.signature_of(method_def) if signature - return_type = signature.return_type - - return return_type.to_s if return_type && meaningful_type?(return_type) - - return + return signature.valid_return_type_string end rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) return unless rbs_sig type_string = rbs_sig.return_type.to_s - return unless meaningful_type_string?(type_string) + return if Tapioca::Runtime::Signature::MEANINGLESS_TYPE_STRINGS.include?(type_string) type_string end @@ -103,11 +72,7 @@ def lookup_arg_type_of_method(obj, method) signature = Runtime::Reflection.signature_of(method_def) if signature - first_arg_type = signature.arg_types.dig(0, 1) - - return first_arg_type.to_s if first_arg_type && meaningful_type?(first_arg_type) - - return + return signature.valid_first_arg_type_string end rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) @@ -117,7 +82,7 @@ def lookup_arg_type_of_method(obj, method) return unless first_param type_string = first_param.type.to_s - return unless meaningful_type_string?(type_string) + return if Tapioca::Runtime::Signature::MEANINGLESS_TYPE_STRINGS.include?(type_string) type_string 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 15bb9e950..c86e6153f 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]] @@ -114,7 +114,7 @@ class MethodNodeAdded < NodeAdded #| Module[top] constant, #| UnboundMethod method, #| RBI::Method node, - #| untyped signature, + #| Tapioca::Runtime::Signature? signature, #| Array[[Symbol, String]] parameters, #| ?rbs_lookup: Gem::Pipeline::RBSMethodLookup? #| ) -> void diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 470f45f32..51133e493 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -142,7 +142,6 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public (signature || rbs_lookup&.comments&.signatures&.any?) && type == :req && parameters.size == 1 && - (signature.nil? || signature.arg_types.size == 1) && method_name[-1] == "=" if writer_method_with_sig diff --git a/lib/tapioca/gem/listeners/sorbet_signatures.rb b/lib/tapioca/gem/listeners/sorbet_signatures.rb index f87ab8169..540f4aa21 100644 --- a/lib/tapioca/gem/listeners/sorbet_signatures.rb +++ b/lib/tapioca/gem/listeners/sorbet_signatures.rb @@ -15,7 +15,7 @@ class SorbetSignatures < Base def on_method(event) signature = event.signature if signature - event.node.sigs << compile_signature(signature, event.parameters) + event.node.sigs << signature.compile_to_rbi_sig(event.parameters) { |sym| @pipeline.push_symbol(sym) } return end @@ -160,59 +160,6 @@ def nesting_for(event) event.symbol.delete_prefix("::").split("::").reject(&:empty?) 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) - end - # @override #: (NodeAdded event) -> bool def ignore?(event) diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 9753471a9..3a27acf13 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -107,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, #| ?rbs_lookup: RBSMethodLookup? #| ) -> void diff --git a/lib/tapioca/internal.rb b/lib/tapioca/internal.rb index 5b4a7e93c..ecfd13661 100644 --- a/lib/tapioca/internal.rb +++ b/lib/tapioca/internal.rb @@ -52,6 +52,8 @@ require "tapioca/helpers/sorbet_helper" require "tapioca/helpers/rbi_helper" +require "tapioca/runtime/signature" + require "tapioca/helpers/package_url" require "tapioca/helpers/cli_helper" require "tapioca/helpers/config_helper" diff --git a/lib/tapioca/runtime/reflection.rb b/lib/tapioca/runtime/reflection.rb index cb0c4350b..0d1d0c1ca 100644 --- a/lib/tapioca/runtime/reflection.rb +++ b/lib/tapioca/runtime/reflection.rb @@ -123,14 +123,15 @@ def qualified_name_of(constant) SignatureBlockError = Class.new(Tapioca::Error) - #: ((UnboundMethod | Method) method) -> untyped + #: ((UnboundMethod | Method) method) -> Signature? def signature_of!(method) - T::Utils.signature_for_method(method) + sorbet_signature = T::Utils.signature_for_method(method) + SorbetSignature.new(sorbet_signature) if sorbet_signature rescue LoadError, StandardError Kernel.raise SignatureBlockError end - #: ((UnboundMethod | Method) method) -> untyped + #: ((UnboundMethod | Method) method) -> Signature? def signature_of(method) signature_of!(method) rescue SignatureBlockError diff --git a/lib/tapioca/runtime/signature.rb b/lib/tapioca/runtime/signature.rb new file mode 100644 index 000000000..bbdde8d73 --- /dev/null +++ b/lib/tapioca/runtime/signature.rb @@ -0,0 +1,232 @@ +# 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 + # `UnboundMethod` via `signature.method`; callers that introspect + # parameter names / source locations want that wrapped method, not + # the surface one. + # @abstract + #: -> 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 an `RBI::Sig`. `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 } -> 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 } -> 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 + end +end From 5216bd9aab27ce7d29db0ad84743eacce5a31a23 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 01:41:51 +0300 Subject: [PATCH 07/11] Wrap inline RBS comments in a Tapioca::Runtime::RbsSignature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DSL pipeline used to translate inline `#: ...` comments into a bare `RBI::Sig` via `Tapioca::RBS::DslSignatures.build` and then have every consumer (`Dsl::Compiler#rbs_*`, `ActiveModelTypeHelper`'s fallback branches) reach into the resulting `RBI::Sig` directly — `params.first.type.to_s`, `return_type.to_s`, the meaningless-type filter, etc. — duplicating the same surface that `SorbetSignature` already encapsulates. Wrap the parsed sig in a new `Tapioca::Runtime::RbsSignature` subclass of `Tapioca::Runtime::Signature`. It carries the original method, the qualified `RBI::Sig`, and the RBS method-level annotations (`# @abstract`, `# @override`, `# @without_runtime`, ...). The interface is the same one `SorbetSignature` already exposes: - `method` - `parameter_type_strings` - `return_type_string` / `valid_return_type_string` - `valid_first_arg_type_string` - `compile_to_rbi_sig(parameters) { |sym| ... }` `compile_to_rbi_sig` is where the RBS-specific bits — annotation application, the `method_added` / `singleton_method_added` `without_runtime` rule — finally live in one place instead of being inlined into each consumer. `# @without_runtime` is back to driving `sig.without_runtime = true` on the emitted RBI sig (rather than dropping the sig entirely, which was a holdover from the rewriter days that didn't make sense for static RBI generation). The spec that previously asserted the without-runtime method had no sig is updated to expect the `T::Sig::WithoutRuntime.sig` form Sorbet's static checker wants. Caller migrations: - `Tapioca::RBS::DslSignatures.build` returns `RbsSignature?` and folds annotation harvesting into the construction. - `Dsl::Compiler#rbs_parameter_types_for` and `Dsl::Compiler#rbs_return_type_for` delegate to `signature.parameter_type_strings` / `return_type_string`. - `ActiveModelTypeHelper#lookup_return_type_of_method` and `lookup_arg_type_of_method` collapse from two branches into one `signature&.valid_…_string` call sourced from a single `lookup_signature_of_method` that picks Sorbet sig first and RBS sig as fallback. - `Signature#method` widens to `(Method | UnboundMethod)` to accommodate the DSL-side `obj.method(:foo)` call sites; the gem pipeline narrows back to `UnboundMethod` at its call site via `Method#unbind`. The gem-pipeline-side `MethodNodeAdded#rbs_lookup` / `Pipeline::RBSMethodLookup` / `SorbetSignatures#compile_rbs_lookup` path stays as-is for now — that's the next commit. This commit just lays the polymorphic groundwork so the DSL side already runs through it. The pre-existing `T.must` typecheck error in `Dsl::Compiler#compile_method_parameters_to_rbi` is also gone as a side effect: `parameters_types_from_signature` now returns a concrete `Array[String]`, so `method_types[index]` is `String?` (not `T.untyped`) and the `T.must` is no longer redundant. --- lib/tapioca/dsl/compiler.rb | 8 +- .../dsl/helpers/active_model_type_helper.rb | 50 ++----- lib/tapioca/gem/listeners/methods.rb | 5 +- .../gem/listeners/sorbet_signatures.rb | 17 +-- lib/tapioca/rbs/dsl_signatures.rb | 20 ++- lib/tapioca/runtime/signature.rb | 127 +++++++++++++++++- spec/tapioca/gem/pipeline_spec.rb | 2 + 7 files changed, 164 insertions(+), 65 deletions(-) diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index ad4e363c5..038321ea6 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -192,9 +192,7 @@ def compile_method_return_type_to_rbi(method_def) #: ((Method | UnboundMethod) method_def) -> Array[String]? def rbs_parameter_types_for(method_def) sig = Tapioca::RBS::DslSignatures.build(method_def) - return unless sig - - sig.params.map { |param| param.type.to_s } + sig&.parameter_type_strings end # Looks up inline RBS comments for `method_def` via the host app's @@ -203,9 +201,7 @@ def rbs_parameter_types_for(method_def) #: ((Method | UnboundMethod) method_def) -> String? def rbs_return_type_for(method_def) sig = Tapioca::RBS::DslSignatures.build(method_def) - return unless sig - - sig.return_type.to_s + sig&.return_type_string 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 2781490bc..a1259ad4a 100644 --- a/lib/tapioca/dsl/helpers/active_model_type_helper.rb +++ b/lib/tapioca/dsl/helpers/active_model_type_helper.rb @@ -39,52 +39,30 @@ def lookup_tapioca_type(obj) end # Returns the return type of `method` on `obj` as a string, using - # the Sorbet runtime signature when one is registered and falling - # back to inline RBS comments otherwise. Returns nil when no - # meaningful type can be discovered. + # 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) - method_def = lookup_method(obj, method) - return unless method_def - - signature = Runtime::Reflection.signature_of(method_def) - if signature - return signature.valid_return_type_string - end - - rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) - return unless rbs_sig - - type_string = rbs_sig.return_type.to_s - return if Tapioca::Runtime::Signature::MEANINGLESS_TYPE_STRINGS.include?(type_string) - - type_string + lookup_signature_of_method(obj, method)&.valid_return_type_string end # Returns the first arg's type of `method` on `obj` as a string, - # using the Sorbet runtime signature when one is registered and - # falling back to inline RBS comments otherwise. Returns nil when - # no meaningful type can be discovered. + # 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) + lookup_signature_of_method(obj, method)&.valid_first_arg_type_string + end + + # 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) method_def = lookup_method(obj, method) return unless method_def - signature = Runtime::Reflection.signature_of(method_def) - if signature - return signature.valid_first_arg_type_string - end - - rbs_sig = Tapioca::RBS::DslSignatures.build(method_def) - return unless rbs_sig - - first_param = rbs_sig.params.first - return unless first_param - - type_string = first_param.type.to_s - return if Tapioca::Runtime::Signature::MEANINGLESS_TYPE_STRINGS.include?(type_string) - - type_string + Runtime::Reflection.signature_of(method_def) || Tapioca::RBS::DslSignatures.build(method_def) end #: (untyped obj, Symbol method) -> Method? diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 51133e493..0e82de8e7 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -75,7 +75,10 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public begin signature = signature_of!(method) - method = signature.method if signature #: UnboundMethod + 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 diff --git a/lib/tapioca/gem/listeners/sorbet_signatures.rb b/lib/tapioca/gem/listeners/sorbet_signatures.rb index 540f4aa21..f70466b05 100644 --- a/lib/tapioca/gem/listeners/sorbet_signatures.rb +++ b/lib/tapioca/gem/listeners/sorbet_signatures.rb @@ -31,18 +31,11 @@ def on_method(event) # method types (via the `rbi` gem's `MethodTypeTranslator` for plain # methods, or `TypeTranslator` for attr_* methods), fully-qualifies # every constant reference using the pipeline's Rubydex graph, and - # applies any `# @abstract`, `# @override`, etc. annotations that go - # with them. - # - # When the RBS sig carries a `@without_runtime` annotation we skip - # emitting the sig entirely — this matches the existing rewriter-based - # behavior, where `T::Sig::WithoutRuntime.sig` blocks are not picked up - # by Sorbet's runtime and therefore never made it into the generated - # RBI. + # applies any `# @abstract`, `# @override`, `# @without_runtime`, etc. + # annotations directly to the emitted `RBI::Sig`. #: (MethodNodeAdded event, Pipeline::RBSMethodLookup rbs_lookup) -> void def compile_rbs_lookup(event, rbs_lookup) method_annotations = rbs_lookup.comments.method_annotations - return if method_annotations.any? { |a| a.string == "@without_runtime" } qualifier = Tapioca::RBS::TypeQualifier.new(@pipeline.gem_graph, nesting_for(event)) node = event.node @@ -53,9 +46,9 @@ def compile_rbs_lookup(event, rbs_lookup) apply_method_annotations(sig, method_annotations) - # Sorbet runtime doesn't support `sig` on `method_added` or - # `singleton_method_added`, so we always tag those with - # `without_runtime`. + # `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 node.name == "method_added" || node.name == "singleton_method_added" sig.without_runtime = true end diff --git a/lib/tapioca/rbs/dsl_signatures.rb b/lib/tapioca/rbs/dsl_signatures.rb index fef152e8c..8540be21f 100644 --- a/lib/tapioca/rbs/dsl_signatures.rb +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -15,11 +15,14 @@ module RBS # require-hook rewriter. module DslSignatures class << self - # Returns the {RBI::Sig} extracted from inline RBS comments next to - # `method_def`'s source declaration, fully qualified through the - # host-app graph. Returns nil when no RBS info is available or the - # signature can't be parsed. - #: ((Method | UnboundMethod) method_def) -> RBI::Sig? + # 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 @@ -54,7 +57,12 @@ def build(method_def) qualifier = TypeQualifier.new(graph, nesting_for(definition)) qualify_sig!(sig, qualifier) - sig + + Tapioca::Runtime::RbsSignature.new( + method_def, + sig, + annotations: parsed.method_annotations.map(&:string), + ) rescue ::RBS::ParsingError, ::RBI::Error nil end diff --git a/lib/tapioca/runtime/signature.rb b/lib/tapioca/runtime/signature.rb index bbdde8d73..b5a462183 100644 --- a/lib/tapioca/runtime/signature.rb +++ b/lib/tapioca/runtime/signature.rb @@ -38,11 +38,11 @@ class Signature # The method this signature was attached to. Sorbet's runtime wraps # methods with sigs in a layer that points back to the original - # `UnboundMethod` via `signature.method`; callers that introspect - # parameter names / source locations want that wrapped method, not - # the surface one. + # method via `signature.method`; callers that introspect parameter + # names / source locations want that wrapped method, not the + # surface one. # @abstract - #: -> UnboundMethod + #: -> (Method | UnboundMethod) def method = raise NotImplementedError, "Abstract method called" # Parameter type strings in positional source order, ready to feed @@ -228,5 +228,124 @@ def meaningful_runtime_type?(type) !MEANINGLESS_TYPES.include?(type) end end + + # Concrete {Signature} backed by an inline RBS comment that has already + # been translated into an `RBI::Sig` by the RBS pipeline. The wrapper + # carries the method-level annotations (`# @abstract`, `# @override`, + # `# @without_runtime`, ...) alongside the parsed sig so + # {#compile_to_rbi_sig} can apply them when the caller asks for the + # `RBI::Sig`. + class RbsSignature < Signature + #: ( + #| (Method | UnboundMethod) method, + #| RBI::Sig sig, + #| ?annotations: Array[String] + #| ) -> void + def initialize(method, sig, annotations: []) + super() + @method = method + @sig = sig + @annotations = annotations + end + + # @override + #: -> (Method | UnboundMethod) + def method # rubocop:disable Style/TrivialAccessors + @method + end + + # @override + #: -> Array[String] + def parameter_type_strings + @sig.params.map { |param| param.type.to_s } + end + + # @override + #: -> String + def return_type_string + @sig.return_type.to_s + end + + # @override + #: -> String? + def valid_first_arg_type_string + first_param = @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 } -> RBI::Sig + def compile_to_rbi_sig(parameters, &push_symbol) + sig = clone_sig(@sig) + apply_method_annotations(sig) + + # 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`. + sig.params.each { |param| push_symbol.call(param.type.to_s) } + push_symbol.call(sig.return_type.to_s) + + sig + end + + private + + # 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/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index 8691eece1..f17f08fc2 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -4704,6 +4704,8 @@ def bar(a, b:); end def foo; end def foo=(_arg0); end + + T::Sig::WithoutRuntime.sig { returns(::NotExisting) } def qux; end class << self From 958ede2d00fabf57c2767850e083024e225237c5 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 02:12:24 +0300 Subject: [PATCH 08/11] Unify gem and DSL RBS lookups behind Tapioca::Runtime::Signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths now build an `RbsSignature` via the shared `Tapioca::RBS::SignatureBuilder`: parse the `#:` comments, translate to `RBI::Sig`, qualify every constant against a Rubydex graph for the declaration's lexical scope. They differ only in which graph they pass in — workspace vs. gem. `Reflection.signature_of` now takes an optional block as the RBS lookup override: callers that need a non-default scope (the gem-RBI pipeline) pass one; everything else gets the workspace-scoped `DslSignatures.build` by default. `compile_to_rbi_sig` returns `Array[RBI::Sig]` so RBS overloads survive the polymorphic interface. This deletes ~120 lines of duplicated translate/qualify/annotate code from `SorbetSignatures` and `DslSignatures`, drops the `RBSMethodLookup` wrapper and the `MethodNodeAdded#rbs_lookup` plumbing, and lets the gem listener collapse to a single `signature.compile_to_rbi_sig` call for both backends. --- lib/tapioca/dsl/compiler.rb | 35 +--- .../dsl/helpers/active_model_type_helper.rb | 2 +- lib/tapioca/gem/events.rb | 14 +- lib/tapioca/gem/listeners/methods.rb | 84 ++++---- .../gem/listeners/sorbet_signatures.rb | 143 +------------ lib/tapioca/gem/pipeline.rb | 67 +++--- lib/tapioca/internal.rb | 3 +- lib/tapioca/rbs/dsl_signatures.rb | 180 +--------------- lib/tapioca/rbs/signature_builder.rb | 193 ++++++++++++++++++ lib/tapioca/runtime/reflection.rb | 29 ++- lib/tapioca/runtime/signature.rb | 78 ++++--- 11 files changed, 357 insertions(+), 471 deletions(-) create mode 100644 lib/tapioca/rbs/signature_builder.rb diff --git a/lib/tapioca/dsl/compiler.rb b/lib/tapioca/dsl/compiler.rb index 038321ea6..45a9a7350 100644 --- a/lib/tapioca/dsl/compiler.rb +++ b/lib/tapioca/dsl/compiler.rb @@ -135,14 +135,7 @@ def create_method_from_def(scope, method_def, class_method: false) def compile_method_parameters_to_rbi(method_def) signature = signature_of(method_def) method_def = signature.nil? ? method_def : signature.method - method_types = if signature - parameters_types_from_signature(method_def, signature) - else - # No runtime sig — fall back to inline RBS comments parsed straight - # from source. Returns nil when no RBS info is available, in which - # case we use `T.untyped` for every parameter. - rbs_parameter_types_for(method_def) || method_def.parameters.map { "T.untyped" } - end + method_types = parameters_types_from_signature(method_def, signature) parameters = method_def.parameters #: Array[[Symbol, Symbol?]] @@ -177,31 +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 signature.return_type_string if signature - - rbs_return = rbs_return_type_for(method_def) - return sanitize_signature_types(rbs_return) if rbs_return - - "T.untyped" - end - - # Looks up inline RBS comments for `method_def` via the host app's - # Rubydex graph and returns the parameter types as strings, in the - # same order as `method_def.parameters`. Returns nil when there's no - # RBS info attached to the method declaration. - #: ((Method | UnboundMethod) method_def) -> Array[String]? - def rbs_parameter_types_for(method_def) - sig = Tapioca::RBS::DslSignatures.build(method_def) - sig&.parameter_type_strings - end - - # Looks up inline RBS comments for `method_def` via the host app's - # Rubydex graph and returns the return type as a string. Returns nil - # when there's no RBS info attached to the method declaration. - #: ((Method | UnboundMethod) method_def) -> String? - def rbs_return_type_for(method_def) - sig = Tapioca::RBS::DslSignatures.build(method_def) - sig&.return_type_string + 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 a1259ad4a..295a32fef 100644 --- a/lib/tapioca/dsl/helpers/active_model_type_helper.rb +++ b/lib/tapioca/dsl/helpers/active_model_type_helper.rb @@ -62,7 +62,7 @@ def lookup_signature_of_method(obj, method) method_def = lookup_method(obj, method) return unless method_def - Runtime::Reflection.signature_of(method_def) || Tapioca::RBS::DslSignatures.build(method_def) + Runtime::Reflection.signature_of(method_def) end #: (untyped obj, Symbol method) -> Method? diff --git a/lib/tapioca/gem/events.rb b/lib/tapioca/gem/events.rb index c86e6153f..1a8e6dff9 100644 --- a/lib/tapioca/gem/events.rb +++ b/lib/tapioca/gem/events.rb @@ -101,30 +101,20 @@ class MethodNodeAdded < NodeAdded #: Array[[Symbol, String]] attr_reader :parameters - # Inline RBS lookup for the method's source declaration, when the method - # has no Sorbet runtime signature. Used by the `SorbetSignatures` - # listener to synthesize a `sig {}` directly from `#: -> ...` style - # comments, carrying along the kind (regular def vs. attr_*) so the - # listener can interpret the signature correctly. - #: Gem::Pipeline::RBSMethodLookup? - attr_reader :rbs_lookup - #: ( #| String symbol, #| Module[top] constant, #| UnboundMethod method, #| RBI::Method node, #| Tapioca::Runtime::Signature? signature, - #| Array[[Symbol, String]] parameters, - #| ?rbs_lookup: Gem::Pipeline::RBSMethodLookup? + #| Array[[Symbol, String]] parameters #| ) -> void - def initialize(symbol, constant, method, node, signature, parameters, rbs_lookup: nil) # rubocop:disable Metrics/ParameterLists + def initialize(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists super(symbol, constant) @node = node @method = method @signature = signature @parameters = parameters - @rbs_lookup = rbs_lookup end end end diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 0e82de8e7..5f6871bf8 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -74,7 +74,15 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public return unless method_owned_by_constant?(method, constant) begin - signature = signature_of!(method) + # 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 @@ -100,28 +108,6 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public signature = nil end - # When no Sorbet runtime signature is registered, look for inline RBS - # comments in source. This is the path Tapioca uses to surface - # `#: -> ...` style signatures without needing the require-hook - # rewriter. - rbs_lookup = nil #: Pipeline::RBSMethodLookup? - if signature.nil? && scope_constant - rbs_lookup = @pipeline.rbs_comments_for_method( - scope_constant, - method.name, - is_singleton: constant.singleton_class?, - source_location: method.source_location, - ) - - # For `attr_accessor`, Sorbet only attaches a runtime sig to the - # reader; the writer is left bare. We match that convention here so - # the generated RBI stays in line with the existing - # `sig + attr_accessor` output. - if rbs_lookup && rbs_lookup.kind == :attr_accessor && method.name.to_s.end_with?("=") - rbs_lookup = nil - end - end - method_name = method.name.to_s return unless valid_method_name?(method_name) return if struct_method?(constant, method_name) @@ -137,12 +123,12 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public else # 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 any kind - # of signature available — Sorbet runtime or RBS — and we're - # dealing with a single-required-arg writer method, fall back to - # that convention instead of an anonymous `_arg0`. + # 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 || rbs_lookup&.comments&.signatures&.any?) && + signature && type == :req && parameters.size == 1 && method_name[-1] == "=" @@ -185,18 +171,42 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public end end - @pipeline.push_method( - symbol_name, - constant, - method, - rbi_method, - signature, - sanitized_parameters, - rbs_lookup: rbs_lookup, - ) + @pipeline.push_method(symbol_name, constant, method, rbi_method, signature, sanitized_parameters) 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_signatures.rb b/lib/tapioca/gem/listeners/sorbet_signatures.rb index f70466b05..4aefe8de8 100644 --- a/lib/tapioca/gem/listeners/sorbet_signatures.rb +++ b/lib/tapioca/gem/listeners/sorbet_signatures.rb @@ -5,152 +5,17 @@ module Tapioca module Gem module Listeners class SorbetSignatures < Base - include Runtime::Reflection - include RBIHelper - private # @override #: (MethodNodeAdded event) -> void def on_method(event) signature = event.signature - if signature - event.node.sigs << signature.compile_to_rbi_sig(event.parameters) { |sym| @pipeline.push_symbol(sym) } - return - end - - rbs_lookup = event.rbs_lookup - return unless rbs_lookup - return if rbs_lookup.comments.signatures.empty? - - compile_rbs_lookup(event, rbs_lookup) - end - - # Builds RBI sigs for `node` from a set of inline `#: ...` RBS comments - # captured on the method's source declaration. Translates Spoom/RBS - # method types (via the `rbi` gem's `MethodTypeTranslator` for plain - # methods, or `TypeTranslator` for attr_* methods), fully-qualifies - # every constant reference using the pipeline's Rubydex graph, and - # applies any `# @abstract`, `# @override`, `# @without_runtime`, etc. - # annotations directly to the emitted `RBI::Sig`. - #: (MethodNodeAdded event, Pipeline::RBSMethodLookup rbs_lookup) -> void - def compile_rbs_lookup(event, rbs_lookup) - method_annotations = rbs_lookup.comments.method_annotations - - qualifier = Tapioca::RBS::TypeQualifier.new(@pipeline.gem_graph, nesting_for(event)) - node = event.node - - rbs_lookup.comments.signatures.each do |signature| - sig = build_rbi_sig(node, signature.string, rbs_lookup.kind, qualifier) - next unless sig - - apply_method_annotations(sig, method_annotations) - - # `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 node.name == "method_added" || node.name == "singleton_method_added" - sig.without_runtime = true - end - - push_sig_symbols(sig) - node.sigs << sig - end - end - - # Parses a single RBS signature string and translates it into an - # {RBI::Sig} with fully-qualified type strings. For regular methods - # the string is parsed as an `RBS::MethodType`; for attr_* methods it - # is parsed as a plain `RBS::Type`, then wrapped into a getter or - # setter sig depending on the kind of attr method. - #: (RBI::Method node, String signature_string, Symbol kind, Tapioca::RBS::TypeQualifier qualifier) -> RBI::Sig? - def build_rbi_sig(node, signature_string, kind, qualifier) - case kind - when :attr_reader, :attr_accessor - attr_type = ::RBS::Parser.parse_type(signature_string) - sig = ::RBI::Sig.new - sig.return_type = qualifier.visit(::RBI::RBS::TypeTranslator.translate(attr_type)) - sig - when :attr_writer - attr_type = ::RBS::Parser.parse_type(signature_string) - sig = ::RBI::Sig.new - translated = qualifier.visit(::RBI::RBS::TypeTranslator.translate(attr_type)) - attr_name = node.name.to_s.delete_suffix("=") - sig.params << ::RBI::SigParam.new(attr_name, translated) - sig.return_type = translated - sig - else - method_type = ::RBS::Parser.parse_method_type(signature_string) - rbi_sig = ::RBI::RBS::MethodTypeTranslator.translate(node, method_type) - qualify_sig(rbi_sig, qualifier) - rbi_sig - end - rescue ::RBS::ParsingError, ::RBI::Error - nil - end - - # Walks an `RBI::Sig`, replacing each `Type` param and return type - # with its fully-qualified string form (so the printer emits the - # already-qualified text verbatim and never recurses back into the - # default RBI serializer). - #: (RBI::Sig sig, Tapioca::RBS::TypeQualifier qualifier) -> void - def qualify_sig(sig, qualifier) - new_params = sig.params.map do |param| - param_type = param.type - new_type = param_type.is_a?(::RBI::Type) ? qualifier.visit(param_type) : param_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 - - #: (RBI::Sig sig, Array[Tapioca::RBS::Comments::Annotation] annotations) -> void - def apply_method_annotations(sig, annotations) - annotations.each do |annotation| - case annotation.string - 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 - end - - # Pushes every type symbol referenced by an RBI sig into the pipeline so - # downstream symbol resolution still sees those constants. - #: (RBI::Sig sig) -> void - def push_sig_symbols(sig) - sig.params.each do |param| - push_type_symbols(param.type.to_s) - end - push_type_symbols(sig.return_type.to_s) - end - - #: (String type_string) -> void - def push_type_symbols(type_string) - @pipeline.push_symbol(sanitize_signature_types(type_string)) - end + return unless signature - # Lexical nesting (e.g. `["Foo", "Bar"]`) for a method defined under - # `Foo::Bar`. For singleton methods the nesting is the attached class - # path, since RBS comments are written against the actual class scope. - #: (MethodNodeAdded event) -> Array[String] - def nesting_for(event) - event.symbol.delete_prefix("::").split("::").reject(&:empty?) + event.node.sigs.concat( + signature.compile_to_rbi_sig(event.parameters) { |sym| @pipeline.push_symbol(sym) }, + ) end # @override diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 3a27acf13..fe04a6e50 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -108,19 +108,10 @@ def push_foreign_scope(symbol, constant, node) #| UnboundMethod method, #| RBI::Method node, #| Tapioca::Runtime::Signature? signature, - #| Array[[Symbol, String]] parameters, - #| ?rbs_lookup: RBSMethodLookup? + #| Array[[Symbol, String]] parameters #| ) -> void - def push_method(symbol, constant, method, node, signature, parameters, rbs_lookup: nil) # rubocop:disable Metrics/ParameterLists - @events << Gem::MethodNodeAdded.new( - symbol, - constant, - method, - node, - signature, - parameters, - rbs_lookup: rbs_lookup, - ) + def push_method(symbol, constant, method, node, signature, parameters) # rubocop:disable Metrics/ParameterLists + @events << Gem::MethodNodeAdded.new(symbol, constant, method, node, signature, parameters) end # Constants and properties filtering @@ -217,36 +208,27 @@ def rbs_comments_for_constant(constant) parse_rbs_comments(definition) end - # Result of an inline RBS lookup for a method declaration: the parsed - # comments and the kind of method definition found (regular `def`, - # `attr_reader`, `attr_writer`, or `attr_accessor`). - class RBSMethodLookup - #: Tapioca::RBS::Comments::Parsed - attr_reader :comments - - #: Symbol - attr_reader :kind # :method, :attr_reader, :attr_writer, :attr_accessor - - #: (Tapioca::RBS::Comments::Parsed comments, Symbol kind) -> void - def initialize(comments, kind) - @comments = comments - @kind = kind - end - end - - # Returns the parsed RBS comments attached to the source-level declaration - # of a method `method_name` on `scope_constant`. Used by listeners to - # pick up method-level RBS signatures and annotations when no Sorbet - # `sig {}` block is available at runtime. + # 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. + # `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. # - # When `source_location` is provided, the matching definition is selected - # by file/line; otherwise the first definition in this gem is used. - #: (Module[top] scope_constant, Symbol method_name, ?is_singleton: bool, ?source_location: [String, Integer]?) -> RBSMethodLookup? - def rbs_comments_for_method(scope_constant, method_name, is_singleton: false, source_location: nil) + # 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 @@ -272,9 +254,6 @@ def rbs_comments_for_method(scope_constant, method_name, is_singleton: false, so definition = pick_definition(declaration, source_location) return unless definition - comments = parse_rbs_comments(definition) - return if comments.empty? - kind = case definition when Rubydex::AttrReaderDefinition then :attr_reader when Rubydex::AttrWriterDefinition then :attr_writer @@ -282,7 +261,7 @@ def rbs_comments_for_method(scope_constant, method_name, is_singleton: false, so else :method end - RBSMethodLookup.new(comments, kind) + [definition, kind] end # Helpers diff --git a/lib/tapioca/internal.rb b/lib/tapioca/internal.rb index ecfd13661..f309003a7 100644 --- a/lib/tapioca/internal.rb +++ b/lib/tapioca/internal.rb @@ -45,7 +45,6 @@ require "tapioca/rbs/comments" require "tapioca/rbs/type_qualifier" -require "tapioca/rbs/dsl_signatures" require "tapioca/helpers/gem_helper" require "tapioca/helpers/git_attributes" @@ -53,6 +52,8 @@ 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" diff --git a/lib/tapioca/rbs/dsl_signatures.rb b/lib/tapioca/rbs/dsl_signatures.rb index 8540be21f..59a794b55 100644 --- a/lib/tapioca/rbs/dsl_signatures.rb +++ b/lib/tapioca/rbs/dsl_signatures.rb @@ -16,55 +16,27 @@ module RBS 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. + # 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, kind = find_declaration(method_def, file, line) - return unless declaration + 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 - parsed = parse_rbs_comments(definition) - return if parsed.signatures.empty? - - # When the source carries multiple `#: ... -> ...` overload lines, - # Sorbet's runtime can only attach one signature to a given method - # anyway, so we pick the last overload — same convention the - # spoom-based rewriter used. We checked `signatures.empty?` above, - # so `last` is non-nil here. - signature_string = parsed.signatures.last&.string - - rbi_method = build_rbi_method(method_def) - sig = case kind - when :attr_reader, :attr_accessor - build_attr_sig(signature_string, attr_name_from(method_def), writer: false) - when :attr_writer - build_attr_sig(signature_string, attr_name_from(method_def), writer: true) - else - build_method_sig(signature_string, rbi_method) - end - return unless sig - - qualifier = TypeQualifier.new(graph, nesting_for(definition)) - qualify_sig!(sig, qualifier) - - Tapioca::Runtime::RbsSignature.new( - method_def, - sig, - annotations: parsed.method_annotations.map(&:string), - ) - rescue ::RBS::ParsingError, ::RBI::Error - nil + SignatureBuilder.build(method_def, definition, kind, graph) end # Returns the per-process Rubydex graph used to look up declarations @@ -356,136 +328,6 @@ def pick_definition(declaration, file, line) declaration.definitions.first end - - #: (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 - - # Lexical nesting at the definition's source position, expressed in - # the shape Rubydex's `Graph#resolve_constant` expects: short names, - # outermost first. - # - # The translation from `Definition#lexical_nesting` (which is - # deepest first and gives 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 - - # Walk outermost-first so we can compare each scope against the - # one above it in the lexical chain. - 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? - # Outermost: always use the fully-qualified declaration name - # so compound-path openings (`class Foo::Bar; ...`) keep - # their full identity. - decl_name - elsif decl_name == "#{parent_decl_name}::#{scope.name}" - # Naturally nested: short name is fine, Rubydex walks into - # the parent. - scope.name - else - # Compound or absolute-path opening: mark this entry as - # absolute so Rubydex restarts the walk from top-level. - "::#{decl_name}" - end - - result << entry - parent_decl_name = decl_name - end - - result - end - - #: ((Method | UnboundMethod) method_def) -> RBI::Method - def build_rbi_method(method_def) - rbi = RBI::Method.new(method_def.name.to_s) - method_def.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_def) -> String - def attr_name_from(method_def) - method_def.name.to_s.delete_suffix("=") - end - - #: (String signature_string, RBI::Method rbi_method) -> RBI::Sig? - def build_method_sig(signature_string, rbi_method) - method_type = ::RBS::Parser.parse_method_type(signature_string) - ::RBI::RBS::MethodTypeTranslator.translate(rbi_method, method_type) - 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 - - #: (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 end end 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/runtime/reflection.rb b/lib/tapioca/runtime/reflection.rb index 0d1d0c1ca..9912ee817 100644 --- a/lib/tapioca/runtime/reflection.rb +++ b/lib/tapioca/runtime/reflection.rb @@ -123,17 +123,34 @@ def qualified_name_of(constant) SignatureBlockError = Class.new(Tapioca::Error) - #: ((UnboundMethod | Method) method) -> Signature? - def signature_of!(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) - SorbetSignature.new(sorbet_signature) if sorbet_signature + 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) -> Signature? - 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 index b5a462183..1e388a056 100644 --- a/lib/tapioca/runtime/signature.rb +++ b/lib/tapioca/runtime/signature.rb @@ -79,13 +79,19 @@ def valid_return_type_string #: -> String? def valid_first_arg_type_string = raise NotImplementedError, "Abstract method called" - # Compiles this signature into an `RBI::Sig`. `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. + # 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 } -> RBI::Sig + #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> Array[RBI::Sig] def compile_to_rbi_sig(parameters, &push_symbol) = raise NotImplementedError, "Abstract method called" end @@ -148,7 +154,7 @@ def valid_first_arg_type_string end # @override - #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> RBI::Sig + #: (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 @@ -170,7 +176,7 @@ def compile_to_rbi_sig(parameters, &push_symbol) apply_mode!(sig) sig.is_final = final? - sig + [sig] end private @@ -229,22 +235,29 @@ def meaningful_runtime_type?(type) end end - # Concrete {Signature} backed by an inline RBS comment that has already - # been translated into an `RBI::Sig` by the RBS pipeline. The wrapper - # carries the method-level annotations (`# @abstract`, `# @override`, - # `# @without_runtime`, ...) alongside the parsed sig so - # {#compile_to_rbi_sig} can apply them when the caller asks for the - # `RBI::Sig`. + # 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, - #| RBI::Sig sig, + #| Array[RBI::Sig] sigs, #| ?annotations: Array[String] #| ) -> void - def initialize(method, sig, annotations: []) + def initialize(method, sigs, annotations: []) super() @method = method - @sig = sig + @sigs = sigs @annotations = annotations end @@ -257,19 +270,19 @@ def method # rubocop:disable Style/TrivialAccessors # @override #: -> Array[String] def parameter_type_strings - @sig.params.map { |param| param.type.to_s } + last_sig.params.map { |param| param.type.to_s } end # @override #: -> String def return_type_string - @sig.return_type.to_s + last_sig.return_type.to_s end # @override #: -> String? def valid_first_arg_type_string - first_param = @sig.params.first + first_param = last_sig.params.first return unless first_param type_string = first_param.type.to_s @@ -279,22 +292,29 @@ def valid_first_arg_type_string end # @override - #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> RBI::Sig + #: (Array[[Symbol, String]] parameters) { (String symbol) -> void } -> Array[RBI::Sig] def compile_to_rbi_sig(parameters, &push_symbol) - sig = clone_sig(@sig) - apply_method_annotations(sig) + @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`. - sig.params.each { |param| push_symbol.call(param.type.to_s) } - push_symbol.call(sig.return_type.to_s) + # 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) - sig + 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 From 1e332ce1da82e68e32f4aec4bf6145f8faca246f Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 19:04:39 +0300 Subject: [PATCH 09/11] Require Rubydex 0.2.5 or later 0.2.5 ships `Definition#lexical_owner` and `Definition#lexical_nesting`, which is the only Rubydex API this branch needed from the unreleased main. Now that it's out, we can drop the github pins from the dev Gemfile and from MockProject's subprocess Gemfile and bump the gemspec floor. --- Gemfile | 4 ---- Gemfile.lock | 21 +++++++++++---------- spec/helpers/mock_project.rb | 6 ------ tapioca.gemspec | 2 +- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/Gemfile b/Gemfile index 80be20162..43b343c3b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,10 +4,6 @@ source "https://rubygems.org" gemspec -# Pull Rubydex from main until the version that ships -# `Definition#lexical_owner` / `Definition#lexical_nesting` is released. -gem "rubydex", github: "Shopify/rubydex", branch: "main" - CURRENT_RAILS_VERSION = "8.1" rails_version = ENV.fetch("RAILS_VERSION", CURRENT_RAILS_VERSION) diff --git a/Gemfile.lock b/Gemfile.lock index c3bac701d..8b50bff1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,3 @@ -GIT - remote: https://github.com/Shopify/rubydex.git - revision: de4039036a04ce55ff2927994a7961aebb579da8 - branch: main - specs: - rubydex (0.2.5) - GIT remote: https://github.com/paracycle/json_api_client.git revision: 606196035e27f172e686194b583bf4704296a00f @@ -27,7 +20,7 @@ PATH netrc (>= 0.11.0) parallel (>= 1.21.0) rbi (>= 0.3.7) - rubydex (>= 0.1.0.beta10) + rubydex (>= 0.2.5) sorbet-static-and-runtime (>= 0.6.12698) spoom (>= 1.7.16) thor (>= 1.2.0) @@ -361,6 +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.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) @@ -465,7 +463,6 @@ DEPENDENCIES rubocop-sorbet (>= 0.4.1) ruby-lsp (>= 0.23.1) ruby-lsp-rails (>= 0.4) - rubydex! shopify-money sidekiq smart_properties @@ -599,7 +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.5) + 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/spec/helpers/mock_project.rb b/spec/helpers/mock_project.rb index ad86047dc..d9afa5453 100644 --- a/spec/helpers/mock_project.rb +++ b/spec/helpers/mock_project.rb @@ -44,12 +44,6 @@ def tapioca_gemfile source("https://rubygems.org") gemspec name: "tapioca", path: "#{TAPIOCA_PATH}" - - # Mirror the dev Gemfile's pin so subprocess test runs pick up the - # Rubydex version that exposes `Definition#lexical_owner` and - # `Definition#lexical_nesting`. Drop once tapioca.gemspec is bumped - # to require a released Rubydex with that API. - gem "rubydex", github: "Shopify/rubydex", branch: "main" GEMFILE end diff --git a/tapioca.gemspec b/tapioca.gemspec index 5a9ef14b0..425399803 100644 --- a/tapioca.gemspec +++ b/tapioca.gemspec @@ -27,7 +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("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") From 4bddb050fe5cdd1cd6d409091e86b6d57bc9c808 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 19:11:00 +0300 Subject: [PATCH 10/11] Update README.md --- README.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ee2e1eaec..e9b827fee 100644 --- a/README.md +++ b/README.md @@ -490,33 +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 - -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 ``` From 437081ae5c1666b846079ec1bad7a6b3b39f3d71 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Fri, 29 May 2026 21:12:46 +0300 Subject: [PATCH 11/11] Regenerate Rubydex RBI for 0.2.5 Drops the commit-sha suffix from the filename now that we depend on a released Rubydex. --- ...4a7961aebb579da8.rbi => rubydex@0.2.5.rbi} | 266 +++++++++--------- 1 file changed, 133 insertions(+), 133 deletions(-) rename sorbet/rbi/gems/{rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi => rubydex@0.2.5.rbi} (81%) diff --git a/sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi b/sorbet/rbi/gems/rubydex@0.2.5.rbi similarity index 81% rename from sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi rename to sorbet/rbi/gems/rubydex@0.2.5.rbi index e79d1dd07..3981c09a2 100644 --- a/sorbet/rbi/gems/rubydex@0.2.5-de4039036a04ce55ff2927994a7961aebb579da8.rbi +++ b/sorbet/rbi/gems/rubydex@0.2.5.rbi @@ -11,45 +11,45 @@ # pkg:gem/rubydex#lib/rubydex/version.rb:3 module Rubydex; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrAccessorDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrReaderDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::AttrWriterDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Class < ::Rubydex::Namespace include ::Rubydex::Visibility - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassDefinition < ::Rubydex::Definition - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::ConstantReference)) } def superclass; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassVariable < ::Rubydex::Declaration - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ClassVariableDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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 } @@ -64,48 +64,48 @@ class Rubydex::Comment def string; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Constant < ::Rubydex::Declaration include ::Rubydex::Visibility - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantAlias < ::Rubydex::Declaration include ::Rubydex::Visibility - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def target; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantAliasDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantReference < ::Rubydex::Reference abstract! - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end @@ -114,25 +114,25 @@ class Rubydex::ConstantReference < ::Rubydex::Reference end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ConstantVisibilityDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Declaration abstract! - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Declaration) } def owner; end @@ -142,66 +142,66 @@ class Rubydex::Declaration sig { abstract.returns(::T::Enumerable[::Rubydex::Reference]) } def references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def unqualified_name; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def new(*args); end end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Definition abstract! - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Comment]) } def comments; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def declaration; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Boolean) } def deprecated?; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Definition]) } def lexical_nesting; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Definition)) } def lexical_owner; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Location)) } def name_location; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def new(*args); end end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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 } @@ -242,23 +242,23 @@ class Rubydex::DisplayLocation < ::Rubydex::Location def to_s; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Document - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Definition]) } def definitions; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def uri; end class << self private - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def new(*args); end end end @@ -281,34 +281,34 @@ class Rubydex::Failure def message; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::GlobalVariable < ::Rubydex::Declaration - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::GlobalVariableAliasDefinition < ::Rubydex::Definition; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Graph # 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:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Failure]) } def check_integrity; end @@ -320,7 +320,7 @@ class Rubydex::Graph # 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:14 + # 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 @@ -328,7 +328,7 @@ class Rubydex::Graph # # See `complete_expression` for the semantics of `nesting` and `self_receiver`. # - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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 @@ -337,7 +337,7 @@ class Rubydex::Graph # 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:14 + # 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 @@ -346,54 +346,54 @@ class Rubydex::Graph # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def complete_namespace_access(*_arg0); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def constant_references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def declarations; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Diagnostic]) } def diagnostics; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Document]) } def documents; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(encoding: String).void } def encoding=(encoding); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[String]) } def excluded_paths; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # 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:14 + # 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 @@ -404,30 +404,30 @@ class Rubydex::Graph sig { returns(::T::Array[::String]) } def index_workspace; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def keyword(_arg0); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def method_references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.self_type) } def resolve; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) } def search(query); end @@ -474,20 +474,20 @@ Rubydex::Graph::INDEXABLE_EXTENSIONS = T.let(T.unsafe(nil), Array) # pkg:gem/rubydex#lib/rubydex/mixin.rb:15 class Rubydex::Include < ::Rubydex::Mixin; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::InstanceVariable < ::Rubydex::Declaration - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[T.untyped]) } def references; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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.rb:14 +# 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 } @@ -502,7 +502,7 @@ class Rubydex::Keyword def name; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::KeywordParameter # pkg:gem/rubydex#lib/rubydex/keyword_parameter.rb:9 sig { params(name: ::String).void } @@ -516,7 +516,7 @@ 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.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Location include ::Comparable @@ -578,49 +578,49 @@ end # pkg:gem/rubydex#lib/rubydex/location.rb:7 class Rubydex::Location::NotFileUriError < ::StandardError; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Method < ::Rubydex::Declaration include ::Rubydex::Visibility - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::MethodReference]) } def references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodAliasDefinition < ::Rubydex::Definition - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def signatures; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodDefinition < ::Rubydex::Definition - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def signatures; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodReference < ::Rubydex::Reference - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Location) } def location; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::Declaration)) } def receiver; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::MethodVisibilityDefinition < ::Rubydex::Definition; end # pkg:gem/rubydex#lib/rubydex/mixin.rb:4 @@ -636,53 +636,53 @@ class Rubydex::Mixin def constant_reference; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Module < ::Rubydex::Namespace include ::Rubydex::Visibility - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def visibility; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ModuleDefinition < ::Rubydex::Definition - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Namespace < ::Rubydex::Declaration abstract! - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def ancestors; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Namespace]) } def descendants; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # 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:14 + # 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:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::Declaration]) } def members; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Enumerable[Rubydex::ConstantReference]) } def references; end - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T.nilable(Rubydex::SingletonClass)) } def singleton_class; end end @@ -692,11 +692,11 @@ end # pkg:gem/rubydex#lib/rubydex/mixin.rb:18 class Rubydex::Prepend < ::Rubydex::Mixin; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Reference abstract! - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def initialize(_arg0, _arg1); end # pkg:gem/rubydex#lib/rubydex/reference.rb:6 @@ -706,19 +706,19 @@ class Rubydex::Reference class << self private - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 def new(*args); end end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::ResolvedConstantReference < ::Rubydex::ConstantReference - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(Rubydex::Declaration) } def declaration; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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 } @@ -775,25 +775,25 @@ class Rubydex::Signature def rest_positional_parameter; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::ForwardParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::KeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::OptionalKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::OptionalPositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# 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 } @@ -808,38 +808,38 @@ class Rubydex::Signature::Parameter def name; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::PositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::PostParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::RestKeywordParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Signature::RestPositionalParameter < ::Rubydex::Signature::Parameter; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::SingletonClass < ::Rubydex::Namespace # pkg:gem/rubydex#lib/rubydex/declaration.rb:40 - sig { returns(::Rubydex::Declaration) } + sig { returns(Rubydex::Declaration) } def attached_class; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::SingletonClassDefinition < ::Rubydex::Definition - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(T::Array[Rubydex::Mixin]) } def mixins; end end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::Todo < ::Rubydex::Namespace; end -# pkg:gem/rubydex#lib/rubydex.rb:14 +# pkg:gem/rubydex#lib/rubydex.rb:11 class Rubydex::UnresolvedConstantReference < ::Rubydex::ConstantReference - # pkg:gem/rubydex#lib/rubydex.rb:14 + # pkg:gem/rubydex#lib/rubydex.rb:11 sig { returns(String) } def name; end end