tapioca 0.4.15 → 0.4.20

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.
@@ -8,7 +8,6 @@ module Tapioca
8
8
  const(:outdir, String)
9
9
  const(:prerequire, T.nilable(String))
10
10
  const(:postrequire, String)
11
- const(:generate_command, String)
12
11
  const(:exclude, T::Array[String])
13
12
  const(:typed_overrides, T::Hash[String, String])
14
13
  const(:todos_path, String)
@@ -27,6 +26,7 @@ module Tapioca
27
26
  TAPIOCA_PATH = T.let("#{SORBET_PATH}/tapioca", String)
28
27
  TAPIOCA_CONFIG = T.let("#{TAPIOCA_PATH}/config.yml", String)
29
28
 
29
+ DEFAULT_COMMAND = T.let("bin/tapioca", String)
30
30
  DEFAULT_POSTREQUIRE = T.let("#{TAPIOCA_PATH}/require.rb", String)
31
31
  DEFAULT_RBIDIR = T.let("#{SORBET_PATH}/rbi", String)
32
32
  DEFAULT_DSLDIR = T.let("#{DEFAULT_RBIDIR}/dsl", String)
@@ -10,9 +10,13 @@ module Tapioca
10
10
 
11
11
  sig { params(command: Symbol, options: T::Hash[String, T.untyped]).returns(Config) }
12
12
  def from_options(command, options)
13
- Config.from_hash(
14
- merge_options(default_options(command), config_options, options)
15
- )
13
+ merged_options = merge_options(default_options(command), config_options, options)
14
+
15
+ puts(<<~MSG) if merged_options.include?("generate_command")
16
+ DEPRECATION: The `-c` and `--cmd` flags will be removed in a future release.
17
+ MSG
18
+
19
+ Config.from_hash(merged_options)
16
20
  end
17
21
 
18
22
  private
@@ -40,14 +44,6 @@ module Tapioca
40
44
  DEFAULT_OPTIONS.merge("outdir" => default_outdir)
41
45
  end
42
46
 
