tapioca 0.4.14 → 0.4.19

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,
@@ -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