tapioca 0.6.1 → 0.6.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: 4eea2d365ede4ba5398d65a2042c7dc86270ec7309cb0bc9747357f44ea1c675
4
- data.tar.gz: 7f7252a0619b138664fe5d30acf0858fb09eb9403d513a06266cc58c464dd3b2
3
+ metadata.gz: df803cbfcb6a400e88175b21c0bfd3d5c8089853d5ca09cf8f7b7d0076ce201f
4
+ data.tar.gz: fc580b1ae8202f4d94a45d75665c07a072c7ffde6652aaa3580dd1e8a196288c
5
5
  SHA512:
6
- metadata.gz: 1ac4530bd50af56e2acee0a630571dbc7452cf10e55ec89088ddb5ed8f9a350fae0b4031602fa13b5b319390172e55bdebe6a3d3b6a722e88fe7902aceb5bb61
7
- data.tar.gz: 26a6365606966861c36ffcea2b247a7dfc5395925d04a9d89f35273ce6ae9c3f29ee3d94002c3dd49c46f05acda9e202025a79cf9e4dd0fbebe49ff542d575ae
6
+ metadata.gz: a36a8f3dc321ce2cf8f5402f3b00aee7ef9e6ea2748ee95371b64dceec217df218fd8dc6ca786cfce90478c9c0db7c14bde1f43276f76a65b4ed2d6fa7093672
7
+ data.tar.gz: f688b633d7e0ed4627a5929aef8293619641609c143c43082b291330f9fe5fb2c5a72e9e70be0f2d57274908b4f1fc48dd6ff8dcb4d7d815c894af9567a21417
data/Gemfile CHANGED
@@ -36,4 +36,9 @@ group(:development, :test) do
36
36
  gem("aasm", require: false)
37
37
  gem("bcrypt", require: false)
38
38
  gem("xpath", require: false)
39
+
40
+ # net-smtp was removed from default gems in Ruby 3.1, but is used by the `mail` gem.
41
+ # So we need to add it as a dependency until `mail` is fixed:
42
+ # https://github.com/rails/rails/blob/0919aa97260ab8240150278d3b07a1547489e3fd/Gemfile#L178-L191
43
+ gem("net-smtp", "0.3.1", require: false)
39
44
  end
data/README.md CHANGED
@@ -80,8 +80,8 @@ Command: `tapioca init`
80
80
 
81
81
  This will create the `sorbet/config` and `sorbet/tapioca/require.rb` files for you, if they don't exist. If any of the files already exist, they will not be changed.
82
82
 
83
+ <!-- START_HELP_COMMAND_INIT -->
83
84
  ```shell
84
- $ bundle exec tapioca help init
85
85
  Usage:
86
86
  tapioca init
87
87
 
@@ -92,6 +92,7 @@ Options:
92
92
 
93
93
  initializes folder structure
94
94
  ```
95
+ <!-- END_HELP_COMMAND_INIT -->
95
96
 
96
97
  ### Generate RBI files for gems
97
98
 
@@ -99,13 +100,13 @@ Command: `tapioca gem [gems...]`
99
100
 
100
101
  This will generate RBIs for the specified gems and place them in the RBI directory.
101
102
 
103
+ <!-- START_HELP_COMMAND_GEM -->
102
104
  ```shell
103
- $ bundle exec tapioca help gem
104
105
  Usage:
105
106
  tapioca gem [gem...]
106
107
 
107
108
  Options:
108
- --out, -o, [--outdir=directory] # The output directory for generated RBI files
109
+ --out, -o, [--outdir=directory] # The output directory for generated gem RBI files
109
110
  # Default: sorbet/rbi/gems
110
111
  [--file-header], [--no-file-header] # Add a "This file is generated" header on top of each generated RBI file
111
112
  # Default: true
@@ -113,10 +114,10 @@ Options:
113
114
  --pre, -b, [--prerequire=file] # A file to be required before Bundler.require is called
114
115
  --post, -a, [--postrequire=file] # A file to be required after Bundler.require is called
115
116
  # Default: sorbet/tapioca/require.rb
116
- -x, [--exclude=gem [gem ...]] # Excludes the given gem(s) from RBI generation
117
- --typed, -t, [--typed-overrides=gem:level [gem:level ...]] # Overrides for typed sigils for generated gem RBIs
117
+ -x, [--exclude=gem [gem ...]] # Exclude the given gem(s) from RBI generation
118
+ --typed, -t, [--typed-overrides=gem:level [gem:level ...]] # Override for typed sigils for generated gem RBIs
118
119
  # Default: {"activesupport"=>"false"}
119
- [--verify], [--no-verify] # Verifies RBIs are up-to-date
120
+ [--verify], [--no-verify] # Verify RBIs are up-to-date
120
121
  [--doc], [--no-doc] # Include YARD documentation from sources when generating RBIs. Warning: this might be slow
121
122
  [--exported-gem-rbis], [--no-exported-gem-rbis] # Include RBIs found in the `rbi/` directory of the gem
122
123
  # Default: true
@@ -128,6 +129,7 @@ Options:
128
129
 
129
130
  generate RBIs from gems
130
131
  ```
132
+ <!-- END_HELP_COMMAND_GEM -->
131
133
 
132
134
  ### Generate the list of all unresolved constants
133
135
 
@@ -135,13 +137,13 @@ Command: `tapioca todo`
135
137
 
136
138
  This will generate the file `sorbet/rbi/todo.rbi` defining all unresolved constants as empty modules.
137
139
 
