tapioca 0.5.1 → 0.5.5

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/lib/tapioca/cli.rb +54 -139
  4. data/lib/tapioca/compilers/dsl/active_model_secure_password.rb +101 -0
  5. data/lib/tapioca/compilers/dsl/active_record_enum.rb +1 -1
  6. data/lib/tapioca/compilers/dsl/active_record_fixtures.rb +86 -0
  7. data/lib/tapioca/compilers/dsl/active_record_typed_store.rb +41 -33
  8. data/lib/tapioca/compilers/dsl/active_support_concern.rb +0 -2
  9. data/lib/tapioca/compilers/dsl/base.rb +12 -0
  10. data/lib/tapioca/compilers/dsl/identity_cache.rb +0 -1
  11. data/lib/tapioca/compilers/dsl/mixed_in_class_attributes.rb +74 -0
  12. data/lib/tapioca/compilers/dsl/smart_properties.rb +4 -4
  13. data/lib/tapioca/compilers/dsl_compiler.rb +7 -6
  14. data/lib/tapioca/compilers/dynamic_mixin_compiler.rb +198 -0
  15. data/lib/tapioca/compilers/sorbet.rb +0 -1
  16. data/lib/tapioca/compilers/symbol_table/symbol_generator.rb +84 -153
  17. data/lib/tapioca/compilers/symbol_table_compiler.rb +5 -11
  18. data/lib/tapioca/config.rb +1 -0
  19. data/lib/tapioca/config_builder.rb +1 -0
  20. data/lib/tapioca/constant_locator.rb +7 -1
  21. data/lib/tapioca/gemfile.rb +11 -5
  22. data/lib/tapioca/generators/base.rb +61 -0
  23. data/lib/tapioca/generators/dsl.rb +362 -0
  24. data/lib/tapioca/generators/gem.rb +345 -0
  25. data/lib/tapioca/generators/init.rb +79 -0
  26. data/lib/tapioca/generators/require.rb +52 -0
  27. data/lib/tapioca/generators/todo.rb +76 -0
  28. data/lib/tapioca/generators.rb +9 -0
  29. data/lib/tapioca/internal.rb +1 -2
  30. data/lib/tapioca/loader.rb +2 -2
  31. data/lib/tapioca/rbi_ext/model.rb +44 -0
  32. data/lib/tapioca/reflection.rb +8 -1
  33. data/lib/tapioca/version.rb +1 -1
  34. data/lib/tapioca.rb +2 -0
  35. metadata +34 -12
  36. data/lib/tapioca/generator.rb +0 -717
@@ -14,6 +14,7 @@ module Tapioca
14
14
  const(:todos_path, String)
15
15
  const(:generators, T::Array[String])
16
16
  const(:file_header, T::Boolean, default: true)
17
+ const(:doc, T::Boolean, default: false)
17
18
 
18
19
  sig { returns(Pathname) }
19
20
  def outpath
@@ -67,6 +67,7 @@ module Tapioca
67
67
  "todos_path" => Config::DEFAULT_TODOSPATH,
68
68
  "generators" => [],
69
69
  "file_header" => true,
70
+ "doc" => false,
70
71
  }.freeze, T::Hash[String, T.untyped])
71
72
  end
72
73
  end
@@ -17,8 +17,14 @@ module Tapioca
17
17
  TracePoint.trace(:class) do |tp|
18
18
  unless tp.self.singleton_class?
19
19
  key = name_of(tp.self)
20
+ file = tp.path
21
+ if file == "(eval)"
22
+ file = T.must(caller_locations)
23
+ .drop_while { |loc| loc.path == "(eval)" }
24
+ .first&.path
25
+ end
20
26
  @class_files[key] ||= Set.new
21
- @class_files[key] << tp.path
27
+ @class_files[key] << file
22
28
  end
23
29
  end
24
30
 
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "bundler"
5
+ require "logger"
6
+ require "yard-sorbet"
5
7
 
6
8
  module Tapioca
7
9
  class Gemfile
@@ -9,10 +11,7 @@ module Tapioca
9
11
 
10
12
  Spec = T.type_alias do
11
13
  T.any(
12
- T.all(
13
- ::Bundler::StubSpecification,
14
- ::Bundler::RemoteSpecification
15
- ),
14
+ ::Bundler::StubSpecification,
16
15
  ::Gem::Specification
17
16
  )
18
17
  end
@@ -116,7 +115,9 @@ module Tapioca
116
115
  sig { returns(T::Array[Pathname]) }
117
116
  def files
118
117
  if default_gem?
119
- @spec.files.map do |file|
118
+ # `Bundler::RemoteSpecification` delegates missing methods to
119
+ # `Gem::Specification`, so `files` actually always exists on spec.
120
+ T.unsafe(@spec).files.map do |file|
120
121
  ruby_lib_dir.join(file)
121
122
  end
122
123
  else
@@ -145,6 +146,11 @@ module Tapioca
145
146
  end
146
147
  end
147
148
 
149
+ sig { void }
150
+ def parse_yard_docs
151
+ files.each { |path| YARD.parse(path.to_s, [], Logger::Severity::FATAL) }
152
+ end
153
+
148
154
  private
149
155
 
150
156
  sig { returns(T::Boolean) }
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # TODO: Remove me when logging logic has been abstracted.
5
+ require "thor"
6
+
7
+ module Tapioca
8
+ module Generators
9
+ class Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ class FileWriter < Thor
14
+ include Thor::Actions
15
+ end
16
+
17
+ # TODO: Remove me when logging logic has been abstracted
18
+ include Thor::Base
19
+
20
+ abstract!
21
+
22
+ sig { params(default_command: String, file_writer: Thor::Actions).void }
23
+ def initialize(default_command:, file_writer: FileWriter.new)
24
+ @file_writer = file_writer
25
+ @default_command = default_command
26
+ end
27
+
28
+ sig { abstract.void }
29
+ def generate; end
30
+
31
+ private
32
+
33
+ # TODO: Remove me when logging logic has been abstracted
34
+ sig { params(message: String, color: T.any(Symbol, T::Array[Symbol])).void }
35
+ def say_error(message = "", *color)
36
+ force_new_line = (message.to_s !~ /( |\t)\Z/)
37
+ # NOTE: This is a hack. We're no longer subclassing from Thor::Shell::Color
38
+ # so we no longer have access to the prepare_message call.
39
+ # We should update this to remove this.
40
+ buffer = shell.send(:prepare_message, *T.unsafe([message, *T.unsafe(color)]))
41
+ buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
42
+
43
+ $stderr.print(buffer)
44
+ $stderr.flush
45
+ end
46
+
47
+ sig do
48
+ params(
49
+ path: T.any(String, Pathname),
50
+ content: String,
51
+ force: T::Boolean,
52
+ skip: T::Boolean,
53
+ verbose: T::Boolean
54
+ ).void
55
+ end
56
+ def create_file(path, content, force: true, skip: false, verbose: true)
57
+ @file_writer.create_file(path, force: force, skip: skip, verbose: verbose) { content }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -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