tapioca 0.2.8 → 0.4.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +25 -1
  3. data/README.md +23 -2
  4. data/Rakefile +15 -4
  5. data/lib/tapioca.rb +15 -9
  6. data/lib/tapioca/cli.rb +41 -12
  7. data/lib/tapioca/compilers/dsl/action_controller_helpers.rb +129 -0
  8. data/lib/tapioca/compilers/dsl/action_mailer.rb +65 -0
  9. data/lib/tapioca/compilers/dsl/active_record_associations.rb +285 -0
  10. data/lib/tapioca/compilers/dsl/active_record_columns.rb +387 -0
  11. data/lib/tapioca/compilers/dsl/active_record_enum.rb +112 -0
  12. data/lib/tapioca/compilers/dsl/active_record_identity_cache.rb +213 -0
  13. data/lib/tapioca/compilers/dsl/active_record_scope.rb +100 -0
  14. data/lib/tapioca/compilers/dsl/active_record_typed_store.rb +170 -0
  15. data/lib/tapioca/compilers/dsl/active_resource.rb +140 -0
  16. data/lib/tapioca/compilers/dsl/active_support_current_attributes.rb +126 -0
  17. data/lib/tapioca/compilers/dsl/base.rb +165 -0
  18. data/lib/tapioca/compilers/dsl/frozen_record.rb +96 -0
  19. data/lib/tapioca/compilers/dsl/protobuf.rb +144 -0
  20. data/lib/tapioca/compilers/dsl/smart_properties.rb +173 -0
  21. data/lib/tapioca/compilers/dsl/state_machines.rb +378 -0
  22. data/lib/tapioca/compilers/dsl/url_helpers.rb +83 -0
  23. data/lib/tapioca/compilers/dsl_compiler.rb +121 -0
  24. data/lib/tapioca/compilers/requires_compiler.rb +67 -0
  25. data/lib/tapioca/compilers/sorbet.rb +34 -0
  26. data/lib/tapioca/compilers/symbol_table/symbol_generator.rb +210 -50
  27. data/lib/tapioca/compilers/symbol_table/symbol_loader.rb +3 -17
  28. data/lib/tapioca/compilers/todos_compiler.rb +32 -0
  29. data/lib/tapioca/config.rb +42 -0
  30. data/lib/tapioca/config_builder.rb +75 -0
  31. data/lib/tapioca/constant_locator.rb +1 -0
  32. data/lib/tapioca/core_ext/class.rb +23 -0
  33. data/lib/tapioca/gemfile.rb +44 -9
  34. data/lib/tapioca/generator.rb +248 -70
  35. data/lib/tapioca/loader.rb +20 -9
  36. data/lib/tapioca/sorbet_config_parser.rb +77 -0
  37. data/lib/tapioca/version.rb +1 -1
  38. metadata +33 -51
@@ -2,16 +2,12 @@
2
2
  # typed: true
3
3
 
4
4
  require 'json'
5
- require 'pathname'
6
5
  require 'tempfile'
7
- require 'shellwords'
8
6
 
9
7
  module Tapioca
10
8
  module Compilers
11
9
  module SymbolTable
12
10
  module SymbolLoader
13
- SORBET = Pathname.new(Gem::Specification.find_by_name("sorbet-static").full_gem_path) / "libexec" / "sorbet"
14
-
15
11
  class << self
16
12
  extend(T::Sig)
17
13
 
@@ -33,7 +29,7 @@ module Tapioca
33
29
  file.write(Array(paths).join("\n"))
34
30
  file.flush
35
31
 
36
- symbol_table_json_from("@#{file.path}")
32
+ symbol_table_json_from("@#{file.path.shellescape}")
37
33
  end, T.nilable(String))
38
34
 
39
35
  return Set.new if output.nil? || output.empty?
@@ -44,7 +40,7 @@ module Tapioca
44
40
 
45
41
  def ignored_symbols
46
42
  unless @ignored_symbols
47
- output = symbol_table_json_from("''", table_type: "symbol-table-full-json")
43
+ output = symbol_table_json_from("-e ''", table_type: "symbol-table-full-json")
48
44
  json = JSON.parse(output)
49
45
  @ignored_symbols = SymbolTableParser.parse(json)
50
46
  end
@@ -53,17 +49,7 @@ module Tapioca
53
49
  end
54
50
 
55
51
  def symbol_table_json_from(input, table_type: "symbol-table-json")
