tapioca 0.5.0 → 0.5.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,362 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ module Generators
6
+ class Dsl < Base
7
+ sig do
8
+ params(
9
+ requested_constants: T::Array[String],
10
+ outpath: Pathname,
11
+ generators: T::Array[String],
12
+ exclude_generators: T::Array[String],
13
+ file_header: T::Boolean,
14
+ compiler_path: String,
15
+ tapioca_path: String,
16
+ default_command: String,
17
+ file_writer: Thor::Actions,
18
+ should_verify: T::Boolean,
19
+ quiet: T::Boolean,
20
+ verbose: T::Boolean
21
+ ).void
22
+ end
23
+ def initialize(
24
+ requested_constants:,
25
+ outpath:,
26
+ generators:,
27
+ exclude_generators:,
28
+ file_header:,
29
+ compiler_path:,
30
+ tapioca_path:,
31
+ default_command:,
32
+ file_writer: FileWriter.new,
33
+ should_verify: false,
34
+ quiet: false,
35
+ verbose: false
36
+ )
37
+ @requested_constants = requested_constants
38
+ @outpath = outpath
39
+ @generators = generators
40
+ @exclude_generators = exclude_generators
41
+ @file_header = file_header
42
+ @compiler_path = compiler_path
43
+ @tapioca_path = tapioca_path
44
+ @should_verify = should_verify
45
+ @quiet = quiet
46
+ @verbose = verbose
47
+
48
+ super(default_command: default_command, file_writer: file_writer)
49
+
50
+ @loader = T.let(nil, T.nilable(Loader))
51
+ end
52
+
53
+ sig { override.void }
54
+ def generate
55
+ load_application(eager_load: @requested_constants.empty?)
56
+ abort_if_pending_migrations!
57
+ load_dsl_generators
58
+
59
+ if @should_verify
60
+ say("Checking for out-of-date RBIs...")
61
+ else
62
+ say("Compiling DSL RBI files...")
63
+ end
64
+ say("")
65
+
66
+ outpath = @should_verify ? Pathname.new(Dir.mktmpdir) : @outpath
67
+ rbi_files_to_purge = existing_rbi_filenames(@requested_constants)
68
+
69
+ compiler = Compilers::DslCompiler.new(
70
+ requested_constants: constantize(@requested_constants),
71
+ requested_generators: constantize_generators(@generators),
72
+ excluded_generators: constantize_generators(@exclude_generators),
73
+ error_handler: ->(error) {
74
+ say_error(error, :bold, :red)
75
+ }
76
+ )
77
+
78
+ compiler.run do |constant, contents|
79
+ constant_name = T.must(Reflection.name_of(constant))
80
+
81
+ if @verbose && !@quiet
82
+ say_status(:processing, constant_name, :yellow)
83
+ end
84
+
85
+ filename = compile_dsl_rbi(
86
+ constant_name,
87
+ contents,
88
+ outpath: outpath,
89
+ quiet: @should_verify || @quiet && !@verbose
90
+ )
91
+
92
+ if filename
93
+ rbi_files_to_purge.delete(filename)
94
+ end
95
+ end
96
+ say("")
97
+
98
+ if @should_verify
99
+ perform_dsl_verification(outpath)
100
+ else
101
+ purge_stale_dsl_rbi_files(rbi_files_to_purge)
102
+
103
+ say("Done", :green)
104
+
105
+ say("All operations performed in working directory.", [:green, :bold])
106
+ say("Please review changes and commit them.", [:green, :bold])
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ sig { params(eager_load: T::Boolean).void }
113
+ def load_application(eager_load:)
114
+ say("Loading Rails application... ")
115
+
116
+ loader.load_rails_application(
117
+ environment_load: true,
118
+ eager_load: eager_load
119
+ )
120
+
121
+ say("Done", :green)
122
+ end
123
+
124
+ sig { void }
125
+ def abort_if_pending_migrations!
126
+ return unless File.exist?("config/application.rb")
127
+ return unless defined?(::Rake)
128
+
129
+ Rails.application.load_tasks
130
+ if Rake::Task.task_defined?("db:abort_if_pending_migrations")
131
+ Rake::Task["db:abort_if_pending_migrations"].invoke
132
+ end
133
+ end
134
+
135
+ sig { void }
136
+ def load_dsl_generators
137
+ say("Loading DSL generator classes... ")
138
+
139
+ Dir.glob([
140
+ "#{@compiler_path}/*.rb",
141
+ "#{@tapioca_path}/generators/**/*.rb",
142
+ ]).each do |generator|
143
+ require File.expand_path(generator)
144
+ end
145
+
146
+ say("Done", :green)
147
+ end
148
+
149
+ sig { params(requested_constants: T::Array[String], path: Pathname).returns(T::Set[Pathname]) }
150
+ def existing_rbi_filenames(requested_constants, path: @outpath)
151
+ filenames = if requested_constants.empty?
152
+ Pathname.glob(path / "**/*.rbi")
153
+ else
154
+ requested_constants.map do |constant_name|
155
+ dsl_rbi_filename(constant_name)
156
+ end
157
+ end
158
+
159
+ filenames.to_set
160
+ end
161
+
162
+ sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
163
+ def constantize(constant_names)
164
+ constant_map = constant_names.map do |name|
165
+ [name, Object.const_get(name)]
166
+ rescue NameError
167
+ [name, nil]
168
+ end.to_h
169
+
170
+ unprocessable_constants = constant_map.select { |_, v| v.nil? }
171
+ unless unprocessable_constants.empty?
172
+ unprocessable_constants.each do |name, _|
173
+ say("Error: Cannot find constant '#{name}'", :red)
174
+ remove(dsl_rbi_filename(name))
175
+ end
176
+
177
+ exit(1)
178
+ end
179
+
180
+ constant_map.values
181
+ end
182
+
183
+ sig { params(generator_names: T::Array[String]).returns(T::Array[T.class_of(Compilers::Dsl::Base)]) }
184
+ def constantize_generators(generator_names)
185
+ generator_map = generator_names.map do |name|
186
+ # Try to find built-in tapioca generator first, then globally defined generator. The
187
+ # explicit `break` ensures the class is returned, not the `potential_name`.
188
+ generator_klass = ["Tapioca::Compilers::Dsl::#{name}", name].find do |potential_name|
189
+ break Object.const_get(potential_name)
190
+ rescue NameError
191
+ # Skip if we can't find generator by the potential name
192
+ end
193
+
194
+ [name, generator_klass]
195
+ end.to_h
196
+
197
+ unprocessable_generators = generator_map.select { |_, v| v.nil? }
198
+ unless unprocessable_generators.empty?
199
+ unprocessable_generators.each do |name, _|
200
+ say("Error: Cannot find generator '#{name}'", :red)
201
+ end
202
+
203
+ exit(1)
204
+ end
205
+
206
+ generator_map.values
207
+ end
208
+
209
+ sig do
210
+ params(
211
+ constant_name: String,
212
+ rbi: RBI::File,
213
+ outpath: Pathname,
214
+ quiet: T::Boolean
215
+ ).returns(T.nilable(Pathname))
216
+ end
217
+ def compile_dsl_rbi(constant_name, rbi, outpath: @outpath, quiet: false)
218
+ return if rbi.empty?
219
+
220
+ filename = outpath / rbi_filename_for(constant_name)
221
+
222
+ rbi.set_file_header(
223
+ generate_command_for(constant_name),
224
+ reason: "dynamic methods in `#{constant_name}`",
225
+ display_heading: @file_header
226
+ )
227
+
228
+ create_file(filename, rbi.transformed_string, verbose: !quiet)
229
+
230
+ filename
231
+ end
232
+
233
+ sig { params(dir: Pathname).void }
234
+ def perform_dsl_verification(dir)
235
+ diff = verify_dsl_rbi(tmp_dir: dir)
236
+
237
+ report_diff_and_exit_if_out_of_date(diff, "dsl")
238
+ ensure
239
+ FileUtils.remove_entry(dir)
240
+ end
241
+
242
+ sig { params(files: T::Set[Pathname]).void }
243
+ def purge_stale_dsl_rbi_files(files)
244
+ if files.any?
245
+ say("Removing stale RBI files...")
246
+
247
+ files.sort.each do |filename|
248
+ remove(filename)
249
+ end
250
+ say("")
251
+ end
252
+ end
253
+
254
+ sig { params(constant_name: String).returns(Pathname) }
255
+ def dsl_rbi_filename(constant_name)
256
+ @outpath / "#{underscore(constant_name)}.rbi"
257
+ end
258
+
259
+ sig { params(filename: Pathname).void }
260
+ def remove(filename)
261
+ return unless filename.exist?
262
+ say("-- Removing: #{filename}")
263
+ filename.unlink
264
+ end
265
+
266
+ sig { params(tmp_dir: Pathname).returns(T::Hash[String, Symbol]) }
267
+ def verify_dsl_rbi(tmp_dir:)
268
+ diff = {}
269
+
270
+ existing_rbis = rbi_files_in(@outpath)
271
+ new_rbis = rbi_files_in(tmp_dir)
272
+
273
+ added_files = (new_rbis - existing_rbis)
274
+
275
+ added_files.each do |file|
276
+ diff[file] = :added
277
+ end
278
+
279
+ removed_files = (existing_rbis - new_rbis)
280
+
281
+ removed_files.each do |file|
282
+ diff[file] = :removed
283
+ end
284
+
285
+ common_files = (existing_rbis & new_rbis)
286
+
287
+ changed_files = common_files.map do |filename|
288
+ filename unless FileUtils.identical?(@outpath / filename, tmp_dir / filename)
289
+ end.compact
290
+
291
+ changed_files.each do |file|
292
+ diff[file] = :changed
293
+ end
294
+
295
+ diff
296
+ end
297
+
298
+ sig { params(cause: Symbol, files: T::Array[String]).returns(String) }
299
+ def build_error_for_files(cause, files)
300
+ filenames = files.map do |file|
301
+ @outpath / file
302
+ end.join("\n - ")
303
+
304
+ " File(s) #{cause}:\n - #{filenames}"
305
+ end
306
+
307
+ sig { params(diff: T::Hash[String, Symbol], command: String).void }
308
+ def report_diff_and_exit_if_out_of_date(diff, command)
309
+ if diff.empty?
310
+ say("Nothing to do, all RBIs are up-to-date.")
311
+ else
312
+ say("RBI files are out-of-date. In your development environment, please run:", :green)
313
+ say(" `#{@default_command} #{command}`", [:green, :bold])
314
+ say("Once it is complete, be sure to commit and push any changes", :green)
315
+
316
+ say("")
317
+
318
+ say("Reason:", [:red])
319
+ diff.group_by(&:last).sort.each do |cause, diff_for_cause|
320
+ say(build_error_for_files(cause, diff_for_cause.map(&:first)))
321
+ end
322
+
323
+ exit(1)
324
+ end
325
+ end
326
+
327
+ sig { params(path: Pathname).returns(T::Array[Pathname]) }
328
+ def rbi_files_in(path)
329
+ Pathname.glob(path / "**/*.rbi").map do |file|
330
+ file.relative_path_from(path)
331
+ end.sort
332
+ end
333
+
334
+ sig { returns(Loader) }
335
+ def loader
336
+ @loader ||= Loader.new
337
+ end
338
+
339
+ sig { params(class_name: String).returns(String) }
340
+ def underscore(class_name)
341
+ return class_name unless /[A-Z-]|::/.match?(class_name)
342
+
343
+ word = class_name.to_s.gsub("::", "/")
344
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
345
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
346
+ word.tr!("-", "_")
347
+ word.downcase!
348
+ word
349
+ end
350
+
351
+ sig { params(constant: String).returns(String) }
352
+ def rbi_filename_for(constant)
353
+ underscore(constant) + ".rbi"
354
+ end
355
+
356
+ sig { params(constant: String).returns(String) }
357
+ def generate_command_for(constant)
358
+ "#{@default_command} dsl #{constant}"
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,345 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ module Generators
6
+ class Gem < Base
7
+ sig do
8
+ params(
9
+ gem_names: T::Array[String],
10
+ gem_excludes: T::Array[String],
11
+ prerequire: T.nilable(String),
12
+ postrequire: String,
13
+ typed_overrides: T::Hash[String, String],
14
+ default_command: String,
15
+ outpath: Pathname,
16
+ file_header: T::Boolean,
17
+ doc: T::Boolean,
18
+ file_writer: Thor::Actions
19
+ ).void
20
+ end
21
+ def initialize(
22
+ gem_names:,
23
+ gem_excludes:,
24
+ prerequire:,
25
+ postrequire:,
26
+ typed_overrides:,
27
+ default_command:,
28
+ outpath:,
29
+ file_header:,
30
+ doc:,
31
+ file_writer: FileWriter.new
32
+ )
33
+ @gem_names = gem_names
34
+ @gem_excludes = gem_excludes
35
+ @prerequire = prerequire
36
+ @postrequire = postrequire
37
+ @typed_overrides = typed_overrides
38
+ @outpath = outpath
39
+ @file_header = file_header
40
+
41
+ super(default_command: default_command, file_writer: file_writer)
42
+
43
+ @loader = T.let(nil, T.nilable(Loader))
44
+ @bundle = T.let(nil, T.nilable(Gemfile))
45
+ @existing_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
46
+ @expected_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
47
+ @doc = T.let(doc, T::Boolean)
48
+ end
49
+
50
+ sig { override.void }
51
+ def generate
52
+ require_gem_file
53
+
54
+ gems_to_generate(@gem_names)
55
+ .reject { |gem| @gem_excludes.include?(gem.name) }
56
+ .each do |gem|
57
+ say("Processing '#{gem.name}' gem:", :green)
58
+ shell.indent do
59
+ compile_gem_rbi(gem)
60
+ puts
61
+ end
62
+ end
63
+
64
+ say("All operations performed in working directory.", [:green, :bold])
65
+ say("Please review changes and commit them.", [:green, :bold])
66
+ end
67
+
68
+ sig { params(should_verify: T::Boolean).void }
69
+ def sync(should_verify: false)
70
+ if should_verify
71
+ say("Checking for out-of-date RBIs...")
72
+ say("")
73
+ perform_sync_verification
74
+ return
75
+ end
76
+
77
+ anything_done = [
78
+ perform_removals,
79
+ perform_additions,
80
+ ].any?
81
+
82
+ if anything_done
83
+ say("All operations performed in working directory.", [:green, :bold])
84
+ say("Please review changes and commit them.", [:green, :bold])
85
+ else
86
+ say("No operations performed, all RBIs are up-to-date.", [:green, :bold])
87
+ end
88
+
89
+ puts
90
+ end
91
+
92
+ private
93
+
94
+ sig { returns(Loader) }
95
+ def loader
96
+ @loader ||= Loader.new
97
+ end
98
+
99
+ sig { returns(Gemfile) }
100
+ def bundle
101
+ @bundle ||= Gemfile.new
102
+ end
103
+
104
+ sig { void }
105
+ def require_gem_file
106
+ say("Requiring all gems to prepare for compiling... ")
107
+ begin
108
+ loader.load_bundle(bundle, @prerequire, @postrequire)
109
+ rescue LoadError => e
110
+ explain_failed_require(@postrequire, e)
111
+ exit(1)
112
+ end
113
+ say(" Done", :green)
114
+ unless bundle.missing_specs.empty?
115
+ say(" completed with missing specs: ")
116
+ say(bundle.missing_specs.join(", "), :yellow)
117
+ end
118
+ puts
119
+ end
120
+
121
+ sig { params(gem_names: T::Array[String]).returns(T::Array[Gemfile::GemSpec]) }
122
+ def gems_to_generate(gem_names)
123
+ return bundle.dependencies if gem_names.empty?
124
+
125
+ gem_names.map do |gem_name|
126
+ gem = bundle.gem(gem_name)
127
+ if gem.nil?
128
+ say("Error: Cannot find gem '#{gem_name}'", :red)
129
+ exit(1)
130
+ end
131
+ gem
132
+ end
133
+ end
134
+
135
+ sig { params(gem: Gemfile::GemSpec).void }
136
+ def compile_gem_rbi(gem)
137
+ gem_name = set_color(gem.name, :yellow, :bold)
138
+ say("Compiling #{gem_name}, this may take a few seconds... ")
139
+
140
+ rbi = RBI::File.new(strictness: @typed_overrides[gem.name] || "true")
141
+ rbi.set_file_header(
142
+ "#{@default_command} gem #{gem.name}",
143
+ reason: "types exported from the `#{gem.name}` gem",
144
+ display_heading: @file_header
145
+ )
146
+
147
+ Compilers::SymbolTableCompiler.new.compile(gem, rbi, 0, @doc)
148
+
149
+ if rbi.empty?
150
+ rbi.set_empty_body_content
151
+ say("Done (empty output)", :yellow)
152
+ else
153
+ say("Done", :green)
154
+ end
155
+
156
+ create_file(@outpath / gem.rbi_file_name, rbi.transformed_string)
157
+
158
+ T.unsafe(Pathname).glob((@outpath / "#{gem.name}@*.rbi").to_s) do |file|
159
+ remove(file) unless file.basename.to_s == gem.rbi_file_name
160
+ end
161
+ end
162
+
163
+ sig { void }
164
+ def perform_sync_verification
165
+ diff = {}
166
+
167
+ removed_rbis.each do |gem_name|
168
+ filename = existing_rbi(gem_name)
169
+ diff[filename] = :removed
170
+ end
171
+
172
+ added_rbis.each do |gem_name|
173
+ filename = expected_rbi(gem_name)
174
+ diff[filename] = gem_rbi_exists?(gem_name) ? :changed : :added
175
+ end
176
+
177
+ report_diff_and_exit_if_out_of_date(diff, "gem")
178
+ end
179
+
180
+ sig { void }
181
+ def perform_removals
182
+ say("Removing RBI files of gems that have been removed:", [:blue, :bold])
183
+ puts
184
+
185
+ anything_done = T.let(false, T::Boolean)
186
+
187
+ gems = removed_rbis
188
+
189
+ shell.indent do
190
+ if gems.empty?
191
+ say("Nothing to do.")
192
+ else
193
+ gems.each do |removed|
194
+ filename = existing_rbi(removed)
195
+ remove(filename)
196
+ end
197
+
198
+ anything_done = true
199
+ end
200
+ end
201
+
202
+ puts
203
+
204
+ anything_done
205
+ end
206
+
207
+ sig { void }
208
+ def perform_additions
209
+ say("Generating RBI files of gems that are added or updated:", [:blue, :bold])
210
+ puts
211
+
212
+ anything_done = T.let(false, T::Boolean)
213
+
214
+ gems = added_rbis
215
+
216
+ shell.indent do
217
+ if gems.empty?
218
+ say("Nothing to do.")
219
+ else
220
+ require_gem_file
221
+
222
+ gems.each do |gem_name|
223
+ filename = expected_rbi(gem_name)
224
+
225
+ if gem_rbi_exists?(gem_name)
226
+ old_filename = existing_rbi(gem_name)
227
+ move(old_filename, filename) unless old_filename == filename
228
+ end
229
+
230
+ gem = T.must(bundle.gem(gem_name))
231
+ compile_gem_rbi(gem)
232
+ puts
233
+ end
234
+ end
235
+
236
+ anything_done = true
237
+ end
238
+
239
+ puts
240
+
241
+ anything_done
242
+ end
243
+
244
+ sig { params(file: String, error: LoadError).void }
245
+ def explain_failed_require(file, error)
246
+ say_error("\n\nLoadError: #{error}", :bold, :red)
247
+ say_error("\nTapioca could not load all the gems required by your application.", :yellow)
248
+ say_error("If you populated ", :yellow)
249
+ say_error("#{file} ", :bold, :blue)
250
+ say_error("with ", :yellow)
251
+ say_error("`#{@default_command} require`", :bold, :blue)
252
+ say_error("you should probably review it and remove the faulty line.", :yellow)
253
+ end
254
+
255
+ sig { params(filename: Pathname).void }
256
+ def remove(filename)
257
+ return unless filename.exist?
258
+ say("-- Removing: #{filename}")
259
+ filename.unlink
260
+ end
261
+
262
+ sig { returns(T::Array[String]) }
263
+ def removed_rbis
264
+ (existing_rbis.keys - expected_rbis.keys).sort
265
+ end
266
+
267
+ sig { params(gem_name: String).returns(Pathname) }
268
+ def existing_rbi(gem_name)
269
+ gem_rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
270
+ end
271
+
272
+ sig { returns(T::Array[String]) }
273
+ def added_rbis
274
+ expected_rbis.select do |name, value|
275
+ existing_rbis[name] != value
276
+ end.keys.sort
277
+ end
278
+
279
+ sig { params(gem_name: String).returns(Pathname) }
280
+ def expected_rbi(gem_name)
281
+ gem_rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
282
+ end
283
+
284
+ sig { params(gem_name: String).returns(T::Boolean) }
285
+ def gem_rbi_exists?(gem_name)
286
+ existing_rbis.key?(gem_name)
287
+ end
288
+
289
+ sig { params(diff: T::Hash[String, Symbol], command: String).void }
290
+ def report_diff_and_exit_if_out_of_date(diff, command)
291
+ if diff.empty?
292
+ say("Nothing to do, all RBIs are up-to-date.")
293
+ else
294
+ say("RBI files are out-of-date. In your development environment, please run:", :green)
295
+ say(" `#{Config::DEFAULT_COMMAND} #{command}`", [:green, :bold])
296
+ say("Once it is complete, be sure to commit and push any changes", :green)
297
+
298
+ say("")
299
+
300
+ say("Reason:", [:red])
301
+ diff.group_by(&:last).sort.each do |cause, diff_for_cause|
302
+ say(build_error_for_files(cause, diff_for_cause.map(&:first)))
303
+ end
304
+
305
+ exit(1)
306
+ end
307
+ end
308
+
309
+ sig { params(old_filename: Pathname, new_filename: Pathname).void }
310
+ def move(old_filename, new_filename)
311
+ say("-> Moving: #{old_filename} to #{new_filename}")
312
+ old_filename.rename(new_filename.to_s)
313
+ end
314
+
315
+ sig { returns(T::Hash[String, String]) }
316
+ def existing_rbis
317
+ @existing_rbis ||= Pathname.glob((@outpath / "*@*.rbi").to_s)
318
+ .map { |f| T.cast(f.basename(".*").to_s.split("@", 2), [String, String]) }
319
+ .to_h
320
+ end
321
+
322
+ sig { returns(T::Hash[String, String]) }
323
+ def expected_rbis
324
+ @expected_rbis ||= bundle.dependencies
325
+ .reject { |gem| @gem_excludes.include?(gem.name) }
326
+ .map { |gem| [gem.name, gem.version.to_s] }
327
+ .to_h
328
+ end
329
+
330
+ sig { params(gem_name: String, version: String).returns(Pathname) }
331
+ def gem_rbi_filename(gem_name, version)
332
+ @outpath / "#{gem_name}@#{version}.rbi"
333
+ end
334
+
335
+ sig { params(cause: Symbol, files: T::Array[String]).returns(String) }
336
+ def build_error_for_files(cause, files)
337
+ filenames = files.map do |file|
338
+ @outpath / file
339
+ end.join("\n - ")
340
+
341
+ " File(s) #{cause}:\n - #{filenames}"
342
+ end
343
+ end
344
+ end
345
+ end