class Tapioca::Generator

Constants

EMPTY_RBI_COMMENT

Attributes

config[R]

Public Class Methods

new(config) click to toggle source
Calls superclass method
# File lib/tapioca/generator.rb, line 19
def initialize(config)
  @config = config
  @bundle = T.let(nil, T.nilable(Gemfile))
  @loader = T.let(nil, T.nilable(Loader))
  @compiler = T.let(nil, T.nilable(Compilers::SymbolTableCompiler))
  @existing_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
  @expected_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
  super()
end

Public Instance Methods

build_dsl(requested_constants, should_verify: false, quiet: false, verbose: false) click to toggle source
# File lib/tapioca/generator.rb, line 124
def build_dsl(requested_constants, should_verify: false, quiet: false, verbose: false)
  load_application(eager_load: requested_constants.empty?)
  abort_if_pending_migrations!
  load_dsl_generators

  if should_verify
    say("Checking for out-of-date RBIs...")
  else
    say("Compiling DSL RBI files...")
  end
  say("")

  outpath = should_verify ? Pathname.new(Dir.mktmpdir) : config.outpath
  rbi_files_to_purge = existing_rbi_filenames(requested_constants)

  compiler = Compilers::DslCompiler.new(
    requested_constants: constantize(requested_constants),
    requested_generators: constantize_generators(config.generators),
    excluded_generators: constantize_generators(config.exclude_generators),
    error_handler: ->(error) {
      say_error(error, :bold, :red)
    }
  )

  compiler.run do |constant, contents|
    constant_name = T.must(Reflection.name_of(constant))

    if verbose && !quiet
      say("Processing: ", [:yellow])
      say(constant_name)
    end

    filename = compile_dsl_rbi(
      constant_name,
      contents,
      outpath: outpath,
      quiet: should_verify || quiet && !verbose
    )

    if filename
      rbi_files_to_purge.delete(filename)
    end
  end
  say("")

  if should_verify
    perform_dsl_verification(outpath)
  else
    purge_stale_dsl_rbi_files(rbi_files_to_purge)

    say("Done", :green)

    say("All operations performed in working directory.", [:green, :bold])
    say("Please review changes and commit them.", [:green, :bold])
  end
end
build_gem_rbis(gem_names) click to toggle source
# File lib/tapioca/generator.rb, line 30
def build_gem_rbis(gem_names)
  require_gem_file

  gems_to_generate(gem_names)
    .reject { |gem| config.exclude.include?(gem.name) }
    .each do |gem|
      say("Processing '#{gem.name}' gem:", :green)
      indent do
        compile_gem_rbi(gem)
        puts
      end
    end

  say("All operations performed in working directory.", [:green, :bold])
  say("Please review changes and commit them.", [:green, :bold])
end
build_requires() click to toggle source
# File lib/tapioca/generator.rb, line 48
def build_requires
  requires_path = Config::DEFAULT_POSTREQUIRE
  compiler = Compilers::RequiresCompiler.new(Config::SORBET_CONFIG)
  name = set_color(requires_path, :yellow, :bold)
  say("Compiling #{name}, this may take a few seconds... ")

  rb_string = compiler.compile
  if rb_string.empty?
    say("Nothing to do", :green)
    return
  end

  # Clean all existing requires before regenerating the list so we update
  # it with the new one found in the client code and remove the old ones.
  File.delete(requires_path) if File.exist?(requires_path)

  content = String.new
  content << "# typed: true\n"
  content << "# frozen_string_literal: true\n\n"
  content << rb_string

  outdir = File.dirname(requires_path)
  FileUtils.mkdir_p(outdir)
  File.write(requires_path, content)

  say("Done", :green)

  say("All requires from this application have been written to #{name}.", [:green, :bold])
  cmd = set_color("#{Config::DEFAULT_COMMAND} sync", :yellow, :bold)
  say("Please review changes and commit them, then run `#{cmd}`.", [:green, :bold])