56
- IO.popen(
57
- [
58
- SORBET,
59
- # We don't want to pick up any sorbet/config files in cwd
60
- "--no-config",
61
- "--print=#{table_type}",
62
- "--quiet",
63
- input,
64
- ].shelljoin,
65
- err: "/dev/null"
66
- ).read
52
+ Tapioca::Compilers::Sorbet.run("--no-config", "--print=#{table_type}", input)
67
53
  end
68
54
  end
69
55
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ # typed: strong
3
+
4
+ module Tapioca
5
+ module Compilers
6
+ # Taken from https://github.com/sorbet/sorbet/blob/master/gems/sorbet/lib/todo-rbi.rb
7
+ class TodosCompiler
8
+ extend(T::Sig)
9
+
10
+ sig do
11
+ returns(String)
12
+ end
13
+ def compile
14
+ list_todos.each_line.map do |line|
15
+ next if line.include?("<") || line.include?("class_of")
16
+ "module #{line.strip.gsub('T.untyped::', '')}; end"
17
+ end.compact.join("\n")
18
+ end
19
+
20
+ private
21
+
22
+ sig { returns(String) }
23
+ def list_todos
24
+ Tapioca::Compilers::Sorbet.run(
25
+ "--print=missing-constants",
26
+ "--stdout-hup-hack",
27
+ "--no-error-count"
28
+ ).strip
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ class Config < T::Struct
6
+ extend(T::Sig)
7
+
8
+ const(:outdir, String)
9
+ const(:prerequire, T.nilable(String))
10
+ const(:postrequire, String)
11
+ const(:generate_command, String)
12
+ const(:exclude, T::Array[String])
13
+ const(:typed_overrides, T::Hash[String, String])
14
+ const(:todos_path, String)
15
+ const(:generators, T::Array[String])
16
+
17
+ sig { returns(Pathname) }
18
+ def outpath
19
+ @outpath = T.let(@outpath, T.nilable(Pathname))
20
+ @outpath ||= Pathname.new(outdir)
21
+ end
22
+
23
+ private_class_method :new
24
+
25
+ SORBET_PATH = T.let("sorbet", String)
26
+ SORBET_CONFIG = T.let("#{SORBET_PATH}/config", String)
27
+ TAPIOCA_PATH = T.let("#{SORBET_PATH}/tapioca", String)
28
+ TAPIOCA_CONFIG = T.let("#{TAPIOCA_PATH}/config.yml", String)
29
+
30
+ DEFAULT_POSTREQUIRE = T.let("#{TAPIOCA_PATH}/require.rb", String)
31
+ DEFAULT_RBIDIR = T.let("#{SORBET_PATH}/rbi", String)
32
+ DEFAULT_DSLDIR = T.let("#{DEFAULT_RBIDIR}/dsl", String)
33
+ DEFAULT_GEMDIR = T.let("#{DEFAULT_RBIDIR}/gems", String)
34
+ DEFAULT_TODOSPATH = T.let("#{DEFAULT_RBIDIR}/todo.rbi", String)
35
+
36
+ DEFAULT_OVERRIDES = T.let({
37
+ # ActiveSupport overrides some core methods with different signatures
38
+ # so we generate a typed: false RBI for it to suppress errors
39
+ "activesupport" => "false",
40
+ }.freeze, T::Hash[String, String])
41
+ end
42
+ end
@@ -0,0 +1,75 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'yaml'
5
+
6
+ module Tapioca
7
+ class ConfigBuilder
8
+ class << self
9
+ extend(T::Sig)
10
+
11
+ sig { params(command: Symbol, options: T::Hash[String, T.untyped]).returns(Config) }
12
+ def from_options(command, options)
13
+ Config.from_hash(
14
+ merge_options(default_options(command), config_options, options)
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ sig { returns(T::Hash[String, T.untyped]) }
21
+ def config_options
22
+ if File.exist?(Config::TAPIOCA_CONFIG)
23
+ YAML.load_file(Config::TAPIOCA_CONFIG, fallback: {})
24
+ else
25
+ {}
26
+ end
27
+ end
28
+
29
+ sig { params(command: Symbol).returns(T::Hash[String, T.untyped]) }
30
+ def default_options(command)
31
+ default_outdir = case command
32
+ when :sync, :generate
33
+ Config::DEFAULT_GEMDIR
34
+ when :dsl
35
+ Config::DEFAULT_DSLDIR
36
+ else
37
+ Config::SORBET_PATH
38
+ end
39
+
40
+ DEFAULT_OPTIONS.merge("outdir" => default_outdir)
41
+ end
42
+
43
+ sig { returns(String) }
44
+ def default_command
45
+ command = File.basename($PROGRAM_NAME)
46
+ args = ARGV.join(" ")
47
+
48
+ "#{command} #{args}".strip
49
+ end
50
+
51
+ sig { params(options: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
52
+ def merge_options(*options)
53
+ options.each_with_object({}) do |option, result|
54
+ result.merge!(option) do |_, this_val, other_val|
55
+ if this_val.is_a?(Hash) && other_val.is_a?(Hash)
56
+ this_val.merge(other_val)
57
+ else
58
+ other_val
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ DEFAULT_OPTIONS = T.let({
66
+ "postrequire" => Config::DEFAULT_POSTREQUIRE,
67
+ "outdir" => nil,
68
+ "generate_command" => default_command,
69
+ "exclude" => [],
70
+ "typed_overrides" => Config::DEFAULT_OVERRIDES,
71
+ "todos_path" => Config::DEFAULT_TODOSPATH,
72
+ "generators" => [],
73
+ }.freeze, T::Hash[String, T.untyped])
74
+ end
75
+ end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'set'
@@ -0,0 +1,23 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ class Class
5
+ # Returns an array with all classes that are < than its receiver.
6
+ #
7
+ # class C; end
8
+ # C.descendants # => []
9
+ #
10
+ # class B < C; end
11
+ # C.descendants # => [B]
12
+ #
13
+ # class A < B; end
14
+ # C.descendants # => [B, A]
15
+ #
16
+ # class D < C; end
17
+ # C.descendants # => [B, A, D]
18
+ def descendants
19
+ ObjectSpace.each_object(singleton_class).reject do |k|
20
+ k.singleton_class? || k == self
21
+ end
22
+ end
23
+ end
@@ -28,7 +28,7 @@ module Tapioca
28
28
  sig { returns(T::Array[Gem]) }
29
29
  def dependencies
30
30
  @dependencies ||= begin
31
- specs = definition.specs.to_a
31
+ specs = definition.locked_gems.specs.to_a
32
32
 
33
33
  definition
34
34
  .resolve
@@ -79,17 +79,18 @@ module Tapioca
79
79
  extend(T::Sig)
80
80
 
81
81
  IGNORED_GEMS = T.let(%w{
82
- sorbet sorbet-static sorbet-runtime tapioca
82
+ sorbet sorbet-static sorbet-runtime
83
83
  }.freeze, T::Array[String])
84
84
 
85
85
  sig { returns(String) }
86
- attr_reader :full_gem_path
86
+ attr_reader :full_gem_path, :version
87
87
 
88
88
  sig { params(spec: Spec).void }
89
89
  def initialize(spec)
90
90
  @spec = T.let(spec, Tapioca::Gemfile::Spec)
91
- real_gem_path = File.realpath(@spec.full_gem_path.to_s)
91
+ real_gem_path = to_realpath(@spec.full_gem_path)
92
92
  @full_gem_path = T.let(real_gem_path, String)
93
+ @version = T.let(version_string, String)
93
94
  end
94
95
 
95
96
  sig { params(gemfile_dir: String).returns(T::Boolean) }
@@ -109,18 +110,52 @@ module Tapioca
109
110
  @spec.name
110
111
  end
111
112
 
112
- sig { returns(::Gem::Version) }
113
- def version
114
- @spec.version
115
- end
116
-
117
113
  sig { returns(String) }
118
114
  def rbi_file_name
119
115
  "#{name}@#{version}.rbi"
120
116
  end
121
117
 
118
+ sig { params(path: String).returns(T::Boolean) }
119
+ def contains_path?(path)
120
+ to_realpath(path).start_with?(full_gem_path) || has_parent_gemspec?(path)
121
+ end
122
+
122
123
  private
123
124
 
125
+ sig { returns(String) }
126
+ def version_string
127
+ version = @spec.version.to_s
128
+ version += "-#{@spec.source.revision}" if Bundler::Source::Git === @spec.source
129
+ version
130
+ end
131
+
132
+ sig { params(path: String).returns(T::Boolean) }
133
+ def has_parent_gemspec?(path)
134
+ # For some Git installed gems the location of the loaded file can
135
+ # be different from the gem path as indicated by the spec file
136
+ #
137
+ # To compensate for these cases, we walk up the directory hierarchy
138
+ # from the given file and try to match a <gem-name.gemspec> file in
139
+ # one of those folders to see if the path really belongs in the given gem
140
+ # or not.
141
+ return false unless Bundler::Source::Git === @spec.source
142
+ parent = Pathname.new(path)
143
+
144
+ until parent.root?
145
+ parent = parent.parent.expand_path
146
+ return true if parent.join("#{name}.gemspec").file?
147
+ end
148
+
149
+ false
150
+ end
151
+
152
+ sig { params(path: T.any(String, Pathname)).returns(String) }
153
+ def to_realpath(path)
154
+ path_string = path.to_s
155
+ path_string = File.realpath(path_string) if File.exist?(path_string)
156
+ path_string
157
+ end
158
+
124
159
  sig { returns(T::Boolean) }
125
160
  def gem_ignored?
126
161
  IGNORED_GEMS.include?(name)
@@ -8,41 +8,16 @@ module Tapioca
8
8
  class Generator < ::Thor::Shell::Color
9
9
  extend(T::Sig)
10
10
 
11
- SORBET_CONFIG = "sorbet/config"
12
- DEFAULT_POSTREQUIRE = "sorbet/tapioca/require.rb"
13
- DEFAULT_OUTDIR = "sorbet/rbi/gems"
14
- DEFAULT_OVERRIDES = T.let({
15
- # ActiveSupport overrides some core methods with different signatures
16
- # so we generate a typed: false RBI for it to suppress errors
17
- "activesupport" => "false",
18
- }.freeze, T::Hash[String, String])
19
-
20
- sig { returns(Pathname) }
21
- attr_reader :outdir
22
- sig { returns(T.nilable(String)) }
23
- attr_reader :prerequire
24
- sig { returns(T.nilable(String)) }
25
- attr_reader :postrequire
26
- sig { returns(String) }
27
- attr_reader :command
28
- sig { returns(T::Hash[String, String]) }
29
- attr_reader :typed_overrides
11
+ sig { returns(Config) }
12
+ attr_reader :config
30
13
 
31
14
  sig do
32
15
  params(
33
- outdir: T.nilable(String),
34
- prerequire: T.nilable(String),
35
- postrequire: T.nilable(String),
36
- command: T.nilable(String),
37
- typed_overrides: T.nilable(T::Hash[String, String])
16
+ config: Config
38
17
  ).void
39
18
  end
40
- def initialize(outdir: nil, prerequire: nil, postrequire: nil, command: nil, typed_overrides: nil)
41
- @outdir = T.let(Pathname.new(outdir || DEFAULT_OUTDIR), Pathname)
42
- @prerequire = T.let(prerequire, T.nilable(String))
43
- @postrequire = T.let(postrequire || DEFAULT_POSTREQUIRE, T.nilable(String))
44
- @command = T.let(command || default_command, String)
45
- @typed_overrides = T.let(typed_overrides || {}, T::Hash[String, String])
19
+ def initialize(config)
20
+ @config = config
46
21
  @bundle = T.let(nil, T.nilable(Gemfile))
47
22
  @loader = T.let(nil, T.nilable(Loader))
48
23
  @compiler = T.let(nil, T.nilable(Compilers::SymbolTableCompiler))
@@ -55,14 +30,115 @@ module Tapioca
55
30
  def build_gem_rbis(gem_names)
56
31
  require_gem_file
57
32
 
58
- gems_to_generate(gem_names).map do |gem|
59
- say("Processing '#{gem.name}' gem:", :green)
60
- indent do
61
- compile_rbi(gem)
62
- puts
33
+ gems_to_generate(gem_names)
34
+ .reject { |gem| config.exclude.include?(gem.name) }
35
+ .each do |gem|
36
+ say("Processing '#{gem.name}' gem:", :green)
37
+ indent do
38
+ compile_gem_rbi(gem)
39
+ puts
40
+ end
63
41
  end
42
+
43
+ say("All operations performed in working directory.", [:green, :bold])
44
+ say("Please review changes and commit them.", [:green, :bold])
45
+ end
46
+
47
+ sig { void }
48
+ def build_requires
49
+ requires_path = Config::DEFAULT_POSTREQUIRE
50
+ compiler = Compilers::RequiresCompiler.new(Config::SORBET_CONFIG)
51
+ name = set_color(requires_path, :yellow, :bold)
52
+ say("Compiling #{name}, this may take a few seconds... ")
53
+
54
+ rb_string = compiler.compile
55
+ if rb_string.empty?
56
+ say("Nothing to do", :green)
57
+ return
58
+ end
59
+
60
+ # Clean all existing requires before regenerating the list so we update
61
+ # it with the new one found in the client code and remove the old ones.
62
+ File.delete(requires_path) if File.exist?(requires_path)
63
+
64
+ content = String.new
65
+ content << rbi_header(
66
+ config.generate_command,
67
+ reason: "explicit gem requires",
68
+ strictness: "false"
69
+ )
70
+ content << rb_string
71
+
72
+ outdir = File.dirname(requires_path)
73
+ FileUtils.mkdir_p(outdir)
74
+ File.write(requires_path, content)
75
+
76
+ say("Done", :green)
77
+
78
+ say("All requires from this application have been written to #{name}.", [:green, :bold])
79
+ cmd = set_color("tapioca sync", :yellow, :bold)
80
+ say("Please review changes and commit them, then run #{cmd}.", [:green, :bold])
81
+ end
82
+
83
+ sig { void }
84
+ def build_todos
85
+ todos_path = config.todos_path
86
+ compiler = Compilers::TodosCompiler.new
87
+ name = set_color(todos_path, :yellow, :bold)
88
+ say("Compiling #{name}, this may take a few seconds... ")
89
+
90
+ # Clean all existing unresolved constants before regenerating the list
91
+ # so Sorbet won't grab them as already resolved.
92
+ File.delete(todos_path) if File.exist?(todos_path)
93
+
94
+ rbi_string = compiler.compile
95
+ if rbi_string.empty?
96
+ say("Nothing to do", :green)
97
+ return
98
+ end
99
+
100
+ content = String.new
101
+ content << rbi_header(
102
+ config.generate_command,
103
+ reason: "unresolved constants",
104
+ strictness: "false"
105
+ )
106
+ content << rbi_string
107
+ content << "\n"
108
+
109
+ outdir = File.dirname(todos_path)
110
+ FileUtils.mkdir_p(outdir)
111
+ File.write(todos_path, content)
112
+
113
+ say("Done", :green)
114
+
115
+ say("All unresolved constants have been written to #{name}.", [:green, :bold])
116
+ say("Please review changes and commit them.", [:green, :bold])
117
+ end
118
+
119
+ sig { params(requested_constants: T::Array[String]).void }
120
+ def build_dsl(requested_constants)
121
+ load_application(eager_load: requested_constants.empty?)
122
+ load_dsl_generators
123
+
124
+ say("Compiling DSL RBI files...")
125
+ say("")
126
+
127
+ compiler = Compilers::DslCompiler.new(
128
+ requested_constants: constantize(requested_constants),
129
+ requested_generators: config.generators,
130
+ error_handler: ->(error) {
131
+ say_error(error, :bold, :red)
132
+ }
133
+ )
134
+
135
+ compiler.run do |constant, contents|
136
+ compile_dsl_rbi(constant, contents)
64
137
  end
65
138
 
139
+ say("")
140
+ say("Done", :green)
141
+
66
142
  say("All operations performed in working directory.", [:green, :bold])
67
143
  say("Please review changes and commit them.", [:green, :bold])
68
144
  end
@@ -86,13 +162,10 @@ module Tapioca
86
162
 
87
163
  private
88
164
 
89
- sig { returns(String) }
90
- def default_command
91
- command = File.basename($PROGRAM_NAME)
92
- args = ARGV.join(" ")
93
-
94
- "#{command} #{args}"
95
- end
165
+ EMPTY_RBI_COMMENT = <<~CONTENT
166
+ # THIS IS AN EMPTY RBI FILE.
167
+ # see https://github.com/Shopify/tapioca/blob/master/README.md#manual-gem-requires
168
+ CONTENT
96
169
 
97
170
  sig { returns(Gemfile) }
98
171
  def bundle
@@ -112,42 +185,111 @@ module Tapioca
112
185
  sig { void }
113
186
  def require_gem_file
114
187
  say("Requiring all gems to prepare for compiling... ")
115
- loader.load_bundle(prerequire, postrequire)
188
+ begin
189
+ loader.load_bundle(config.prerequire, config.postrequire)
190
+ rescue LoadError => e
191
+ explain_failed_require(config.postrequire, e)
192
+ exit(1)
193
+ end
116
194
  say(" Done", :green)
117
195
  puts
118
196
  end
119
197
 
198
+ sig { params(file: String, error: LoadError).void }
199
+ def explain_failed_require(file, error)
200
+ say_error("\n\nLoadError: #{error}", :bold, :red)
201
+ say_error("\nTapioca could not load all the gems required by your application.", :yellow)
202
+ say_error("If you populated ", :yellow)
203
+ say_error("#{file} ", :bold, :blue)
204
+ say_error("with ", :yellow)
205
+ say_error("tapioca require", :bold, :blue)
206
+ say_error("you should probably review it and remove the faulty line.", :yellow)
207
+ end
208
+
209
+ sig do
210
+ params(
211
+ message: String,
212
+ color: T.any(Symbol, T::Array[Symbol]),
213
+ ).void
214
+ end
215
+ def say_error(message = "", *color)
216
+ force_new_line = (message.to_s !~ /( |\t)\Z/)
217
+ buffer = prepare_message(*T.unsafe([message, *T.unsafe(color)]))
218
+ buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
219
+
220
+ stderr.print(buffer)
221
+ stderr.flush
222
+ end
223
+
224
+ sig { params(eager_load: T::Boolean).void }
225
+ def load_application(eager_load:)
226
+ say("Loading Rails application... ")
227
+
228
+ loader.load_rails(
229
+ environment_load: true,
230
+ eager_load: eager_load
231
+ )
232
+
233
+ say("Done", :green)
234
+ end
235
+
236
+ sig { void }
237
+ def load_dsl_generators
238
+ say("Loading DSL generator classes... ")
239
+
240
+ Dir.glob([
241
+ "#{__dir__}/compilers/dsl/*.rb",
242
+ "#{Config::TAPIOCA_PATH}/generators/**/*.rb",
243
+ ]).each do |generator|
244
+ require File.expand_path(generator)
245
+ end
246
+
247
+ say("Done", :green)
248
+ end
249
+
250
+ sig { params(constant_names: T::Array[String]).returns(T::Array[Module]) }
251
+ def constantize(constant_names)
252
+ constant_names.map do |name|
253
+ begin
254
+ name.constantize
255
+ rescue NameError
256
+ nil
257
+ end
258
+ end.compact
259
+ end
260
+
120
261
  sig { returns(T::Hash[String, String]) }
121
262
  def existing_rbis
122
- @existing_rbis ||= Pathname.glob((Pathname.new(outdir) / "*@*.rbi").to_s)
123
- .map { |f| f.basename(".*").to_s.split('@') }
263
+ @existing_rbis ||= Pathname.glob((config.outpath / "*@*.rbi").to_s)
264
+ .map { |f| T.cast(f.basename(".*").to_s.split('@', 2), [String, String]) }
124
265
  .to_h
125
266
  end
126
267
 
127
268
  sig { returns(T::Hash[String, String]) }
128
269
  def expected_rbis
129
270
  @expected_rbis ||= bundle.dependencies
271
+ .reject { |gem| config.exclude.include?(gem.name) }
130
272
  .map { |gem| [gem.name, gem.version.to_s] }
131
273
  .to_h
132
274
  end
133
275
 
134
276
  sig { params(gem_name: String, version: String).returns(Pathname) }
135
- def rbi_filename(gem_name, version)
136
- outdir / "#{gem_name}@#{version}.rbi"
277
+ def gem_rbi_filename(gem_name, version)
278
+ config.outpath / "#{gem_name}@#{version}.rbi"
137
279
  end
138
280
 
139
281
  sig { params(gem_name: String).returns(Pathname) }
140
282
  def existing_rbi(gem_name)
141
- rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
283
+ gem_rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
142
284
  end
143
285
 
144
286
  sig { params(gem_name: String).returns(Pathname) }
145
287
  def expected_rbi(gem_name)
146
- rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
288
+ gem_rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
147
289
  end
148
290
 
149
291
  sig { params(gem_name: String).returns(T::Boolean) }
150
- def rbi_exists?(gem_name)
292
+ def gem_rbi_exists?(gem_name)
151
293
  existing_rbis.key?(gem_name)
152
294
  end
153
295
 
@@ -225,13 +367,13 @@ module Tapioca
225
367
  gems.each do |gem_name|
226
368
  filename = expected_rbi(gem_name)
227
369
 
228
- if rbi_exists?(gem_name)
370
+ if gem_rbi_exists?(gem_name)
229
371
  old_filename = existing_rbi(gem_name)
230
372
  move(old_filename, filename) unless old_filename == filename
231
373
  end
232
374
 
233
375
  gem = T.must(bundle.gem(gem_name))
234
- compile_rbi(gem)
376
+ compile_gem_rbi(gem)
235
377
  add(filename)
236
378
 
237
379
  puts
@@ -263,37 +405,73 @@ module Tapioca
263
405
  end
264
406
  end
265
407
 
266
- sig { params(command: String, typed_sigil: String).returns(String) }
267
- def rbi_header(command, typed_sigil)
268
- <<~HEAD
269
- # This file is autogenerated. Do not edit it by hand. Regenerate it with:
270
- # #{command}
408
+ sig { params(command: String, reason: T.nilable(String), strictness: T.nilable(String)).returns(String) }
409
+ def rbi_header(command, reason: nil, strictness: nil)
410
+ statement = <<~HEAD
411
+ # DO NOT EDIT MANUALLY
412
+ # This is an autogenerated file for #{reason}.
413
+ # Please instead update this file by running `#{command}`.
414
+ HEAD
271
415
 
272
- # typed: #{typed_sigil}
416
+ sigil = <<~SIGIL if strictness
417
+ # typed: #{strictness}
418
+ SIGIL
273
419
 
274
- HEAD
420
+ [statement, sigil].compact.join("\n").strip.concat("\n\n")
275
421
  end
276
422
 
277
423
  sig { params(gem: Gemfile::Gem).void }
278
- def compile_rbi(gem)
424
+ def compile_gem_rbi(gem)
279
425
  compiler = Compilers::SymbolTableCompiler.new
280
426
  gem_name = set_color(gem.name, :yellow, :bold)
281
427
  say("Compiling #{gem_name}, this may take a few seconds... ")
282
428
 
283
- typed_sigil = typed_overrides[gem.name] || DEFAULT_OVERRIDES[gem.name] || "true"
284
-
285
- content = compiler.compile(gem)
286
- content.prepend(rbi_header(command, typed_sigil))
287
-
288
- FileUtils.mkdir_p(outdir)
289
- filename = outdir / gem.rbi_file_name
429
+ strictness = config.typed_overrides[gem.name] || "true"
430
+ rbi_body_content = compiler.compile(gem)
431
+ content = String.new
432
+ content << rbi_header(
433
+ config.generate_command,
434
+ reason: "types exported from the `#{gem.name}` gem",
435
+ strictness: strictness
436
+ )
437
+
438
+ FileUtils.mkdir_p(config.outdir)
439
+ filename = config.outpath / gem.rbi_file_name
440
+
441
+ if rbi_body_content.strip.empty?
442
+ content << EMPTY_RBI_COMMENT
443
+ say("Done (empty output)", :yellow)
444
+ else
445
+ content << rbi_body_content
446
+ say("Done", :green)
447
+ end
290
448
  File.write(filename.to_s, content)
291
449
 
292
- say("Done", :green)
293
-
294
- Pathname.glob((outdir / "#{gem.name}@*.rbi").to_s) do |file|
450
+ Pathname.glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
295
451
  remove(file) unless file.basename.to_s == gem.rbi_file_name
296
452
  end
297
453
  end
454
+
455
+ sig { params(constant: Module, contents: String).void }
456
+ def compile_dsl_rbi(constant, contents)
457
+ return if contents.nil?
458
+
459
+ command = format(config.generate_command, constant.name)
460
+ constant_name = Module.instance_method(:name).bind(constant).call
461
+ rbi_name = constant_name.underscore + ".rbi"
462
+ filename = config.outpath / rbi_name
463
+
464
+ out = String.new
465
+ out << rbi_header(
466
+ command,
467
+ reason: "dynamic methods in `#{constant.name}`"
468
+ )
469
+ out << contents
470
+
471
+ FileUtils.mkdir_p(File.dirname(filename))
472
+ File.write(filename, out)
473
+ say("Wrote: ", [:green])
474
+ say(filename)
475
+ end
298
476
  end
299
477
  end