tapioca 0.6.0 → 0.6.4

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.
@@ -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)