end
build_todos() click to toggle source
# File lib/tapioca/generator.rb, line 81
def build_todos
  todos_path = config.todos_path
  compiler = Compilers::TodosCompiler.new
  name = set_color(todos_path, :yellow, :bold)
  say("Compiling #{name}, this may take a few seconds... ")

  # Clean all existing unresolved constants before regenerating the list
  # so Sorbet won't grab them as already resolved.
  File.delete(todos_path) if File.exist?(todos_path)

  rbi_string = compiler.compile
  if rbi_string.empty?
    say("Nothing to do", :green)
    return
  end

  content = String.new
  content << rbi_header(
    "#{Config::DEFAULT_COMMAND} todo",
    reason: "unresolved constants",
    strictness: "false"
  )
  content << rbi_string
  content << "\n"

  outdir = File.dirname(todos_path)
  FileUtils.mkdir_p(outdir)
  File.write(todos_path, content)

  say("Done", :green)

  say("All unresolved constants have been written to #{name}.", [:green, :bold])
  say("Please review changes and commit them.", [:green, :bold])
end
sync_rbis_with_gemfile(should_verify: false) click to toggle source
# File lib/tapioca/generator.rb, line 182
def sync_rbis_with_gemfile(should_verify: false)
  if should_verify
    say("Checking for out-of-date RBIs...")
    say("")
    perform_sync_verification
    return
  end

  anything_done = [
    perform_removals,
    perform_additions,
  ].any?

  if anything_done
    say("All operations performed in working directory.", [:green, :bold])
    say("Please review changes and commit them.", [:green, :bold])
  else
    say("No operations performed, all RBIs are up-to-date.", [:green, :bold])
  end

  puts
end

Private Instance Methods

abort_if_pending_migrations!() click to toggle source
# File lib/tapioca/generator.rb, line 697
def abort_if_pending_migrations!
  return unless File.exist?("config/application.rb")
  return unless defined?(::Rake)

  Rails.application.load_tasks
  Rake::Task["db:abort_if_pending_migrations"].invoke if Rake::Task.task_defined?("db:abort_if_pending_migrations")
end
add(filename) click to toggle source
# File lib/tapioca/generator.rb, line 409
def add(filename)
  say("++ Adding: #{filename}")
end
added_rbis() click to toggle source
# File lib/tapioca/generator.rb, line 402
def added_rbis
  expected_rbis.select do |name, value|
    existing_rbis[name] != value
  end.keys.sort
end
build_error_for_files(cause, files) click to toggle source
# File lib/tapioca/generator.rb, line 623
def build_error_for_files(cause, files)
  filenames = files.map do |file|
    config.outpath / file
  end.join("\n  - ")

  "  File(s) #{cause}:\n  - #{filenames}"
end
bundle() click to toggle source
# File lib/tapioca/generator.rb, line 213
def bundle
  @bundle ||= Gemfile.new
end
compile_dsl_rbi(constant_name, contents, outpath: config.outpath, quiet: false) click to toggle source
# File lib/tapioca/generator.rb, line 566
def compile_dsl_rbi(constant_name, contents, outpath: config.outpath, quiet: false)
  return if contents.nil?

  rbi_name = underscore(constant_name) + ".rbi"
  filename = outpath / rbi_name

  out = String.new
  out << rbi_header(
    "#{Config::DEFAULT_COMMAND} dsl #{constant_name}",
    reason: "dynamic methods in `#{constant_name}`"
  )
  out << contents

  FileUtils.mkdir_p(File.dirname(filename))
  File.write(filename, out)

  unless quiet
    say("Wrote: ", [:green])
    say(filename)
  end

  filename
end
compile_gem_rbi(gem) click to toggle source
# File lib/tapioca/generator.rb, line 531
def compile_gem_rbi(gem)
  compiler = Compilers::SymbolTableCompiler.new
  gem_name = set_color(gem.name, :yellow, :bold)
  say("Compiling #{gem_name}, this may take a few seconds... ")

  strictness = config.typed_overrides[gem.name] || "true"
  rbi_body_content = compiler.compile(gem)
  content = String.new
  content << rbi_header(
    "#{Config::DEFAULT_COMMAND} sync",
    reason: "types exported from the `#{gem.name}` gem",
    strictness: strictness
  )

  FileUtils.mkdir_p(config.outdir)
  filename = config.outpath / gem.rbi_file_name

  if rbi_body_content.strip.empty?
    content << EMPTY_RBI_COMMENT
    say("Done (empty output)", :yellow)
  else
    content << rbi_body_content
    say("Done", :green)
  end
  File.write(filename.to_s, content)

  T.unsafe(Pathname).glob((config.outpath / "#{gem.name}@*.rbi").to_s) do |file|
    remove(file) unless file.basename.to_s == gem.rbi_file_name
  end
