tapioca 0.4.0 → 0.4.5

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +26 -1
  3. data/README.md +16 -0
  4. data/Rakefile +16 -4
  5. data/lib/tapioca.rb +6 -2
  6. data/lib/tapioca/cli.rb +25 -3
  7. data/lib/tapioca/compilers/dsl/action_controller_helpers.rb +130 -0
  8. data/lib/tapioca/compilers/dsl/action_mailer.rb +65 -0
  9. data/lib/tapioca/compilers/dsl/active_record_associations.rb +267 -0
  10. data/lib/tapioca/compilers/dsl/active_record_columns.rb +404 -0
  11. data/lib/tapioca/compilers/dsl/active_record_enum.rb +112 -0
  12. data/lib/tapioca/compilers/dsl/active_record_identity_cache.rb +212 -0
  13. data/lib/tapioca/compilers/dsl/active_record_scope.rb +100 -0
  14. data/lib/tapioca/compilers/dsl/active_record_typed_store.rb +168 -0
  15. data/lib/tapioca/compilers/dsl/active_resource.rb +140 -0
  16. data/lib/tapioca/compilers/dsl/active_support_current_attributes.rb +126 -0
  17. data/lib/tapioca/compilers/dsl/base.rb +165 -0
  18. data/lib/tapioca/compilers/dsl/frozen_record.rb +96 -0
  19. data/lib/tapioca/compilers/dsl/protobuf.rb +144 -0
  20. data/lib/tapioca/compilers/dsl/smart_properties.rb +173 -0
  21. data/lib/tapioca/compilers/dsl/state_machines.rb +378 -0
  22. data/lib/tapioca/compilers/dsl/url_helpers.rb +160 -0
  23. data/lib/tapioca/compilers/dsl_compiler.rb +121 -0
  24. data/lib/tapioca/compilers/requires_compiler.rb +67 -0
  25. data/lib/tapioca/compilers/symbol_table/symbol_generator.rb +195 -32
  26. data/lib/tapioca/config.rb +11 -6
  27. data/lib/tapioca/config_builder.rb +19 -9
  28. data/lib/tapioca/constant_locator.rb +1 -0
  29. data/lib/tapioca/core_ext/class.rb +23 -0
  30. data/lib/tapioca/gemfile.rb +32 -9
  31. data/lib/tapioca/generator.rb +200 -24
  32. data/lib/tapioca/loader.rb +30 -9
  33. data/lib/tapioca/version.rb +1 -1
  34. metadata +31 -40
@@ -12,6 +12,7 @@ module Tapioca
12
12
  const(:exclude, T::Array[String])
13
13
  const(:typed_overrides, T::Hash[String, String])
14
14
  const(:todos_path, String)
15
+ const(:generators, T::Array[String])
15
16
 
16
17
  sig { returns(Pathname) }
17
18
  def outpath
@@ -21,17 +22,21 @@ module Tapioca
21
22
 
22
23
  private_class_method :new
23
24
 
24
- CONFIG_FILE_PATH = "sorbet/tapioca/config.yml"
25
- SORBET_CONFIG = "sorbet/config"
25
+ SORBET_PATH = T.let("sorbet", String)
26
+ SORBET_CONFIG = T.let("#{SORBET_PATH}/config", String)
27
+ TAPIOCA_PATH = T.let("#{SORBET_PATH}/tapioca", String)
28
+ TAPIOCA_CONFIG = T.let("#{TAPIOCA_PATH}/config.yml", String)
29
+
30
+ DEFAULT_POSTREQUIRE = T.let("#{TAPIOCA_PATH}/require.rb", String)
31
+ DEFAULT_RBIDIR = T.let("#{SORBET_PATH}/rbi", String)
32
+ DEFAULT_DSLDIR = T.let("#{DEFAULT_RBIDIR}/dsl", String)
33
+ DEFAULT_GEMDIR = T.let("#{DEFAULT_RBIDIR}/gems", String)
34
+ DEFAULT_TODOSPATH = T.let("#{DEFAULT_RBIDIR}/todo.rbi", String)
26
35
 
