tapioca 0.4.17 → 0.4.22

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,
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ class String
5
+ extend T::Sig
6
+
7
+ sig { returns(String) }
8
+ def underscore
9
+ return self unless /[A-Z-]|::/.match?(self)
10
+
11
+ word = to_s.gsub("::", "/")
12
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
13
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
14
+ word.tr!("-", "_")
15
+ word.downcase!
16
+ word
17
+ end
18
+ end
@@ -176,7 +176,7 @@ module Tapioca
176
176
 
177
177
  sig { returns(T::Boolean) }
178
178
  def gem_in_bundle_path?
179
- full_gem_path.start_with?(Bundler.bundle_path.to_s)
179
+ full_gem_path.start_with?(Bundler.bundle_path.to_s, Bundler.app_cache.to_s)
180
180
  end
181
181
  end
182
182
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require 'pathname'
5
5
  require 'thor'
6
+ require "tapioca/core_ext/string"
6
7
 
7
8
  module Tapioca
8
9
  class Generator < ::Thor::Shell::Color
@@ -63,7 +64,7 @@ module Tapioca
63
64
 
64
65
  content = String.new
65
66
  content << rbi_header(
66
- config.generate_command,
67
+ "#{Config::DEFAULT_COMMAND} require",
67
68
  reason: "explicit gem requires",
68
69
  strictness: "false"
69
70
  )
@@ -76,8 +77,8 @@ module Tapioca
76
77
  say("Done", :green)
77
78
 
78
79
  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])
80
+ cmd = set_color("#{Config::DEFAULT_COMMAND} sync", :yellow, :bold)
81
+ say("Please review changes and commit them, then run `#{cmd}`.", [:green, :bold])
81
82
  end
82
83
 
83
84
  sig { void }
@@ -99,7 +100,7 @@ module Tapioca
99
100
 
100
101
  content = String.new
101
102
  content << rbi_header(
102
- config.generate_command,
103
+ "#{Config::DEFAULT_COMMAND} todo",
103
104
  reason: "unresolved constants",
104
105
  strictness: "false"
105
106
  )
@@ -116,16 +117,27 @@ module Tapioca
116
117
  say("Please review changes and commit them.", [:green, :bold])
117
118
  end
118
119
 
119
- sig { params(requested_constants: T::Array[String]).void }
120
- def build_dsl(requested_constants)
120
+ sig do
121
+ params(
122
+ requested_constants: T::Array[String],
123
+ should_verify: T::Boolean,
124
+ quiet: T::Boolean
125
+ ).void
126
+ end
127
+ def build_dsl(requested_constants, should_verify: false, quiet: false)
121
128
  load_application(eager_load: requested_constants.empty?)
122
129
  load_dsl_generators
123
130
 
124
- rbi_files_to_purge = existing_rbi_filenames(requested_constants)
125
-
126
- say("Compiling DSL RBI files...")
131
+ if should_verify
132
+ say("Checking for out-of-date RBIs...")
133
+ else
134
+ say("Compiling DSL RBI files...")
135
+ end
127
136
  say("")
128
137
 
138
+ outpath = should_verify ? Pathname.new(Dir.mktmpdir) : config.outpath
139
+ rbi_files_to_purge = existing_rbi_filenames(requested_constants)
140
+
129
141
  compiler = Compilers::DslCompiler.new(
130
142
  requested_constants: constantize(requested_constants),
131
143
  requested_generators: config.generators,
@@ -135,24 +147,31 @@ module Tapioca
135
147
  )
136
148
 
137
149
  compiler.run do |constant, contents|
138
- filename = compile_dsl_rbi(constant, contents)
139
- rbi_files_to_purge.delete(filename) if filename
140
- end
150
+ constant_name = Module.instance_method(:name).bind(constant).call
141
151
 
142
- unless rbi_files_to_purge.empty?
143
- say("")
144
- say("Removing stale RBI files...")
152
+ filename = compile_dsl_rbi(
153
+ constant_name,
154
+ contents,
155
+ outpath: outpath,
156
+ quiet: should_verify || quiet
157
+ )
145
158
 
146
- rbi_files_to_purge.sort.each do |filename|
147
- remove(filename)
159
+ if filename
160
+ rbi_files_to_purge.delete(filename)
148
161
  end
149
162
  end
150
-
151
163
  say("")
152
- say("Done", :green)
153
164
 
154
- say("All operations performed in working directory.", [:green, :bold])
155
- say("Please review changes and commit them.", [:green, :bold])
165
+ if should_verify
166
+ perform_dsl_verification(outpath)
167
+ else
168
+ purge_stale_dsl_rbi_files(rbi_files_to_purge)
169
+
170
+ say("Done", :green)
171
+
172
+ say("All operations performed in working directory.", [:green, :bold])
173
+ say("Please review changes and commit them.", [:green, :bold])
174
+ end
156
175
  end
