tapioca 0.5.0 → 0.5.4

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