tapioca 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d3606b2440f453c78a226d8beec0ce61673d416fa74dba7d0380cd31404feaa
4
- data.tar.gz: c290cccb23e39c3423913357b187d74de382de4c818194676b206e0981f21c17
3
+ metadata.gz: 0b165a37e65e25d71de2a502703464d1f877232f4b1d1a173052821e0c7e2170
4
+ data.tar.gz: f75734a7d89e0dba3f4fbee6c86ab3754dfad69cdaa0bd2575c872ec3020bb65
5
5
  SHA512:
6
- metadata.gz: 4d655006a08405d41158b918625eadbcf7eec8b43da4c9077a7a9fe3ad94ee549865c27f0c66ae1fc0424afc8e6f30806e60f2ed661067264d920e52c6f04b70
7
- data.tar.gz: 96c0d237d2fda54650e919d74fc838b5121b714dcb3a4d35d60836911895265b99b29848f399b7f529050d4c930c3669e52ca3787dfbf1671b883883f39b6bba
6
+ metadata.gz: 54ab42e4c24755c84f831c8fc5f40db823061558507f4b810af16f7b98c3adac9e02a53b64f9be1f0fb824efec2ca87bec4d6b133b63c37ddb247af1e38b3c45
7
+ data.tar.gz: 8465d7d2a74d715a966addd29069ec850f4d002662674dab172d0165f5d22228ba9855d70c16a15e8564613b758c76921f9919995da079ad04d8d03e039c47ba
data/README.md CHANGED
@@ -50,6 +50,9 @@ Tapioca makes it easy to work with [Sorbet](https://sorbet.org) in your codebase
50
50
  * [Using DSL compiler options](#using-dsl-compiler-options)
51
51
  * [Writing custom DSL compilers](#writing-custom-dsl-compilers)
52
52
  * [Writing custom DSL extensions](#writing-custom-dsl-extensions)
53
+ * [Rewriting RBS comments to Sorbet signatures](#rewriting-rbs-comments-to-sorbet-signatures)
54
+ * [Caching rewrites with Bootsnap](#caching-rewrites-with-bootsnap)
55
+ * [Priming the cache from CI](#priming-the-cache-from-ci)
53
56
  * [RBI files for missing constants and methods](#rbi-files-for-missing-constants-and-methods)
54
57
  * [Configuration](#configuration)
55
58
  * [Editor Integration](#editor-integration)
@@ -489,33 +492,35 @@ Usage:
489
492
  tapioca dsl [constant...]
490
493
 
491
494
  Options:
492
- --out, -o, [--outdir=directory] # The output directory for generated DSL RBI files
493
- # Default: sorbet/rbi/dsl
494
- [--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file
495
- # Default: true
496
- [--only=compiler [compiler ...]] # Only run supplied DSL compiler(s)
497
- [--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s)
498
- [--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date
499
- # Default: false
500
- -q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output
501
- # Default: false
502
- -w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto)
503
- [--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped
504
- # Default: 120
505
- -e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs
506
- # Default: development
507
- -l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers
508
- # Default: false
509
- [--app-root=APP_ROOT] # The path to the Rails application
510
- # Default: .
511
- [--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application
512
- # Default: true
513
- [--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s)
514
- [--compiler-options=key:value] # Options to pass to the DSL compilers
515
- -c, [--config=<config file path>] # Path to the Tapioca configuration file
516
- # Default: sorbet/tapioca/config.yml
517
- -V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes
518
- # Default: false
495
+ --out, -o, [--outdir=directory] # The output directory for generated DSL RBI files
496
+ # Default: sorbet/rbi/dsl
497
+ [--file-header], [--no-file-header], [--skip-file-header] # Add a "This file is generated" header on top of each generated RBI file
498
+ # Default: true
499
+ [--only=compiler [compiler ...]] # Only run supplied DSL compiler(s)
500
+ [--exclude=compiler [compiler ...]] # Exclude supplied DSL compiler(s)
501
+ [--verify], [--no-verify], [--skip-verify] # Verifies RBIs are up-to-date
502
+ # Default: false
503
+ [--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.
504
+ # Default: false
505
+ -q, [--quiet], [--no-quiet], [--skip-quiet] # Suppresses file creation output
506
+ # Default: false
507
+ -w, [--workers=N] # Number of parallel workers to use when generating RBIs (default: auto)
508
+ [--rbi-max-line-length=N] # Set the max line length of generated RBIs. Signatures longer than the max line length will be wrapped
509
+ # Default: 120
510
+ -e, [--environment=ENVIRONMENT] # The Rack/Rails environment to use when generating RBIs
511
+ # Default: development
512
+ -l, [--list-compilers], [--no-list-compilers], [--skip-list-compilers] # List all loaded compilers
513
+ # Default: false
514
+ [--app-root=APP_ROOT] # The path to the Rails application
515
+ # Default: .
516
+ [--halt-upon-load-error], [--no-halt-upon-load-error], [--skip-halt-upon-load-error] # Halt upon a load error while loading the Rails application
517
+ # Default: true
518
+ [--skip-constant=constant [constant ...]] # Do not generate RBI definitions for the given application constant(s)
519
+ [--compiler-options=key:value] # Options to pass to the DSL compilers
520
+ -c, [--config=<config file path>] # Path to the Tapioca configuration file
521
+ # Default: sorbet/tapioca/config.yml
522
+ -V, [--verbose], [--no-verbose], [--skip-verbose] # Verbose output for debugging purposes
523
+ # Default: false
519
524
 
520
525
  Generate RBIs for dynamic methods
521
526
  ```
@@ -836,6 +841,42 @@ In order for DSL extensions to be discovered by Tapioca, they either needs to be
836
841
 
837
842
  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).
838
843
 
844
+ ### Rewriting RBS comments to Sorbet signatures
845
+
846
+ 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.
847
+
848
+ 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.
849
+
850
+ #### Caching rewrites with Bootsnap
851
+
852
+ `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`:
853
+
854
+ ```shell
855
+ $ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl
856
+ ```
857
+
858
+ 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.
859
+
860
+ `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`:
861
+
862
+ ```ruby
863
+ # e.g. config/boot.rb
864
+ require "bootsnap/setup" unless ENV["TAPIOCA_RBS_CACHE"] == "1"
865
+ ```
866
+
867
+ #### Priming the cache from CI
868
+
869
+ 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:
870
+
871
+ ```shell
872
+ # Prime: populate the cache.
873
+ $ TAPIOCA_RBS_CACHE=1 bin/tapioca dsl --only-bootsnap-rbs-cache
874
+
875
+ # Consumer: read from the populated cache.
876
+ # BOOTSNAP_READONLY=1 prevents bootsnap from writing back to a read-only mount.
877
+ $ TAPIOCA_RBS_CACHE=1 BOOTSNAP_READONLY=1 bin/tapioca dsl
878
+ ```
879
+
839
880
  ### RBI files for missing constants and methods
840
881
 
841
882
  Even after generating the RBIs, it is possible that some constants or methods are still undefined for Sorbet.
@@ -957,6 +998,7 @@ dsl:
957
998
  only: []
958
999
  exclude: []
959
1000
  verify: false
1001
+ only_bootsnap_rbs_cache: false
960
1002
  quiet: false
961
1003
  workers: 1
962
1004
  rbi_max_line_length: 120
data/lib/tapioca/cli.rb CHANGED
@@ -103,6 +103,10 @@ module Tapioca
103
103
  type: :boolean,
104
104
  default: false,
105
105
  desc: "Verifies RBIs are up-to-date"
106
+ option :only_bootsnap_rbs_cache,
107
+ type: :boolean,
108
+ default: false,
109
+ 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."
106
110
  option :quiet,
107
111
  aliases: ["-q"],
108
112
  type: :boolean,
@@ -146,6 +150,12 @@ module Tapioca
146
150
  def dsl(*constant_or_paths)
147
151
  set_environment(options)
148
152
 
153
+ if options[:only_bootsnap_rbs_cache] && (options[:verify] || options[:list_compilers])
154
+ conflicting = options[:verify] ? "--verify" : "--list-compilers"
155
+ raise MalformattedArgumentError,
156
+ "Options '--only-bootsnap-rbs-cache' and '#{conflicting}' are mutually exclusive"
157
+ end
158
+
149
159
  # Assume anything starting with a capital letter or colon is a class, otherwise a path
150
160
  constants, paths = constant_or_paths.partition { |c| c =~ /\A[A-Z:]/ }
151
161
 
@@ -173,7 +183,7 @@ module Tapioca
173
183
  elsif options[:list_compilers]
174
184
  Commands::DslCompilerList.new(**command_args)
175
185
  else
176
- Commands::DslGenerate.new(**command_args)
186
+ Commands::DslGenerate.new(**command_args, only_bootsnap_rbs_cache: options[:only_bootsnap_rbs_cache])
177
187
  end
178
188
 
179
189
  command.run
@@ -95,6 +95,11 @@ module Tapioca
95
95
  )
96
96
  end.compact
97
97
 
98
+ if @only.any?
99
+ say("Skipping stale RBI removal because `--only` is set.", :yellow)
100
+ return Set.new
101
+ end
102
+
98
103
  files_to_purge = existing_rbi_files - generated_files
99
104
 
100
105
  files_to_purge
@@ -4,6 +4,12 @@
4
4
  module Tapioca
5
5
  module Commands
6
6
  class DslGenerate < AbstractDsl
7
+ #: (?only_bootsnap_rbs_cache: bool, **untyped) -> void
8
+ def initialize(only_bootsnap_rbs_cache: false, **kwargs)
9
+ @only_bootsnap_rbs_cache = only_bootsnap_rbs_cache
10
+ super(**T.unsafe(kwargs))
11
+ end
12
+
7
13
  private
8
14
 
9
15
  # @override
@@ -11,6 +17,15 @@ module Tapioca
11
17
  def execute
12
18
  load_application
13
19
 
20
+ if @only_bootsnap_rbs_cache
21
+ if ENV["TAPIOCA_RBS_CACHE"] == "1"
22
+ say("Bootsnap RBS cache populated, exiting before RBI generation.", :green)
23
+ else
24
+ say_error("Warning: --only-bootsnap-rbs-cache requires TAPIOCA_RBS_CACHE=1 to populate the cache", :yellow)
25
+ end
26
+ return
27
+ end
28
+
14
29
  say("Compiling DSL RBI files...")
15
30
  say("")
16
31
 
@@ -33,7 +33,7 @@ module Tapioca
33
33
  # include GeneratedDelegatedTypeMethods
34
34
  #
35
35
  # module GeneratedDelegatedTypeMethods
36
- # sig { params(args: T.untyped).returns(T.any(Message, Comment)) }
36
+ # sig { params(args: T.untyped).returns(T.any(::Message, ::Comment)) }
37
37
  # def build_entryable(*args); end
38
38
  #
39
39
  # sig { returns(Class) }
@@ -45,7 +45,7 @@ module Tapioca
45
45
  # sig { returns(T::Boolean) }
46
46
  # def message?; end
47
47
  #
48
- # sig { returns(T.nilable(Message)) }
48
+ # sig { returns(T.nilable(::Message)) }
49
49
  # def message; end
50
50
  #
51
51
  # sig { returns(T.nilable(Integer)) }
@@ -54,7 +54,7 @@ module Tapioca
54
54
  # sig { returns(T::Boolean) }
55
55
  # def comment?; end
56
56
  #
57
- # sig { returns(T.nilable(Comment)) }
57
+ # sig { returns(T.nilable(::Comment)) }
58
58
  # def comment; end
59
59
  #
60
60
  # sig { returns(T.nilable(Integer)) }
@@ -67,6 +67,9 @@ module Tapioca
67
67
  class ActiveRecordDelegatedTypes < Compiler
68
68
  include Helpers::ActiveRecordConstantsHelper
69
69
 
70
+ # A delegated type entry paired with the fully-qualified constant name it resolves to.
71
+ ResolvedType = Struct.new(:raw_name, :qualified_name, keyword_init: true)
72
+
70
73
  # @override
71
74
  #: -> void
72
75
  def decorate
@@ -77,8 +80,11 @@ module Tapioca
77
80
  constant.__tapioca_delegated_types.each do |role, data|
78
81
  types = data.fetch(:types)
79
82
  options = data.fetch(:options, {})
80
- populate_role_accessors(mod, role, types)
81
- populate_type_helpers(mod, role, types, options)
83
+ resolved_types = types.map do |type|
84
+ ResolvedType.new(raw_name: type, qualified_name: qualified_type_name(type, role))
85
+ end
86
+ populate_role_accessors(mod, role, resolved_types)
87
+ populate_type_helpers(mod, role, resolved_types, options)
82
88
  end
83
89
  end
84
90
 
@@ -96,8 +102,8 @@ module Tapioca
96
102
 
97
103
  private
98
104
 
99
- #: (RBI::Scope mod, Symbol role, Array[String] types) -> void
100
- def populate_role_accessors(mod, role, types)
105
+ #: (RBI::Scope mod, Symbol role, Array[ResolvedType] resolved_types) -> void
106
+ def populate_role_accessors(mod, role, resolved_types)
101
107
  mod.create_method(
102
108
  "#{role}_name",
103
109
  parameters: [],
@@ -113,20 +119,20 @@ module Tapioca
113
119
  mod.create_method(
114
120
  "build_#{role}",
115
121
  parameters: [create_rest_param("args", type: "T.untyped")],
116
- return_type: types.size == 1 ? types.first : "T.any(#{types.join(", ")})",
122
+ return_type: build_return_type(resolved_types),
117
123
  )
118
124
  end
119
125
 
120
- #: (RBI::Scope mod, Symbol role, Array[String] types, Hash[Symbol, untyped] options) -> void
121
- def populate_type_helpers(mod, role, types, options)
122
- types.each do |type|
123
- populate_type_helper(mod, role, type, options)
126
+ #: (RBI::Scope mod, Symbol role, Array[ResolvedType] resolved_types, Hash[Symbol, untyped] options) -> void
127
+ def populate_type_helpers(mod, role, resolved_types, options)
128
+ resolved_types.each do |resolved_type|
129
+ populate_type_helper(mod, role, resolved_type, options)
124
130
  end
125
131
  end
126
132
 
127
- #: (RBI::Scope mod, Symbol role, String type, Hash[Symbol, untyped] options) -> void
128
- def populate_type_helper(mod, role, type, options)
129
- singular = type.tableize.tr("/", "_").singularize
133
+ #: (RBI::Scope mod, Symbol role, ResolvedType resolved_type, Hash[Symbol, untyped] options) -> void
134
+ def populate_type_helper(mod, role, resolved_type, options)
135
+ singular = resolved_type.raw_name.tableize.tr("/", "_").singularize
130
136
  query = "#{singular}?"
131
137
  primary_key = options[:primary_key] || "id"
132
138
  role_id = options[:foreign_key] || "#{role}_id"
@@ -142,7 +148,7 @@ module Tapioca
142
148
  mod.create_method(
143
149
  singular,
144
150
  parameters: [],
145
- return_type: "T.nilable(#{type})",
151
+ return_type: "T.nilable(#{resolved_type.qualified_name})",
146
152
  )
147
153
 
148
154
  mod.create_method(
@@ -151,6 +157,48 @@ module Tapioca
151
157
  return_type: as_nilable_type(getter_type),
152
158
  )
153
159
  end
160
+
161
+ # Collapses to `T.untyped` if any member is `T.untyped`, since `T.any(::Foo, T.untyped)`
162
+ # is equivalent to `T.untyped` in Sorbet and the per-type error has already been recorded.
163
+ #: (Array[ResolvedType] resolved_types) -> String
164
+ def build_return_type(resolved_types)
165
+ qualified_types = resolved_types.map(&:qualified_name)
166
+ if qualified_types.include?("T.untyped")
167
+ "T.untyped"
168
+ elsif qualified_types.size == 1
169
+ qualified_types.fetch(0)
170
+ else
171
+ "T.any(#{qualified_types.join(", ")})"
172
+ end
173
+ end
174
+
175
+ # Resolves a delegated type entry to a fully-qualified constant name. The strings passed
176
+ # to `delegated_type(..., types: %w[...])` are written verbatim into the generated RBI,
177
+ # but the surrounding `class A::B::C` scope omits `A` and `A::B` from Sorbet's lexical
178
+ # nesting, so a bare `D` reference fails to resolve to `A::B::D` even when that constant
179
+ # exists. `compute_type` is `ActiveRecord::Base`'s own (private) namespace-walking lookup
180
+ # — the same one Rails uses for STI and polymorphic associations — so it resolves both
181
+ # bare and fully-qualified names. When the constant can't be resolved (NameError) or its
182
+ # qualified name can't be derived (anonymous class) we record a compiler error and emit
183
+ # `T.untyped`, which both surfaces the problem and keeps the generated RBI type-checkable.
184
+ #: (String type, Symbol role) -> String
185
+ def qualified_type_name(type, role)
186
+ klass = constant.send(:compute_type, type)
187
+ qualified_name = qualified_name_of(klass)
188
+ return qualified_name if qualified_name
189
+
190
+ add_unresolvable_type_error(type, role)
191
+ rescue NameError, LoadError
192
+ add_unresolvable_type_error(type, role)
193
+ end
194
+
195
+ #: (String type, Symbol role) -> String
196
+ def add_unresolvable_type_error(type, role)
197
+ add_error(<<~MSG.strip)
198
+ Cannot generate delegated_type `#{role}` on `#{constant}` since the type `#{type}` could not be resolved.
199
+ MSG
200
+ "T.untyped"
201
+ end
154
202
  end
155
203
  end
156
204
  end
@@ -80,7 +80,9 @@ module Tapioca
80
80
  signature = Runtime::Reflection.signature_of(method)
81
81
  return_type = signature&.return_type
82
82
 
83
- valid_return_type?(return_type) ? return_type.to_s : "T.untyped"
83
+ # Wrap as non-nilable for required arguments. `coerce_input` supports both
84
+ # required and optional; optional arguments are re-wrapped below based on `type.non_null?`
85
+ valid_return_type?(return_type) ? (T::Utils.unwrap_nilable(return_type) || return_type).to_s : "T.untyped"
84
86
  when GraphQL::Schema::InputObject.singleton_class
85
87
  type_for_constant(unwrapped_type)
86
88
  when Module
@@ -138,7 +138,7 @@ module Tapioca
138
138
  active_compilers.each do |compiler|
139
139
  constants.merge(compiler.processable_constants)
140
140
  end
141
- constants = filter_anonymous_and_reloaded_constants(constants)
141
+ constants = filter_anonymous_constants(constants)
142
142
  constants -= skipped_constants
143
143
 
144
144
  unless requested_constants.empty? && requested_paths.empty?
@@ -154,32 +154,8 @@ module Tapioca
154
154
  end
155
155
 
156
156
  #: (Set[Module[top]] constants) -> Set[Module[top]]
157
- def filter_anonymous_and_reloaded_constants(constants)
158
- # Group constants by their names
159
- constants_by_name = constants
160
- .group_by { |c| Runtime::Reflection.name_of(c) }
161
- .select { |name, _| !name.nil? }
162
-
163
- constants_by_name = T.cast(constants_by_name, T::Hash[String, T::Array[T::Module[T.anything]]])
164
-
165
- # Find the constants that have been reloaded
166
- reloaded_constants = constants_by_name.select { |_, constants| constants.size > 1 }.keys
167
-
168
- unless reloaded_constants.empty? || @lsp_addon
169
- reloaded_constant_names = reloaded_constants.map { |name| "`#{name}`" }.join(", ")
170
-
171
- $stderr.puts("WARNING: Multiple constants with the same name: #{reloaded_constant_names}")
172
- $stderr.puts("Make sure some object is not holding onto these constants during an app reload.")
173
- end
174
-
175
- # Look up all the constants back from their names. The resulting constant set will be the
176
- # set of constants that are actually in memory with those names.
177
- filtered_constants = constants_by_name
178
- .keys
179
- .map { |name| T.cast(Runtime::Reflection.constantize(name), T::Module[T.anything]) }
180
- .select { |mod| Runtime::Reflection.constant_defined?(mod) }
181
-
182
- Set.new.compare_by_identity.merge(filtered_constants)
157
+ def filter_anonymous_constants(constants)
158
+ constants.keep_if { |constant| Runtime::Reflection.name_of(constant) }
183
159
  end
184
160
 
185
161
  #: (Module[top] constant) -> RBI::File?
@@ -12,6 +12,7 @@ require "tapioca/runtime/dynamic_mixin_compiler"
12
12
  require "tapioca/sorbet_ext/backcompat_patches"
13
13
  require "tapioca/sorbet_ext/name_patch"
14
14
  require "tapioca/sorbet_ext/generic_name_patch"
15
+ require "tapioca/sorbet_ext/generic_type_patch"
15
16
  require "tapioca/sorbet_ext/proc_bind_patch"
16
17
  require "tapioca/sorbet_ext/void_patch"
17
18
  require "tapioca/runtime/generic_type_registry"
@@ -1,39 +1,93 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "require-hooks/setup"
5
-
6
4
  # This code rewrites RBS comments back into Sorbet's signatures as the files are being loaded.
7
5
  # This will allow `sorbet-runtime` to wrap the methods as if they were originally written with the `sig{}` blocks.
8
6
  # This will in turn allow Tapioca to use this signatures to generate typed RBI files.
9
7
 
10
- begin
11
- # When in a `bootsnap` environment, files are loaded from the cache and won't trigger the `source_transform` method.
12
- # The `require-hooks` gem comes with a `bootsnap` mode that will disable the `bootsnap/compile_cache/iseq` caching.
13
- # Sadly, we're way to early in the boot process to use it as bootsnap won't be loaded yet and the `require-hooks`
14
- # setup won't pick it up.
15
- #
16
- # As a workaround, if we can preemptively require `bootsnap` and `bootsnap/compile_cache/iseq` we manually override
17
- # the `load_iseq` method to disable the caching mechanism.
18
- #
19
- # This will make the Rails app load slower but allows us to trigger the RBS -> RBI source transform.
20
- require "bootsnap"
21
- require "bootsnap/compile_cache/iseq"
22
-
23
- module Bootsnap
24
- module CompileCache
25
- module ISeq
26
- module InstructionSequenceMixin
27
- #: (String) -> RubyVM::InstructionSequence
28
- def load_iseq(path)
29
- super if defined?(super)
8
+ module Tapioca
9
+ module RBS
10
+ class HostBootsnapSetupError < StandardError; end
11
+
12
+ # Raises when the host calls `Bootsnap.setup` after tapioca's setup. Host's call
13
+ # would overwrite tapioca's cache directory, so rewritten iseqs would end up in
14
+ # the host's regular cache.
15
+ module BootsnapGuard
16
+ extend T::Sig
17
+
18
+ sig { params(_kwargs: T.untyped).void }
19
+ def setup(**_kwargs)
20
+ Kernel.raise HostBootsnapSetupError, <<~MSG
21
+ Bootsnap.setup was called while TAPIOCA_RBS_CACHE=1 is set. Tapioca already
22
+ configured bootsnap with a dedicated cache directory; re-running setup
23
+ would overwrite that config and start writing rewritten iseqs into your
24
+ host's cache.
25
+
26
+ Gate your host's Bootsnap.setup on the env var, e.g. in config/boot.rb:
27
+
28
+ require "bootsnap/setup" unless ENV["TAPIOCA_RBS_CACHE"] == "1"
29
+ MSG
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # When TAPIOCA_RBS_CACHE=1, set up bootsnap with a dedicated cache directory
36
+ # and load require-hooks so the RBS-rewritten iseqs get cached. Subsequent
37
+ # runs read the rewritten iseq directly and skip the rewrite.
38
+ #
39
+ # After our setup, BootsnapGuard is prepended so the host application can't
40
+ # replace our cache directory.
41
+ if ENV["TAPIOCA_RBS_CACHE"] == "1"
42
+ begin
43
+ require "bootsnap"
44
+ # Respect BOOTSNAP_READONLY for consumers reading a pre-populated cache
45
+ # (e.g. a CI prime step).
46
+ readonly = !["0", "false", false].include?(ENV.fetch("BOOTSNAP_READONLY") { false })
47
+ Bootsnap.setup(
48
+ cache_dir: ENV.fetch("TAPIOCA_BOOTSNAP_CACHE_DIR", File.join(Dir.pwd, "tmp/cache/bootsnap-tapioca-rbs")),
49
+ development_mode: true,
50
+ load_path_cache: true,
51
+ compile_cache_iseq: true,
52
+ compile_cache_yaml: true,
53
+ readonly: readonly,
54
+ revalidation: true,
55
+ )
56
+ Bootsnap.log_stats!
57
+ Bootsnap.singleton_class.prepend(Tapioca::RBS::BootsnapGuard)
58
+ rescue LoadError
59
+ # Bootsnap is not in the bundle, skip iseq caching.
60
+ end
61
+
62
+ require "require-hooks/setup"
63
+ else
64
+ require "require-hooks/setup"
65
+
66
+ begin
67
+ # Disable Bootsnap's iseq cache unless TAPIOCA_RBS_CACHE=1 enabled the separate cache above.
68
+ #
69
+ # This is necessary because host apps can call Bootsnap.setup after tapioca loads this file. When that happens,
70
+ # Bootsnap installs `load_iseq` and serves files from its cache, which bypasses RequireHooks.source_transform.
71
+ # Preloading bootsnap's iseq support lets us override `load_iseq` before setup installs it, preserving the default
72
+ # RBS rewrite behavior at the cost of slower app boot.
73
+ require "bootsnap"
74
+ require "bootsnap/compile_cache/iseq"
75
+
76
+ module Bootsnap
77
+ module CompileCache
78
+ module ISeq
79
+ module InstructionSequenceMixin
80
+ #: (String) -> RubyVM::InstructionSequence
81
+ def load_iseq(path)
82
+ super if defined?(super) # Disable Bootsnap's hook, but trigger any others.
83
+ end
30
84
  end
31
85
  end
32
86
  end
33
87
  end
88
+ rescue LoadError
89
+ # Bootsnap is not in the bundle, we don't need to do anything.
34
90
  end
35
- rescue LoadError
36
- # Bootsnap is not in the bundle, we don't need to do anything.
37
91
  end
38
92
 
39
93
  # We need to include `T::Sig` very early to make sure that the `sig` method is available since gems using RBS comments
@@ -47,7 +101,8 @@ RequireHooks.source_transform(patterns: ["**/*.rb"]) do |path, source|
47
101
 
48
102
  # For performance reasons, we only rewrite files that use Sorbet.
49
103
  if source =~ /^\s*#\s*typed: (ignore|false|true|strict|strong|__STDLIB_INTERNAL)/
50
- Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs(source, file: path)
104
+ # Sorbet runtime only supports one signature per method, so keep the last overload.
105
+ Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs(source, file: path, overloads_strategy: :translate_last)
51
106
  end
52
107
  rescue Spoom::Sorbet::Translate::Error
53
108
  # If we can't translate the RBS comments back into Sorbet's signatures, we just skip the file.
@@ -6,7 +6,7 @@ module Tapioca
6
6
  # This class is responsible for storing and looking up information related to generic types.
7
7
  #
8
8
  # The class stores 2 different kinds of data, in two separate lookup tables:
9
- # 1. a lookup of generic type instances by name: `@generic_instances`
9
+ # 1. a lookup of generic type instances by constant and name: `@generic_instances`
10
10
  # 2. a lookup of type variable serializer by constant and type variable
11
11
  # instance: `@type_variables`
12
12
  #
@@ -21,7 +21,7 @@ module Tapioca
21
21
  # variable to type variable serializers. This allows us to associate type variables
22
22
  # to the constant names that represent them, easily.
23
23
  module GenericTypeRegistry
24
- @generic_instances = {} #: Hash[String, Module[top]]
24
+ @generic_instances = {}.compare_by_identity #: Hash[Module[top], Hash[String, Module[top]]]
25
25
 
26
26
  @type_variables = {}.compare_by_identity #: Hash[Module[top], Array[TypeVariableModule]]
27
27
 
@@ -45,8 +45,9 @@ module Tapioca
45
45
  # and cloning the given constant so that we can return a type that is the same
46
46
  # as the current type but is a different instance and has a different name method.
47
47
  #
48
- # We cache those cloned instances by their name in `@generic_instances`, so that
49
- # we don't keep instantiating a new type every single time it is referenced.
48
+ # We cache those cloned instances by their original constant and their name in
49
+ # `@generic_instances`, so that we don't keep instantiating a new type every single
50
+ # time it is referenced.
50
51
  # For example, `[Foo[Integer], Foo[Integer], Foo[Integer], Foo[String]]` will only
51
52
  # result in 2 clones (1 for `Foo[Integer]` and another for `Foo[String]`) and
52
53
  # 2 hash lookups (for the other two `Foo[Integer]`s).
@@ -64,12 +65,15 @@ module Tapioca
64
65
  #
65
66
  # Also, we try to memoize the generic type based on the name, so that
66
67
  # we don't have to keep recreating them all the time.
67
- @generic_instances[name] ||= create_generic_type(constant, name)
68
+ generic_instances = @generic_instances[constant] ||= {}
69
+ generic_instances[name] ||= create_generic_type(constant, name)
68
70
  end
69
71
 
70
72
  #: (Object instance) -> bool
71
73
  def generic_type_instance?(instance)
72
- @generic_instances.values.any? { |generic_type| generic_type === instance }
74
+ @generic_instances.values.any? do |generic_instances|
75
+ generic_instances.values.any? { |generic_type| generic_type === instance }
76
+ end
73
77
  end
74
78
 
75
79
  #: (Module[top] constant) -> Array[TypeVariableModule]?
@@ -88,7 +92,7 @@ module Tapioca
88
92
  # can return it from the original methods as well.
89
93
  #: (untyped constant, TypeVariableModule type_variable) -> void
90
94
  def register_type_variable(constant, type_variable)
91
- type_variables = lookup_or_initialize_type_variables(constant)
95
+ type_variables = @type_variables[constant] ||= []
92
96
 
93
97
  type_variables << type_variable
94
98
  end
@@ -163,11 +167,6 @@ module Tapioca
163
167
  owner.send(:define_method, :inherited, inherited_method)
164
168
  end
165
169
  end
166
-
167
- #: (Module[top] constant) -> Array[TypeVariableModule]
168
- def lookup_or_initialize_type_variables(constant)
169
- @type_variables[constant] ||= []
170
- end
171
170
  end
172
171
  end
173
172
  end
@@ -82,26 +82,6 @@ module T
82
82
  prepend GenericPatch
83
83
  end
84
84
  end
85
-
86
- module Utils
87
- module Private
88
- module PrivateCoercePatch
89
- def coerce_and_check_module_types(val, check_val, check_module_type)
90
- if val.is_a?(Tapioca::TypeVariableModule)
91
- val.coerce_to_type_variable
92
- elsif val.respond_to?(:__tapioca_override_type)
93
- val.__tapioca_override_type
94
- else
95
- super
96
- end
97
- end
98
- end
99
-
100
- class << self
101
- prepend(PrivateCoercePatch)
102
- end
103
- end
104
- end
105
85
  end
106
86
 
107
87
  module Tapioca
@@ -0,0 +1,48 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module T
5
+ module Utils
6
+ module Private
7
+ # Preserve Tapioca's generic type variables and instantiated generic
8
+ # names when Sorbet coerces them into runtime types.
9
+ module TapiocaGenericTypeCoercePatch
10
+ def coerce_and_check_module_types(val, check_val, check_module_type)
11
+ if val.is_a?(Tapioca::TypeVariableModule)
12
+ val.coerce_to_type_variable
13
+ elsif val.respond_to?(:__tapioca_override_type)
14
+ val.__tapioca_override_type
15
+ else
16
+ super
17
+ end
18
+ end
19
+ end
20
+
21
+ class << self
22
+ prepend(TapiocaGenericTypeCoercePatch)
23
+ end
24
+ end
25
+ end
26
+
27
+ module Private
28
+ module Casts
29
+ module TapiocaGenericTypeCastPatch
30
+ # https://github.com/sorbet/sorbet/commit/b8d64c7fd9a08e2b9159b5d592bc2de6d586b44a
31
+ # inlines the Module fast path in `T.let`, `T.cast`, `T.bind`, and
32
+ # `T.assert_type!`, so generic module clones can reach this cast path
33
+ # without going through `T::Utils::Private::TapiocaGenericTypeCoercePatch`.
34
+ def cast(value, type, cast_method)
35
+ if type.respond_to?(:__tapioca_override_type)
36
+ type = type.__tapioca_override_type
37
+ end
38
+
39
+ super(value, type, cast_method)
40
+ end
41
+ end
42
+
43
+ class << self
44
+ prepend(TapiocaGenericTypeCastPatch)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Tapioca
5
- VERSION = "0.19.1"
5
+ VERSION = "0.19.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tapioca
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.19.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ufuk Kayserilioglu
@@ -144,14 +144,14 @@ dependencies:
144
144
  requirements:
145
145
  - - ">="
146
146
  - !ruby/object:Gem::Version
147
- version: 1.7.9
147
+ version: 1.7.16
148
148
  type: :runtime
149
149
  prerelease: false
150
150
  version_requirements: !ruby/object:Gem::Requirement
151
151
  requirements:
152
152
  - - ">="
153
153
  - !ruby/object:Gem::Version
154
- version: 1.7.9
154
+ version: 1.7.16
155
155
  - !ruby/object:Gem::Dependency
156
156
  name: tsort
157
157
  requirement: !ruby/object:Gem::Requirement
@@ -305,6 +305,7 @@ files:
305
305
  - lib/tapioca/runtime/trackers/tracker.rb
306
306
  - lib/tapioca/sorbet_ext/backcompat_patches.rb
307
307
  - lib/tapioca/sorbet_ext/generic_name_patch.rb
308
+ - lib/tapioca/sorbet_ext/generic_type_patch.rb
308
309
  - lib/tapioca/sorbet_ext/name_patch.rb
309
310
  - lib/tapioca/sorbet_ext/proc_bind_patch.rb
310
311
  - lib/tapioca/sorbet_ext/void_patch.rb