tapioca 0.6.1 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
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