27
- DEFAULT_POSTREQUIRE = "sorbet/tapioca/require.rb"
28
- DEFAULT_RBIDIR = "sorbet/rbi"
29
- DEFAULT_OUTDIR = T.let("#{DEFAULT_RBIDIR}/gems", String)
30
36
  DEFAULT_OVERRIDES = T.let({
31
37
  # ActiveSupport overrides some core methods with different signatures
32
38
  # so we generate a typed: false RBI for it to suppress errors
33
39
  "activesupport" => "false",
34
40
  }.freeze, T::Hash[String, String])
35
- DEFAULT_TODOSPATH = T.let("#{DEFAULT_RBIDIR}/todo.rbi", String)
36
41
  end
37
42
  end
@@ -8,10 +8,10 @@ module Tapioca
8
8
  class << self
9
9
  extend(T::Sig)
10
10
 
11
- sig { params(options: T::Hash[String, T.untyped]).returns(Config) }
12
- def from_options(options)
11
+ sig { params(command: Symbol, options: T::Hash[String, T.untyped]).returns(Config) }
12
+ def from_options(command, options)
13
13
  Config.from_hash(
14
- merge_options(default_options, config_options, options)
14
+ merge_options(default_options(command), config_options, options)
15
15
  )
16
16
  end
17
17
 
@@ -19,16 +19,25 @@ module Tapioca
19
19
 
20
20
  sig { returns(T::Hash[String, T.untyped]) }
21
21
  def config_options
22
- if File.exist?(Config::CONFIG_FILE_PATH)
23
- YAML.load_file(Config::CONFIG_FILE_PATH, fallback: {})
22
+ if File.exist?(Config::TAPIOCA_CONFIG)
23
+ YAML.load_file(Config::TAPIOCA_CONFIG, fallback: {})
24
24
  else
25
25
  {}
26
26
  end
27
27
  end
28
28
 
29
- sig { returns(T::Hash[String, T.untyped]) }
30
- def default_options
31
- DEFAULT_OPTIONS
29
+ sig { params(command: Symbol).returns(T::Hash[String, T.untyped]) }
30
+ def default_options(command)
31
+ default_outdir = case command
32
+ when :sync, :generate
33
+ Config::DEFAULT_GEMDIR
34
+ when :dsl
35
+ Config::DEFAULT_DSLDIR
36
+ else
37
+ Config::SORBET_PATH
38
+ end
39
+
40
+ DEFAULT_OPTIONS.merge("outdir" => default_outdir)
32
41
  end
33
42
 
34
43
  sig { returns(String) }
@@ -55,11 +64,12 @@ module Tapioca
55
64
 
56
65
  DEFAULT_OPTIONS = T.let({
57
66
  "postrequire" => Config::DEFAULT_POSTREQUIRE,
58
- "outdir" => Config::DEFAULT_OUTDIR,
67
+ "outdir" => nil,
59
68
  "generate_command" => default_command,
60
69
  "exclude" => [],
61
70
  "typed_overrides" => Config::DEFAULT_OVERRIDES,
62
71
  "todos_path" => Config::DEFAULT_TODOSPATH,
72
+ "generators" => [],
63
73
  }.freeze, T::Hash[String, T.untyped])
64
74
  end
65
75
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'set'
@@ -0,0 +1,23 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ class Class
5
+ # Returns an array with all classes that are < than its receiver.
6
+ #
7
+ # class C; end
8
+ # C.descendants # => []
9
+ #
10
+ # class B < C; end
11
+ # C.descendants # => [B]
12
+ #
13
+ # class A < B; end
14
+ # C.descendants # => [B, A]
15
+ #
16
+ # class D < C; end
17
+ # C.descendants # => [B, A, D]
18
+ def descendants
19
+ ObjectSpace.each_object(singleton_class).reject do |k|
20
+ k.singleton_class? || k == self
21
+ end
22
+ end
23
+ end
@@ -28,7 +28,7 @@ module Tapioca
28
28
  sig { returns(T::Array[Gem]) }