140
+ <!-- START_HELP_COMMAND_TODO -->
138
141
  ```shell
139
- $ bundle exec tapioca help todo
140
142
  Usage:
141
143
  tapioca todo
142
144
 
143
145
  Options:
144
- [--todo-file=TODO_FILE]
146
+ [--todo-file=TODO_FILE] # Path to the generated todo RBI file
145
147
  # Default: sorbet/rbi/todo.rbi
146
148
  [--file-header], [--no-file-header] # Add a "This file is generated" header on top of each generated RBI file
147
149
  # Default: true
@@ -151,6 +153,7 @@ Options:
151
153
 
152
154
  generate the list of unresolved constants
153
155
  ```
156
+ <!-- END_HELP_COMMAND_TODO -->
154
157
 
155
158
  ### Generate DSL RBI files
156
159
 
@@ -158,18 +161,18 @@ Command: `tapioca dsl [constant...]`
158
161
 
159
162
  This will generate DSL RBIs for specified constants (or for all handled constants, if a constant name is not supplied). You can read about DSL RBI generators supplied by `tapioca` in [the manual](manual/generators.md).
160
163
 
164
+ <!-- START_HELP_COMMAND_DSL -->
161
165
  ```shell
162
- $ bundle exec tapioca help dsl
163
166
  Usage:
164
167
  tapioca dsl [constant...]
165
168
 
166
169
  Options:
167
- --out, -o, [--outdir=directory] # The output directory for generated RBI files
170
+ --out, -o, [--outdir=directory] # The output directory for generated DSL RBI files
168
171
  # Default: sorbet/rbi/dsl
169
172
  [--file-header], [--no-file-header] # Add a "This file is generated" header on top of each generated RBI file
170
173
  # Default: true
171
- [--only=generator [generator ...]] # Only run supplied DSL generators
172
- [--exclude=generator [generator ...]] # Exclude supplied DSL generators
174
+ [--only=generator [generator ...]] # Only run supplied DSL generator(s)
175
+ [--exclude=generator [generator ...]] # Exclude supplied DSL generator(s)
173
176
  [--verify], [--no-verify] # Verifies RBIs are up-to-date
174
177
  -q, [--quiet], [--no-quiet] # Supresses file creation output
175
178
  -w, [--workers=N] # EXPERIMENTAL: Number of parallel workers to use when generating RBIs
@@ -180,6 +183,8 @@ Options:
180
183
 
181
184
  generate RBIs for dynamic methods
182
185
  ```
186
+ <!-- END_HELP_COMMAND_DSL -->
187
+
183
188
  ## Configuration
184
189
 
185
190
  Tapioca supports loading command defaults from a configuration file. The default configuration
@@ -192,20 +197,57 @@ For example, if you always want to generate gem RBIs with inline documentation,
192
197
 
193
198
  ```yaml
194
199
  gem:
195
- docs: true
200
+ doc: true
196
201
  ```
197
202
 
198
203
  Additionally, if you always want to exclude the `AASM` and `ActiveRecordFixtures` DSL compilers in your DSL RBI generation runs, your config file would then look like this:
199
204
 
200
205
  ```yaml
201
206
  gem:
202
- docs: true
207
+ doc: true
203
208
  dsl:
204
209
  exclude:
205
210
  - UrlHelpers
206
211
  - ActiveRecordFixtures
207
212
  ```
208
213
 
214
+ The full configuration file, with each option and its default value, would look something like this:
215
+ <!-- START_CONFIG_TEMPLATE -->
216
+ ```yaml
217
+ ---
218
+ require:
219
+ postrequire: sorbet/tapioca/require.rb
220
+ todo:
221
+ todo_file: sorbet/rbi/todo.rbi
222
+ file_header: true
223
+ dsl:
224
+ outdir: sorbet/rbi/dsl
225
+ file_header: true
226
+ only: []
227
+ exclude: []
228
+ verify: false
229
+ quiet: false
230
+ workers: 1
231
+ gem:
232
+ outdir: sorbet/rbi/gems
233
+ file_header: true
234
+ all: false
235
+ prerequire: ''
236
+ postrequire: sorbet/tapioca/require.rb
237
+ exclude: []
238
+ typed_overrides:
239
+ activesupport: 'false'
240
+ verify: false
241
+ doc: false
242
+ exported_gem_rbis: true
243
+ workers: 1
244
+ clean_shims:
245
+ gem_rbi_dir: sorbet/rbi/gems
246
+ dsl_rbi_dir: sorbet/rbi/dsl
247
+ shim_rbi_dir: sorbet/rbi/shims
248
+ ```
249
+ <!-- END_CONFIG_TEMPLATE -->
250
+
209
251
  ## Contributing
210
252
 
