diff --git a/Library/Homebrew/abstract_command.rb b/Library/Homebrew/abstract_command.rb new file mode 100644 index 0000000000..0831c05702 --- /dev/null +++ b/Library/Homebrew/abstract_command.rb @@ -0,0 +1,51 @@ +# typed: strong +# frozen_string_literal: true + +module Homebrew + # Subclass this to implement a `brew` command. This is preferred to declaring a named function in the `Homebrew` + # module, because: + # - Each Command lives in an isolated namespace. + # - Each Command implements a defined interface. + # - `args` is available as an ivar, and thus does not need to be passed as an argument to helper methods. + # + # To subclass, implement a `run` method and provide a `cmd_args` block to document the command and its allowed args. + # To generate method signatures for command args, run `brew typecheck --update`. + class AbstractCommand + extend T::Helpers + + abstract! + + class << self + sig { returns(T.nilable(CLI::Parser)) } + attr_reader :parser + + sig { returns(String) } + def command_name = T.must(name).split("::").fetch(-1).downcase + + # @return the AbstractCommand subclass associated with the brew CLI command name. + sig { params(name: String).returns(T.nilable(T.class_of(AbstractCommand))) } + def command(name) = subclasses.find { _1.command_name == name } + + private + + sig { params(block: T.proc.bind(CLI::Parser).void).void } + def cmd_args(&block) + @parser = T.let(CLI::Parser.new(&block), T.nilable(CLI::Parser)) + end + end + + sig { returns(CLI::Args) } + attr_reader :args + + sig { params(argv: T::Array[String]).void } + def initialize(argv = ARGV.freeze) + parser = self.class.parser + raise "Commands must include a `cmd_args` block" if parser.nil? + + @args = T.let(parser.parse(argv), CLI::Args) + end + + sig { abstract.void } + def run; end + end +end diff --git a/Library/Homebrew/brew.rb b/Library/Homebrew/brew.rb index c50bf61ab5..2e785889bc 100644 --- a/Library/Homebrew/brew.rb +++ b/Library/Homebrew/brew.rb @@ -59,6 +59,7 @@ begin ENV["PATH"] = path.to_s + require "abstract_command" require "commands" require "settings" @@ -83,7 +84,12 @@ begin end if internal_cmd || Commands.external_ruby_v2_cmd_path(cmd) - Homebrew.send Commands.method_name(cmd) + cmd_class = Homebrew::AbstractCommand.command(T.must(cmd)) + if cmd_class + cmd_class.new.run + else + Homebrew.public_send Commands.method_name(cmd) + end elsif (path = Commands.external_ruby_cmd_path(cmd)) require?(path) exit Homebrew.failed? ? 1 : 0 diff --git a/Library/Homebrew/cli/args.rbi b/Library/Homebrew/cli/args.rbi new file mode 100644 index 0000000000..7c161fd39b --- /dev/null +++ b/Library/Homebrew/cli/args.rbi @@ -0,0 +1,19 @@ +# typed: strict + +# This file contains global args as defined in `Homebrew::CLI::Parser.global_options` +# `Command`-specific args are defined in the commands themselves, with type signatures +# generated by the `Tapioca::Compilers::Args` compiler. + +class Homebrew::CLI::Args + sig { returns(T::Boolean) } + def debug?; end + + sig { returns(T::Boolean) } + def help?; end + + sig { returns(T::Boolean) } + def quiet?; end + + sig { returns(T::Boolean) } + def verbose?; end +end diff --git a/Library/Homebrew/cli/parser.rb b/Library/Homebrew/cli/parser.rb index b9dcb1a84f..30d0938843 100644 --- a/Library/Homebrew/cli/parser.rb +++ b/Library/Homebrew/cli/parser.rb @@ -248,12 +248,14 @@ module Homebrew @conflicts << options.map { |option| option_to_name(option) } end - def option_to_name(option) + def self.option_to_name(option) option.sub(/\A--?(\[no-\])?/, "") .tr("-", "_") .delete("=") end + def option_to_name(option) = self.class.option_to_name(option) + def name_to_option(name) if name.length == 1 "-#{name}" diff --git a/Library/Homebrew/cmd/list.rb b/Library/Homebrew/cmd/list.rb index 1b69e2596e..619bc49feb 100644 --- a/Library/Homebrew/cmd/list.rb +++ b/Library/Homebrew/cmd/list.rb @@ -1,6 +1,7 @@ # typed: true # frozen_string_literal: true +require "abstract_command" require "metafiles" require "formula" require "cli/parser" @@ -8,226 +9,234 @@ require "cask/list" require "system_command" module Homebrew - extend SystemCommand::Mixin + module Cmd + class List < AbstractCommand + include SystemCommand::Mixin - sig { returns(CLI::Parser) } - def self.list_args - Homebrew::CLI::Parser.new do - description <<~EOS - List all installed formulae and casks. - If is provided, summarise the paths within its current keg. - If is provided, list its artifacts. - EOS - switch "--formula", "--formulae", - description: "List only formulae, or treat all named arguments as formulae." - switch "--cask", "--casks", - description: "List only casks, or treat all named arguments as casks." - switch "--full-name", - description: "Print formulae with fully-qualified names. Unless `--full-name`, `--versions` " \ - "or `--pinned` are passed, other options (i.e. `-1`, `-l`, `-r` and `-t`) are " \ - "passed to `ls`(1) which produces the actual output." - switch "--versions", - description: "Show the version number for installed formulae, or only the specified " \ - "formulae if are provided." - switch "--multiple", - depends_on: "--versions", - description: "Only show formulae with multiple versions installed." - switch "--pinned", - description: "List only pinned formulae, or only the specified (pinned) " \ - "formulae if are provided. See also `pin`, `unpin`." - # passed through to ls - switch "-1", - description: "Force output to be one entry per line. " \ - "This is the default when output is not to a terminal." - switch "-l", - description: "List formulae and/or casks in long format. " \ - "Has no effect when a formula or cask name is passed as an argument." - switch "-r", - description: "Reverse the order of the formulae and/or casks sort to list the oldest entries first. " \ - "Has no effect when a formula or cask name is passed as an argument." - switch "-t", - description: "Sort formulae and/or casks by time modified, listing most recently modified first. " \ - "Has no effect when a formula or cask name is passed as an argument." + cmd_args do + description <<~EOS + List all installed formulae and casks. + If is provided, summarise the paths within its current keg. + If is provided, list its artifacts. + EOS + switch "--formula", "--formulae", + description: "List only formulae, or treat all named arguments as formulae." + switch "--cask", "--casks", + description: "List only casks, or treat all named arguments as casks." + switch "--full-name", + description: "Print formulae with fully-qualified names. Unless `--full-name`, `--versions` " \ + "or `--pinned` are passed, other options (i.e. `-1`, `-l`, `-r` and `-t`) are " \ + "passed to `ls`(1) which produces the actual output." + switch "--versions", + description: "Show the version number for installed formulae, or only the specified " \ + "formulae if are provided." + switch "--multiple", + depends_on: "--versions", + description: "Only show formulae with multiple versions installed." + switch "--pinned", + description: "List only pinned formulae, or only the specified (pinned) " \ + "formulae if are provided. See also `pin`, `unpin`." + # passed through to ls + switch "-1", + description: "Force output to be one entry per line. " \ + "This is the default when output is not to a terminal." + switch "-l", + description: "List formulae and/or casks in long format. " \ + "Has no effect when a formula or cask name is passed as an argument." + switch "-r", + description: "Reverse the order of the formulae and/or casks sort to list the oldest entries first. " \ + "Has no effect when a formula or cask name is passed as an argument." + switch "-t", + description: "Sort formulae and/or casks by time modified, listing most recently modified first. " \ + "Has no effect when a formula or cask name is passed as an argument." - conflicts "--formula", "--cask" - conflicts "--pinned", "--cask" - conflicts "--multiple", "--cask" - conflicts "--pinned", "--multiple" - ["-1", "-l", "-r", "-t"].each do |flag| - conflicts "--versions", flag - conflicts "--pinned", flag - end - ["--versions", "--pinned", "-l", "-r", "-t"].each do |flag| - conflicts "--full-name", flag + conflicts "--formula", "--cask" + conflicts "--pinned", "--cask" + conflicts "--multiple", "--cask" + conflicts "--pinned", "--multiple" + ["-1", "-l", "-r", "-t"].each do |flag| + conflicts "--versions", flag + conflicts "--pinned", flag + end + ["--versions", "--pinned", "-l", "-r", "-t"].each do |flag| + conflicts "--full-name", flag + end + + named_args [:installed_formula, :installed_cask] end - named_args [:installed_formula, :installed_cask] - end - end + sig { override.void } + def run + if args.full_name? + unless args.cask? + formula_names = args.no_named? ? Formula.installed : args.named.to_resolved_formulae + full_formula_names = formula_names.map(&:full_name).sort(&tap_and_name_comparison) + full_formula_names = Formatter.columns(full_formula_names) unless args.public_send(:"1?") + puts full_formula_names if full_formula_names.present? + end + if args.cask? || (!args.formula? && args.no_named?) + cask_names = if args.no_named? + Cask::Caskroom.casks + else + args.named.to_formulae_and_casks(only: :cask, method: :resolve) + end + # The cast is because `Keg`` does not define `full_name` + full_cask_names = T.cast(cask_names, T::Array[T.any(Formula, Cask::Cask)]) + .map(&:full_name).sort(&tap_and_name_comparison) + full_cask_names = Formatter.columns(full_cask_names) unless args.public_send(:"1?") + puts full_cask_names if full_cask_names.present? + end + elsif args.pinned? + filtered_list + elsif args.versions? + filtered_list unless args.cask? + list_casks if args.cask? || (!args.formula? && !args.multiple? && args.no_named?) + elsif args.no_named? + ENV["CLICOLOR"] = nil - def self.list - args = list_args.parse + ls_args = [] + ls_args << "-1" if args.public_send(:"1?") + ls_args << "-l" if args.l? + ls_args << "-r" if args.r? + ls_args << "-t" if args.t? - if args.full_name? - unless args.cask? - formula_names = args.no_named? ? Formula.installed : args.named.to_resolved_formulae - full_formula_names = formula_names.map(&:full_name).sort(&tap_and_name_comparison) - full_formula_names = Formatter.columns(full_formula_names) unless args.public_send(:"1?") - puts full_formula_names if full_formula_names.present? + if !args.cask? && HOMEBREW_CELLAR.exist? && HOMEBREW_CELLAR.children.any? + ohai "Formulae" if $stdout.tty? && !args.formula? + safe_system "ls", *ls_args, HOMEBREW_CELLAR + puts if $stdout.tty? && !args.formula? + end + if !args.formula? && Cask::Caskroom.any_casks_installed? + ohai "Casks" if $stdout.tty? && !args.cask? + safe_system "ls", *ls_args, Cask::Caskroom.path + end + else + kegs, casks = args.named.to_kegs_to_casks + + if args.verbose? || !$stdout.tty? + find_args = %w[-not -type d -not -name .DS_Store -print] + system_command! "find", args: kegs.map(&:to_s) + find_args, print_stdout: true if kegs.present? + system_command! "find", args: casks.map(&:caskroom_path) + find_args, print_stdout: true if casks.present? + else + kegs.each { |keg| PrettyListing.new keg } if kegs.present? + list_casks if casks.present? + end + end end - if args.cask? || (!args.formula? && args.no_named?) - cask_names = if args.no_named? + + private + + def filtered_list + names = if args.no_named? + Formula.racks + else + racks = args.named.map { |n| Formulary.to_rack(n) } + racks.select do |rack| + Homebrew.failed = true unless rack.exist? + rack.exist? + end + end + if args.pinned? + pinned_versions = {} + names.sort.each do |d| + keg_pin = (HOMEBREW_PINNED_KEGS/d.basename.to_s) + pinned_versions[d] = keg_pin.readlink.basename.to_s if keg_pin.exist? || keg_pin.symlink? + end + pinned_versions.each do |d, version| + puts d.basename.to_s.concat(args.versions? ? " #{version}" : "") + end + else # --versions without --pinned + names.sort.each do |d| + versions = d.subdirs.map { |pn| pn.basename.to_s } + next if args.multiple? && versions.length < 2 + + puts "#{d.basename} #{versions * " "}" + end + end + end + + def list_casks + casks = if args.no_named? Cask::Caskroom.casks else - args.named.to_formulae_and_casks(only: :cask, method: :resolve) - end - full_cask_names = cask_names.map(&:full_name).sort(&tap_and_name_comparison) - full_cask_names = Formatter.columns(full_cask_names) unless args.public_send(:"1?") - puts full_cask_names if full_cask_names.present? - end - elsif args.pinned? - filtered_list(args:) - elsif args.versions? - filtered_list(args:) unless args.cask? - list_casks(args:) if args.cask? || (!args.formula? && !args.multiple? && args.no_named?) - elsif args.no_named? - ENV["CLICOLOR"] = nil - - ls_args = [] - ls_args << "-1" if args.public_send(:"1?") - ls_args << "-l" if args.l? - ls_args << "-r" if args.r? - ls_args << "-t" if args.t? - - if !args.cask? && HOMEBREW_CELLAR.exist? && HOMEBREW_CELLAR.children.any? - ohai "Formulae" if $stdout.tty? && !args.formula? - safe_system "ls", *ls_args, HOMEBREW_CELLAR - puts if $stdout.tty? && !args.formula? - end - if !args.formula? && Cask::Caskroom.any_casks_installed? - ohai "Casks" if $stdout.tty? && !args.cask? - safe_system "ls", *ls_args, Cask::Caskroom.path - end - else - kegs, casks = args.named.to_kegs_to_casks - - if args.verbose? || !$stdout.tty? - find_args = %w[-not -type d -not -name .DS_Store -print] - system_command! "find", args: kegs.map(&:to_s) + find_args, print_stdout: true if kegs.present? - system_command! "find", args: casks.map(&:caskroom_path) + find_args, print_stdout: true if casks.present? - else - kegs.each { |keg| PrettyListing.new keg } if kegs.present? - list_casks(args:) if casks.present? - end - end - end - - def self.filtered_list(args:) - names = if args.no_named? - Formula.racks - else - racks = args.named.map { |n| Formulary.to_rack(n) } - racks.select do |rack| - Homebrew.failed = true unless rack.exist? - rack.exist? - end - end - if args.pinned? - pinned_versions = {} - names.sort.each do |d| - keg_pin = (HOMEBREW_PINNED_KEGS/d.basename.to_s) - pinned_versions[d] = keg_pin.readlink.basename.to_s if keg_pin.exist? || keg_pin.symlink? - end - pinned_versions.each do |d, version| - puts d.basename.to_s.concat(args.versions? ? " #{version}" : "") - end - else # --versions without --pinned - names.sort.each do |d| - versions = d.subdirs.map { |pn| pn.basename.to_s } - next if args.multiple? && versions.length < 2 - - puts "#{d.basename} #{versions * " "}" - end - end - end - - def self.list_casks(args:) - casks = if args.no_named? - Cask::Caskroom.casks - else - args.named.dup.delete_if do |n| - Homebrew.failed = true unless Cask::Caskroom.path.join(n).exist? - !Cask::Caskroom.path.join(n).exist? - end.to_formulae_and_casks(only: :cask) - end - return if casks.blank? - - Cask::List.list_casks( - *casks, - one: args.public_send(:"1?"), - full_name: args.full_name?, - versions: args.versions?, - ) - end -end - -class PrettyListing - def initialize(path) - Pathname.new(path).children.sort_by { |p| p.to_s.downcase }.each do |pn| - case pn.basename.to_s - when "bin", "sbin" - pn.find { |pnn| puts pnn unless pnn.directory? } - when "lib" - print_dir pn do |pnn| - # dylibs have multiple symlinks and we don't care about them - (pnn.extname == ".dylib" || pnn.extname == ".pc") && !pnn.symlink? - end - when ".brew" - next # Ignore .brew - else - if pn.directory? - if pn.symlink? - puts "#{pn} -> #{pn.readlink}" - else - print_dir pn + filtered_args = args.named.dup.delete_if do |n| + Homebrew.failed = true unless Cask::Caskroom.path.join(n).exist? + !Cask::Caskroom.path.join(n).exist? end - elsif Metafiles.list?(pn.basename.to_s) - puts pn + # NamedAargs subclasses array + T.cast(filtered_args, Homebrew::CLI::NamedArgs).to_formulae_and_casks(only: :cask) + end + return if casks.blank? + + Cask::List.list_casks( + *casks, + one: args.public_send(:"1?"), + full_name: args.full_name?, + versions: args.versions?, + ) + end + end + + class PrettyListing + def initialize(path) + Pathname.new(path).children.sort_by { |p| p.to_s.downcase }.each do |pn| + case pn.basename.to_s + when "bin", "sbin" + pn.find { |pnn| puts pnn unless pnn.directory? } + when "lib" + print_dir pn do |pnn| + # dylibs have multiple symlinks and we don't care about them + (pnn.extname == ".dylib" || pnn.extname == ".pc") && !pnn.symlink? + end + when ".brew" + next # Ignore .brew + else + if pn.directory? + if pn.symlink? + puts "#{pn} -> #{pn.readlink}" + else + print_dir pn + end + elsif Metafiles.list?(pn.basename.to_s) + puts pn + end + end + end + end + + private + + def print_dir(root) + dirs = [] + remaining_root_files = [] + other = "" + + root.children.sort.each do |pn| + if pn.directory? + dirs << pn + elsif block_given? && yield(pn) + puts pn + other = "other " + elsif pn.basename.to_s != ".DS_Store" + remaining_root_files << pn + end + end + + dirs.each do |d| + files = [] + d.find { |pn| files << pn unless pn.directory? } + print_remaining_files files, d + end + + print_remaining_files remaining_root_files, root, other + end + + def print_remaining_files(files, root, other = "") + if files.length == 1 + puts files + elsif files.length > 1 + puts "#{root}/ (#{files.length} #{other}files)" end end end end - - def print_dir(root) - dirs = [] - remaining_root_files = [] - other = "" - - root.children.sort.each do |pn| - if pn.directory? - dirs << pn - elsif block_given? && yield(pn) - puts pn - other = "other " - elsif pn.basename.to_s != ".DS_Store" - remaining_root_files << pn - end - end - - dirs.each do |d| - files = [] - d.find { |pn| files << pn unless pn.directory? } - print_remaining_files files, d - end - - print_remaining_files remaining_root_files, root, other - end - - def print_remaining_files(files, root, other = "") - if files.length == 1 - puts files - elsif files.length > 1 - puts "#{root}/ (#{files.length} #{other}files)" - end - end end diff --git a/Library/Homebrew/dev-cmd/prof.rb b/Library/Homebrew/dev-cmd/prof.rb index b33bc82bfb..1242b3eace 100644 --- a/Library/Homebrew/dev-cmd/prof.rb +++ b/Library/Homebrew/dev-cmd/prof.rb @@ -1,65 +1,64 @@ -# typed: true +# typed: strict # frozen_string_literal: true +require "abstract_command" require "cli/parser" module Homebrew - module_function + module DevCmd + class Prof < AbstractCommand + cmd_args do + description <<~EOS + Run Homebrew with a Ruby profiler. For example, `brew prof readall`. + EOS + switch "--stackprof", + description: "Use `stackprof` instead of `ruby-prof` (the default)." - sig { returns(CLI::Parser) } - def prof_args - Homebrew::CLI::Parser.new do - description <<~EOS - Run Homebrew with a Ruby profiler. For example, `brew prof readall`. - EOS - switch "--stackprof", - description: "Use `stackprof` instead of `ruby-prof` (the default)." - - named_args :command, min: 1 - end - end - - def prof - args = prof_args.parse - - Homebrew.install_bundler_gems!(groups: ["prof"], setup_path: false) - - brew_rb = (HOMEBREW_LIBRARY_PATH/"brew.rb").resolved_path - FileUtils.mkdir_p "prof" - cmd = args.named.first - - case Commands.path(cmd)&.extname - when ".rb" - # expected file extension so we do nothing - when ".sh" - raise UsageError, <<~EOS - `#{cmd}` is a Bash command! - Try `hyperfine` for benchmarking instead. - EOS - else - raise UsageError, "`#{cmd}` is an unknown command!" - end - - Homebrew.setup_gem_environment! - - if args.stackprof? - with_env HOMEBREW_STACKPROF: "1" do - system(*HOMEBREW_RUBY_EXEC_ARGS, brew_rb, *args.named) + named_args :command, min: 1 + end + + sig { override.void } + def run + Homebrew.install_bundler_gems!(groups: ["prof"], setup_path: false) + + brew_rb = (HOMEBREW_LIBRARY_PATH/"brew.rb").resolved_path + FileUtils.mkdir_p "prof" + cmd = args.named.first + + case Commands.path(cmd)&.extname + when ".rb" + # expected file extension so we do nothing + when ".sh" + raise UsageError, <<~EOS + `#{cmd}` is a Bash command! + Try `hyperfine` for benchmarking instead. + EOS + else + raise UsageError, "`#{cmd}` is an unknown command!" + end + + Homebrew.setup_gem_environment! + + if args.stackprof? + with_env HOMEBREW_STACKPROF: "1" do + system(*HOMEBREW_RUBY_EXEC_ARGS, brew_rb, *args.named) + end + output_filename = "prof/d3-flamegraph.html" + safe_system "stackprof --d3-flamegraph prof/stackprof.dump > #{output_filename}" + else + output_filename = "prof/call_stack.html" + safe_system "ruby-prof", "--printer=call_stack", "--file=#{output_filename}", brew_rb, "--", *args.named + end + + exec_browser output_filename + rescue OptionParser::InvalidOption => e + ofail e + + # The invalid option could have been meant for the subcommand. + # Suggest `brew prof list -r` -> `brew prof -- list -r` + args = ARGV - ["--"] + puts "Try `brew prof -- #{args.join(" ")}` instead." end - output_filename = "prof/d3-flamegraph.html" - safe_system "stackprof --d3-flamegraph prof/stackprof.dump > #{output_filename}" - else - output_filename = "prof/call_stack.html" - safe_system "ruby-prof", "--printer=call_stack", "--file=#{output_filename}", brew_rb, "--", *args.named end - - exec_browser output_filename - rescue OptionParser::InvalidOption => e - ofail e - - # The invalid option could have been meant for the subcommand. - # Suggest `brew prof list -r` -> `brew prof -- list -r` - args = ARGV - ["--"] - puts "Try `brew prof -- #{args.join(" ")}` instead." end end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cli/args.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cli/args.rbi index c88f445543..06f3fb5b04 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cli/args.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cli/args.rbi @@ -146,18 +146,12 @@ class Homebrew::CLI::Args sig { returns(T::Boolean) } def custom_remote?; end - sig { returns(T::Boolean) } - def d?; end - sig { returns(T.nilable(String)) } def days; end sig { returns(T::Boolean) } def debian?; end - sig { returns(T::Boolean) } - def debug?; end - sig { returns(T::Boolean) } def debug_symbols?; end @@ -317,12 +311,6 @@ class Homebrew::CLI::Args sig { returns(T.nilable(T::Array[String])) } def groups; end - sig { returns(T::Boolean) } - def h?; end - - sig { returns(T::Boolean) } - def help?; end - sig { returns(T.nilable(T::Array[String])) } def hide; end @@ -401,9 +389,6 @@ class Homebrew::CLI::Args sig { returns(T.nilable(String)) } def keyboard_layoutdir; end - sig { returns(T::Boolean) } - def l?; end - sig { returns(T.nilable(T::Array[String])) } def language; end @@ -461,9 +446,6 @@ class Homebrew::CLI::Args sig { returns(T::Boolean) } def missing?; end - sig { returns(T::Boolean) } - def multiple?; end - sig { returns(T.nilable(String)) } def n; end @@ -578,9 +560,6 @@ class Homebrew::CLI::Args sig { returns(T::Boolean) } def perl?; end - sig { returns(T::Boolean) } - def pinned?; end - sig { returns(T::Boolean) } def plain?; end @@ -632,18 +611,12 @@ class Homebrew::CLI::Args sig { returns(T.nilable(String)) } def python_package_name; end - sig { returns(T::Boolean) } - def q?; end - sig { returns(T.nilable(String)) } def qlplugindir; end sig { returns(T::Boolean) } def quarantine?; end - sig { returns(T::Boolean) } - def quiet?; end - sig { returns(T.nilable(String)) } def r; end @@ -752,9 +725,6 @@ class Homebrew::CLI::Args sig { returns(T::Boolean) } def skip_style?; end - sig { returns(T::Boolean) } - def stackprof?; end - sig { returns(T.nilable(String)) } def start_with; end @@ -773,9 +743,6 @@ class Homebrew::CLI::Args sig { returns(T::Boolean) } def syntax?; end - sig { returns(T::Boolean) } - def t?; end - sig { returns(T.nilable(String)) } def tag; end @@ -839,15 +806,9 @@ class Homebrew::CLI::Args sig { returns(T.nilable(T::Array[String])) } def user; end - sig { returns(T::Boolean) } - def v?; end - sig { returns(T::Boolean) } def variations?; end - sig { returns(T::Boolean) } - def verbose?; end - sig { returns(T.nilable(String)) } def version; end @@ -857,9 +818,6 @@ class Homebrew::CLI::Args sig { returns(T.nilable(String)) } def version_intel; end - sig { returns(T::Boolean) } - def versions?; end - sig { returns(T.nilable(String)) } def vst3_plugindir; end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/list.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/list.rbi new file mode 100644 index 0000000000..a5b8ec328e --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/list.rbi @@ -0,0 +1,40 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::Cmd::List`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::Cmd::List`. + +class Homebrew::CLI::Args + sig { returns(T::Boolean) } + def cask?; end + + sig { returns(T::Boolean) } + def casks?; end + + sig { returns(T::Boolean) } + def formula?; end + + sig { returns(T::Boolean) } + def formulae?; end + + sig { returns(T::Boolean) } + def full_name?; end + + sig { returns(T::Boolean) } + def l?; end + + sig { returns(T::Boolean) } + def multiple?; end + + sig { returns(T::Boolean) } + def pinned?; end + + sig { returns(T::Boolean) } + def r?; end + + sig { returns(T::Boolean) } + def t?; end + + sig { returns(T::Boolean) } + def versions?; end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/prof.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/prof.rbi new file mode 100644 index 0000000000..27f3a84c69 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/prof.rbi @@ -0,0 +1,10 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::DevCmd::Prof`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::DevCmd::Prof`. + +class Homebrew::CLI::Args + sig { returns(T::Boolean) } + def stackprof?; end +end diff --git a/Library/Homebrew/sorbet/tapioca/compilers/args.rb b/Library/Homebrew/sorbet/tapioca/compilers/args.rb index 5a226f1eaa..b79843eb8f 100644 --- a/Library/Homebrew/sorbet/tapioca/compilers/args.rb +++ b/Library/Homebrew/sorbet/tapioca/compilers/args.rb @@ -2,10 +2,17 @@ # frozen_string_literal: true require_relative "../../../global" +require "cli/parser" module Tapioca module Compilers class Args < Tapioca::Dsl::Compiler + GLOBAL_OPTIONS = T.let( + Homebrew::CLI::Parser.global_options.map do |short_option, long_option, _| + [short_option, long_option].map { "#{Homebrew::CLI::Parser.option_to_name(_1)}?" } + end.flatten.freeze, T::Array[String] + ) + # This is ugly, but we're moving to a new interface that will use a consistent DSL # These are cmd/dev-cmd methods that end in `_args` but are not parsers NON_PARSER_ARGS_METHODS = T.let([ @@ -16,34 +23,34 @@ module Tapioca # FIXME: Enable cop again when https://github.com/sorbet/sorbet/issues/3532 is fixed. # rubocop:disable Style/MutableConstant - ConstantType = type_member { { fixed: T.class_of(Homebrew::CLI::Args) } } + Parsable = T.type_alias { T.any(T.class_of(Homebrew::CLI::Args), T.class_of(Homebrew::AbstractCommand)) } + ConstantType = type_member { { fixed: Parsable } } # rubocop:enable Style/MutableConstant - sig { override.returns(T::Enumerable[T.class_of(Homebrew::CLI::Args)]) } + sig { override.returns(T::Enumerable[Parsable]) } def self.gather_constants # require all the commands to ensure the _arg methods are defined ["cmd", "dev-cmd"].each do |dir| Dir[File.join(__dir__, "../../../#{dir}", "*.rb")].each { require(_1) } end - [Homebrew::CLI::Args] + [Homebrew::CLI::Args] + Homebrew::AbstractCommand.subclasses end sig { override.void } def decorate - root.create_path(Homebrew::CLI::Args) do |klass| - Homebrew.methods(false).select { _1.end_with?("_args") }.each do |args_method_name| - next if NON_PARSER_ARGS_METHODS.include?(args_method_name) + if constant == Homebrew::CLI::Args + root.create_path(Homebrew::CLI::Args) do |klass| + Homebrew.methods(false).select { _1.end_with?("_args") }.each do |args_method_name| + next if NON_PARSER_ARGS_METHODS.include?(args_method_name) - parser = Homebrew.method(args_method_name).call - comma_array_methods = comma_arrays(parser) - args_table(parser).each do |method_name, value| - # some args are used in multiple commands (this is ok as long as they have the same type) - next if klass.nodes.any? { T.cast(_1, RBI::Method).name.to_sym == method_name } - - return_type = get_return_type(method_name, value, comma_array_methods) - klass.create_method(method_name.to_s, return_type:) + parser = Homebrew.method(args_method_name).call + create_args_methods(klass, parser) end end + else + root.create_path(Homebrew::CLI::Args) do |klass| + create_args_methods(klass, T.must(T.cast(constant, T.class_of(Homebrew::AbstractCommand)).parser)) + end end end @@ -69,6 +76,22 @@ module Tapioca "T.nilable(String)" end end + + private + + sig { params(klass: RBI::Scope, parser: Homebrew::CLI::Parser).void } + def create_args_methods(klass, parser) + comma_array_methods = comma_arrays(parser) + args_table(parser).each do |method_name, value| + method_name_str = method_name.to_s + next if GLOBAL_OPTIONS.include?(method_name_str) + # some args are used in multiple commands (this is ok as long as they have the same type) + next if klass.nodes.any? { T.cast(_1, RBI::Method).name == method_name_str } + + return_type = get_return_type(method_name, value, comma_array_methods) + klass.create_method(method_name_str, return_type:) + end + end end end end diff --git a/Library/Homebrew/test/abstract_command_spec.rb b/Library/Homebrew/test/abstract_command_spec.rb new file mode 100644 index 0000000000..0b3b5e05a4 --- /dev/null +++ b/Library/Homebrew/test/abstract_command_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "abstract_command" + +RSpec.describe Homebrew::AbstractCommand do + describe "subclasses" do + before do + cat = Class.new(described_class) do + cmd_args do + switch "--foo" + flag "--bar=" + end + def run; end + end + stub_const("Cat", cat) + end + + describe "parsing args" do + it "parses valid args" do + expect { Cat.new(["--foo"]).run }.not_to raise_error + end + + it "allows access to args" do + expect(Cat.new(["--bar", "baz"]).args[:bar]).to eq("baz") + end + + it "raises on invalid args" do + expect { Cat.new(["--bat"]) }.to raise_error(OptionParser::InvalidOption) + end + end + + describe "command names" do + it "has a default command name" do + expect(Cat.command_name).to eq("cat") + end + + it "can lookup command" do + expect(described_class.command("cat")).to be(Cat) + end + + describe "when command name is overridden" do + before do + tac = Class.new(described_class) do + def self.command_name = "t-a-c" + def run; end + end + stub_const("Tac", tac) + end + + it "can be looked up by command name" do + expect(described_class.command("t-a-c")).to be(Tac) + end + end + end + end +end diff --git a/Library/Homebrew/test/abstract_command_spec.rbi b/Library/Homebrew/test/abstract_command_spec.rbi new file mode 100644 index 0000000000..f282e9a02f --- /dev/null +++ b/Library/Homebrew/test/abstract_command_spec.rbi @@ -0,0 +1,4 @@ +# typed: strict + +class Cat < Homebrew::AbstractCommand; end +class Tac < Homebrew::AbstractCommand; end diff --git a/Library/Homebrew/test/cmd/list_spec.rb b/Library/Homebrew/test/cmd/list_spec.rb index 414862251c..b5b4a52902 100644 --- a/Library/Homebrew/test/cmd/list_spec.rb +++ b/Library/Homebrew/test/cmd/list_spec.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true +require "cmd/list" require "cmd/shared_examples/args_parse" -RSpec.describe "brew list" do +RSpec.describe Homebrew::Cmd::List do let(:formulae) { %w[bar foo qux] } it_behaves_like "parseable arguments" diff --git a/Library/Homebrew/test/cmd/shared_examples/args_parse.rb b/Library/Homebrew/test/cmd/shared_examples/args_parse.rb index efce6f3e96..b2a89271ae 100644 --- a/Library/Homebrew/test/cmd/shared_examples/args_parse.rb +++ b/Library/Homebrew/test/cmd/shared_examples/args_parse.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples "parseable arguments" do +RSpec.shared_examples "parseable arguments" do |argv: []| subject(:method_name) { "#{command_name.tr("-", "_")}_args" } let(:command_name) do |example| @@ -8,10 +8,13 @@ RSpec.shared_examples "parseable arguments" do end it "can parse arguments" do - require "dev-cmd/#{command_name}" unless require? "cmd/#{command_name}" - - parser = Homebrew.public_send(method_name) - - expect(parser).to respond_to(:parse) + if described_class + cmd = described_class.new(argv) + expect(cmd.args).to be_a Homebrew::CLI::Args + else + require "dev-cmd/#{command_name}" unless require? "cmd/#{command_name}" + parser = Homebrew.public_send(method_name) + expect(parser).to respond_to(:parse) + end end end diff --git a/Library/Homebrew/test/dev-cmd/prof_spec.rb b/Library/Homebrew/test/dev-cmd/prof_spec.rb index 25565776d1..7737daa100 100644 --- a/Library/Homebrew/test/dev-cmd/prof_spec.rb +++ b/Library/Homebrew/test/dev-cmd/prof_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require "cmd/shared_examples/args_parse" +require "dev-cmd/prof" -RSpec.describe "brew prof" do - it_behaves_like "parseable arguments" +RSpec.describe Homebrew::DevCmd::Prof do + it_behaves_like "parseable arguments", argv: ["--", "help"] describe "integration tests", :integration_test, :needs_network do after do diff --git a/Library/Homebrew/test/sorbet/tapioca/compilers/args_spec.rb b/Library/Homebrew/test/sorbet/tapioca/compilers/args_spec.rb index 631a80d2c8..7cc5318923 100644 --- a/Library/Homebrew/test/sorbet/tapioca/compilers/args_spec.rb +++ b/Library/Homebrew/test/sorbet/tapioca/compilers/args_spec.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true -# require 'tapioca' require "tapioca/dsl" -require_relative "../../../../sorbet/tapioca/compilers/args" +require "sorbet/tapioca/compilers/args" RSpec.describe Tapioca::Compilers::Args do let(:compiler) { described_class.new(Tapioca::Dsl::Pipeline.new(requested_constants: []), RBI::Tree.new, Homebrew) } let(:list_parser) do require "cmd/list" - Homebrew.list_args + Homebrew::Cmd::List.parser end # good testing candidate, bc it has multiple for each of switch, flag, and comma_array args: let(:update_python_resources_parser) do