29
29
  def dependencies
30
30
  @dependencies ||= begin
31
- specs = definition.specs.to_a
31
+ specs = definition.locked_gems.specs.to_a
32
32
 
33
33
  definition
34
34
  .resolve
@@ -79,17 +79,18 @@ module Tapioca
79
79
  extend(T::Sig)
80
80
 
81
81
  IGNORED_GEMS = T.let(%w{
82
- sorbet sorbet-static sorbet-runtime tapioca
82
+ sorbet sorbet-static sorbet-runtime
83
83
  }.freeze, T::Array[String])
84
84
 
85
85
  sig { returns(String) }
86
- attr_reader :full_gem_path
86
+ attr_reader :full_gem_path, :version
87
87
 
88
88
  sig { params(spec: Spec).void }
89
89
  def initialize(spec)
90
90
  @spec = T.let(spec, Tapioca::Gemfile::Spec)
91
91
  real_gem_path = to_realpath(@spec.full_gem_path)
92
92
  @full_gem_path = T.let(real_gem_path, String)
93
+ @version = T.let(version_string, String)
93
94
  end
94
95
 
95
96
  sig { params(gemfile_dir: String).returns(T::Boolean) }
@@ -109,11 +110,6 @@ module Tapioca
109
110
  @spec.name
110
111
  end
111
112
 
112
- sig { returns(::Gem::Version) }
113
- def version
114
- @spec.version
115
- end
116
-
117
113
  sig { returns(String) }
118
114
  def rbi_file_name
119
115
  "#{name}@#{version}.rbi"
@@ -121,11 +117,38 @@ module Tapioca
121
117
 
122
118
  sig { params(path: String).returns(T::Boolean) }
123
119
  def contains_path?(path)
124
- to_realpath(path).start_with?(full_gem_path)
120
+ to_realpath(path).start_with?(full_gem_path) || has_parent_gemspec?(path)
125
121
  end
126
122
 
127
123
  private
128
124
 
125
+ sig { returns(String) }
126
+ def version_string
127
+ version = @spec.version.to_s
128
+ version += "-#{@spec.source.revision}" if Bundler::Source::Git === @spec.source
129
+ version
130
+ end
131
+
132
+ sig { params(path: String).returns(T::Boolean) }
133
+ def has_parent_gemspec?(path)
134
+ # For some Git installed gems the location of the loaded file can
135
+ # be different from the gem path as indicated by the spec file
136
+ #
137
+ # To compensate for these cases, we walk up the directory hierarchy
138
+ # from the given file and try to match a <gem-name.gemspec> file in
139
+ # one of those folders to see if the path really belongs in the given gem
140
+ # or not.
141
+ return false unless Bundler::Source::Git === @spec.source
142
+ parent = Pathname.new(path)
143
+
144
+ until parent.root?
145
+ parent = parent.parent.expand_path
146
+ return true if parent.join("#{name}.gemspec").file?
147
+ end
148
+
149
+ false
150
+ end
151
+
129
152
  sig { params(path: T.any(String, Pathname)).returns(String) }
130
153
  def to_realpath(path)
131
154
  path_string = path.to_s
@@ -35,7 +35,7 @@ module Tapioca
35
35
  .each do |gem|
36
36
  say("Processing '#{gem.name}' gem:", :green)
37
37
  indent do
38
- compile_rbi(gem)
38
+ compile_gem_rbi(gem)
39
39
  puts
40
40
  end
41
41
  end
@@ -44,6 +44,42 @@ module Tapioca
44
44
  say("Please review changes and commit them.", [:green, :bold])