211
253
  Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/tapioca. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](https://github.com/Shopify/tapioca/blob/main/CODE_OF_CONDUCT.md) code of conduct.
data/lib/tapioca/cli.rb CHANGED
@@ -47,6 +47,7 @@ module Tapioca
47
47
  desc "todo", "generate the list of unresolved constants"
48
48
  option :todo_file,
49
49
  type: :string,
50
+ desc: "Path to the generated todo RBI file",
50
51
  default: DEFAULT_TODO_FILE
51
52
  option :file_header,
52
53
  type: :boolean,
@@ -296,7 +296,7 @@ module Tapioca
296
296
 
297
297
  sig { params(type: String).returns(String) }
298
298
  def as_nilable_type(type)
299
- return type if type.start_with?("T.nilable(")
299
+ return type if type.start_with?("T.nilable(") || type == "T.untyped"
300
300
  "T.nilable(#{type})"
301
301
  end
302
302
  end
@@ -136,11 +136,13 @@ module Tapioca
136
136
  method_def = signature.nil? ? method_def : signature.method
137
137
  method_types = parameters_types_from_signature(method_def, signature)
138
138
 
139
- method_def.parameters.each_with_index.map do |(type, name), index|
139
+ parameters = T.let(method_def.parameters, T::Array[[Symbol, T.nilable(Symbol)]])
140
+
141
+ parameters.each_with_index.map do |(type, name), index|
140
142
  fallback_arg_name = "_arg#{index}"
141
143
 
142
- name ||= fallback_arg_name
143
- name = name.to_s.gsub(/&|\*/, fallback_arg_name) # avoid incorrect names from `delegate`
144
+ name = name ? name.to_s : fallback_arg_name
145
+ name = fallback_arg_name unless valid_parameter_name?(name)
144
146
  method_type = T.must(method_types[index])
145
147
 
146
148
  case type
@@ -173,6 +175,11 @@ module Tapioca
173
175
  return_type = "T.untyped" if return_type == "<NOT-TYPED>"
174
176
  return_type
175
177
  end
178
+
179
+ sig { params(name: String).returns(T::Boolean) }
180
+ def valid_parameter_name?(name)
181
+ name.match?(/^[[[:alnum:]]_]+$/)
182
+ end
176
183
  end
177
184
  end
178
185
  end
@@ -174,20 +174,31 @@ class DynamicMixinCompiler
174
174
 
175
175
  sig { params(tree: RBI::Tree).returns([T::Array[Module], T::Array[Module]]) }
176
176
  def compile_mixes_in_class_methods(tree)
177
- includes = dynamic_includes.select { |mod| (name = name_of(mod)) && !name.start_with?("T::") }
178
- includes.each do |mod|
177
+ includes = dynamic_includes.map do |mod|
179
178
  qname = qualified_name_of(mod)
180
- tree << RBI::Include.new(T.must(qname))
181
- end
179
+
180
+ next if qname.nil? || qname.empty?
181
+ next if filtered_mixin?(qname)
182
+
183
+ tree << RBI::Include.new(qname)
184
+
185
+ mod
186
+ end.compact
182
187
 
183
188
  # If we can generate multiple mixes_in_class_methods, then we want to use all dynamic extends that are not the
184
189
  # constant itself
185
- mixed_in_class_methods = dynamic_extends.select { |mod| mod != @constant }
190
+ mixed_in_class_methods = dynamic_extends.select do |mod|
191
+ mod != @constant && !module_included_by_another_dynamic_extend?(mod, dynamic_extends)
192
+ end
193
+
186
194
  return [[], []] if mixed_in_class_methods.empty?
187
195
 
188
196
  mixed_in_class_methods.each do |mod|
189
197
  qualified_name = qualified_name_of(mod)
198
+
190
199
  next if qualified_name.nil? || qualified_name.empty?
200
+ next if filtered_mixin?(qualified_name)
201
+
191
202
  tree << RBI::MixesInClassMethods.new(qualified_name)
192
203
  end
193
204
 
@@ -195,4 +206,18 @@ class DynamicMixinCompiler
195
206
  rescue
196
207
  [[], []] # silence errors
197
208
  end
209
+
210
+ sig { params(mod: Module, dynamic_extends: T::Array[Module]).returns(T::Boolean) }
211
+ def module_included_by_another_dynamic_extend?(mod, dynamic_extends)
212
+ dynamic_extends.any? do |dynamic_extend|
213
+ mod != dynamic_extend && ancestors_of(dynamic_extend).include?(mod)
214
+ end
215
+ end
216
+
217
+ sig { params(qualified_mixin_name: String).returns(T::Boolean) }
218
+ def filtered_mixin?(qualified_mixin_name)
219
+ # filter T:: namespace mixins that aren't T::Props
220
+ # T::Props and subconstants have semantic value
221
+ qualified_mixin_name.start_with?("::T::") && !qualified_mixin_name.start_with?("::T::Props")
222
+ end
198
223
  end
@@ -17,10 +17,9 @@ module Tapioca
17
17
  def compile
18
18
  config = Spoom::Sorbet::Config.parse_file(@sorbet_path)
19
19
  files = collect_files(config)
20
+ names_in_project = files.map { |file| [File.basename(file, ".rb"), true] }.to_h
20
21
  files.flat_map do |file|
21
- collect_requires(file).reject do |req|
22
- name_in_project?(files, req)
23
- end
22
+ collect_requires(file).reject { |req| names_in_project[req] }
24
23
  end.sort.uniq.map do |name|
25
24
  "require \"#{name}\"\n"
26
25
  end.join
@@ -45,9 +44,10 @@ module Tapioca
45
44
 
46
45
  sig { params(file_path: String).returns(T::Enumerable[String]) }
47
46
  def collect_requires(file_path)
48
- File.read(file_path).lines.map do |line|
47
+ File.binread(file_path).lines.map do |line|
49
48
  /^\s*require\s*(\(\s*)?['"](?<name>[^'"]+)['"](\s*\))?/.match(line) { |m| m["name"] }
50
49
  end.compact
50
+ .reject { |require| require.include?('#{') } # ignore interpolation
51
51
  end
52
52
 
53
53
  sig { params(config: Spoom::Sorbet::Config, file_path: Pathname).returns(T::Boolean) }
@@ -83,13 +83,6 @@ module Tapioca
83
83
  def path_parts(path)
84
84
  T.unsafe(path).descend.map { |part| part.basename.to_s }
85
85
  end
86
-
87
- sig { params(files: T::Enumerable[String], name: String).returns(T::Boolean) }
88
- def name_in_project?(files, name)
89
- files.any? do |file|
90
- File.basename(file, ".rb") == name
91
- end
92
- end
93
86
  end
94
87
  end
95
88
  end
@@ -141,7 +141,6 @@ module Tapioca
141
141
  return if symbol_ignored?(name)
142
142
 
143
143
  klass = class_of(value)
144
- return if klass == TypeMember || klass == TypeTemplate
145
144
 
146
145
  klass_name = if klass == ObjectSpace::WeakMap
147
146
  # WeakMap is an implicit generic with one type variable
@@ -172,6 +171,7 @@ module Tapioca
172
171
  sig { params(tree: RBI::Tree, name: String, constant: Module).void }
173
172
  def compile_module(tree, name, constant)
174
173
  return unless defined_in_gem?(constant, strict: false)
174
+ return if Tapioca::TypeVariableModule === constant
175
175
 
176
176
  comments = documentation_comments(name)
177
177
  scope =
@@ -282,30 +282,16 @@ module Tapioca
282
282
  type_variables = GenericTypeRegistry.lookup_type_variables(constant)
283
283
  return unless type_variables
284
284
 
285
- # Create a map of subconstants (via their object ids) to their names.
286
- # We need this later when we want to lookup the name of the registered type
287
- # variable via the value of the type variable constant.
288
- subconstant_to_name_lookup = constants_of(constant)
289
- .each_with_object({}.compare_by_identity) do |constant_name, table|
290
- table[constantize(constant_name.to_s, namespace: constant)] = constant_name.to_s
291
- end
292
-
293
285
  # Map each type variable to its string representation.
294
286
  #
295
- # Each entry of `type_variables` maps an object_id to a String,
287
+ # Each entry of `type_variables` maps a Module to a String,
296
288
  # and the order they are inserted into the hash is the order they should be
297
289
  # defined in the source code.
298
- #
299
- # By looping over these entries and then getting the actual constant name
300
- # from the `subconstant_to_name_lookup` we defined above, gives us all the
301
- # information we need to serialize type variable definitions.
302
- type_variable_declarations = type_variables.map do |type_variable, serialized_type_variable|
303
- constant_name = subconstant_to_name_lookup[type_variable]
304
- type_variable.name = constant_name
305
- # Here, we know that constant_value will be an instance of
306
- # T::Types::CustomTypeVariable, which knows how to serialize
307
- # itself to a type_member/type_template
308
- tree << RBI::TypeMember.new(constant_name, serialized_type_variable)
290
+ type_variable_declarations = type_variables.map do |type_variable|
291
+ type_variable_name = type_variable.name
292
+ next unless type_variable_name
293
+
294
+ tree << RBI::TypeMember.new(type_variable_name, type_variable.serialize)
309
295
  end
310
296
 
311
297
  return if type_variable_declarations.empty?
@@ -392,7 +378,7 @@ module Tapioca
392
378
  .select do |mod|
393
379
  name = name_of(mod)
394
380
 
395
- name && !name.start_with?("T::")
381
+ name && !filtered_mixin?(name)
396
382
  end
397
383
  .map do |mod|
398
384
  add_to_symbol_queue(name_of(mod))
@@ -496,7 +482,9 @@ module Tapioca
496
482
  sanitized_parameters = parameters.each_with_index.map do |(type, name), index|
497
483
  fallback_arg_name = "_arg#{index}"
498
484
 
499
- unless name
485
+ name = if name
486
+ name.to_s
487
+ else
500
488
  # For attr_writer methods, Sorbet signatures have the name
501
489
  # of the method (without the trailing = sign) as the name of
502
490
  # the only parameter. So, if the parameter does not have a name
@@ -512,15 +500,15 @@ module Tapioca
512
500
  method_name[-1] == "="
513
501
  )