43
- sig { returns(String) }
44
- def default_command
45
- command = File.basename($PROGRAM_NAME)
46
- args = ARGV.join(" ")
47
-
48
- "#{command} #{args}".strip
49
- end
50
-
51
47
  sig { params(options: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
52
48
  def merge_options(*options)
53
49
  options.each_with_object({}) do |option, result|
@@ -65,7 +61,6 @@ module Tapioca
65
61
  DEFAULT_OPTIONS = T.let({
66
62
  "postrequire" => Config::DEFAULT_POSTREQUIRE,
67
63
  "outdir" => nil,
68
- "generate_command" => default_command,
69
64
  "exclude" => [],
70
65
  "typed_overrides" => Config::DEFAULT_OVERRIDES,
71
66
  "todos_path" => Config::DEFAULT_TODOSPATH,
@@ -63,7 +63,7 @@ module Tapioca
63
63
 
64
64
  content = String.new
65
65
  content << rbi_header(
66
- config.generate_command,
66
+ "#{Config::DEFAULT_COMMAND} require",
67
67
  reason: "explicit gem requires",
68
68
  strictness: "false"
69
69
  )
@@ -76,8 +76,8 @@ module Tapioca
76
76
  say("Done", :green)
77
77
 
78
78
  say("All requires from this application have been written to #{name}.", [:green, :bold])
79
- cmd = set_color("tapioca sync", :yellow, :bold)
80
- say("Please review changes and commit them, then run #{cmd}.", [:green, :bold])
79
+ cmd = set_color("#{Config::DEFAULT_COMMAND} sync", :yellow, :bold)
80
+ say("Please review changes and commit them, then run `#{cmd}`.", [:green, :bold])
81
81
  end
82
82
 
83
83
  sig { void }
@@ -99,7 +99,7 @@ module Tapioca
99
99
 
100
100
  content = String.new
101
101
  content << rbi_header(
102
- config.generate_command,
102
+ "#{Config::DEFAULT_COMMAND} todo",
103
103
  reason: "unresolved constants",
104
104
  strictness: "false"
105
105
  )
@@ -116,14 +116,27 @@ module Tapioca
116
116
  say("Please review changes and commit them.", [:green, :bold])
117
117
  end
118
118
 
119
- sig { params(requested_constants: T::Array[String]).void }
120
- def build_dsl(requested_constants)
119
+ sig do
120
+ params(
121
+ requested_constants: T::Array[String],
122
+ should_verify: T::Boolean,
123
+ quiet: T::Boolean
124
+ ).void
125
+ end
126
+ def build_dsl(requested_constants, should_verify: false, quiet: false)
121
127
  load_application(eager_load: requested_constants.empty?)
122
128
  load_dsl_generators
123
129
 
124
- say("Compiling DSL RBI files...")
130
+ if should_verify
131
+ say("Checking for out-of-date RBIs...")
132
+ else
133
+ say("Compiling DSL RBI files...")
134
+ end
125
135
  say("")
126
136
 
137
+ outpath = should_verify ? Pathname.new(Dir.mktmpdir) : config.outpath
138
+ rbi_files_to_purge = existing_rbi_filenames(requested_constants)
139
+
127
140
  compiler = Compilers::DslCompiler.new(
128
141
  requested_constants: constantize(requested_constants),
129
142
  requested_generators: config.generators,
@@ -132,15 +145,35 @@ module Tapioca
132
145
  }
133
146
  )
134
147
 
148
+ constant_lookup = {}
149
+
135
150
  compiler.run do |constant, contents|
136
- compile_dsl_rbi(constant, contents)
151
+ constant_name = Module.instance_method(:name).bind(constant).call
152
+
153
+ filename = compile_dsl_rbi(
154
+ constant_name,
155
+ contents,
156
+ outpath: outpath,
157
+ quiet: should_verify || quiet
158
+ )
159
+
160
+ if filename
161
+ rbi_files_to_purge.delete(filename)
162
+ constant_lookup[filename.relative_path_from(outpath)] = constant_name
163
+ end
137
164
  end
138
-
139
165
  say("")
140
- say("Done", :green)
141
166
 
142
- say("All operations performed in working directory.", [:green, :bold])
143
- say("Please review changes and commit them.", [:green, :bold])
167
+ if should_verify
168
+ perform_dsl_verification(outpath, constant_lookup)
169
+ else
170
+ purge_stale_dsl_rbi_files(rbi_files_to_purge)
171
+
172
+ say("Done", :green)
173
+
174
+ say("All operations performed in working directory.", [:green, :bold])
175
+ say("Please review changes and commit them.", [:green, :bold])
176
+ end
144
177
  end
145
178
 
146
179
  sig { void }
@@ -206,7 +239,7 @@ module Tapioca
206
239
  say_error("If you populated ", :yellow)
207
240
  say_error("#{file} ", :bold, :blue)
208
241
  say_error("with ", :yellow)
209
- say_error("tapioca require", :bold, :blue)
242
+ say_error("`#{Config::DEFAULT_COMMAND} require`", :bold, :blue)
210
243
  say_error("you should probably review it and remove the faulty line.", :yellow)
211
244
  end
212
245
 
@@ -253,13 +286,38 @@ module Tapioca
253
286
 
254
287
  sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
255
288
  def constantize(constant_names)
256
- constant_names.map do |name|
289
+ constant_map = constant_names.map do |name|
257
290
  begin
258
- name.constantize
291
+ [name, name.constantize]
259
292
  rescue NameError
260
- nil
293
+ [name, nil]
261
294
  end
262
- end.compact
295
+ end.to_h
296
+
297
+ unprocessable_constants = constant_map.select { |_, v| v.nil? }
298
+ unless unprocessable_constants.empty?
299
+ unprocessable_constants.each do |name, _|
300
+ say("Error: Cannot find constant '#{name}'", :red)
301
+ remove(dsl_rbi_filename(name))
302
+ end
303
+
304
+ exit(1)
305
+ end
306
+
307
+ constant_map.values
308
+ end
309
+
310
+ sig { params(requested_constants: T::Array[String], path: Pathname).returns(T::Set[Pathname]) }
311
+ def existing_rbi_filenames(requested_constants, path: config.outpath)
312
+ filenames = if requested_constants.empty?
313
+ Pathname.glob(path / "**/*.rbi")
314
+ else
315
+ requested_constants.map do |constant_name|
316
+ dsl_rbi_filename(constant_name)
317
+ end
318
+ end
319
+
320
+ filenames.to_set
263
321
  end
264
322
 
265
323
  sig { returns(T::Hash[String, String]) }
@@ -277,6 +335,11 @@ module Tapioca
277
335
  .to_h
278
336
  end
279
337
 
338
+ sig { params(constant_name: String).returns(Pathname) }
339
+ def dsl_rbi_filename(constant_name)
340
+ config.outpath / "#{constant_name.underscore}.rbi"
341
+ end
342
+
280
343
  sig { params(gem_name: String, version: String).returns(Pathname) }
281
344
  def gem_rbi_filename(gem_name, version)
282
345
  config.outpath / "#{gem_name}@#{version}.rbi"
@@ -316,6 +379,7 @@ module Tapioca
316
379
 
317
380
  sig { params(filename: Pathname).void }
318
381
  def remove(filename)
382
+ return unless filename.exist?
319
383
  say("-- Removing: #{filename}")
320
384
  filename.unlink
321
385
  end
@@ -434,7 +498,7 @@ module Tapioca
434
498
  rbi_body_content = compiler.compile(gem)
435
499
  content = String.new
436
500
  content << rbi_header(
437
- config.generate_command,
501
+ "#{Config::DEFAULT_COMMAND} sync",
438
502
  reason: "types exported from the `#{gem.name}` gem",
439
503
  strictness: strictness
440
504
  )
@@ -451,31 +515,122 @@ module Tapioca
451
515
  end
452
516
  File.write(filename.to_s, content)
453
517
 
454
- Pathname.glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
518
+ T.unsafe(Pathname).glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
455
519
  remove(file) unless file.basename.to_s == gem.rbi_file_name
456
520
  end
457
521
  end
458
522
 
459
- sig { params(constant: Module, contents: String).void }
460
- def compile_dsl_rbi(constant, contents)
523
+ sig do
524
+ params(constant_name: String, contents: String, outpath: Pathname, quiet: T::Boolean)
525
+ .returns(T.nilable(Pathname))
526
+ end
527
+ def compile_dsl_rbi(constant_name, contents, outpath: config.outpath, quiet: false)
461
528
  return if contents.nil?
462
529
 
463
- command = format(config.generate_command, constant.name)
464
- constant_name = Module.instance_method(:name).bind(constant).call
465
530
  rbi_name = constant_name.underscore + ".rbi"
466
- filename = config.outpath / rbi_name
531
+ filename = outpath / rbi_name
467
532
 
468
533
  out = String.new
469
534
  out << rbi_header(
470
- command,
471
- reason: "dynamic methods in `#{constant.name}`"
535
+ "#{Config::DEFAULT_COMMAND} dsl #{constant_name}",
536
+ reason: "dynamic methods in `#{constant_name}`"
472
537
  )
473
538
  out << contents
474
539
 
475
540
  FileUtils.mkdir_p(File.dirname(filename))
476
541
  File.write(filename, out)
477
- say("Wrote: ", [:green])
478
- say(filename)
542
+
543
+ unless quiet
544
+ say("Wrote: ", [:green])
545
+ say(filename)
546
+ end
547
+
548
+ filename
549
+ end
550
+
551
+ sig { params(tmp_dir: Pathname).returns(T::Hash[String, Symbol]) }
552
+ def verify_dsl_rbi(tmp_dir:)
553
+ diff = {}
554
+
555
+ existing_rbis = rbi_files_in(config.outpath)
556
+ new_rbis = rbi_files_in(tmp_dir)
557
+
558
+ added_files = (new_rbis - existing_rbis)
559
+
560
+ added_files.each do |file|
561
+ diff[file] = :added
562
+ end
563
+
564
+ removed_files = (existing_rbis - new_rbis)
565
+
566
+ removed_files.each do |file|
567
+ diff[file] = :removed
568
+ end
569
+
570
+ common_files = (existing_rbis & new_rbis)
571
+
572
+ changed_files = common_files.map do |filename|
573
+ filename unless FileUtils.identical?(config.outpath / filename, tmp_dir / filename)
574
+ end.compact
575
+
576
+ changed_files.each do |file|
577
+ diff[file] = :changed
578
+ end
579
+
580
+ diff
581
+ end
582
+
583
+ sig { params(cause: Symbol, files: T::Array[String]).returns(String) }
584
+ def build_error_for_files(cause, files)
585
+ filenames = files.map do |file|
586
+ config.outpath / file
587
+ end.join("\n - ")
588
+
589
+ " File(s) #{cause}:\n - #{filenames}"
590
+ end
591
+
592
+ sig { params(path: Pathname).returns(T::Array[Pathname]) }
593
+ def rbi_files_in(path)
594
+ Pathname.glob(path / "**/*.rbi").map do |file|
595
+ file.relative_path_from(path)
596
+ end.sort
597
+ end
598
+
599
+ sig { params(dir: Pathname, constant_lookup: T::Hash[String, String]).void }
600
+ def perform_dsl_verification(dir, constant_lookup)
601
+ diff = verify_dsl_rbi(tmp_dir: dir)
602
+
603
+ if diff.empty?
604
+ say("Nothing to do, all RBIs are up-to-date.")
605
+ else
606
+ constants = T.unsafe(constant_lookup).values_at(*diff.keys).join(" ")
607
+
608
+ say("RBI files are out-of-date, please run:")
609
+ say(" `#{Config::DEFAULT_COMMAND} dsl #{constants}`")
610
+
611
+ say("")
612
+
613
+ say("Reason:", [:red])
614
+ diff.group_by(&:last).sort.each do |cause, diff_for_cause|
615
+ say(build_error_for_files(cause, diff_for_cause.map(&:first)))
616
+ end
617
+
618
+ exit(1)
619
+ end
620
+ ensure
621
+ FileUtils.remove_entry(dir)
622
+ end
623
+
624
+ sig { params(files: T::Set[Pathname]).void }
625
+ def purge_stale_dsl_rbi_files(files)
626
+ if files.any?
627
+ say("Removing stale RBI files...")
628
+
629
+ files.sort.each do |filename|
630
+ remove(filename)
631
+ end
632
+ say("")
633
+ end
479
634
  end
480
635
  end
481
636
  end
@@ -0,0 +1,219 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ # This class is responsible for storing and looking up information related to generic types.
6
+ #
7
+ # The class stores 2 different kinds of data, in two separate lookup tables:
8
+ # 1. a lookup of generic type instances by name: `@generic_instances`
9
+ # 2. a lookup of type variable serializer by constant and type variable
10
+ # instance: `@type_variables`
11
+ #
12
+ # By storing the above data, we can cheaply query each constant against this registry
13
+ # to see if it declares any generic type variables. This becomes a simple lookup in the
14
+ # `@type_variables` hash table with the given constant.
15
+ #
16
+ # If there is no entry, then we can cheaply know that we can skip generic type
17
+ # information generation for this type.
18
+ #
19
+ # On the other hand, if we get a result, then the result will be a hash of type
20
+ # variable to type variable serializers. This allows us to associate type variables
21
+ # to the constant names that represent them, easily.
22
+ module GenericTypeRegistry
23
+ @generic_instances = T.let(
24
+ {},
25
+ T::Hash[String, Module]
26
+ )
27
+
28
+ @type_variables = T.let(
29
+ {},
30
+ T::Hash[Integer, T::Hash[Integer, String]]
31
+ )
32
+
33
+ class << self
34
+ extend T::Sig
35
+
36
+ # This method is responsible for building the name of the instantiated concrete type
37
+ # and cloning the given constant so that we can return a type that is the same
38
+ # as the current type but is a different instance and has a different name method.
39
+ #
40
+ # We cache those cloned instances by their name in `@generic_instances`, so that
41
+ # we don't keep instantiating a new type every single time it is referenced.
42
+ # For example, `[Foo[Integer], Foo[Integer], Foo[Integer], Foo[String]]` will only
43
+ # result in 2 clones (1 for `Foo[Integer]` and another for `Foo[String]`) and
44
+ # 2 hash lookups (for the other two `Foo[Integer]`s).
45
+ #
46
+ # This method returns the created or cached clone of the constant.
47
+ sig { params(constant: T.untyped, types: T.untyped).returns(Module) }
48
+ def register_type(constant, types)
49
+ # Build the name of the instantiated generic type,
50
+ # something like `"Foo[X, Y, Z]"`
51
+ type_list = types.map { |type| T::Utils.coerce(type).name }.join(", ")
52
+ name = "#{name_of(constant)}[#{type_list}]"
53
+
54
+ # Create a generic type with an overridden `name`
55
+ # method that returns the name we constructed above.
56
+ #
57
+ # Also, we try to memoize the generic type based on the name, so that
58
+ # we don't have to keep recreating them all the time.
59
+ @generic_instances[name] ||= create_generic_type(constant, name)
60
+ end
61
+
62
+ sig do
63
+ params(
64
+ constant: T.untyped,
65
+ type_member: T::Types::TypeVariable,
66
+ fixed: T.untyped,
67
+ lower: T.untyped,
68
+ upper: T.untyped
69
+ ).void
70
+ end
71
+ def register_type_member(constant, type_member, fixed, lower, upper)
72
+ register_type_variable(constant, :type_member, type_member, fixed, lower, upper)
73
+ end
74
+
75
+ sig do
76
+ params(
77
+ constant: T.untyped,
78
+ type_template: T::Types::TypeVariable,
79
+ fixed: T.untyped,
80
+ lower: T.untyped,
81
+ upper: T.untyped
82
+ ).void
83
+ end
84
+ def register_type_template(constant, type_template, fixed, lower, upper)
85
+ register_type_variable(constant, :type_template, type_template, fixed, lower, upper)
86
+ end
87
+
88
+ sig { params(constant: Module).returns(T.nilable(T::Hash[Integer, String])) }
89
+ def lookup_type_variables(constant)
90
+ @type_variables[object_id_of(constant)]
91
+ end
92
+
93
+ private
94
+
95
+ sig { params(constant: Module, name: String).returns(Module) }
96
+ def create_generic_type(constant, name)
97
+ generic_type = case constant
98
+ when Class
99
+ # For classes, we want to create a subclass, so that an instance of
100
+ # the generic class `Foo[Bar]` is still a `Foo`. That is:
101
+ # `Foo[Bar].new.is_a?(Foo)` should be true, which isn't the case
102
+ # if we just clone the class. But subclassing works just fine.
103
+ create_sealed_safe_subclass(constant)
104
+ else
105
+ # This can only be a module and it is fine to just clone modules
106
+ # since they can't have instances and will not have `is_a?` relationships.
107
+ # Moreover, we never `include`/`extend` any generic modules into the
108
+ # ancestor tree, so this doesn't become a problem with checking the
109
+ # instance of a class being `is_a?` of a module type.
110
+ constant.clone
111
+ end
112
+
113
+ # Let's set the `name` method to return the proper generic name
114
+ generic_type.define_singleton_method(:name) { name }
115
+
116
+ # Return the generic type we created
117
+ generic_type
118
+ end
119
+
120
+ # This method is called from intercepted calls to `type_member` and `type_template`.
121
+ # We get passed all the arguments to those methods, as well as the `T::Types::TypeVariable`
122
+ # instance generated by the Sorbet defined `type_member`/`type_template` call on `T::Generic`.
123
+ #
124
+ # This method creates a `String` with that data and stores it in the
125
+ # `@type_variables` lookup table, keyed by the `constant` and `type_variable`.
126
+ #
127
+ # Finally, the original `type_variable` is returned from this method, so that the caller
128
+ # can return it from the original methods as well.
129
+ sig do
130
+ params(
131
+ constant: T.untyped,
132
+ type_variable_type: T.enum([:type_member, :type_template]),
133
+ type_variable: T::Types::TypeVariable,
134
+ fixed: T.untyped,
135
+ lower: T.untyped,
136
+ upper: T.untyped
137
+ ).void
138
+ end
139
+ # rubocop:disable Metrics/ParameterLists
140
+ def register_type_variable(constant, type_variable_type, type_variable, fixed, lower, upper)
141
+ # rubocop:enable Metrics/ParameterLists
142
+ type_variables = lookup_or_initialize_type_variables(constant)
143
+
144
+ type_variables[object_id_of(type_variable)] = serialize_type_variable(
145
+ type_variable_type,
146
+ type_variable.variance,
147
+ fixed,
148
+ lower,
149
+ upper
150
+ )
151
+ end
152
+
153
+ sig { params(constant: Class).returns(Class) }
154
+ def create_sealed_safe_subclass(constant)
155
+ # If the constant is not sealed let's just bail early.
156
+ # We just return a subclass of the constant.
157
+ return Class.new(constant) unless T::Private::Sealed.sealed_module?(constant)
158
+
159
+ # Since sealed classes can normally not be subclassed, we need to trick
160
+ # sealed classes into thinking that the generic type we are
161
+ # creating by subclassing is actually safe for sealed types.
162
+ #
163
+ # Get the filename the sealed class was declared in
164
+ decl_file = constant.instance_variable_get(:@sorbet_sealed_module_decl_file)
165
+ begin
166
+ # Clear the current declaration filename on the class
167
+ constant.remove_instance_variable(:@sorbet_sealed_module_decl_file)
168
+ # Make this file be the declaration filename so that Sorbet runtime
169
+ # does not shout at us for an invalid subclassing.
170
+ T.cast(constant, T::Helpers).sealed!
171
+ # return a subclass
172
+ Class.new(constant)
173
+ ensure
174
+ # Reinstate the original declaration filename
175
+ constant.instance_variable_set(:@sorbet_sealed_module_decl_file, decl_file)
176
+ end
177
+ end
178
+
179
+ sig { params(constant: Module).returns(T::Hash[Integer, String]) }
180
+ def lookup_or_initialize_type_variables(constant)
181
+ @type_variables[object_id_of(constant)] ||= {}
182
+ end
183
+
184
+ sig do
185
+ params(
186
+ type_variable_type: Symbol,
187
+ variance: Symbol,
188
+ fixed: T.untyped,
189
+ lower: T.untyped,
190
+ upper: T.untyped
191
+ ).returns(String)
192
+ end
193
+ def serialize_type_variable(type_variable_type, variance, fixed, lower, upper)
194
+ parts = []
195
+ parts << ":#{variance}" unless variance == :invariant
196
+ parts << "fixed: #{fixed}" if fixed
197
+ parts << "lower: #{lower}" unless lower == T.untyped
198
+ parts << "upper: #{upper}" unless upper == BasicObject
199
+
200
+ parameters = parts.join(", ")
201
+
202
+ serialized = T.let(type_variable_type.to_s, String)
203
+ serialized += "(#{parameters})" unless parameters.empty?
204
+
205
+ serialized
206
+ end
207
+
208
+ sig { params(constant: Module).returns(T.nilable(String)) }
209
+ def name_of(constant)
210
+ Module.instance_method(:name).bind(constant).call
211
+ end
212
+
213
+ sig { params(object: BasicObject).returns(Integer) }
214
+ def object_id_of(object)
215
+ Object.instance_method(:object_id).bind(object).call
216
+ end
217
+ end
218
+ end
219
+ end