157
176
 
158
177
  sig { void }
@@ -218,7 +237,7 @@ module Tapioca
218
237
  say_error("If you populated ", :yellow)
219
238
  say_error("#{file} ", :bold, :blue)
220
239
  say_error("with ", :yellow)
221
- say_error("tapioca require", :bold, :blue)
240
+ say_error("`#{Config::DEFAULT_COMMAND} require`", :bold, :blue)
222
241
  say_error("you should probably review it and remove the faulty line.", :yellow)
223
242
  end
224
243
 
@@ -267,7 +286,7 @@ module Tapioca
267
286
  def constantize(constant_names)
268
287
  constant_map = constant_names.map do |name|
269
288
  begin
270
- [name, name.constantize]
289
+ [name, Object.const_get(name)]
271
290
  rescue NameError
272
291
  [name, nil]
273
292
  end
@@ -286,10 +305,10 @@ module Tapioca
286
305
  constant_map.values
287
306
  end
288
307
 
289
- sig { params(requested_constants: T::Array[String]).returns(T::Set[Pathname]) }
290
- def existing_rbi_filenames(requested_constants)
308
+ sig { params(requested_constants: T::Array[String], path: Pathname).returns(T::Set[Pathname]) }
309
+ def existing_rbi_filenames(requested_constants, path: config.outpath)
291
310
  filenames = if requested_constants.empty?
292
- Pathname.glob(config.outpath / "**/*.rbi")
311
+ Pathname.glob(path / "**/*.rbi")
293
312
  else
294
313
  requested_constants.map do |constant_name|
295
314
  dsl_rbi_filename(constant_name)
@@ -477,7 +496,7 @@ module Tapioca
477
496
  rbi_body_content = compiler.compile(gem)
478
497
  content = String.new
479
498
  content << rbi_header(
480
- config.generate_command,
499
+ "#{Config::DEFAULT_COMMAND} sync",
481
500
  reason: "types exported from the `#{gem.name}` gem",
482
501
  strictness: strictness
483
502
  )
@@ -494,33 +513,121 @@ module Tapioca
494
513
  end
495
514
  File.write(filename.to_s, content)
496
515
 
497
- Pathname.glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
516
+ T.unsafe(Pathname).glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
498
517
  remove(file) unless file.basename.to_s == gem.rbi_file_name
499
518
  end
500
519
  end
501
520
 
502
- sig { params(constant: Module, contents: String).returns(T.nilable(Pathname)) }
503
- def compile_dsl_rbi(constant, contents)
521
+ sig do
522
+ params(constant_name: String, contents: String, outpath: Pathname, quiet: T::Boolean)
523
+ .returns(T.nilable(Pathname))
524
+ end
525
+ def compile_dsl_rbi(constant_name, contents, outpath: config.outpath, quiet: false)
504
526
  return if contents.nil?
505
527
 
506
- command = format(config.generate_command, constant.name)
507
- constant_name = Module.instance_method(:name).bind(constant).call
508
528
  rbi_name = constant_name.underscore + ".rbi"
509
- filename = config.outpath / rbi_name
529
+ filename = outpath / rbi_name
510
530
 
511
531
  out = String.new
512
532
  out << rbi_header(
513
- command,
514
- reason: "dynamic methods in `#{constant.name}`"
533
+ "#{Config::DEFAULT_COMMAND} dsl #{constant_name}",
534
+ reason: "dynamic methods in `#{constant_name}`"
515
535
  )
516
536
  out << contents
517
537
 
518
538
  FileUtils.mkdir_p(File.dirname(filename))
519
539
  File.write(filename, out)
520
- say("Wrote: ", [:green])
521
- say(filename)
540
+
541
+ unless quiet
542
+ say("Wrote: ", [:green])
543
+ say(filename)
544
+ end
522
545
 
523
546
  filename
524
547
  end