end
compiler() click to toggle source
# File lib/tapioca/generator.rb, line 223
def compiler
  @compiler ||= Compilers::SymbolTableCompiler.new
end
constantize(constant_names) click to toggle source
# File lib/tapioca/generator.rb, line 297
def constantize(constant_names)
  constant_map = constant_names.map do |name|
    [name, Object.const_get(name)]
  rescue NameError
    [name, nil]
  end.to_h

  unprocessable_constants = constant_map.select { |_, v| v.nil? }
  unless unprocessable_constants.empty?
    unprocessable_constants.each do |name, _|
      say("Error: Cannot find constant '#{name}'", :red)
      remove(dsl_rbi_filename(name))
    end

    exit(1)
  end

  constant_map.values
end
constantize_generators(generator_names) click to toggle source
# File lib/tapioca/generator.rb, line 318
def constantize_generators(generator_names)
  generator_map = generator_names.map do |name|
    # Try to find built-in tapioca generator first, then globally defined generator. The
    # explicit `break` ensures the class is returned, not the `potential_name`.
    generator_klass = ["Tapioca::Compilers::Dsl::#{name}", name].find do |potential_name|
      break Object.const_get(potential_name)
    rescue NameError
      # Skip if we can't find generator by the potential name
    end

    [name, generator_klass]
  end.to_h

  unprocessable_generators = generator_map.select { |_, v| v.nil? }
  unless unprocessable_generators.empty?
    unprocessable_generators.each do |name, _|
      say("Error: Cannot find generator '#{name}'", :red)
    end

    exit(1)
  end

  generator_map.values
end
dsl_rbi_filename(constant_name) click to toggle source
# File lib/tapioca/generator.rb, line 372
def dsl_rbi_filename(constant_name)
  config.outpath / "#{underscore(constant_name)}.rbi"
end
existing_rbi(gem_name) click to toggle source
# File lib/tapioca/generator.rb, line 382
def existing_rbi(gem_name)
  gem_rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
end
existing_rbi_filenames(requested_constants, path: config.outpath) click to toggle source
# File lib/tapioca/generator.rb, line 344
def existing_rbi_filenames(requested_constants, path: config.outpath)
  filenames = if requested_constants.empty?
    Pathname.glob(path / "**/*.rbi")
  else
    requested_constants.map do |constant_name|
      dsl_rbi_filename(constant_name)
    end
  end

  filenames.to_set
end
existing_rbis() click to toggle source
# File lib/tapioca/generator.rb, line 357
def existing_rbis
  @existing_rbis ||= Pathname.glob((config.outpath / "*@*.rbi").to_s)
    .map { |f| T.cast(f.basename(".*").to_s.split("@", 2), [String, String]) }
    .to_h
end
expected_rbi(gem_name) click to toggle source
# File lib/tapioca/generator.rb, line 387
def expected_rbi(gem_name)
  gem_rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
end
expected_rbis() click to toggle source
# File lib/tapioca/generator.rb, line 364
def expected_rbis
  @expected_rbis ||= bundle.dependencies
    .reject { |gem| config.exclude.include?(gem.name) }
    .map { |gem| [gem.name, gem.version.to_s] }
    .to_h
end
explain_failed_require(file, error) click to toggle source
# File lib/tapioca/generator.rb, line 245
def explain_failed_require(file, error)
  say_error("\n\nLoadError: #{error}", :bold, :red)
  say_error("\nTapioca could not load all the gems required by your application.", :yellow)
  say_error("If you populated ", :yellow)
  say_error("#{file} ", :bold, :blue)
  say_error("with ", :yellow)
  say_error("`#{Config::DEFAULT_COMMAND} require`", :bold, :blue)
  say_error("you should probably review it and remove the faulty line.", :yellow)
