tapioca 0.4.0 → 0.4.5

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