tapioca 0.4.14 → 0.4.19

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,
@@ -53,13 +53,13 @@ module Tapioca
53
53
 
54
54
  sig { returns([T::Array[Gem], T::Array[String]]) }
55
55
  def load_dependencies
56
- specs = definition.locked_gems.specs.to_a
56
+ deps = definition.locked_gems.dependencies.values
57
57
 
58
58
  missing_specs = T::Array[String].new
59
59
 
60
60
  dependencies = definition
61
61
  .resolve
62
- .materialize(specs, missing_specs)
62
+ .materialize(deps, missing_specs)
63
63
  .map { |spec| Gem.new(spec) }
64
64
  .reject { |gem| gem.ignore?(dir) }
65
65
  .uniq(&:rbi_file_name)
@@ -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,26 @@ 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
+ ).void
124
+ end
125
+ def build_dsl(requested_constants, should_verify: false)
121
126
  load_application(eager_load: requested_constants.empty?)
122
127
  load_dsl_generators
123
128
 
124
- say("Compiling DSL RBI files...")
129
+ if should_verify
130
+ say("Checking for out-of-date RBIs...")
131
+ else
132
+ say("Compiling DSL RBI files...")
133
+ end
125
134
  say("")
126
135
 
136
+ outpath = should_verify ? Dir.mktmpdir : config.outpath
137
+ rbi_files_to_purge = existing_rbi_filenames(requested_constants)
138
+
127
139
  compiler = Compilers::DslCompiler.new(
128
140
  requested_constants: constantize(requested_constants),
129
141
  requested_generators: config.generators,
@@ -133,14 +145,21 @@ module Tapioca
133
145
  )
134
146
 
135
147
  compiler.run do |constant, contents|
136
- compile_dsl_rbi(constant, contents)
148
+ filename = compile_dsl_rbi(constant, contents, outpath: Pathname.new(outpath))
149
+ rbi_files_to_purge.delete(filename) if filename
137
150
  end
138
-
139
151
  say("")
140
- say("Done", :green)
141
152
 
142
- say("All operations performed in working directory.", [:green, :bold])
143
- say("Please review changes and commit them.", [:green, :bold])
153
+ if should_verify
154
+ perform_dsl_verification(outpath)
155
+ else
156
+ purge_stale_dsl_rbi_files(rbi_files_to_purge)
157
+
158
+ say("Done", :green)
159
+
160
+ say("All operations performed in working directory.", [:green, :bold])
161
+ say("Please review changes and commit them.", [:green, :bold])
162
+ end
144
163
  end
145
164
 
146
165
  sig { void }
@@ -206,7 +225,7 @@ module Tapioca
206
225
  say_error("If you populated ", :yellow)
207
226
  say_error("#{file} ", :bold, :blue)
208
227
  say_error("with ", :yellow)
209
- say_error("tapioca require", :bold, :blue)
228
+ say_error("`#{Config::DEFAULT_COMMAND} require`", :bold, :blue)
210
229
  say_error("you should probably review it and remove the faulty line.", :yellow)
211
230
  end
212
231
 
@@ -253,13 +272,38 @@ module Tapioca
253
272
 
254
273
  sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
255
274
  def constantize(constant_names)
256
- constant_names.map do |name|
275
+ constant_map = constant_names.map do |name|
257
276
  begin
258
- name.constantize
277
+ [name, name.constantize]
259
278
  rescue NameError
260
- nil
279
+ [name, nil]
261
280
  end
262
- end.compact
281
+ end.to_h
282
+
283
+ unprocessable_constants = constant_map.select { |_, v| v.nil? }
284
+ unless unprocessable_constants.empty?
285
+ unprocessable_constants.each do |name, _|
286
+ say("Error: Cannot find constant '#{name}'", :red)
287
+ remove(dsl_rbi_filename(name))
288
+ end
289
+
290
+ exit(1)
291
+ end
292
+
293
+ constant_map.values
294
+ end
295
+
296
+ sig { params(requested_constants: T::Array[String], path: Pathname).returns(T::Set[Pathname]) }
297
+ def existing_rbi_filenames(requested_constants, path: config.outpath)
298
+ filenames = if requested_constants.empty?
299
+ Pathname.glob(path / "**/*.rbi")
300
+ else
301
+ requested_constants.map do |constant_name|
302
+ dsl_rbi_filename(constant_name)
303
+ end
304
+ end
305
+
306
+ filenames.to_set
263
307
  end
264
308
 
265
309
  sig { returns(T::Hash[String, String]) }
@@ -277,6 +321,11 @@ module Tapioca
277
321
  .to_h
278
322
  end
279
323
 
324
+ sig { params(constant_name: String).returns(Pathname) }
325
+ def dsl_rbi_filename(constant_name)
326
+ config.outpath / "#{constant_name.underscore}.rbi"
327
+ end
328
+
280
329
  sig { params(gem_name: String, version: String).returns(Pathname) }
281
330
  def gem_rbi_filename(gem_name, version)
282
331
  config.outpath / "#{gem_name}@#{version}.rbi"
@@ -316,6 +365,7 @@ module Tapioca
316
365
 
317
366
  sig { params(filename: Pathname).void }
318
367
  def remove(filename)
368
+ return unless filename.exist?
319
369
  say("-- Removing: #{filename}")
320
370
  filename.unlink
321
371
  end