end
gem_rbi_exists?(gem_name) click to toggle source
# File lib/tapioca/generator.rb, line 392
def gem_rbi_exists?(gem_name)
  existing_rbis.key?(gem_name)
end
gem_rbi_filename(gem_name, version) click to toggle source
# File lib/tapioca/generator.rb, line 377
def gem_rbi_filename(gem_name, version)
  config.outpath / "#{gem_name}@#{version}.rbi"
end
gems_to_generate(gem_names) click to toggle source
# File lib/tapioca/generator.rb, line 496
def gems_to_generate(gem_names)
  return bundle.dependencies if gem_names.empty?

  gem_names.map do |gem_name|
    gem = bundle.gem(gem_name)
    if gem.nil?
      say("Error: Cannot find gem '#{gem_name}'", :red)
      exit(1)
    end
    gem
  end
end
load_application(eager_load:) click to toggle source
# File lib/tapioca/generator.rb, line 271
def load_application(eager_load:)
  say("Loading Rails application... ")

  loader.load_rails_application(
    environment_load: true,
    eager_load: eager_load
  )

  say("Done", :green)
end
load_dsl_generators() click to toggle source
# File lib/tapioca/generator.rb, line 283
def load_dsl_generators
  say("Loading DSL generator classes... ")

  Dir.glob([
    "#{__dir__}/compilers/dsl/*.rb",
    "#{Config::TAPIOCA_PATH}/generators/**/*.rb",
  ]).each do |generator|
    require File.expand_path(generator)
  end

  say("Done", :green)
end
loader() click to toggle source
# File lib/tapioca/generator.rb, line 218
def loader
  @loader ||= Loader.new
end
move(old_filename, new_filename) click to toggle source
# File lib/tapioca/generator.rb, line 421
def move(old_filename, new_filename)
  say("-> Moving: #{old_filename} to #{new_filename}")
  old_filename.rename(new_filename.to_s)
end
perform_additions() click to toggle source
# File lib/tapioca/generator.rb, line 454
def perform_additions
  say("Generating RBI files of gems that are added or updated:", [:blue, :bold])
  puts

  anything_done = T.let(false, T::Boolean)

  gems = added_rbis

  indent do
    if gems.empty?
      say("Nothing to do.")
    else
      require_gem_file

      gems.each do |gem_name|
        filename = expected_rbi(gem_name)

        if gem_rbi_exists?(gem_name)
          old_filename = existing_rbi(gem_name)
          move(old_filename, filename) unless old_filename == filename
        end

        gem = T.must(bundle.gem(gem_name))
        compile_gem_rbi(gem)
        add(filename)

        puts
      end
    end

    anything_done = true
  end

  puts

  anything_done
end
perform_dsl_verification(dir) click to toggle source
# File lib/tapioca/generator.rb, line 639
def perform_dsl_verification(dir)
  diff = verify_dsl_rbi(tmp_dir: dir)

  report_diff_and_exit_if_out_of_date(diff, "dsl")
ensure
  FileUtils.remove_entry(dir)
end
perform_removals() click to toggle source
# File lib/tapioca/generator.rb, line 427
def perform_removals
  say("Removing RBI files of gems that have been removed:", [:blue, :bold])
  puts

  anything_done = T.let(false, T::Boolean)

  gems = removed_rbis

  indent do
    if gems.empty?
      say("Nothing to do.")
    else
      gems.each do |removed|
        filename = existing_rbi(removed)
        remove(filename)
      end

      anything_done = true
    end
  end

  puts

  anything_done
end
perform_sync_verification() click to toggle source
# File lib/tapioca/generator.rb, line 660
def perform_sync_verification
  diff = {}

  removed_rbis.each do |gem_name|
    filename = existing_rbi(gem_name)
    diff[filename] = :removed
  end

  added_rbis.each do |gem_name|
    filename = expected_rbi(gem_name)
    diff[filename] = gem_rbi_exists?(gem_name) ? :changed : :added
  end

  report_diff_and_exit_if_out_of_date(diff, "sync")
end
purge_stale_dsl_rbi_files(files) click to toggle source
# File lib/tapioca/generator.rb, line 648
def purge_stale_dsl_rbi_files(files)
  if files.any?
    say("Removing stale RBI files...")

    files.sort.each do |filename|
      remove(filename)
    end
    say("")
  end