514
502
 
515
- name = if writer_method_with_sig
516
- T.must(method_name[0...-1]).to_sym
503
+ if writer_method_with_sig
504
+ method_name.delete_suffix("=")
517
505
  else
518
506
  fallback_arg_name
519
507
  end
520
508
  end
521
509
 
522
510
  # Sanitize param names
523
- name = name.to_s.gsub(/[^a-zA-Z0-9_]/, fallback_arg_name)
511
+ name = fallback_arg_name unless valid_parameter_name?(name)
524
512
 
525
513
  [type, name]
526
514
  end
@@ -613,6 +601,13 @@ module Tapioca
613
601
  SymbolLoader.ignore_symbol?(symbol_name)
614
602
  end
615
603
 
604
+ sig { params(mixin_name: String).returns(T::Boolean) }
605
+ def filtered_mixin?(mixin_name)
606
+ # filter T:: namespace mixins that aren't T::Props
607
+ # T::Props and subconstants have semantic value
608
+ mixin_name.start_with?("T::") && !mixin_name.start_with?("T::Props")
609
+ end
610
+
616
611
  SPECIAL_METHOD_NAMES = T.let([
617
612
  "!", "~", "+@", "**", "-@", "*", "/", "%", "+", "-", "<<", ">>", "&", "|", "^",
618
613
  "<", "<=", "=>", ">", ">=", "==", "===", "!=", "=~", "!~", "<=>", "[]", "[]=", "`"
@@ -624,6 +619,11 @@ module Tapioca
624
619
  !!name.match(/^[[:word:]]+[?!=]?$/)
625
620
  end
626
621
 
622
+ sig { params(name: String).returns(T::Boolean) }
623
+ def valid_parameter_name?(name)
624
+ name.match?(/^[[[:alnum:]]_]+$/)
625
+ end
626
+
627
627
  sig { params(method: UnboundMethod).returns(T::Boolean) }
628
628
  def method_in_gem?(method)
629
629
  source_location = method.source_location&.first
@@ -99,6 +99,9 @@ module Tapioca
99
99
  sig { returns(String) }
100
100
  attr_reader :full_gem_path, :version
101
101
 
102
+ sig { returns(T::Array[Pathname]) }
103
+ attr_reader :files
104
+
102
105
  sig { params(spec: Spec).void }
103
106
  def initialize(spec)
104
107
  @spec = T.let(spec, Tapioca::Gemfile::Spec)
@@ -106,6 +109,7 @@ module Tapioca
106
109
  @full_gem_path = T.let(real_gem_path, String)
107
110
  @version = T.let(version_string, String)
108
111
  @exported_rbi_files = T.let(nil, T.nilable(T::Array[String]))
112
+ @files = T.let(collect_files, T::Array[Pathname])
109
113
  end
110
114
 
111
115
  sig { params(gemfile_dir: String).returns(T::Boolean) }
@@ -113,21 +117,6 @@ module Tapioca
113
117
  gem_ignored? || gem_in_app_dir?(gemfile_dir)
114
118
  end
115
119
 
116
- sig { returns(T::Array[Pathname]) }
117
- def files
118
- if default_gem?
119
- # `Bundler::RemoteSpecification` delegates missing methods to
120
- # `Gem::Specification`, so `files` actually always exists on spec.
121
- T.unsafe(@spec).files.map do |file|
122
- ruby_lib_dir.join(file)
123
- end
124
- else
125
- @spec.full_require_paths.flat_map do |path|
126
- Pathname.glob((Pathname.new(path) / "**/*.rb").to_s)
127
- end
128
- end
129
- end
130
-
131
120
  sig { returns(String) }
132
121
  def name
133
122
  @spec.name
@@ -154,7 +143,7 @@ module Tapioca
154
143
 
155
144
  sig { returns(T::Array[String]) }
156
145
  def exported_rbi_files
157
- @exported_rbi_files ||= Dir.glob("#{full_gem_path}/rbi/**/*.rbi")
146
+ @exported_rbi_files ||= Dir.glob("#{full_gem_path}/rbi/**/*.rbi").sort
158
147
  end
159
148
 
160
149
  sig { returns(T::Boolean) }
@@ -176,14 +165,49 @@ module Tapioca
176
165
 
177
166
  private
178
167
 
179
- sig { returns(T::Boolean) }
168
+ sig { returns(T::Array[Pathname]) }
169
+ def collect_files
170
+ if default_gem?
171
+ # `Bundler::RemoteSpecification` delegates missing methods to
172
+ # `Gem::Specification`, so `files` actually always exists on spec.
173
+ T.unsafe(@spec).files.map do |file|
174
+ resolve_to_ruby_lib_dir(file)
175
+ end
176
+ else
177
+ @spec.full_require_paths.flat_map do |path|
178
+ Pathname.glob((Pathname.new(path) / "**/*.rb").to_s)
179
+ end
180
+ end
181
+ end
182
+
183
+ sig { returns(T.nilable(T::Boolean)) }
180
184
  def default_gem?
181
185
  @spec.respond_to?(:default_gem?) && @spec.default_gem?
182
186
  end
183
187
 
184
- sig { returns(Pathname) }
185
- def ruby_lib_dir
186
- Pathname.new(RbConfig::CONFIG["rubylibdir"])
188
+ sig { returns(Regexp) }
189
+ def require_paths_prefix_matcher
190
+ @require_paths_prefix_matcher = T.let(@require_paths_prefix_matcher, T.nilable(Regexp))
191
+
192
+ @require_paths_prefix_matcher ||= begin
193
+ require_paths = T.unsafe(@spec).require_paths
194
+ prefix_matchers = require_paths.map { |rp| Regexp.new("^#{rp}/") }
195
+ Regexp.union(prefix_matchers)
196
+ end
197
+ end
198
+
199
+ sig { params(file: String).returns(Pathname) }
200
+ def resolve_to_ruby_lib_dir(file)
201
+ # We want to match require prefixes but fallback to an empty match
202
+ # if none of the require prefixes actually match. This is so that
203
+ # we can always replace the match with the Ruby lib directory and
204
+ # we would have properly resolved the file under the Ruby lib dir.
205
+ prefix_matcher = Regexp.union(require_paths_prefix_matcher, //)
206
+
207
+ ruby_lib_dir = RbConfig::CONFIG["rubylibdir"]
208
+ file = file.sub(prefix_matcher, "#{ruby_lib_dir}/")
209
+
210
+ Pathname.new(file).expand_path
187
211
  end
188
212
 
189
213
  sig { returns(String) }
@@ -11,8 +11,8 @@ module Tapioca
11
11
  include Thor::Actions
12
12
  end
13
13
 
14
- include CliHelper
15
14
  include Thor::Base
15
+ include CliHelper
16
16
 
17
17
  abstract!
18
18
 
@@ -20,7 +20,6 @@ module Tapioca
20
20
  # variable to type variable serializers. This allows us to associate type variables
21
21
  # to the constant names that represent them, easily.
22
22
  module GenericTypeRegistry
23
- TypeVariable = T.type_alias { T.any(TypeMember, TypeTemplate) }
24
23
  @generic_instances = T.let(
25
24
  {},
26
25
  T::Hash[String, Module]
@@ -28,7 +27,7 @@ module Tapioca
28
27
 
29
28
  @type_variables = T.let(
30
29
  {}.compare_by_identity,
31
- T::Hash[Module, T::Hash[TypeVariable, String]]
30
+ T::Hash[Module, T::Array[TypeVariableModule]]
32
31
  )
33
32
 
34
33
  class << self
@@ -60,7 +59,12 @@ module Tapioca
60
59
  @generic_instances[name] ||= create_generic_type(constant, name)
61
60
  end
62
61
 
63
- sig { params(constant: Module).returns(T.nilable(T::Hash[TypeVariable, String])) }
62
+ sig { params(instance: Object).returns(T::Boolean) }
63
+ def generic_type_instance?(instance)
64
+ @generic_instances.values.any? { |generic_type| generic_type === instance }
65
+ end
66
+
67
+ sig { params(constant: Module).returns(T.nilable(T::Array[TypeVariableModule])) }
64
68
  def lookup_type_variables(constant)
65
69
  @type_variables[constant]
66
70
  end
@@ -77,13 +81,13 @@ module Tapioca
77
81
  sig do
78
82
  params(
79
83
  constant: T.untyped,
80
- type_variable: TypeVariable,
84
+ type_variable: TypeVariableModule,
81
85
  ).void
82
86
  end
83
87
  def register_type_variable(constant, type_variable)
84
88
  type_variables = lookup_or_initialize_type_variables(constant)
85
89
 
86
- type_variables[type_variable] = type_variable.serialize
90
+ type_variables << type_variable
87
91
  end
88
92
 
89
93
  private
@@ -109,6 +113,17 @@ module Tapioca
109
113
  # Let's set the `name` method to return the proper generic name
110
114
  generic_type.define_singleton_method(:name) { name }
111
115
 
116
+ # We need to define a `<=` method on the cloned constant, so that Sorbet
117
+ # can do covariance/contravariance checks on the type variables.
118
+ #
119
+ # Normally, we would be doing proper covariance/contravariance checks here, but
120
+ # that is not necessary, since we are not implementing a runtime type checker
121
+ # here. It is just enough for the checks to pass, so that we can serialize the
122
+ # signatures, assuming the sigs were well-formed.
123
+ #
124
+ # So we act like all subtype checks pass.
125
+ generic_type.define_singleton_method(:<=) { |_| true }
126
+
112
127
  # Return the generic type we created
113
128
  generic_type
114
129
  end
@@ -140,9 +155,9 @@ module Tapioca
140
155
  end
141
156
  end
142
157
 
143
- sig { params(constant: Module).returns(T::Hash[TypeVariable, String]) }
158
+ sig { params(constant: Module).returns(T::Array[TypeVariableModule]) }
144
159
  def lookup_or_initialize_type_variables(constant)
145
- @type_variables[constant] ||= {}.compare_by_identity
160
+ @type_variables[constant] ||= []
146
161
  end
147
162
  end
148
163
  end
@@ -43,13 +43,13 @@ class ActiveRecordColumnTypeHelper
43
43
  setter_type = getter_type
44
44
 
45
45
  if column&.null
46
- return ["T.nilable(#{getter_type})", "T.nilable(#{setter_type})"]
46
+ return [as_nilable_type(getter_type), as_nilable_type(setter_type)]
47
47
  end
48
48
 
49
49
  if column_name == @constant.primary_key ||
50
50
  column_name == "created_at" ||
51
51
  column_name == "updated_at"
52
- getter_type = "T.nilable(#{getter_type})"
52
+ getter_type = as_nilable_type(getter_type)
53
53
  end
54
54
 
55
55
  [getter_type, setter_type]
@@ -63,9 +63,19 @@ class ActiveRecordColumnTypeHelper
63
63
  !(constant.singleton_class < Object.const_get(:StrongTypeGeneration))
64
64
  end
65
65
 
66
+ sig { params(type: String).returns(String) }
67
+ def as_nilable_type(type)
68
+ if type.start_with?("T.nilable(") || type == "T.untyped"
69
+ type
70
+ else
71
+ "T.nilable(#{type})"
72
+ end
73
+ end
74
+
66
75
  sig { params(column_type: Object).returns(String) }
67
76
  def handle_unknown_type(column_type)
68
77
  return "T.untyped" unless ActiveModel::Type::Value === column_type
78
+ return "T.untyped" if Tapioca::GenericTypeRegistry.generic_type_instance?(column_type)
69
79
 
70
80
  lookup_return_type_of_method(column_type, :deserialize) ||
71
81
  lookup_return_type_of_method(column_type, :cast) ||
@@ -12,15 +12,16 @@ module Tapioca
12
12
 
13
13
  sig { params(message: String, color: T.any(Symbol, T::Array[Symbol])).void }
14
14
  def say_error(message = "", *color)
15
- force_new_line = (message.to_s !~ /( |\t)\Z/)
16
- # NOTE: This is a hack. We're no longer subclassing from Thor::Shell::Color
17
- # so we no longer have access to the prepare_message call.
18
- # We should update this to remove this.
19
- buffer = shell.send(:prepare_message, *T.unsafe([message, *T.unsafe(color)]))
20
- buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
21
-
22
- $stderr.print(buffer)
23
- $stderr.flush
15
+ # Thor has its own `say_error` now, but it has two problems:
16
+ # 1. it adds the padding around all the messages, even if they continue on
17
+ # the same line, and
18
+ # 2. it accepts a last parameter which breaks the ability to pass color values
19
+ # as splats.
20
+ #
21
+ # So we implement our own version here to work around those problems.
22
+ shell.indent(-shell.padding) do
23
+ super(message, color)
24
+ end
24
25
  end
25
26
  end
26
27
  end
@@ -6,6 +6,9 @@ require "yaml"
6
6
  module Tapioca
7
7
  module ConfigHelper
8
8
  extend T::Sig
9
+ extend T::Helpers
10
+
11
+ requires_ancestor { Thor }
9
12
 
10
13
  sig { returns(String) }
11
14
  attr_reader :command_name
@@ -60,9 +63,122 @@ module Tapioca
60
63
  config = YAML.load_file(config_file, fallback: {})
61
64
  end
62
65
 
66
+ validate_config!(config_file, config)
67
+
63
68
  Thor::CoreExt::HashWithIndifferentAccess.new(config[command_name] || {})
64
69
  end
65
70
 
71
+ sig { params(config_file: String, config: T::Hash[T.untyped, T.untyped]).void }
72
+ def validate_config!(config_file, config)
73
+ # To ensure that this is not re-entered, we mark during validation
74
+ return if @validating_config
75
+ @validating_config = T.let(true, T.nilable(T::Boolean))
76
+
77
+ commands = T.cast(self, Thor).class.commands
78
+
79
+ errors = config.flat_map do |config_key, config_options|
80
+ command = commands[config_key.to_s]
81
+
82
+ unless command
83
+ next build_error("unknown key `#{config_key}`")
84
+ end
85
+
86
+ validate_config_options(command.options, config_key, config_options || {})
87
+ end.compact
88
+
89
+ unless errors.empty?
90
+ print_errors(config_file, errors)
91
+ exit(1)
92
+ end
93
+ ensure
94
+ @validating_config = false
95
+ end
96
+
97
+ sig do
98
+ params(
99
+ command_options: T::Hash[Symbol, Thor::Option],
100
+ config_key: String,
101
+ config_options: T::Hash[T.untyped, T.untyped]
102
+ ).returns(T::Array[ConfigError])
103
+ end
104
+ def validate_config_options(command_options, config_key, config_options)
105
+ config_options.map do |config_option_key, config_option_value|
106
+ command_option = command_options[config_option_key.to_sym]
107
+
108
+ unless command_option
109
+ next build_error("unknown option `#{config_option_key}` for key `#{config_key}`")
110
+ end
111
+
112
+ config_option_value_type = case config_option_value
113
+ when FalseClass, TrueClass
114
+ :boolean
115
+ when Numeric
116
+ :numeric
117
+ when Hash
118
+ :hash
119
+ when Array
120
+ :array
121
+ when String
122
+ :string
123
+ else
124
+ :object
125
+ end
126
+
127
+ unless config_option_value_type == command_option.type
128
+ next build_error("invalid value for option `#{config_option_key}` for key `#{config_key}` " \
129
+ "- expected `#{command_option.type.capitalize}` but found #{config_option_value_type.capitalize}")
130
+ end
131
+ end.compact
132
+ end
133
+
134
+ class ConfigErrorMessagePart < T::Struct
135
+ const :message, String
136
+ const :colors, T::Array[Symbol]
137
+ end
138
+
139
+ class ConfigError < T::Struct
140
+ const :message_parts, T::Array[ConfigErrorMessagePart]
141
+ end
142
+
143
+ sig { params(msg: String).returns(ConfigError) }
144
+ def build_error(msg)
145
+ parts = msg.split(/(`[^`]+` ?)/)
146
+
147
+ message_parts = parts.map do |part|
148
+ match = part.match(/`([^`]+)`( ?)/)
149
+
150
+ if match
151
+ ConfigErrorMessagePart.new(
152
+ message: "#{match[1]}#{match[2]}",
153
+ colors: [:bold, :blue]
154
+ )
155
+ else
156
+ ConfigErrorMessagePart.new(
157
+ message: part,
158
+ colors: [:yellow]
159
+ )
160
+ end
161
+ end
162
+
163
+ ConfigError.new(
164
+ message_parts: message_parts
165
+ )
166
+ end
167
+
168
+ sig { params(config_file: String, errors: T::Array[ConfigError]).void }
169
+ def print_errors(config_file, errors)
170
+ say_error("\nConfiguration file ", :red)
171
+ say_error("#{config_file} ", :blue, :bold)
172
+ say_error("has the following errors:\n\n", :red)
173
+
174
+ errors.each do |error|
175
+ say_error("- ")
176
+ error.message_parts.each do |part|
177
+ T.unsafe(self).say_error(part.message, *part.colors)
178
+ end
179
+ end
180
+ end
181
+
66
182
  sig do
67
183
  params(options: T.nilable(Thor::CoreExt::HashWithIndifferentAccess))
68
184
  .returns(Thor::CoreExt::HashWithIndifferentAccess)
@@ -23,17 +23,31 @@ module T
23
23
  def type_member(variance = :invariant, fixed: nil, lower: T.untyped, upper: BasicObject)
24
24
  # `T::Generic#type_member` just instantiates a `T::Type::TypeMember` instance and returns it.
25
25
  # We use that when registering the type member and then later return it from this method.
26
- type_member = Tapioca::TypeMember.new(variance, fixed, lower, upper)
27
- Tapioca::GenericTypeRegistry.register_type_variable(self, type_member)
28
- type_member
26
+ Tapioca::TypeVariableModule.new(
27
+ T.cast(self, Module),
28
+ Tapioca::TypeVariableModule::Type::Member,
29
+ variance,
30
+ fixed,
31
+ lower,
32
+ upper
33
+ ).tap do |type_variable|
34
+ Tapioca::GenericTypeRegistry.register_type_variable(self, type_variable)
35
+ end
29
36
  end
30
37
 
31
38
  def type_template(variance = :invariant, fixed: nil, lower: T.untyped, upper: BasicObject)
32
39
  # `T::Generic#type_template` just instantiates a `T::Type::TypeTemplate` instance and returns it.
33
40
  # We use that when registering the type template and then later return it from this method.
34
- type_template = Tapioca::TypeTemplate.new(variance, fixed, lower, upper)
35
- Tapioca::GenericTypeRegistry.register_type_variable(self, type_template)
36
- type_template
41
+ Tapioca::TypeVariableModule.new(
42
+ T.cast(self, Module),
43
+ Tapioca::TypeVariableModule::Type::Template,
44
+ variance,
45
+ fixed,
46
+ lower,
47
+ upper
48
+ ).tap do |type_variable|
49
+ Tapioca::GenericTypeRegistry.register_type_variable(self, type_variable)
50
+ end
37
51
  end
38
52
  end
39
53
 
@@ -42,15 +56,26 @@ module T
42
56
 
43
57
  module Types
44
58
  class Simple
45
- # This module intercepts calls to the `name` method for
46
- # simple types, so that it can ask the name to the type if
47
- # the type is generic, since, by this point, we've created
48
- # a clone of that type with the `name` method returning the
49
- # appropriate name for that specific concrete type.
50
- module GenericNamePatch
59
+ module GenericPatch
60
+ def valid?(obj)
61
+ # Since `Tapioca::TypeVariable` is a `Module`, it will be wrapped by a
62
+ # `Simple` type. We want to always make type variable types valid, so we
63
+ # need to explicitly check that `raw_type` is a `Tapioca::TypeVariable`
64
+ # and return `true`
65
+ if defined?(Tapioca::TypeVariableModule) && Tapioca::TypeVariableModule === @raw_type
66
+ return true
67
+ end
68
+
69
+ obj.is_a?(@raw_type)
70
+ end
71
+
72
+ # This method intercepts calls to the `name` method for simple types, so that
73
+ # it can ask the name to the type if the type is generic, since, by this point,
74
+ # we've created a clone of that type with the `name` method returning the
75
+ # appropriate name for that specific concrete type.
51
76
  def name
52
- if T::Generic === @raw_type
53
- # for types that are generic, use the name
77
+ if T::Generic === @raw_type || Tapioca::TypeVariableModule === @raw_type
78
+ # for types that are generic or are type variables, use the name
54
79
  # returned by the "name" method of this instance
55
80
  @name ||= T.unsafe(@raw_type).name.freeze
56
81
  else
@@ -60,60 +85,57 @@ module T
60
85
  end
61
86
  end
62
87
 
63
- prepend GenericNamePatch
88
+ prepend GenericPatch
64
89
  end
65
90
  end
66
91
  end
67
92
 
68
93
  module Tapioca
69
- class TypeMember < T::Types::TypeMember
94
+ # This is subclassing from `Module` so that instances of this type will be modules.
95
+ # The reason why we want that is because that means those instances will automatically
96
+ # get bound to the constant names they are assigned to by Ruby. As a result, we don't
97
+ # need to do any matching of constants to type variables to bind their names, Ruby will
98
+ # do that automatically for us and we get the `name` method for free from `Module`.
99
+ class TypeVariableModule < Module
70
100
  extend T::Sig
71
101
 
72
- sig { returns(T.nilable(String)) }
73
- attr_accessor :name
74
-
75
- sig { returns(T.untyped) }
76
- attr_reader :fixed, :lower, :upper
102
+ class Type < T::Enum
103
+ enums do
104
+ Member = new("type_member")
105
+ Template = new("type_template")
106
+ end
107
+ end
77
108
 
78
- sig { params(variance: Symbol, fixed: T.untyped, lower: T.untyped, upper: T.untyped).void }
79
- def initialize(variance, fixed, lower, upper)
80
- super(variance)
109
+ sig do
110
+ params(context: Module, type: Type, variance: Symbol, fixed: T.untyped, lower: T.untyped, upper: T.untyped).void
111
+ end
112
+ def initialize(context, type, variance, fixed, lower, upper) # rubocop:disable Metrics/ParameterLists
113
+ @context = context
114
+ @type = type
115
+ @variance = variance
81
116
  @fixed = fixed
82
117
  @lower = lower
83
118
  @upper = upper
119
+ super()
84
120
  end
85
121
 
86
- sig { returns(String) }
87
- def serialize
88
- parts = []
89
- parts << ":#{@variance}" unless @variance == :invariant
90
- parts << "fixed: #{@fixed}" if @fixed
91
- parts << "lower: #{@lower}" unless @lower == T.untyped
92
- parts << "upper: #{@upper}" unless @upper == BasicObject
93
-
94
- parameters = parts.join(", ")
95
-
96
- serialized = +"type_member"
97
- serialized << "(#{parameters})" unless parameters.empty?
98
- serialized
99
- end
100
- end
101
-
102
- class TypeTemplate < T::Types::TypeTemplate
103
- extend T::Sig
104
-
105
122
  sig { returns(T.nilable(String)) }
106
- attr_accessor :name
107
-
108
- sig { returns(T.untyped) }
109
- attr_reader :fixed, :lower, :upper
123
+ def name
124
+ constant_name = super
125
+
126
+ # This is a hack to work around modules under anonymous modules not having
127
+ # names in 2.6 and 2.7: https://bugs.ruby-lang.org/issues/14895
128
+ #
129
+ # This happens when a type variable is declared under `class << self`, for
130
+ # example.
131
+ #
132
+ # The workaround is to give the parent context a name, at which point, our
133
+ # module gets bound to a name under that name, as well.
134
+ unless constant_name
135
+ constant_name = with_bound_name_pre_3_0 { super }
136
+ end
110
137
 
111
- sig { params(variance: Symbol, fixed: T.untyped, lower: T.untyped, upper: T.untyped).void }
112
- def initialize(variance, fixed, lower, upper)
113
- super(variance)
114
- @fixed = fixed
115
- @lower = lower
116
- @upper = upper
138
+ constant_name&.split("::")&.last
117
139
  end
118
140
 
119
141
  sig { returns(String) }
@@ -126,9 +148,25 @@ module Tapioca
126
148
 
127
149
  parameters = parts.join(", ")
128
150
 
129
- serialized = +"type_template"
151
+ serialized = @type.serialize.dup
130
152
  serialized << "(#{parameters})" unless parameters.empty?
131
153
  serialized
132
154
  end
155
+
156
+ private
157
+
158
+ sig do
159
+ type_parameters(:Result)
160
+ .params(block: T.proc.returns(T.type_parameter(:Result)))
161
+ .returns(T.type_parameter(:Result))
162
+ end
163
+ def with_bound_name_pre_3_0(&block)
164
+ require "securerandom"
165
+ temp_name = "TYPE_VARIABLE_TRACKING_#{SecureRandom.hex}"
166
+ self.class.const_set(temp_name, @context)
167
+ block.call
168
+ ensure
169
+ self.class.send(:remove_const, temp_name) if temp_name
170
+ end
133
171
  end
134
172
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Tapioca
5
- VERSION = "0.6.1"
5
+ VERSION = "0.6.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.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ufuk Kayserilioglu
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: exe
13
13
  cert_chain: []
14
- date: 2021-12-26 00:00:00.000000000 Z
14
+ date: 2022-01-20 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: bundler