45
45
  end
46
46
 
47
+ sig { void }
48
+ def build_requires
49
+ requires_path = Config::DEFAULT_POSTREQUIRE
50
+ compiler = Compilers::RequiresCompiler.new(Config::SORBET_CONFIG)
51
+ name = set_color(requires_path, :yellow, :bold)
52
+ say("Compiling #{name}, this may take a few seconds... ")
53
+
54
+ rb_string = compiler.compile
55
+ if rb_string.empty?
56
+ say("Nothing to do", :green)
57
+ return
58
+ end
59
+
60
+ # Clean all existing requires before regenerating the list so we update
61
+ # it with the new one found in the client code and remove the old ones.
62
+ File.delete(requires_path) if File.exist?(requires_path)
63
+
64
+ content = String.new
65
+ content << rbi_header(
66
+ config.generate_command,
67
+ reason: "explicit gem requires",
68
+ strictness: "false"
69
+ )
70
+ content << rb_string
71
+
72
+ outdir = File.dirname(requires_path)
73
+ FileUtils.mkdir_p(outdir)
74
+ File.write(requires_path, content)
75
+
76
+ say("Done", :green)
77
+
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])
81
+ end
82
+
47
83
  sig { void }
48
84
  def build_todos
49
85
  todos_path = config.todos_path
@@ -62,7 +98,11 @@ module Tapioca
62
98
  end
63
99
 
64
100
  content = String.new
65
- content << rbi_header(config.generate_command, "false")
101
+ content << rbi_header(
102
+ config.generate_command,
103
+ reason: "unresolved constants",
104
+ strictness: "false"
105
+ )
66
106
  content << rbi_string
67
107
  content << "\n"
68
108
 
@@ -76,6 +116,33 @@ module Tapioca
76
116
  say("Please review changes and commit them.", [:green, :bold])
77
117
  end
78
118
 
119
+ sig { params(requested_constants: T::Array[String]).void }
120
+ def build_dsl(requested_constants)
121
+ load_application(eager_load: requested_constants.empty?)
122
+ load_dsl_generators
123
+
124
+ say("Compiling DSL RBI files...")
125
+ say("")
126
+
127
+ compiler = Compilers::DslCompiler.new(
128
+ requested_constants: constantize(requested_constants),
129
+ requested_generators: config.generators,
130
+ error_handler: ->(error) {
131
+ say_error(error, :bold, :red)
132
+ }
133
+ )
134
+
135
+ compiler.run do |constant, contents|
136
+ compile_dsl_rbi(constant, contents)
137
+ end
138
+
139
+ say("")
140
+ say("Done", :green)
141
+
142
+ say("All operations performed in working directory.", [:green, :bold])
143
+ say("Please review changes and commit them.", [:green, :bold])
144
+ end
145
+
79
146
  sig { void }
80
147
  def sync_rbis_with_gemfile
