tapioca 0.4.15 → 0.4.20

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