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.
@@ -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