81
148
  anything_done = [
@@ -95,6 +162,11 @@ module Tapioca
95
162
 
96
163
  private
97
164
 
165
+ EMPTY_RBI_COMMENT = <<~CONTENT
166
+ # THIS IS AN EMPTY RBI FILE.
167
+ # see https://github.com/Shopify/tapioca/blob/master/README.md#manual-gem-requires
168
+ CONTENT
169
+
98
170
  sig { returns(Gemfile) }
99
171
  def bundle
100
172
  @bundle ||= Gemfile.new
@@ -113,15 +185,83 @@ module Tapioca
113
185
  sig { void }
114
186
  def require_gem_file
115
187
  say("Requiring all gems to prepare for compiling... ")
116
- loader.load_bundle(config.prerequire, config.postrequire)
188
+ begin
189
+ loader.load_bundle(config.prerequire, config.postrequire)
190
+ rescue LoadError => e
191
+ explain_failed_require(config.postrequire, e)
192
+ exit(1)
193
+ end
117
194
  say(" Done", :green)
118
195
  puts
119
196
  end
120
197
 
198
+ sig { params(file: String, error: LoadError).void }
199
+ def explain_failed_require(file, error)
200
+ say_error("\n\nLoadError: #{error}", :bold, :red)
201
+ say_error("\nTapioca could not load all the gems required by your application.", :yellow)
202
+ say_error("If you populated ", :yellow)
203
+ say_error("#{file} ", :bold, :blue)
204
+ say_error("with ", :yellow)
205
+ say_error("tapioca require", :bold, :blue)
206
+ say_error("you should probably review it and remove the faulty line.", :yellow)
207
+ end
208
+
209
+ sig do
210
+ params(
211
+ message: String,
212
+ color: T.any(Symbol, T::Array[Symbol]),
213
+ ).void
214
+ end
215
+ def say_error(message = "", *color)
216
+ force_new_line = (message.to_s !~ /( |\t)\Z/)
217
+ buffer = prepare_message(*T.unsafe([message, *T.unsafe(color)]))
218
+ buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
219
+
220
+ stderr.print(buffer)
221
+ stderr.flush
222
+ end
223
+
224
+ sig { params(eager_load: T::Boolean).void }
225
+ def load_application(eager_load:)
226
+ say("Loading Rails application... ")
227
+
228
+ loader.load_rails(
229
+ environment_load: true,
230
+ eager_load: eager_load
231
+ )
232
+
233
+ say("Done", :green)
234
+ end
235
+
236
+ sig { void }
237
+ def load_dsl_generators
238
+ say("Loading DSL generator classes... ")
239
+
240
+ Dir.glob([
241
+ "#{__dir__}/compilers/dsl/*.rb",
242
+ "#{Config::TAPIOCA_PATH}/generators/**/*.rb",
243
+ ]).each do |generator|
244
+ require File.expand_path(generator)
245
+ end
246
+
247
+ say("Done", :green)
248
+ end
249
+
250
+ sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
251
+ def constantize(constant_names)
252
+ constant_names.map do |name|
253
+ begin
254
+ name.constantize
255
+ rescue NameError
256
+ nil
257
+ end
258
+ end.compact
259
+ end
260
+
121
261
  sig { returns(T::Hash[String, String]) }
122
262
  def existing_rbis
123
263
  @existing_rbis ||= Pathname.glob((config.outpath / "*@*.rbi").to_s)
124
- .map { |f| f.basename(".*").to_s.split('@') }
264
+ .map { |f| T.cast(f.basename(".*").to_s.split('@', 2), [String, String]) }
125
265
  .to_h
126
266
  end
127
267
 
@@ -134,22 +274,22 @@ module Tapioca
134
274
  end
135
275
 
136
276
  sig { params(gem_name: String, version: String).returns(Pathname) }
137
- def rbi_filename(gem_name, version)
277
+ def gem_rbi_filename(gem_name, version)
138
278
  config.outpath / "#{gem_name}@#{version}.rbi"
139
279
  end
140
280
 
141
281
  sig { params(gem_name: String).returns(Pathname) }
142
282
  def existing_rbi(gem_name)
143
- rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
283
+ gem_rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
144
284
  end
145
285
 
146
286
  sig { params(gem_name: String).returns(Pathname) }
147
287
  def expected_rbi(gem_name)
148
- rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
288
+ gem_rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
149
289
  end
150
290
 
151
291
  sig { params(gem_name: String).returns(T::Boolean) }
152
- def rbi_exists?(gem_name)
292
+ def gem_rbi_exists?(gem_name)
153
293
  existing_rbis.key?(gem_name)
154
294
  end
155
295
 
@@ -227,13 +367,13 @@ module Tapioca
227
367
  gems.each do |gem_name|
228
368
  filename = expected_rbi(gem_name)
229
369
 
230
- if rbi_exists?(gem_name)
370
+ if gem_rbi_exists?(gem_name)
231
371
  old_filename = existing_rbi(gem_name)
232
372
  move(old_filename, filename) unless old_filename == filename
233
373
  end
234
374
 
235
375
  gem = T.must(bundle.gem(gem_name))
236
- compile_rbi(gem)
376
+ compile_gem_rbi(gem)
237
377
  add(filename)
238
378
 
239
379
  puts
@@ -265,37 +405,73 @@ module Tapioca
265
405
  end
266
406
  end
267
407
 
268
- sig { params(command: String, typed_sigil: String).returns(String) }
269
- def rbi_header(command, typed_sigil)
270
- <<~HEAD
271
- # This file is autogenerated. Do not edit it by hand. Regenerate it with:
272
- # #{command}
408
+ sig { params(command: String, reason: T.nilable(String), strictness: T.nilable(String)).returns(String) }
409
+ def rbi_header(command, reason: nil, strictness: nil)
410
+ statement = <<~HEAD
411
+ # DO NOT EDIT MANUALLY
412
+ # This is an autogenerated file for #{reason}.
413
+ # Please instead update this file by running `#{command}`.
414
+ HEAD
273
415
 
274
- # typed: #{typed_sigil}
416
+ sigil = <<~SIGIL if strictness
417
+ # typed: #{strictness}
418
+ SIGIL
275
419
 
276
- HEAD
420
+ [statement, sigil].compact.join("\n").strip.concat("\n\n")
277
421
  end
278
422
 
279
423
  sig { params(gem: Gemfile::Gem).void }
280
- def compile_rbi(gem)
424
+ def compile_gem_rbi(gem)
281
425
  compiler = Compilers::SymbolTableCompiler.new
282
426
  gem_name = set_color(gem.name, :yellow, :bold)
283
427
  say("Compiling #{gem_name}, this may take a few seconds... ")
284
428
 
285
- typed_sigil = config.typed_overrides[gem.name] || "true"
286
-
287
- content = compiler.compile(gem)
288
- content.prepend(rbi_header(config.generate_command, typed_sigil))
429
+ strictness = config.typed_overrides[gem.name] || "true"
430
+ rbi_body_content = compiler.compile(gem)
431
+ content = String.new
432
+ content << rbi_header(
433
+ config.generate_command,
434
+ reason: "types exported from the `#{gem.name}` gem",
435
+ strictness: strictness
436
+ )
289
437
 
290
438
  FileUtils.mkdir_p(config.outdir)
291
439
  filename = config.outpath / gem.rbi_file_name
292
- File.write(filename.to_s, content)
293
440
 
294
- say("Done", :green)
441
+ if rbi_body_content.strip.empty?
442
+ content << EMPTY_RBI_COMMENT
443
+ say("Done (empty output)", :yellow)
444
+ else
445
+ content << rbi_body_content
446
+ say("Done", :green)
447
+ end
448
+ File.write(filename.to_s, content)
295
449
 
296
450
  Pathname.glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
297
451
  remove(file) unless file.basename.to_s == gem.rbi_file_name
298
452
  end
299
453
  end
454
+
455
+ sig { params(constant: Module, contents: String).void }
456
+ def compile_dsl_rbi(constant, contents)
457
+ return if contents.nil?
458
+
459
+ command = format(config.generate_command, constant.name)
460
+ constant_name = Module.instance_method(:name).bind(constant).call
461
+ rbi_name = constant_name.underscore + ".rbi"
462
+ filename = config.outpath / rbi_name
463
+
464
+ out = String.new
465
+ out << rbi_header(
466
+ command,
467
+ reason: "dynamic methods in `#{constant.name}`"
468
+ )
469
+ out << contents
470
+
471
+ FileUtils.mkdir_p(File.dirname(filename))
472
+ File.write(filename, out)
473
+ say("Wrote: ", [:green])
474
+ say(filename)
475
+ end
300
476
  end
301
477
  end