@@ -434,7 +484,7 @@ module Tapioca
434
484
  rbi_body_content = compiler.compile(gem)
435
485
  content = String.new
436
486
  content << rbi_header(
437
- config.generate_command,
487
+ "#{Config::DEFAULT_COMMAND} sync",
438
488
  reason: "types exported from the `#{gem.name}` gem",
439
489
  strictness: strictness
440
490
  )
@@ -451,23 +501,22 @@ module Tapioca
451
501
  end
452
502
  File.write(filename.to_s, content)
453
503
 
454
- Pathname.glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
504
+ T.unsafe(Pathname).glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
455
505
  remove(file) unless file.basename.to_s == gem.rbi_file_name
456
506
  end
457
507
  end
458
508
 
459
- sig { params(constant: Module, contents: String).void }
460
- def compile_dsl_rbi(constant, contents)
509
+ sig { params(constant: Module, contents: String, outpath: Pathname).returns(T.nilable(Pathname)) }
510
+ def compile_dsl_rbi(constant, contents, outpath: config.outpath)
461
511
  return if contents.nil?
462
512
 
463
- command = format(config.generate_command, constant.name)
464
513
  constant_name = Module.instance_method(:name).bind(constant).call
465
514
  rbi_name = constant_name.underscore + ".rbi"
466
- filename = config.outpath / rbi_name
515
+ filename = outpath / rbi_name
467
516
 
468
517
  out = String.new
469
518
  out << rbi_header(
470
- command,
519
+ "#{Config::DEFAULT_COMMAND} dsl #{constant_name}",
471
520
  reason: "dynamic methods in `#{constant.name}`"
472
521
  )
473
522
  out << contents
@@ -476,6 +525,56 @@ module Tapioca
476
525
  File.write(filename, out)
477
526
  say("Wrote: ", [:green])
478
527
  say(filename)
528
+
529
+ filename
530
+ end
531
+
532
+ sig { params(tmp_dir: Pathname).returns(T.nilable(String)) }
533
+ def verify_dsl_rbi(tmp_dir:)
534
+ existing_rbis = existing_rbi_filenames([]).sort
535
+ new_rbis = existing_rbi_filenames([], path: tmp_dir).grep_v(/gem|shim/).sort
536
+
537
+ return "New file(s) introduced." if existing_rbis.length != new_rbis.length
538
+
539
+ desynced_files = []
540
+
541
+ (0..existing_rbis.length - 1).each do |i|
542
+ desynced_files << new_rbis[i] unless FileUtils.identical?(existing_rbis[i], new_rbis[i])
543
+ end
544
+
545
+ unless desynced_files.empty?
546
+ filenames = desynced_files.map { |f| f.to_s.sub!(tmp_dir.to_s, "sorbet/rbi/dsl") }.join("\n - ")
547
+
548
+ return "File(s) updated:\n - #{filenames}"
549
+ end
550
+
551
+ nil
552
+ end
553
+
554
+ sig { params(dir: String).void }
555
+ def perform_dsl_verification(dir)
556
+ if (error = verify_dsl_rbi(tmp_dir: Pathname.new(dir)))
557
+ say("RBI files are out-of-date, please run `#{Config::DEFAULT_COMMAND} dsl` to update.")
558
+ say("Reason: ", [:red])
559
+ say(error)
560
+ exit(1)
561
+ else
562
+ say("Nothing to do, all RBIs are up-to-date.")
563
+ end
564
+ ensure
565
+ FileUtils.remove_entry(dir)
566
+ end
567
+
568
+ sig { params(files: T::Set[Pathname]).void }
569
+ def purge_stale_dsl_rbi_files(files)
570
+ if files.any?
571
+ say("Removing stale RBI files...")
572
+
573
+ files.sort.each do |filename|
574
+ remove(filename)
575
+ end
576
+ say("")
577
+ end
479
578
  end
480
579
  end
481
580
  end
@@ -0,0 +1,193 @@
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
+ Class.new(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: Module).returns(T::Hash[Integer, String]) }
154
+ def lookup_or_initialize_type_variables(constant)
155
+ @type_variables[object_id_of(constant)] ||= {}
156
+ end
157
+
158
+ sig do
159
+ params(
160
+ type_variable_type: Symbol,
161
+ variance: Symbol,
162
+ fixed: T.untyped,
163
+ lower: T.untyped,
164
+ upper: T.untyped
165
+ ).returns(String)
166
+ end
167
+ def serialize_type_variable(type_variable_type, variance, fixed, lower, upper)
168
+ parts = []
169
+ parts << ":#{variance}" unless variance == :invariant
170
+ parts << "fixed: #{fixed}" if fixed
171
+ parts << "lower: #{lower}" unless lower == T.untyped
172
+ parts << "upper: #{upper}" unless upper == BasicObject
173
+
174
+ parameters = parts.join(", ")
175
+
176
+ serialized = T.let(type_variable_type.to_s, String)
177
+ serialized += "(#{parameters})" unless parameters.empty?
178
+
179
+ serialized
180
+ end
181
+
182
+ sig { params(constant: Module).returns(T.nilable(String)) }
183
+ def name_of(constant)
184
+ Module.instance_method(:name).bind(constant).call
185
+ end
186
+
187
+ sig { params(object: BasicObject).returns(Integer) }
188
+ def object_id_of(object)
189
+ Object.instance_method(:object_id).bind(object).call
190
+ end
191
+ end
192
+ end
193
+ end