tapioca 0.5.0 → 0.5.4

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.
@@ -1,717 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require "pathname"
5
- require "thor"
6
- require "rake"
7
-
8
- module Tapioca
9
- class Generator < ::Thor::Shell::Color
10
- extend(T::Sig)
11
-
12
- sig { returns(Config) }
13
- attr_reader :config
14
-
15
- sig do
16
- params(
17
- config: Config
18
- ).void
19
- end
20
- def initialize(config)
21
- @config = config
22
- @bundle = T.let(nil, T.nilable(Gemfile))
23
- @loader = T.let(nil, T.nilable(Loader))
24
- @compiler = T.let(nil, T.nilable(Compilers::SymbolTableCompiler))
25
- @existing_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
26
- @expected_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
27
- super()
28
- end
29
-
30
- sig { params(gem_names: T::Array[String]).void }
31
- def build_gem_rbis(gem_names)
32
- require_gem_file
33
-
34
- gems_to_generate(gem_names)
35
- .reject { |gem| config.exclude.include?(gem.name) }
36
- .each do |gem|
37
- say("Processing '#{gem.name}' gem:", :green)
38
- indent do
39
- compile_gem_rbi(gem)
40
- puts
41
- end
42
- end
43
-
44
- say("All operations performed in working directory.", [:green, :bold])
45
- say("Please review changes and commit them.", [:green, :bold])
46
- end
47
-
48
- sig { void }
49
- def build_requires
50
- requires_path = Config::DEFAULT_POSTREQUIRE
51
- compiler = Compilers::RequiresCompiler.new(Config::SORBET_CONFIG)
52
- name = set_color(requires_path, :yellow, :bold)
53
- say("Compiling #{name}, this may take a few seconds... ")
54
-
55
- rb_string = compiler.compile
56
- if rb_string.empty?
57
- say("Nothing to do", :green)
58
- return
59
- end
60
-
61
- # Clean all existing requires before regenerating the list so we update
62
- # it with the new one found in the client code and remove the old ones.
63
- File.delete(requires_path) if File.exist?(requires_path)
64
-
65
- content = String.new
66
- content << "# typed: true\n"
67
- content << "# frozen_string_literal: true\n\n"
68
- content << rb_string
69
-
70
- outdir = File.dirname(requires_path)
71
- FileUtils.mkdir_p(outdir)
72
- File.write(requires_path, content)
73
-
74
- say("Done", :green)
75
-
76
- say("All requires from this application have been written to #{name}.", [:green, :bold])
77
- cmd = set_color("#{Config::DEFAULT_COMMAND} sync", :yellow, :bold)
78
- say("Please review changes and commit them, then run `#{cmd}`.", [:green, :bold])
79
- end
80
-
81
- sig { void }
82
- def build_todos
83
- todos_path = config.todos_path
84
- compiler = Compilers::TodosCompiler.new
85
- name = set_color(todos_path, :yellow, :bold)
86
- say("Compiling #{name}, this may take a few seconds... ")
87
-
88
- # Clean all existing unresolved constants before regenerating the list
89
- # so Sorbet won't grab them as already resolved.
90
- File.delete(todos_path) if File.exist?(todos_path)
91
-
92
- rbi_string = compiler.compile
93
- if rbi_string.empty?
94
- say("Nothing to do", :green)
95
- return
96
- end
97
-
98
- content = String.new
99
- content << rbi_header(
100
- "#{Config::DEFAULT_COMMAND} todo",
101
- reason: "unresolved constants",
102
- strictness: "false"
103
- )
104
- content << rbi_string
105
- content << "\n"
106
-
107
- outdir = File.dirname(todos_path)
108
- FileUtils.mkdir_p(outdir)
109
- File.write(todos_path, content)
110
-
111
- say("Done", :green)
112
-
113
- say("All unresolved constants have been written to #{name}.", [:green, :bold])
114
- say("Please review changes and commit them.", [:green, :bold])
115
- end
116
-
117
- sig do
118
- params(
119
- requested_constants: T::Array[String],
120
- should_verify: T::Boolean,
121
- quiet: T::Boolean,
122
- verbose: T::Boolean
123
- ).void
124
- end
125
- def build_dsl(requested_constants, should_verify: false, quiet: false, verbose: false)
126
- load_application(eager_load: requested_constants.empty?)
127
- abort_if_pending_migrations!
128
- load_dsl_generators
129
-
130
- if should_verify
131
- say("Checking for out-of-date RBIs...")
132
- else
133
- say("Compiling DSL RBI files...")
134
- end
135
- say("")
136
-
137
- outpath = should_verify ? Pathname.new(Dir.mktmpdir) : config.outpath
138
- rbi_files_to_purge = existing_rbi_filenames(requested_constants)
139
-
140
- compiler = Compilers::DslCompiler.new(
141
- requested_constants: constantize(requested_constants),
142
- requested_generators: constantize_generators(config.generators),
143
- excluded_generators: constantize_generators(config.exclude_generators),
144
- error_handler: ->(error) {
145
- say_error(error, :bold, :red)
146
- }
147
- )
148
-
149
- compiler.run do |constant, contents|
150
- constant_name = T.must(Reflection.name_of(constant))
151
-
152
- if verbose && !quiet
153
- say("Processing: ", [:yellow])
154
- say(constant_name)
155
- end
156
-
157
- filename = compile_dsl_rbi(
158
- constant_name,
159
- contents,
160
- outpath: outpath,
161
- quiet: should_verify || quiet && !verbose
162
- )
163
-
164
- if filename
165
- rbi_files_to_purge.delete(filename)
166
- end
167
- end
168
- say("")
169
-
170
- if should_verify
171
- perform_dsl_verification(outpath)
172
- else
173
- purge_stale_dsl_rbi_files(rbi_files_to_purge)
174
-
175
- say("Done", :green)
176
-
177
- say("All operations performed in working directory.", [:green, :bold])
178
- say("Please review changes and commit them.", [:green, :bold])
179
- end
180
- end
181
-
182
- sig { params(should_verify: T::Boolean).void }
183
- def sync_rbis_with_gemfile(should_verify: false)
184
- if should_verify
185
- say("Checking for out-of-date RBIs...")
186
- say("")
187
- perform_sync_verification
188
- return
189
- end
190
-
191
- anything_done = [
192
- perform_removals,
193
- perform_additions,
194
- ].any?
195
-
196
- if anything_done
197
- say("All operations performed in working directory.", [:green, :bold])
198
- say("Please review changes and commit them.", [:green, :bold])
199
- else
200
- say("No operations performed, all RBIs are up-to-date.", [:green, :bold])
201
- end
202
-
203
- puts
204
- end
205
-
206
- private
207
-
208
- EMPTY_RBI_COMMENT = <<~CONTENT
209
- # THIS IS AN EMPTY RBI FILE.
210
- # see https://github.com/Shopify/tapioca/wiki/Manual-Gem-Requires
211
- CONTENT
212
-
213
- sig { returns(Gemfile) }
214
- def bundle
215
- @bundle ||= Gemfile.new
216
- end
217
-
218
- sig { returns(Loader) }
219
- def loader
220
- @loader ||= Loader.new
221
- end
222
-
223
- sig { returns(Compilers::SymbolTableCompiler) }
224
- def compiler
225
- @compiler ||= Compilers::SymbolTableCompiler.new
226
- end
227
-
228
- sig { void }
229
- def require_gem_file
230
- say("Requiring all gems to prepare for compiling... ")
231
- begin
232
- loader.load_bundle(bundle, config.prerequire, config.postrequire)
233
- rescue LoadError => e
234
- explain_failed_require(config.postrequire, e)
235
- exit(1)
236
- end
237
- say(" Done", :green)
238
- unless bundle.missing_specs.empty?
239
- say(" completed with missing specs: ")
240
- say(bundle.missing_specs.join(", "), :yellow)
241
- end
242
- puts
243
- end
244
-
245
- sig { params(file: String, error: LoadError).void }
246
- def explain_failed_require(file, error)
247
- say_error("\n\nLoadError: #{error}", :bold, :red)
248
- say_error("\nTapioca could not load all the gems required by your application.", :yellow)
249
- say_error("If you populated ", :yellow)
250
- say_error("#{file} ", :bold, :blue)
251
- say_error("with ", :yellow)
252
- say_error("`#{Config::DEFAULT_COMMAND} require`", :bold, :blue)
253
- say_error("you should probably review it and remove the faulty line.", :yellow)
254
- end
255
-
256
- sig do
257
- params(
258
- message: String,
259
- color: T.any(Symbol, T::Array[Symbol]),
260
- ).void
261
- end
262
- def say_error(message = "", *color)
263
- force_new_line = (message.to_s !~ /( |\t)\Z/)
264
- buffer = prepare_message(*T.unsafe([message, *T.unsafe(color)]))
265
- buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
266
-
267
- stderr.print(buffer)
268
- stderr.flush
269
- end
270
-
271
- sig { params(eager_load: T::Boolean).void }
272
- def load_application(eager_load:)
273
- say("Loading Rails application... ")
274
-
275
- loader.load_rails_application(
276
- environment_load: true,
277
- eager_load: eager_load
278
- )
279
-
280
- say("Done", :green)
281
- end
282
-
283
- sig { void }
284
- def load_dsl_generators
285
- say("Loading DSL generator classes... ")
286
-
287
- Dir.glob([
288
- "#{__dir__}/compilers/dsl/*.rb",
289
- "#{Config::TAPIOCA_PATH}/generators/**/*.rb",
290
- ]).each do |generator|
291
- require File.expand_path(generator)
292
- end
293
-
294
- say("Done", :green)
295
- end
296
-
297
- sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
298
- def constantize(constant_names)
299
- constant_map = constant_names.map do |name|
300
- [name, Object.const_get(name)]
301
- rescue NameError
302
- [name, nil]
303
- end.to_h
304
-
305
- unprocessable_constants = constant_map.select { |_, v| v.nil? }
306
- unless unprocessable_constants.empty?
307
- unprocessable_constants.each do |name, _|
308
- say("Error: Cannot find constant '#{name}'", :red)
309
- remove(dsl_rbi_filename(name))
310
- end
311
-
312
- exit(1)
313
- end
314
-
315
- constant_map.values
316
- end
317
-
318
- sig { params(generator_names: T::Array[String]).returns(T::Array[T.class_of(Compilers::Dsl::Base)]) }
319
- def constantize_generators(generator_names)
320
- generator_map = generator_names.map do |name|
321
- # Try to find built-in tapioca generator first, then globally defined generator. The
322
- # explicit `break` ensures the class is returned, not the `potential_name`.
323
- generator_klass = ["Tapioca::Compilers::Dsl::#{name}", name].find do |potential_name|
324
- break Object.const_get(potential_name)
325
- rescue NameError
326
- # Skip if we can't find generator by the potential name
327
- end
328
-
329
- [name, generator_klass]
330
- end.to_h
331
-
332
- unprocessable_generators = generator_map.select { |_, v| v.nil? }
333
- unless unprocessable_generators.empty?
334
- unprocessable_generators.each do |name, _|
335
- say("Error: Cannot find generator '#{name}'", :red)
336
- end
337
-
338
- exit(1)
339
- end
340
-
341
- generator_map.values
342
- end
343
-
344
- sig { params(requested_constants: T::Array[String], path: Pathname).returns(T::Set[Pathname]) }
345
- def existing_rbi_filenames(requested_constants, path: config.outpath)
346
- filenames = if requested_constants.empty?
347
- Pathname.glob(path / "**/*.rbi")
348
- else
349
- requested_constants.map do |constant_name|
350
- dsl_rbi_filename(constant_name)
351
- end
352
- end
353
-
354
- filenames.to_set
355
- end
356
-
357
- sig { returns(T::Hash[String, String]) }
358
- def existing_rbis
359
- @existing_rbis ||= Pathname.glob((config.outpath / "*@*.rbi").to_s)
360
- .map { |f| T.cast(f.basename(".*").to_s.split("@", 2), [String, String]) }
361
- .to_h
362
- end
363
-
364
- sig { returns(T::Hash[String, String]) }
365
- def expected_rbis
366
- @expected_rbis ||= bundle.dependencies
367
- .reject { |gem| config.exclude.include?(gem.name) }
368
- .map { |gem| [gem.name, gem.version.to_s] }
369
- .to_h
370
- end
371
-
372
- sig { params(constant_name: String).returns(Pathname) }
373
- def dsl_rbi_filename(constant_name)
374
- config.outpath / "#{underscore(constant_name)}.rbi"
375
- end
376
-
377
- sig { params(gem_name: String, version: String).returns(Pathname) }
378
- def gem_rbi_filename(gem_name, version)
379
- config.outpath / "#{gem_name}@#{version}.rbi"
380
- end
381
-
382
- sig { params(gem_name: String).returns(Pathname) }
383
- def existing_rbi(gem_name)
384
- gem_rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
385
- end
386
-
387
- sig { params(gem_name: String).returns(Pathname) }
388
- def expected_rbi(gem_name)
389
- gem_rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
390
- end
391
-
392
- sig { params(gem_name: String).returns(T::Boolean) }
393
- def gem_rbi_exists?(gem_name)
394
- existing_rbis.key?(gem_name)
395
- end
396
-
397
- sig { returns(T::Array[String]) }
398
- def removed_rbis
399
- (existing_rbis.keys - expected_rbis.keys).sort
400
- end
401
-
402
- sig { returns(T::Array[String]) }
403
- def added_rbis
404
- expected_rbis.select do |name, value|
405
- existing_rbis[name] != value
406
- end.keys.sort
407
- end
408
-
409
- sig { params(filename: Pathname).void }
410
- def add(filename)
411
- say("++ Adding: #{filename}")
412
- end
413
-
414
- sig { params(filename: Pathname).void }
415
- def remove(filename)
416
- return unless filename.exist?
417
- say("-- Removing: #{filename}")
418
- filename.unlink
419
- end
420
-
421
- sig { params(old_filename: Pathname, new_filename: Pathname).void }
422
- def move(old_filename, new_filename)
423
- say("-> Moving: #{old_filename} to #{new_filename}")
424
- old_filename.rename(new_filename.to_s)
425
- end
426
-
427
- sig { void }
428
- def perform_removals
429
- say("Removing RBI files of gems that have been removed:", [:blue, :bold])
430
- puts
431
-
432
- anything_done = T.let(false, T::Boolean)
433
-
434
- gems = removed_rbis
435
-
436
- indent do
437
- if gems.empty?
438
- say("Nothing to do.")
439
- else
440
- gems.each do |removed|
441
- filename = existing_rbi(removed)
442
- remove(filename)
443
- end
444
-
445
- anything_done = true
446
- end
447
- end
448
-
449
- puts
450
-
451
- anything_done
452
- end
453
-
454
- sig { void }
455
- def perform_additions
456
- say("Generating RBI files of gems that are added or updated:", [:blue, :bold])
457
- puts
458
-
459
- anything_done = T.let(false, T::Boolean)
460
-
461
- gems = added_rbis
462
-
463
- indent do
464
- if gems.empty?
465
- say("Nothing to do.")
466
- else
467
- require_gem_file
468
-
469
- gems.each do |gem_name|
470
- filename = expected_rbi(gem_name)
471
-
472
- if gem_rbi_exists?(gem_name)
473
- old_filename = existing_rbi(gem_name)
474
- move(old_filename, filename) unless old_filename == filename
475
- end
476
-
477
- gem = T.must(bundle.gem(gem_name))
478
- compile_gem_rbi(gem)
479
- add(filename)
480
-
481
- puts
482
- end
483
- end
484
-
485
- anything_done = true
486
- end
487
-
488
- puts
489
-
490
- anything_done
491
- end
492
-
493
- sig do
494
- params(gem_names: T::Array[String])
495
- .returns(T::Array[Gemfile::GemSpec])
496
- end
497
- def gems_to_generate(gem_names)
498
- return bundle.dependencies if gem_names.empty?
499
-
500
- gem_names.map do |gem_name|
501
- gem = bundle.gem(gem_name)
502
- if gem.nil?
503
- say("Error: Cannot find gem '#{gem_name}'", :red)
504
- exit(1)
505
- end
506
- gem
507
- end
508
- end
509
-
510
- sig { params(command: String, reason: T.nilable(String), strictness: T.nilable(String)).returns(String) }
511
- def rbi_header(command, reason: nil, strictness: nil)
512
- statement = <<~HEAD
513
- # DO NOT EDIT MANUALLY
514
- # This is an autogenerated file for #{reason}.
515
- # Please instead update this file by running `#{command}`.
516
- HEAD
517
-
518
- sigil = <<~SIGIL if strictness
519
- # typed: #{strictness}
520
- SIGIL
521
-
522
- if config.file_header
523
- [statement, sigil].compact.join("\n").strip.concat("\n\n")
524
- elsif sigil
525
- sigil.strip.concat("\n\n")
526
- else
527
- ""
528
- end
529
- end
530
-
531
- sig { params(gem: Gemfile::GemSpec).void }
532
- def compile_gem_rbi(gem)
533
- compiler = Compilers::SymbolTableCompiler.new
534
- gem_name = set_color(gem.name, :yellow, :bold)
535
- say("Compiling #{gem_name}, this may take a few seconds... ")
536
-
537
- strictness = config.typed_overrides[gem.name] || "true"
538
- rbi_body_content = compiler.compile(gem)
539
- content = String.new
540
- content << rbi_header(
541
- "#{Config::DEFAULT_COMMAND} sync",
542
- reason: "types exported from the `#{gem.name}` gem",
543
- strictness: strictness
544
- )
545
-
546
- FileUtils.mkdir_p(config.outdir)
547
- filename = config.outpath / gem.rbi_file_name
548
-
549
- if rbi_body_content.strip.empty?
550
- content << EMPTY_RBI_COMMENT
551
- say("Done (empty output)", :yellow)
552
- else
553
- content << rbi_body_content
554
- say("Done", :green)
555
- end
556
- File.write(filename.to_s, content)
557
-
558
- T.unsafe(Pathname).glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
559
- remove(file) unless file.basename.to_s == gem.rbi_file_name
560
- end
561
- end
562
-
563
- sig do
564
- params(constant_name: String, contents: String, outpath: Pathname, quiet: T::Boolean)
565
- .returns(T.nilable(Pathname))
566
- end
567
- def compile_dsl_rbi(constant_name, contents, outpath: config.outpath, quiet: false)
568
- return if contents.nil?
569
-
570
- rbi_name = underscore(constant_name) + ".rbi"
571
- filename = outpath / rbi_name
572
-
573
- out = String.new
574
- out << rbi_header(
575
- "#{Config::DEFAULT_COMMAND} dsl #{constant_name}",
576
- reason: "dynamic methods in `#{constant_name}`"
577
- )
578
- out << contents
579
-
580
- FileUtils.mkdir_p(File.dirname(filename))
581
- File.write(filename, out)
582
-
583
- unless quiet
584
- say("Wrote: ", [:green])
585
- say(filename)
586
- end
587
-
588
- filename
589
- end
590
-
591
- sig { params(tmp_dir: Pathname).returns(T::Hash[String, Symbol]) }
592
- def verify_dsl_rbi(tmp_dir:)
593
- diff = {}
594
-
595
- existing_rbis = rbi_files_in(config.outpath)
596
- new_rbis = rbi_files_in(tmp_dir)
597
-
598
- added_files = (new_rbis - existing_rbis)
599
-
600
- added_files.each do |file|
601
- diff[file] = :added
602
- end
603
-
604
- removed_files = (existing_rbis - new_rbis)
605
-
606
- removed_files.each do |file|
607
- diff[file] = :removed
608
- end
609
-
610
- common_files = (existing_rbis & new_rbis)
611
-
612
- changed_files = common_files.map do |filename|
613
- filename unless FileUtils.identical?(config.outpath / filename, tmp_dir / filename)
614
- end.compact
615
-
616
- changed_files.each do |file|
617
- diff[file] = :changed
618
- end
619
-
620
- diff
621
- end
622
-
623
- sig { params(cause: Symbol, files: T::Array[String]).returns(String) }
624
- def build_error_for_files(cause, files)
625
- filenames = files.map do |file|
626
- config.outpath / file
627
- end.join("\n - ")
628
-
629
- " File(s) #{cause}:\n - #{filenames}"
630
- end
631
-
632
- sig { params(path: Pathname).returns(T::Array[Pathname]) }
633
- def rbi_files_in(path)
634
- Pathname.glob(path / "**/*.rbi").map do |file|
635
- file.relative_path_from(path)
636
- end.sort
637
- end
638
-
639
- sig { params(dir: Pathname).void }
640
- def perform_dsl_verification(dir)
641
- diff = verify_dsl_rbi(tmp_dir: dir)
642
-
643
- report_diff_and_exit_if_out_of_date(diff, "dsl")
644
- ensure
645
- FileUtils.remove_entry(dir)
646
- end
647
-
648
- sig { params(files: T::Set[Pathname]).void }
649
- def purge_stale_dsl_rbi_files(files)
650
- if files.any?
651
- say("Removing stale RBI files...")
652
-
653
- files.sort.each do |filename|
654
- remove(filename)
655
- end
656
- say("")
657
- end
658
- end
659
-
660
- sig { void }
661
- def perform_sync_verification
662
- diff = {}
663
-
664
- removed_rbis.each do |gem_name|
665
- filename = existing_rbi(gem_name)
666
- diff[filename] = :removed
667
- end
668
-
669
- added_rbis.each do |gem_name|
670
- filename = expected_rbi(gem_name)
671
- diff[filename] = gem_rbi_exists?(gem_name) ? :changed : :added
672
- end
673
-
674
- report_diff_and_exit_if_out_of_date(diff, "sync")
675
- end
676
-
677
- sig { params(diff: T::Hash[String, Symbol], command: String).void }
678
- def report_diff_and_exit_if_out_of_date(diff, command)
679
- if diff.empty?
680
- say("Nothing to do, all RBIs are up-to-date.")
681
- else
682
- say("RBI files are out-of-date. In your development environment, please run:", :green)
683
- say(" `#{Config::DEFAULT_COMMAND} #{command}`", [:green, :bold])
684
- say("Once it is complete, be sure to commit and push any changes", :green)
685
-
686
- say("")
687
-
688
- say("Reason:", [:red])
689
- diff.group_by(&:last).sort.each do |cause, diff_for_cause|
690
- say(build_error_for_files(cause, diff_for_cause.map(&:first)))
691
- end
692
-
693
- exit(1)
694
- end
695
- end
696
-
697
- sig { void }
698
- def abort_if_pending_migrations!
699
- return unless File.exist?("config/application.rb")
700
-
701
- Rails.application.load_tasks
702
- Rake::Task["db:abort_if_pending_migrations"].invoke if Rake::Task.task_defined?("db:abort_if_pending_migrations")
703
- end
704
-
705
- sig { params(class_name: String).returns(String) }
706
- def underscore(class_name)
707
- return class_name unless /[A-Z-]|::/.match?(class_name)
708
-
709
- word = class_name.to_s.gsub("::", "/")
710
- word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
711
- word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
712
- word.tr!("-", "_")
713
- word.downcase!
714
- word
715
- end
716
- end
717
- end