end
rbi_files_in(path) click to toggle source
# File lib/tapioca/generator.rb, line 632
def rbi_files_in(path)
  Pathname.glob(path / "**/*.rbi").map do |file|
    file.relative_path_from(path)
  end.sort
end
rbi_header(command, reason: nil, strictness: nil) click to toggle source
# File lib/tapioca/generator.rb, line 510
    def rbi_header(command, reason: nil, strictness: nil)
      statement = <<~HEAD
        # DO NOT EDIT MANUALLY
        # This is an autogenerated file for #{reason}.
        # Please instead update this file by running `#{command}`.
      HEAD

      sigil = <<~SIGIL if strictness
        # typed: #{strictness}
      SIGIL

      if config.file_header
        [statement, sigil].compact.join("\n").strip.concat("\n\n")
      elsif sigil
        sigil.strip.concat("\n\n")
      else
        ""
      end
    end
remove(filename) click to toggle source
# File lib/tapioca/generator.rb, line 414
def remove(filename)
  return unless filename.exist?
  say("-- Removing: #{filename}")
  filename.unlink
end
removed_rbis() click to toggle source
# File lib/tapioca/generator.rb, line 397
def removed_rbis
  (existing_rbis.keys - expected_rbis.keys).sort
end
report_diff_and_exit_if_out_of_date(diff, command) click to toggle source
# File lib/tapioca/generator.rb, line 677
def report_diff_and_exit_if_out_of_date(diff, command)
  if diff.empty?
    say("Nothing to do, all RBIs are up-to-date.")
  else
    say("RBI files are out-of-date. In your development environment, please run:", :green)
    say("  `#{Config::DEFAULT_COMMAND} #{command}`", [:green, :bold])
    say("Once it is complete, be sure to commit and push any changes", :green)

    say("")

    say("Reason:", [:red])
    diff.group_by(&:last).sort.each do |cause, diff_for_cause|
      say(build_error_for_files(cause, diff_for_cause.map(&:first)))
    end

    exit(1)
  end
end
require_gem_file() click to toggle source
# File lib/tapioca/generator.rb, line 228
def require_gem_file
  say("Requiring all gems to prepare for compiling... ")
  begin
    loader.load_bundle(bundle, config.prerequire, config.postrequire)
  rescue LoadError => e
    explain_failed_require(config.postrequire, e)
    exit(1)
  end
  say(" Done", :green)
  unless bundle.missing_specs.empty?
    say("  completed with missing specs: ")
    say(bundle.missing_specs.join(", "), :yellow)
  end
  puts
end
say_error(message = "", *color) click to toggle source
# File lib/tapioca/generator.rb, line 261
def say_error(message = "", *color)
  force_new_line = (message.to_s !~ /( |\t)\Z/)
  buffer = prepare_message(*T.unsafe([message, *T.unsafe(color)]))
  buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")

  stderr.print(buffer)
  stderr.flush
end
underscore(class_name) click to toggle source
# File lib/tapioca/generator.rb, line 706
def underscore(class_name)
  return class_name unless /[A-Z-]|::/.match?(class_name)

  word = class_name.to_s.gsub("::", "/")
  word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
  word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
  word.tr!("-", "_")
  word.downcase!
  word
end
verify_dsl_rbi(tmp_dir:) click to toggle source
# File lib/tapioca/generator.rb, line 591
def verify_dsl_rbi(tmp_dir:)
  diff = {}

  existing_rbis = rbi_files_in(config.outpath)
  new_rbis = rbi_files_in(tmp_dir)

  added_files = (new_rbis - existing_rbis)

  added_files.each do |file|
    diff[file] = :added
  end

  removed_files = (existing_rbis - new_rbis)

  removed_files.each do |file|
    diff[file] = :removed
  end

  common_files = (existing_rbis & new_rbis)

  changed_files = common_files.map do |filename|
    filename unless FileUtils.identical?(config.outpath / filename, tmp_dir / filename)
  end.compact

  changed_files.each do |file|
    diff[file] = :changed
  end

  diff
end