diff --git a/Library/Homebrew/abstract_command.rb b/Library/Homebrew/abstract_command.rb new file mode 100644 index 0000000000..82fcdf4994 --- /dev/null +++ b/Library/Homebrew/abstract_command.rb @@ -0,0 +1,42 @@ +# typed: strong +# frozen_string_literal: true + +module Homebrew + class AbstractCommand + extend T::Helpers + + abstract! + + class << self + # registers subclasses for lookup by command name + sig { params(subclass: T.class_of(AbstractCommand)).void } + def inherited(subclass) + super + @cmds ||= T.let({}, T.nilable(T::Hash[String, T.class_of(AbstractCommand)])) + @cmds[subclass.command_name] = subclass + end + + sig { params(name: String).returns(T.nilable(T.class_of(AbstractCommand))) } + def command(name) = @cmds&.[](name) + + sig { returns(String) } + def command_name = T.must(name).split("::").fetch(-1).downcase + end + + # @note because `Args` makes use `OpenStruct`, subclasses may need to use a tapioca compiler, + # hash accessors, args.rbi, or other means to make this work with legacy commands: + sig { returns(Homebrew::CLI::Args) } + attr_reader :args + + sig { void } + def initialize + @args = T.let(raw_args.parse, Homebrew::CLI::Args) + end + + sig { abstract.returns(CLI::Parser) } + def raw_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/cmd/list.rb b/Library/Homebrew/cmd/list.rb index 1b69e2596e..47a05b8d62 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,233 @@ 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." + sig { override.returns(CLI::Parser) } + def raw_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." - 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 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(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 - def self.list - args = list_args.parse + ls_args = [] + ls_args << "-1" if args[:"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(args:) if casks.present? + end + end end - if args.cask? || (!args.formula? && args.no_named?) - cask_names = if args.no_named? + + private + + def 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 list_casks(args:) + casks = if args.no_named? Cask::Caskroom.casks else - args.named.to_formulae_and_casks(only: :cask, method: :resolve) + 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 - 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 + return if casks.blank? - 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? + Cask::List.list_casks( + *casks, + one: args.public_send(:"1?"), + full_name: args.full_name?, + versions: args.versions?, + ) 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}" + 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 - print_dir pn + 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 - elsif Metafiles.list?(pn.basename.to_s) - puts pn + 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 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/test/cmd/list_spec.rb b/Library/Homebrew/test/cmd/list_spec.rb index 414862251c..6a9e34c9e7 100644 --- a/Library/Homebrew/test/cmd/list_spec.rb +++ b/Library/Homebrew/test/cmd/list_spec.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true -require "cmd/shared_examples/args_parse" +require "cmd/list" -RSpec.describe "brew list" do +RSpec.describe Homebrew::Cmd::List do let(:formulae) { %w[bar foo qux] } - it_behaves_like "parseable arguments" - it "prints all installed Formulae", :integration_test do formulae.each do |f| (HOMEBREW_CELLAR/f/"1.0/somedir").mkpath