548
+
549
+ sig { params(tmp_dir: Pathname).returns(T::Hash[String, Symbol]) }
550
+ def verify_dsl_rbi(tmp_dir:)
551
+ diff = {}
552
+
553
+ existing_rbis = rbi_files_in(config.outpath)
554
+ new_rbis = rbi_files_in(tmp_dir)
555
+
556
+ added_files = (new_rbis - existing_rbis)
557
+
558
+ added_files.each do |file|
559
+ diff[file] = :added
560
+ end
561
+
562
+ removed_files = (existing_rbis - new_rbis)
563
+
564
+ removed_files.each do |file|
565
+ diff[file] = :removed
566
+ end
567
+
568
+ common_files = (existing_rbis & new_rbis)
569
+
570
+ changed_files = common_files.map do |filename|
571
+ filename unless FileUtils.identical?(config.outpath / filename, tmp_dir / filename)
572
+ end.compact
573
+
574
+ changed_files.each do |file|
575
+ diff[file] = :changed
576
+ end
577
+
578
+ diff
579
+ end
580
+
581
+ sig { params(cause: Symbol, files: T::Array[String]).returns(String) }
582
+ def build_error_for_files(cause, files)
583
+ filenames = files.map do |file|
584
+ config.outpath / file
585
+ end.join("\n - ")
586
+
587
+ " File(s) #{cause}:\n - #{filenames}"
588
+ end
589
+
590
+ sig { params(path: Pathname).returns(T::Array[Pathname]) }
591
+ def rbi_files_in(path)
592
+ Pathname.glob(path / "**/*.rbi").map do |file|
593
+ file.relative_path_from(path)
594
+ end.sort
595
+ end
596
+
597
+ sig { params(dir: Pathname).void }
598
+ def perform_dsl_verification(dir)
599
+ diff = verify_dsl_rbi(tmp_dir: dir)
600
+
601
+ if diff.empty?
602
+ say("Nothing to do, all RBIs are up-to-date.")
603
+ else
604
+ say("RBI files are out-of-date. In your development environment, please run:", :green)
605
+ say(" `#{Config::DEFAULT_COMMAND} dsl`", [:green, :bold])
606
+ say("Once it is complete, be sure to commit and push any changes", :green)
607
+
608
+ say("")
609
+
610
+ say("Reason:", [:red])
611
+ diff.group_by(&:last).sort.each do |cause, diff_for_cause|
612
+ say(build_error_for_files(cause, diff_for_cause.map(&:first)))
613
+ end
614
+
615
+ exit(1)
616
+ end
617
+ ensure
618
+ FileUtils.remove_entry(dir)
619
+ end
620
+
621
+ sig { params(files: T::Set[Pathname]).void }
622
+ def purge_stale_dsl_rbi_files(files)
623
+ if files.any?
624
+ say("Removing stale RBI files...")
625
+
626
+ files.sort.each do |filename|
627
+ remove(filename)
628
+ end
629
+ say("")
630
+ end
631
+ end
525
632
  end
526
633
  end
@@ -0,0 +1,222 @@
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_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_safe_subclass(constant)
155
+ # Lookup the "inherited" class method
156
+ inherited_method = constant.method(:inherited)
157
+ # and the module that defines it
158
+ owner = inherited_method.owner
159
+
160
+ # If no one has overriden the inherited method yet, just subclass
161
+ return Class.new(constant) if Class == owner
162
+
163
+ begin
164
+ # Otherwise, some inherited method could be preventing us
165
+ # from creating subclasses, so let's override it and rescue
166
+ owner.send(:define_method, :inherited) do |s|
167
+ begin
168
+ inherited_method.call(s)
169
+ rescue
170
+ # Ignoring errors
171
+ end
172
+ end
173
+
174
+ # return a subclass
175
+ Class.new(constant)
176
+ ensure
177
+ # Reinstate the original inherited method back.
178
+ owner.send(:define_method, :inherited, inherited_method)
179
+ end
180
+ end
181
+
182
+ sig { params(constant: Module).returns(T::Hash[Integer, String]) }
183
+ def lookup_or_initialize_type_variables(constant)
184
+ @type_variables[object_id_of(constant)] ||= {}
185
+ end
186
+
187
+ sig do
188
+ params(
189
+ type_variable_type: Symbol,
190
+ variance: Symbol,
191
+ fixed: T.untyped,
192
+ lower: T.untyped,
193
+ upper: T.untyped
194
+ ).returns(String)
195
+ end
196
+ def serialize_type_variable(type_variable_type, variance, fixed, lower, upper)
197
+ parts = []
198
+ parts << ":#{variance}" unless variance == :invariant
199
+ parts << "fixed: #{fixed}" if fixed
200
+ parts << "lower: #{lower}" unless lower == T.untyped
201
+ parts << "upper: #{upper}" unless upper == BasicObject
202
+
203
+ parameters = parts.join(", ")
204
+
205
+ serialized = T.let(type_variable_type.to_s, String)
206
+ serialized += "(#{parameters})" unless parameters.empty?
207
+
208
+ serialized
209
+ end
210
+
211
+ sig { params(constant: Module).returns(T.nilable(String)) }
212
+ def name_of(constant)
213
+ Module.instance_method(:name).bind(constant).call
214
+ end
215
+
216
+ sig { params(object: BasicObject).returns(Integer) }
217
+ def object_id_of(object)
218
+ Object.instance_method(:object_id).bind(object).call
219
+ end
220
+ end
221
+ end
222
+ end