# frozen_string_literal: true require "open3" require "optparse" require "shellwords" require "abbrev" require "stringio" module RBS class CLI class LibraryOptions attr_accessor :core_root attr_accessor :config_path attr_reader :repos attr_reader :libs attr_reader :dirs def initialize() @core_root = EnvironmentLoader::DEFAULT_CORE_ROOT @repos = [] @libs = [] @dirs = [] @config_path = Collection::Config.find_config_path || Collection::Config::PATH end def loader repository = Repository.new(no_stdlib: core_root.nil?) repos.each do |repo| repository.add(Pathname(repo)) end loader = EnvironmentLoader.new(core_root: core_root, repository: repository) if config_path lock_path = Collection::Config.to_lockfile_path(config_path) if lock_path.file? lock = Collection::Config::Lockfile.from_lockfile(lockfile_path: lock_path, data: YAML.load_file(lock_path.to_s)) end end loader.add_collection(lock) if lock dirs.each do |dir| loader.add(path: Pathname(dir)) end libs.each do |lib| name, version = lib.split(/:/, 2) next unless name loader.add(library: name, version: version) end loader end def setup_library_options(opts) opts.on("-r LIBRARY", "Load RBS files of the library") do |lib| libs << lib end opts.on("-I DIR", "Load RBS files from the directory") do |dir| dirs << dir end opts.on("--no-stdlib", "Skip loading standard library signatures") do self.core_root = nil end opts.on('--collection PATH', "File path of collection configuration (default: #{@config_path})") do |path| self.config_path = Pathname(path).expand_path end opts.on('--no-collection', 'Ignore collection configuration') do self.config_path = nil end opts.on("--repo DIR", "Add RBS repository") do |dir| repos << dir end opts end end attr_reader :stdout attr_reader :stderr attr_reader :original_args def initialize(stdout:, stderr:) @stdout = stdout @stderr = stderr end COMMANDS = [:ast, :annotate, :list, :ancestors, :methods, :method, :validate, :constant, :paths, :prototype, :vendor, :parse, :test, :collection, :subtract] def parse_logging_options(opts) opts.on("--log-level LEVEL", "Specify log level (defaults to `warn`)") do |level| RBS.logger_level = level end opts.on("--log-output OUTPUT", "Specify the file to output log (defaults to stderr)") do |output| io = File.open(output, "a") or raise RBS.logger_output = io end opts end def has_parser? defined?(RubyVM::AbstractSyntaxTree) end def run(args) @original_args = args.dup options = LibraryOptions.new opts = OptionParser.new opts.banner = <<~USAGE Usage: rbs [options...] [command...] Available commands: #{COMMANDS.join(", ")}, version, help. Options: USAGE options.setup_library_options(opts) parse_logging_options(opts) opts.version = RBS::VERSION opts.order!(args) command = args.shift&.to_sym case command when :version stdout.puts opts.ver when *COMMANDS __send__ :"run_#{command}", args, options else stdout.puts opts.help end end def run_ast(args, options) OptionParser.new do |opts| opts.banner = < #{constant.name}: #{constant.type}" else stdout.puts " => [no constant]" end end def run_paths(args, options) OptionParser.new do |opts| opts.banner = < (path) { # @type var path: Pathname case when path.file? "file" when path.directory? "dir" when !path.exist? "absent" else "unknown" end } loader.each_dir do |source, dir| case source when :core stdout.puts "#{dir} (#{kind_of[dir]}, core)" when Pathname stdout.puts "#{dir} (#{kind_of[dir]})" when EnvironmentLoader::Library stdout.puts "#{dir} (#{kind_of[dir]}, library, name=#{source.name})" end end end def run_prototype(args, options) format = args.shift case format when "rbi", "rb" run_prototype_file(format, args) when "runtime" require_libs = [] relative_libs = [] merge = false owners_included = [] outline = false OptionParser.new do |opts| opts.banner = < do case format when "rbi" Prototype::RBI.new() when "rb" Prototype::RB.new() else raise end end input_paths = args.map {|arg| Pathname(arg) } if output_dir # @type var skip_paths: Array[Pathname] skip_paths = [] # batch mode input_paths.each do |path| stdout.puts "Processing `#{path}`..." ruby_files = if path.file? [path] else path.glob("**/*.rb").sort end ruby_files.each do |file_path| stdout.puts " Generating RBS for `#{file_path}`..." relative_path = if base_dir file_path.relative_path_from(base_dir) else if top = file_path.descend.first case when top == Pathname("lib") file_path.relative_path_from(top) when top == Pathname("app") file_path.relative_path_from(top) else file_path end else file_path end end relative_path = relative_path.cleanpath() if relative_path.absolute? || relative_path.descend.first&.to_s == ".." stdout.puts " ⚠️ Cannot write the RBS to outside of the output dir: `#{relative_path}`" next end output_path = (output_dir + relative_path).sub_ext(".rbs") parser = new_parser[] parser.parse file_path.read() if output_path.file? if force stdout.puts " - Writing RBS to existing file `#{output_path}`..." else stdout.puts " - Skipping existing file `#{output_path}`..." skip_paths << file_path next end else stdout.puts " - Writing RBS to `#{output_path}`..." end (output_path.parent).mkpath output_path.open("w") do |io| writer = Writer.new(out: io) writer.write(parser.decls) end end end unless skip_paths.empty? stdout.puts stdout.puts ">>>> Skipped existing #{skip_paths.size} files. Use `--force` option to update the files." command = original_args.take(original_args.size - input_paths.size) skip_paths.take(10).each do |path| stdout.puts " #{defined?(Bundler) ? "bundle exec " : ""}rbs #{Shellwords.join(command)} --force #{Shellwords.escape(path.to_s)}" end if skip_paths.size > 10 stdout.puts " ..." end end else # file mode parser = new_parser[] input_paths.each do |file| parser.parse file.read() end writer = Writer.new(out: stdout) writer.write parser.decls end end def run_vendor(args, options) clean = false vendor_dir = Pathname("vendor/sigs") OptionParser.new do |opts| opts.banner = <<-EOB Usage: rbs vendor [options...] [gems...] Vendor signatures in the project directory. This command ignores the RBS loading global options, `-r` and `-I`. Examples: $ rbs vendor $ rbs vendor --vendor-dir=sig $ rbs vendor --no-stdlib Options: EOB opts.on("--[no-]clean", "Clean vendor directory (default: no)") do |v| clean = v end opts.on("--vendor-dir [DIR]", "Specify the directory for vendored signatures (default: vendor/sigs)") do |path| vendor_dir = Pathname(path) end end.parse!(args) stdout.puts "Vendoring signatures to #{vendor_dir}..." loader = options.loader() args.each do |gem| name, version = gem.split(/:/, 2) next unless name stdout.puts " Loading library: #{name}, version=#{version}..." loader.add(library: name, version: version) end vendorer = Vendorer.new(vendor_dir: vendor_dir, loader: loader) if clean stdout.puts " Deleting #{vendor_dir}..." vendorer.clean! end stdout.puts " Copying RBS files..." vendorer.copy! end def run_parse(args, options) parse_method = :parse_signature # @type var e_code: String? e_code = nil OptionParser.new do |opts| opts.banner = <<-EOB Usage: rbs parse [files...] Parse given RBS files and print syntax errors. Examples: $ rbs parse sig/app/models.rbs sig/app/controllers.rbs Options: EOB opts.on('-e CODE', 'One line RBS script to parse') { |e| e_code = e } opts.on('--type', 'Parse code as a type') { |e| parse_method = :parse_type } opts.on('--method-type', 'Parse code as a method type') { |e| parse_method = :parse_method_type } end.parse!(args) syntax_error = false bufs = args.flat_map do |path| path = Pathname(path) FileFinder.each_file(path, skip_hidden: false, immediate: true).map do |file_path| Buffer.new(content: file_path.read, name: file_path) end end bufs << Buffer.new(content: e_code, name: '-e') if e_code bufs.each do |buf| RBS.logger.info "Parsing #{buf.name}..." case parse_method when :parse_signature Parser.parse_signature(buf) else Parser.public_send(parse_method, buf, require_eof: true) end rescue RBS::ParsingError => ex stdout.print ex.detailed_message(highlight: true) syntax_error = true end exit 1 if syntax_error end def run_annotate(args, options) require "rbs/annotate" source = RBS::Annotate::RDocSource.new() annotator = RBS::Annotate::RDocAnnotator.new(source: source) preserve = true OptionParser.new do |opts| opts.banner = <<-EOB Usage: rbs annotate [options...] [files...] Import documents from RDoc and update RBS files. Examples: $ rbs annotate stdlib/pathname/**/*.rbs Options: EOB opts.on("--[no-]system", "Load RDoc from system (defaults to true)") {|b| source.with_system_dir = b } opts.on("--[no-]gems", "Load RDoc from gems (defaults to false)") {|b| source.with_gems_dir = b } opts.on("--[no-]site", "Load RDoc from site directory (defaults to false)") {|b| source.with_site_dir = b } opts.on("--[no-]home", "Load RDoc from home directory (defaults to false)") {|b| source.with_home_dir = b } opts.on("-d", "--dir DIRNAME", "Load RDoc from DIRNAME") {|d| source.extra_dirs << Pathname(d) } opts.on("--[no-]arglists", "Generate arglists section (defaults to true)") {|b| annotator.include_arg_lists = b } opts.on("--[no-]filename", "Include source file name in the documentation (defaults to true)") {|b| annotator.include_filename = b } opts.on("--[no-]preserve", "Try preserve the format of the original file (defaults to true)") {|b| preserve = b } end.parse!(args) source.load() args.each do |file| path = Pathname(file) if path.directory? Pathname.glob((path + "**/*.rbs").to_s).each do |path| stdout.puts "Processing #{path}..." annotator.annotate_file(path, preserve: preserve) end else stdout.puts "Processing #{path}..." annotator.annotate_file(path, preserve: preserve) end end end def test_opt options opts = [] opts.push(*options.repos.map {|dir| "--repo #{Shellwords.escape(dir)}"}) opts.push(*options.dirs.map {|dir| "-I #{Shellwords.escape(dir)}"}) opts.push(*options.libs.map {|lib| "-r#{Shellwords.escape(lib)}"}) opts.empty? ? nil : opts.join(" ") end def run_test(args, options) # @type var unchecked_classes: Array[String] unchecked_classes = [] # @type var targets: Array[String] targets = [] # @type var sample_size: String? sample_size = nil # @type var double_suite: String? double_suite = nil (opts = OptionParser.new do |opts| opts.banner = < "#{ENV['RUBYOPT']} -rrbs/test/setup", 'RBS_TEST_OPT' => test_opt(options), 'RBS_TEST_LOGLEVEL' => %w(DEBUG INFO WARN ERROR FATAL)[RBS.logger_level || 5] || "UNKNOWN", 'RBS_TEST_SAMPLE_SIZE' => sample_size, 'RBS_TEST_DOUBLE_SUITE' => double_suite, 'RBS_TEST_UNCHECKED_CLASSES' => (unchecked_classes.join(',') unless unchecked_classes.empty?), 'RBS_TEST_TARGET' => (targets.join(',') unless targets.empty?) } # @type var out: String # @type var err: String out, err, status = __skip__ = Open3.capture3(env_hash, *args) stdout.print(out) stderr.print(err) status end def run_collection(args, options) opts = collection_options(args) params = {} opts.order args.drop(1), into: params config_path = options.config_path or raise lock_path = Collection::Config.to_lockfile_path(config_path) subcommand = Abbrev.abbrev(['install', 'update', 'help'])[args[0]] || args[0] case subcommand when 'install' unless params[:frozen] Collection::Config.generate_lockfile(config_path: config_path, definition: Bundler.definition) end Collection::Installer.new(lockfile_path: lock_path, stdout: stdout).install_from_lockfile when 'update' # TODO: Be aware of argv to update only specified gem Collection::Config.generate_lockfile(config_path: config_path, definition: Bundler.definition, with_lockfile: false) Collection::Installer.new(lockfile_path: lock_path, stdout: stdout).install_from_lockfile when 'init' if config_path.exist? puts "#{config_path} already exists" exit 1 end config_path.write(<<~'YAML') # Download sources sources: - type: git name: ruby/gem_rbs_collection remote: https://github.com/ruby/gem_rbs_collection.git revision: main repo_dir: gems # You can specify local directories as sources also. # - type: local # path: path/to/your/local/repository # A directory to install the downloaded RBSs path: .gem_rbs_collection gems: # Skip loading rbs gem's RBS. # It's unnecessary if you don't use rbs as a library. - name: rbs ignore: true YAML stdout.puts "created: #{config_path}" when 'clean' unless lock_path.exist? puts "#{lock_path} should exist to clean" exit 1 end Collection::Cleaner.new(lockfile_path: lock_path) when 'help' puts opts.help else puts opts.help exit 1 end end def collection_options(args) OptionParser.new do |opts| opts.banner = <<~HELP Usage: rbs collection [install|update|init|clean|help] Manage RBS collection, which contains third party RBS. Examples: # Initialize the configuration file $ rbs collection init # Generate the lock file and install RBSs from the lock file $ rbs collection install # Update the RBSs $ rbs collection update Options: HELP opts.on('--frozen') if args[0] == 'install' end end def run_subtract(args, _) write_to_file = false # @type var subtrahend_paths: Array[String] subtrahend_paths = [] opts = OptionParser.new do |opts| opts.banner = <<~HELP Usage: rbs subtract [options...] minuend.rbs [minuend2.rbs, ...] subtrahend.rbs rbs subtract [options...] minuend.rbs [minuend2.rbs, ...] --subtrahend subtrahend_1.rbs --subtrahend subtrahend_2.rbs Remove duplications between RBS files. Examples: # Generate RBS files from the codebase. $ rbs prototype rb lib/ > generated.rbs # Write more descrictive types by hand. $ $EDITOR handwritten.rbs # Remove hand-written method definitions from generated.rbs. $ rbs subtract --write generated.rbs handwritten.rbs Options: HELP opts.on('-w', '--write', 'Overwrite files directry') { write_to_file = true } opts.on('--subtrahend=PATH', '') { |path| subtrahend_paths << path } opts.parse!(args) end if subtrahend_paths.empty? *minuend_paths, subtrahend_path = args unless subtrahend_path stdout.puts opts.help exit 1 end subtrahend_paths << subtrahend_path else minuend_paths = args end if minuend_paths.empty? stdout.puts opts.help exit 1 end subtrahend = Environment.new.tap do |env| loader = EnvironmentLoader.new(core_root: nil) subtrahend_paths.each do |path| loader.add(path: Pathname(path)) end loader.load(env: env) end minuend_paths.each do |minuend_path| FileFinder.each_file(Pathname(minuend_path), immediate: true, skip_hidden: true) do |rbs_path| buf = Buffer.new(name: rbs_path, content: rbs_path.read) _, dirs, decls = Parser.parse_signature(buf) subtracted = Subtractor.new(decls, subtrahend).call io = StringIO.new w = Writer.new(out: io) w.write(dirs) w.write(subtracted) if write_to_file if io.string.empty? rbs_path.delete else rbs_path.write(io.string) end else stdout.puts(io.string) end end end end end end