diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..438f93fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - '3.1' + - '3.2' + - '3.3' + - '3.4' + - '4.0.6' + - jruby + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake spec + + lint: + name: RuboCop + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0.6' + bundler-cache: true + + - name: Run RuboCop + run: bundle exec rubocop + + security: + name: Bundler Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0.6' + bundler-cache: true + + - name: Run bundler-audit + run: bundle exec bundler-audit check --update diff --git a/.gitignore b/.gitignore index 1f8e0133..f2d7175a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ Gemfile.lock pkg/* .rvmrc -.ruby-version coverage/* diff --git a/.rubocop.yml b/.rubocop.yml index ddd0e0b5..fede4d20 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,7 @@ inherit_from: .rubocop_todo.yml AllCops: - TargetRubyVersion: 3.0 + TargetRubyVersion: 3.1 NewCops: enable SuggestExtensions: false @@ -54,9 +54,6 @@ Style/FormatStringToken: Style/MixinUsage: Enabled: false -Lint/ErbNewArguments: - Enabled: false - Style/DocumentDynamicEvalDefinition: Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 936c5f21..1ea17a55 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,77 +1,81 @@ -# This configuration was generated by `rubocop --auto-gen-config` -# on 2015-02-16 16:08:54 -0800 using RuboCop version 0.29.0. +# This configuration was generated by +# `rubocop --auto-gen-config --no-auto-gen-timestamp` +# using RuboCop version 1.88.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 2 -Security/Eval: - Enabled: false - -# Offense count: 2 +# Offense count: 1 +# Configuration parameters: AllowComments, AllowNil. Lint/SuppressedException: - Enabled: false + Exclude: + - 'lib/commander/user_interaction.rb' -# Offense count: 5 +# Offense count: 6 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 30 # Offense count: 1 -# Configuration parameters: CountComments. +# Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Enabled: false + Max: 250 -# Offense count: 4 +# Offense count: 5 +# Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: Max: 13 -# Offense count: 89 -# Configuration parameters: AllowURI, URISchemes. -Layout/LineLength: - Max: 242 - -# Offense count: 7 -# Configuration parameters: CountComments. +# Offense count: 8 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 36 -# Offense count: 4 +# Offense count: 5 +# Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: Max: 14 -# Offense count: 1 +# Offense count: 2 Naming/AccessorMethodName: - Enabled: false - -# Offense count: 18 -Style/Documentation: - Enabled: false - -# Offense count: 12 -# Configuration parameters: AllowedVariables. -Style/GlobalVars: - Enabled: false + Exclude: + - 'lib/commander/core_ext/object.rb' + - 'lib/commander/help_formatters.rb' # Offense count: 1 -# Configuration parameters: MaxLineLength. +Security/Eval: + Exclude: + - 'lib/commander/delegates.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). Style/IfUnlessModifier: - Enabled: false + Exclude: + - 'lib/commander/runner.rb' + - 'lib/commander/user_interaction.rb' # Offense count: 1 Style/MultilineBlockChain: - Enabled: false + Exclude: + - 'lib/commander/user_interaction.rb' # Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). Style/MultilineTernaryOperator: - Enabled: false + Exclude: + - 'lib/commander/user_interaction.rb' -# Offense count: 5 +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). Style/RescueModifier: - Enabled: false + Exclude: + - 'lib/commander/runner.rb' + - 'lib/commander/user_interaction.rb' -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, Whitelist. -Style/TrivialAccessors: - Enabled: false +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Max: 242 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..d13e837c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5dd410b9..00000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: ruby -cache: bundler -before_install: - - gem update --system - - gem update bundler -rvm: - - 3.0 - - 3.1 - - 3.2 - - 3.3 - - jruby - - ruby-head diff --git a/Gemfile b/Gemfile index 7f4f5e95..ca5b73d6 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,13 @@ source 'https://rubygems.org' gemspec + +group :development, :test do + gem 'bundler-audit', '~> 0.9' + gem 'rake', '~> 13.0' + gem 'rspec', '~> 3.13' + gem 'rubocop', '~> 1.88' + # 1.x requires Ruby >= 3.2; allow the last 0.x on Ruby 3.1 while newer + # rubies still resolve to 1.x. + gem 'simplecov', '>= 0.22', '< 2' +end diff --git a/Manifest b/Manifest deleted file mode 100644 index 6702e7a3..00000000 --- a/Manifest +++ /dev/null @@ -1,38 +0,0 @@ -DEVELOPMENT -History.rdoc -Manifest -README.rdoc -Rakefile -bin/commander -commander.gemspec -lib/commander.rb -lib/commander/blank.rb -lib/commander/command.rb -lib/commander/core_ext.rb -lib/commander/core_ext/array.rb -lib/commander/core_ext/object.rb -lib/commander/delegates.rb -lib/commander/help_formatters.rb -lib/commander/help_formatters/base.rb -lib/commander/help_formatters/terminal.rb -lib/commander/help_formatters/terminal/command_help.erb -lib/commander/help_formatters/terminal/help.erb -lib/commander/help_formatters/terminal_compact.rb -lib/commander/help_formatters/terminal_compact/command_help.erb -lib/commander/help_formatters/terminal_compact/help.erb -lib/commander/import.rb -lib/commander/platform.rb -lib/commander/runner.rb -lib/commander/user_interaction.rb -lib/commander/version.rb -spec/command_spec.rb -spec/core_ext/array_spec.rb -spec/core_ext/object_spec.rb -spec/help_formatters/terminal_spec.rb -spec/runner_spec.rb -spec/spec.opts -spec/spec_helper.rb -spec/ui_spec.rb -tasks/dev_setup.rake -tasks/docs.rake -tasks/gemspec.rake diff --git a/README.md b/README.md index a48f166d..dd251e07 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -[Build Status](https://travis-ci.org/commander-rb/commander) -[![Inline docs](https://inch-ci.org/github/commander-rb/commander.svg)](https://inch-ci.org/github/commander-rb/commander) +[![CI](https://github.com/commander-rb/commander/actions/workflows/ci.yml/badge.svg)](https://github.com/commander-rb/commander/actions/workflows/ci.yml) # Commander @@ -24,9 +23,12 @@ features, and an elegant API. * Sexy paging for long bodies of text * Support for MacOS text-to-speech * Command aliasing (very powerful, as both switches and arguments can be used) -* Growl notification support for MacOS * Use the `commander` executable to initialize a commander driven program +## Requirements + +Ruby >= 3.1 (tested through Ruby 4.0). + ## Installation $ gem install commander @@ -50,7 +52,6 @@ as well as an object, specifying a method to call, so view the RDoc for more inf ### Classic style ```ruby -require 'rubygems' require 'commander/import' # :name is optional, otherwise uses the basename of this executable @@ -93,7 +94,6 @@ $ foobar bar --suffix '}' --prefix '{' **NOTE:** Make sure to use `require 'commander'` rather than `require 'commander/import'`, otherwise Commander methods will still be imported into the global namespace. ```ruby -require 'rubygems' require 'commander' class MyApplication @@ -122,7 +122,6 @@ MyApplication.new.run if $0 == __FILE__ ### Block style ```ruby -require 'rubygems' require 'commander' Commander.configure do @@ -240,30 +239,6 @@ else end ``` -## Growl Notifications - -Commander provides methods for displaying Growl notifications. To use these -methods you need to install https://github.com/tj/growl which utilizes -the [growlnotify](https://growl.info/extras.php#growlnotify) executable. Note that -growl is auto-imported by Commander when available, no need to require. - -```ruby -# Display a generic Growl notification -notify 'Something happened' - -# Display an 'info' status notification -notify_info 'You have #{emails.length} new email(s)' - -# Display an 'ok' status notification -notify_ok 'Gems updated' - -# Display a 'warning' status notification -notify_warning '1 gem failed installation' - -# Display an 'error' status notification -notify_error "Gem #{name} failed" -``` - ## Commander Goodies ### Option Defaults @@ -459,11 +434,11 @@ For feature rich ASCII tables for your terminal app check out the terminal-table ## Running Specifications - $ rake spec + $ bundle exec rake spec OR - $ spec --color spec + $ bundle exec rspec ## Contrib diff --git a/commander.gemspec b/commander.gemspec index 7cf6f2a2..4b8bbb0b 100644 --- a/commander.gemspec +++ b/commander.gemspec @@ -18,18 +18,13 @@ Gem::Specification.new do |s| 'documentation_uri' => "https://www.rubydoc.info/gems/commander/#{s.version}", 'homepage_uri' => s.homepage, 'source_code_uri' => "#{s.homepage}/tree/v#{s.version}", + 'rubygems_mfa_required' => 'true', } - s.required_ruby_version = '>= 3.0' + s.required_ruby_version = '>= 3.1' s.files = `git ls-files`.split("\n").reject { |f| f.match(%r{^(test|spec|features)/}) } - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ['lib'] - s.add_runtime_dependency('highline', '~> 3.0.0') - - s.add_development_dependency('rake') - s.add_development_dependency('rspec', '~> 3.2') - s.add_development_dependency('rubocop', '~> 1.12.1') - s.add_development_dependency('simplecov') + s.add_dependency('highline', '~> 3.1') end diff --git a/lib/commander/blank.rb b/lib/commander/blank.rb index 923ac697..d40f9af5 100644 --- a/lib/commander/blank.rb +++ b/lib/commander/blank.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true +## +# Strips a class of (almost) all its instance methods, leaving it as a +# near-blank slate. Mixed into Commander::Command::Options so that +# +method_missing+ can treat arbitrary option names as accessors without +# clashing with methods Ruby defines on every object (e.g. +send+, +type+). module Blank + ## + # Undefines every instance method on +base+ except those beginning with + # a double underscore and +object_id+, which are kept so the resulting + # object remains minimally usable (e.g. for +__send__+ and identity checks). def self.included(base) base.class_eval do instance_methods.each { |m| undef_method m unless m =~ /^__|object_id/ } diff --git a/lib/commander/command.rb b/lib/commander/command.rb index beca2054..ba5a2331 100644 --- a/lib/commander/command.rb +++ b/lib/commander/command.rb @@ -3,13 +3,29 @@ require 'optparse' module Commander + ## + # A single named command belonging to a Commander::Runner: its + # description, examples, options (parsed via OptionParser), and the + # handler invoked with the remaining arguments once options have been + # parsed. Built up via the block yielded by Runner#command, e.g. + # + # command :foo do |c| + # c.syntax = 'foo [options]' + # c.option '--bar', 'A switch' + # c.action { |args, options| ... } + # end class Command attr_accessor :name, :examples, :syntax, :description, :summary, :proxy_options, :options attr_reader :global_options ## # Options struct. - + # + # An open-ended, OpenStruct-like object populated by #option and + # #proxy_option_struct. Reading an unset attribute returns +nil+; + # writing any attribute (via +some_attr=+) stores it. Almost all of + # Object's own instance methods are stripped out (see Blank) so that + # option names never collide with built-ins like +send+ or +class+. class Options include Blank @@ -17,14 +33,22 @@ def initialize @table = {} end + ## + # The underlying Hash of option name (Symbol) => value pairs. def __hash__ @table end + ## + # Reads or writes an arbitrary option. options.foo reads + # the +:foo+ entry; options.foo = 1 writes it. def method_missing(meth, *args) meth.to_s =~ /=$/ ? @table[meth.to_s.chop.to_sym] = args.first : @table[meth] end + ## + # Merge _defaults_ into this struct, without overwriting any + # options already set. def default(defaults = {}) @table = defaults.merge! @table end @@ -36,7 +60,6 @@ def inspect ## # Initialize new command with specified _name_. - def initialize(name) @name, @examples, @when_called = name.to_s, [], [] @options, @proxy_options = [], [] @@ -55,7 +78,6 @@ def initialize(name) # c.example "Should do something", "my_command something" # end # - def example(description, command) @examples << [description, command] end @@ -106,7 +128,6 @@ def example(description, command) # c.option '--time TIME', Time # c.option '--date [DATE]', Date # - def option(*args, &block) switches, description = Runner.separate_switches_from_description(*args) proc = block || option_proc(switches) @@ -138,7 +159,6 @@ def option(*args, &block) # # Pass an object to handle callback (requires method symbol) # c.when_called SomeObject, :some_method # - def when_called(*args, &block) fail ArgumentError, 'must pass an object, class, or block.' if args.empty? && !block @@ -152,17 +172,15 @@ def when_called(*args, &block) # * parses options, call option blocks # * invokes when_called proc # - def run(*args) call parse_options_and_call_procs(*args) end - #:stopdoc: + # :stopdoc: ## # Parses options and calls associated procs, # returning the arguments remaining. - def parse_options_and_call_procs(*args) return args if args.empty? @@ -177,7 +195,6 @@ def parse_options_and_call_procs(*args) ## # Call the commands when_called block with _args_. - def call(args = []) object, meth = @when_called[0, 2] meth ||= :call @@ -193,7 +210,6 @@ def call(args = []) ## # Creates an Options instance populated with the option values # collected by the #option_proc. - def proxy_option_struct (global_options + proxy_options).each_with_object(Options.new) do |(option, value), options| # options that are present will evaluate to true @@ -207,7 +223,6 @@ def proxy_option_struct # Option proxy proc used when a block is not explicitly passed # via the #option method. This allows commander to auto-populate # and work with option values. - def option_proc(switches) ->(value) { proxy_options << [Runner.switch_to_sym(switches.last), value] } end diff --git a/lib/commander/configure.rb b/lib/commander/configure.rb index 13a79a45..12a1d9f2 100644 --- a/lib/commander/configure.rb +++ b/lib/commander/configure.rb @@ -1,11 +1,39 @@ # frozen_string_literal: true +## +# Commander is the top-level namespace for the whole gem: see +# Commander::Methods for the DSL mixin, Commander::Runner for the engine +# that drives it, and Commander::Command for individual commands. module Commander - def configure(*configuration_opts, &configuration_block) + ## + # Runs a Commander program within an isolated, anonymous module rather + # than polluting the including object (as Commander::Methods normally + # does when included directly). Useful for embedding a Commander CLI + # inside a larger application without leaking its DSL methods. Calls + # Runner#run! once the block has finished configuring commands. + # + # Any _configuration_opts_ are passed through as block arguments. + # + # === Examples + # + # Commander.configure do + # program :name, 'my-tool' + # program :version, '1.0.0' + # program :description, 'Does something useful' + # + # command :greet do |c| + # c.action { |args, _options| say "Hello, #{args.first}!" } + # end + # end + # + # Commander.configure(default_name) do |name| + # program :name, name + # end + def configure(*configuration_opts, &) configuration_module = Module.new configuration_module.extend Commander::Methods - configuration_module.class_exec(*configuration_opts, &configuration_block) + configuration_module.class_exec(*configuration_opts, &) configuration_module.class_exec do run! diff --git a/lib/commander/core_ext/array.rb b/lib/commander/core_ext/array.rb index 39b9b897..94107c0e 100644 --- a/lib/commander/core_ext/array.rb +++ b/lib/commander/core_ext/array.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +## +# Adds Array.parse, used by HighLine's #ask/#ask_for_array so that +# +ask '...', Array+ splits a raw response into a Ruby Array. class Array ## # Split _string_ into an array. Used in @@ -17,7 +20,6 @@ class Array # # or use ask_for_CLASS # list = ask_for_array 'Favorite cookies: ' # - def self.parse(string) # Using reverse + lookahead to work around Ruby 1.8's lack of lookbehind # TODO: simplify now that we don't support Ruby 1.8 diff --git a/lib/commander/core_ext/object.rb b/lib/commander/core_ext/object.rb index 644dec0c..a23c7789 100644 --- a/lib/commander/core_ext/object.rb +++ b/lib/commander/core_ext/object.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +## +# Adds Object#get_binding, used by HelpFormatter::Context to capture a +# target object's binding for evaluation inside an ERB help template. class Object ## # Return the current binding. - def get_binding binding end diff --git a/lib/commander/delegates.rb b/lib/commander/delegates.rb index fe3854f8..cd5ebdda 100644 --- a/lib/commander/delegates.rb +++ b/lib/commander/delegates.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true module Commander + ## + # = Delegates + # + # Mixed into Commander::Methods to expose the singleton + # Commander::Runner instance's DSL (+command+, +program+, +run!+, etc.) + # as top-level methods, so scripts can call them directly instead of + # reaching through Commander::Runner.instance. module Delegates %w( add_command @@ -20,6 +27,9 @@ def #{meth}(*args, &block) END end + ## + # Delegates to Commander::Runner#commands, returning the hash of + # registered command name => Commander::Command pairs. def defined_commands(...) ::Commander::Runner.instance.commands(...) end diff --git a/lib/commander/help_formatters.rb b/lib/commander/help_formatters.rb index 12cac3a7..404fe3ec 100644 --- a/lib/commander/help_formatters.rb +++ b/lib/commander/help_formatters.rb @@ -1,16 +1,30 @@ # frozen_string_literal: true module Commander + ## + # = Help Formatter + # + # Commander's help formatters control the output when + # either the help command, or --help switch are called. + # The default formatter is Commander::HelpFormatter::Terminal. module HelpFormatter autoload :Base, 'commander/help_formatters/base' autoload :Terminal, 'commander/help_formatters/terminal' autoload :TerminalCompact, 'commander/help_formatters/terminal_compact' + ## + # Exposes _target_'s binding to an ERB template, letting the template + # reference _target_'s instance variables and private methods as if + # they were local variables/bare method calls. Subclasses can inject + # additional template-local variables by overriding #decorate_binding. class Context def initialize(target) @target = target end + ## + # The (possibly decorated) binding of _target_, suitable for + # passing to ERB#result. def get_binding @target.instance_eval { binding }.tap do |bind| decorate_binding(bind) @@ -22,20 +36,31 @@ def decorate_binding(_bind) end end + ## + # A Context for rendering the global help template (see + # HelpFormatter::Terminal#render), decorating the target Runner's + # binding with +max_command_length+/+max_aliases_length+ locals so + # the template can align its columns. class ProgramContext < Context def decorate_binding(bind) bind.eval("max_command_length = #{max_command_length(bind)}") bind.eval("max_aliases_length = #{max_aliases_length(bind)}") end + ## + # Length, in characters, of the longest registered command name. def max_command_length(bind) max_key_length(bind.eval('@commands')) end + ## + # Length, in characters, of the longest registered command alias. def max_aliases_length(bind) max_key_length(bind.eval('@aliases')) end + ## + # Length of the longest key in _hash_, or _default_ when _hash_ is empty. def max_key_length(hash, default = 20) longest = hash.keys.max_by(&:size) longest ? longest.size : default @@ -44,6 +69,9 @@ def max_key_length(hash, default = 20) module_function + ## + # Indent each line of _text_ (after the first) by _amount_ spaces. + # Used by the help templates to align multi-line descriptions. def indent(amount, text) text.to_s.gsub("\n", "\n#{' ' * amount}") end diff --git a/lib/commander/help_formatters/base.rb b/lib/commander/help_formatters/base.rb index d6b3126e..7fd4656a 100644 --- a/lib/commander/help_formatters/base.rb +++ b/lib/commander/help_formatters/base.rb @@ -1,23 +1,27 @@ # frozen_string_literal: true module Commander - ## - # = Help Formatter - # - # Commander's help formatters control the output when - # either the help command, or --help switch are called. - # The default formatter is Commander::HelpFormatter::Terminal. - module HelpFormatter + ## + # Base class for help formatters. Subclasses receive the + # Commander::Runner instance being documented and must implement + # #render (global help) and #render_command (per-command help). This + # base implementation renders placeholder text and exists mainly for + # testing and as a minimal example. class Base def initialize(runner) @runner = runner end + ## + # Render the global help text shown for --help with no + # command, or the +help+ command with no arguments. def render 'Implement global help here' end + ## + # Render the help text for a single _command_. def render_command(command) "Implement help for #{command.name} here" end diff --git a/lib/commander/help_formatters/terminal.rb b/lib/commander/help_formatters/terminal.rb index 1a42619b..15288d24 100644 --- a/lib/commander/help_formatters/terminal.rb +++ b/lib/commander/help_formatters/terminal.rb @@ -4,21 +4,29 @@ module Commander module HelpFormatter + ## + # The default help formatter. Renders full-width, verbosely-labeled + # help text (examples, per-option descriptions, etc.) from the ERB + # templates in the neighboring +terminal/+ directory. See + # HelpFormatter::TerminalCompact for a more condensed alternative. class Terminal < Base + ## + # Render the global help text for the program. def render template(:help).result(ProgramContext.new(@runner).get_binding) end + ## + # Render the help text for a single _command_. def render_command(command) template(:command_help).result(Context.new(command).get_binding) end + ## + # Compile the ERB template named _name_ (+:help+ or + # +:command_help+) from this formatter's template directory. def template(name) - if RUBY_VERSION < '2.6' - ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal', "#{name}.erb")), nil, '-') - else - ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal', "#{name}.erb")), trim_mode: '-') - end + ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal', "#{name}.erb")), trim_mode: '-') end end end diff --git a/lib/commander/help_formatters/terminal_compact.rb b/lib/commander/help_formatters/terminal_compact.rb index 6bced60d..82f3a4c5 100644 --- a/lib/commander/help_formatters/terminal_compact.rb +++ b/lib/commander/help_formatters/terminal_compact.rb @@ -4,13 +4,16 @@ module Commander module HelpFormatter + ## + # A condensed variant of HelpFormatter::Terminal, using the ERB + # templates in the neighboring +terminal_compact/+ directory. Enable + # it with program :help_formatter, :compact. class TerminalCompact < Terminal + ## + # Compile the ERB template named _name_ (+:help+ or + # +:command_help+) from this formatter's template directory. def template(name) - if RUBY_VERSION < '2.6' - ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal_compact', "#{name}.erb")), nil, '-') - else - ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal_compact', "#{name}.erb")), trim_mode: '-') - end + ERB.new(File.read(File.join(File.dirname(__FILE__), 'terminal_compact', "#{name}.erb")), trim_mode: '-') end end end diff --git a/lib/commander/methods.rb b/lib/commander/methods.rb index c719260f..c508a280 100644 --- a/lib/commander/methods.rb +++ b/lib/commander/methods.rb @@ -1,6 +1,18 @@ # frozen_string_literal: true module Commander + ## + # = Methods + # + # The primary entry point for adding Commander's DSL to an object. + # Bundles Commander::UI (terminal output/prompting helpers), + # Commander::UI::AskForClass (the +ask_for_*+ family of methods), and + # Commander::Delegates (the +command+/+program+/+run!+ DSL) into a + # single mixin. require 'commander/import' includes this into + # the top-level Object; embedders typically include it directly instead. + # + # When connected to a sufficiently wide terminal, also configures + # HighLine to wrap output five columns short of the terminal width. module Methods include Commander::UI include Commander::UI::AskForClass diff --git a/lib/commander/platform.rb b/lib/commander/platform.rb index 35b6f43c..7c6d301f 100644 --- a/lib/commander/platform.rb +++ b/lib/commander/platform.rb @@ -1,7 +1,13 @@ # frozen_string_literal: true module Commander + ## + # = Platform + # + # Small helpers for detecting the running Ruby engine. module Platform + ## + # Whether the current process is running under JRuby. def self.jruby? defined?(RUBY_ENGINE) && (RUBY_ENGINE == 'jruby') end diff --git a/lib/commander/runner.rb b/lib/commander/runner.rb index 0d02bd9d..20244379 100644 --- a/lib/commander/runner.rb +++ b/lib/commander/runner.rb @@ -3,13 +3,23 @@ require 'optparse' module Commander + ## + # = Runner + # + # The engine behind Commander's DSL: holds the registered commands, + # global options, and program metadata, parses ARGV, and dispatches to + # the matched Commander::Command. Accessed as a singleton + # (Runner.instance) by Commander::Delegates so that the top-level + # +command+/+program+/+run!+ methods all operate on the same runner. class Runner #-- # Exceptions #++ + # Raised when required program information (e.g. :version) is missing. class CommandError < StandardError; end + # Raised when no command matches the given arguments. class InvalidCommandError < CommandError; end attr_reader :commands, :options, :help_formatter_aliases @@ -17,7 +27,6 @@ class InvalidCommandError < CommandError; end ## # Initialize a new command runner. Optionally # supplying _args_ for mocking, or arbitrary usage. - def initialize(args = ARGV) @args, @commands, @aliases, @options = args, {}, {}, [] @help_formatter_aliases = help_formatter_alias_defaults @@ -29,14 +38,12 @@ def initialize(args = ARGV) ## # Return singleton Runner instance. - def self.instance @instance ||= new end ## # Run command parsing and execution process. - def run! trace = @always_trace || false require_program :version, :description @@ -78,14 +85,12 @@ def run! ## # Return program version. - def version format('%s %s', program(:name), program(:version)) end ## # Enable tracing on all executions (bypasses --trace) - def always_trace! @always_trace = true @never_trace = false @@ -93,7 +98,6 @@ def always_trace! ## # Hide the trace option from the help menus and don't add it as a global option - def never_trace! @never_trace = true @always_trace = false @@ -127,7 +131,6 @@ def never_trace! # :help_paging Flag for toggling help paging # :int_message Message to display when interrupted (CTRL + C) # - def program(key, *args, &block) if key == :help && !args.empty? @program[:help] ||= {} @@ -138,7 +141,7 @@ def program(key, *args, &block) @program[key] = block else unless args.empty? - @program[key] = args.count == 1 ? args[0] : args + @program[key] = args.length == 1 ? args[0] : args end @program[key] end @@ -157,7 +160,6 @@ def program(key, *args, &block) # end # end # - def command(name, &block) yield add_command(Commander::Command.new(name)) if block @commands[name.to_s] @@ -166,7 +168,6 @@ def command(name, &block) ## # Add a global option; follows the same syntax as Command#option # This would be used for switches such as --version, --trace, etc. - def global_option(*args, &block) switches, description = Runner.separate_switches_from_description(*args) @options << { @@ -180,7 +181,6 @@ def global_option(*args, &block) ## # Alias command _name_ with _alias_name_. Optionally _args_ may be passed # as if they were being passed straight to the original command via the command-line. - def alias_command(alias_name, name, *args) @commands[alias_name.to_s] = command name @aliases[alias_name.to_s] = args @@ -189,37 +189,32 @@ def alias_command(alias_name, name, *args) ## # Default command _name_ to be used when no other # command is found in the arguments. - def default_command(name) @default_command = name end ## # Add a command object to this runner. - def add_command(command) @commands[command.name] = command end ## # Check if command _name_ is an alias. - def alias?(name) @aliases.include? name.to_s end ## # Check if a command _name_ exists. - def command_exists?(name) @commands[name.to_s] end - #:stopdoc: + # :stopdoc: ## # Get active command within arguments passed to this runner. - def active_command @active_command ||= command(command_name_from_args) end @@ -228,14 +223,12 @@ def active_command # Attempts to locate a command name from within the arguments. # Supports multi-word commands, using the largest possible match. # Returns the default command, if no valid commands found in the args. - def command_name_from_args - @command_name_from_args ||= (longest_valid_command_name_from(@args) || @default_command) + @command_name_from_args ||= longest_valid_command_name_from(@args) || @default_command end ## # Returns array of valid command names found within _args_. - def valid_command_names_from(*args) remove_global_options options, args arg_string = args.delete_if { |value| value =~ /^-/ }.join ' ' @@ -244,14 +237,12 @@ def valid_command_names_from(*args) ## # Help formatter instance. - def help_formatter @help_formatter ||= program(:help_formatter).new self end ## # Return arguments without the command name. - def args_without_command_name removed = [] parts = command_name_from_args.split rescue [] @@ -262,7 +253,6 @@ def args_without_command_name ## # Returns hash of help formatter alias defaults. - def help_formatter_alias_defaults { compact: HelpFormatter::TerminalCompact, @@ -271,7 +261,6 @@ def help_formatter_alias_defaults ## # Returns hash of program defaults. - def program_defaults { help_formatter: HelpFormatter::Terminal, @@ -283,7 +272,6 @@ def program_defaults ## # Creates default commands such as 'help' which is # essentially the same as using the --help switch. - def create_default_commands command :help do |c| c.syntax = 'commander help [command]' @@ -309,7 +297,6 @@ def create_default_commands ## # Raises InvalidCommandError when a _command_ is not found. - def require_valid_command(command = active_command) fail InvalidCommandError, 'invalid command', caller if command.nil? end @@ -318,7 +305,6 @@ def require_valid_command(command = active_command) # Removes global _options_ from _args_. This prevents an invalid # option error from occurring when options are parsed # again for the command. - def remove_global_options(options, args) options.each do |option| switches = option[:switches] @@ -358,8 +344,8 @@ def remove_global_options(options, args) def expand_optionally_negative_switches(switches) switches.reduce([]) do |memo, val| if val =~ /\[no-\]/ - memo << val.gsub(/\[no-\]/, '') - memo << val.gsub(/\[no-\]/, 'no-') + memo << val.gsub('[no-]', '') + memo << val.gsub('[no-]', 'no-') else memo << val end @@ -368,7 +354,6 @@ def expand_optionally_negative_switches(switches) ## # Parse global command options. - def parse_global_options parser = options.inject(OptionParser.new) do |options, option| options.on(*option[:args], &global_option_proc(option[:switches], &option[:proc])) @@ -389,7 +374,6 @@ def parse_global_options # This functionality works whether a block is present for the global # option or not, so simple switches such as --verbose can be used # without a block, and used throughout all commands. - def global_option_proc(switches, &block) lambda do |value| unless active_command.nil? @@ -401,7 +385,6 @@ def global_option_proc(switches, &block) ## # Raises a CommandError when the program any of the _keys_ are not present, or empty. - def require_program(*keys) keys.each do |key| fail CommandError, "program #{key} required" if program(key).nil? || program(key).empty? @@ -410,7 +393,6 @@ def require_program(*keys) ## # Return switches and description separated from the _args_ passed. - def self.separate_switches_from_description(*args) switches = args.find_all { |arg| arg.to_s =~ /^-/ } description = args.last if args.last.is_a?(String) && !args.last.match(/^-/) @@ -428,14 +410,12 @@ def self.separate_switches_from_description(*args) # --file FILE # => :file # --list of,things # => :list # - def self.switch_to_sym(switch) - switch.scan(/[\-\]](\w+)/).join('_').to_sym rescue nil + switch.scan(/[-\]](\w+)/).join('_').to_sym rescue nil end ## # Run the active command. - def run_active_command require_valid_command if alias? command_name_from_args @@ -445,7 +425,7 @@ def run_active_command end end - def say(*args) #:nodoc: + def say(*args) # :nodoc: HighLine.default_instance.say(*args) end @@ -454,7 +434,6 @@ def say(*args) #:nodoc: ## # Attempts to locate a command name from within the provided arguments. # Supports multi-word commands, using the largest possible match. - def longest_valid_command_name_from(args) valid_command_names_from(*args.dup).max end diff --git a/lib/commander/user_interaction.rb b/lib/commander/user_interaction.rb index 84b81ad1..70281056 100644 --- a/lib/commander/user_interaction.rb +++ b/lib/commander/user_interaction.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -require 'tempfile' +require 'pathname' require 'shellwords' +require 'tempfile' module Commander ## @@ -10,27 +11,13 @@ module Commander # Commander's user interaction module mixes in common # methods which extend HighLine's functionality such # as a #password method rather than calling #ask directly. - module UI module_function - #-- - # Auto include growl when available. - #++ - - begin - require 'growl' - rescue LoadError - # Do nothing - else - include Growl - end - ## # Ask the user for a password. Specify a custom # _message_ other than 'Password: ' or override the # default _mask_ of '*'. - def password(message = 'Password: ', mask = '*') pass = ask(message) { |q| q.echo = mask } pass = password message, mask if pass.nil? || pass.empty? @@ -39,10 +26,9 @@ def password(message = 'Password: ', mask = '*') ## # Choose from a set array of _choices_. - - def choose(message = nil, *choices, &block) + def choose(message = nil, *choices, &) say message if message - super(*choices, &block) + super(*choices, &) end ## @@ -53,7 +39,6 @@ def choose(message = nil, *choices, &block) # remove path/to/old_file.rb # remove path/to/old_file2.rb # - def log(action, *args) say format('%15s %s', action, args.join(' ')) end @@ -65,7 +50,6 @@ def log(action, *args) # say_ok 'Everything is fine' # say_ok 'It is ok', 'This is ok too' # - def say_ok(*args) args.each do |arg| say HighLine.default_instance.color(arg, :green) @@ -79,7 +63,6 @@ def say_ok(*args) # say_warning 'This is a warning' # say_warning 'Be careful', 'Think about it' # - def say_warning(*args) args.each do |arg| say HighLine.default_instance.color(arg, :yellow) @@ -93,7 +76,6 @@ def say_warning(*args) # say_error 'Everything is not fine' # say_error 'It is not ok', 'This is not ok too' # - def say_error(*args) args.each do |arg| say HighLine.default_instance.color(arg, :red) @@ -113,7 +95,6 @@ def say_error(*args) # * color: black blue cyan green magenta red white yellow # * style: blink bold clear underline # * highligh: on_ - def color(*args) say HighLine.default_instance.color(*args) end @@ -135,7 +116,6 @@ def color(*args) # # * MacOS only # - def speak(message, voice = :Alex, rate = 175) Thread.new { applescript "say #{message.inspect} using #{voice.to_s.inspect} speaking rate #{rate}" } end @@ -164,10 +144,9 @@ def speak(message, voice = :Alex, rate = 175) # # * MacOS only # - def converse(prompt, responses = {}) i, commands = 0, responses.map { |_key, value| value.inspect }.join(',') - statement = responses.inject '' do |inner_statement, (key, value)| + statement = responses.inject(+'') do |inner_statement, (key, value)| inner_statement << ( (i += 1) == 1 ? @@ -189,7 +168,6 @@ def converse(prompt, responses = {}) ## # Execute apple _script_. - def applescript(script) `osascript -e "#{ script.gsub('"', '\"') }"` end @@ -218,7 +196,6 @@ def applescript(script) # end # end # - def io(input = nil, output = nil, &block) orig_stdin, orig_stdout = $stdin, $stdout $stdin = File.new(input) if input @@ -226,16 +203,15 @@ def io(input = nil, output = nil, &block) return unless block yield - $stdin, $stdout = orig_stdin, orig_stdout - reset_io + ensure + $stdin, $stdout = orig_stdin, orig_stdout if block end ## # Find an editor available in path. Optionally supply the _preferred_ # editor. Returns the name as a string, nil if none is available. - def available_editor(preferred = nil) - [preferred, ENV['EDITOR'], 'mate -w', 'vim', 'vi', 'emacs', 'nano', 'pico'] + [preferred, ENV.fetch('EDITOR', nil), 'mate -w', 'vim', 'vi', 'emacs', 'nano', 'pico'] .compact .find { |name| system("hash #{name.split.first} 2>&-") } end @@ -252,7 +228,6 @@ def available_editor(preferred = nil) # ask_editor('foo') # => prompts EDITOR with default text of 'foo' # ask_editor('foo', 'mate -w') # => prompts TextMate with default text of 'foo' # - def ask_editor(input = nil, preferred_editor = nil) editor = available_editor preferred_editor program = Commander::Runner.instance.program(:name).downcase rescue 'commander' @@ -260,7 +235,7 @@ def ask_editor(input = nil, preferred_editor = nil) begin tmpfile.write input if input tmpfile.close - system("#{editor} #{tmpfile.path.shellescape}") ? IO.read(tmpfile.path) : nil + system("#{editor} #{tmpfile.path.shellescape}") ? File.read(tmpfile.path) : nil ensure tmpfile.unlink end @@ -268,7 +243,6 @@ def ask_editor(input = nil, preferred_editor = nil) ## # Enable paging of output after called. - def enable_paging return unless $stdout.tty? return unless Process.respond_to? :fork @@ -312,7 +286,6 @@ def enable_paging # res = open uri # end # - def progress(arr, options = {}) bar = ProgressBar.new arr.length, options bar.show @@ -320,10 +293,18 @@ def progress(arr, options = {}) end ## - # Implements ask_for_CLASS methods. - + # Implements +ask_for_CLASS+ methods, e.g. +ask_for_float+, + # +ask_for_date+, or +ask_for_pathname+: each prompts the user via + # HighLine and parses the response as the requested class. Common + # classes (Float, Integer, String, Symbol, Regexp, Array, File, + # Pathname) get explicit methods defined below; any other class that + # responds to +.parse+ (Date, Time, URI, ...) is resolved dynamically + # by #method_missing. module AskForClass - DEPRECATED_CONSTANTS = %i[Config TimeoutError MissingSourceFile NIL TRUE FALSE Fixnum Bignum Data].freeze + # Constants that are either deprecated (and would warn when read via + # Object.const_get) or simply not meant to be treated as ask_for_* + # targets; skipped while scanning Object.constants in #method_missing. + DEPRECATED_CONSTANTS = %i[Config TimeoutError MissingSourceFile NIL TRUE FALSE Fixnum Bignum Data ScanError SortedSet].freeze # define methods for common classes [Float, Integer, String, Symbol, Regexp, Array, File, Pathname].each do |klass| @@ -332,7 +313,14 @@ module AskForClass end end - def method_missing(method_name, *arguments, &block) + ## + # Handles ask_for_* calls not covered by one of the + # explicitly defined methods above, by searching for a same-named + # top-level class that implements +.parse+ and asking HighLine to + # parse the response as that class. Falls through to +super+ (and + # so ultimately raises NoMethodError) for unrecognized method names + # or unrecognized classes. + def method_missing(method_name, *arguments, &) if method_name.to_s =~ /^ask_for_(.*)/ if arguments.count != 1 fail ArgumentError, "wrong number of arguments (given #{arguments.count}, expected 1)" @@ -345,10 +333,7 @@ def method_missing(method_name, *arguments, &block) # Ignore constants that trigger deprecation warnings available_classes = (Object.constants - DEPRECATED_CONSTANTS).map do |const| Object.const_get(const) - rescue RuntimeError - # Rescue errors in Ruby 3 for SortedSet: - # The `SortedSet` class has been extracted from the `set` library. - end.compact.select do |const| + end.select do |const| const.instance_of?(Class) && const.respond_to?(:parse) end @@ -370,8 +355,7 @@ def respond_to_missing?(method_name, include_private = false) ## # Substitute _hash_'s keys with their associated values in _str_. - - def replace_tokens(str, hash) #:nodoc: + def replace_tokens(str, hash) # :nodoc: hash.inject(str) do |string, (key, value)| string.gsub ":#{key}", value.to_s end @@ -412,7 +396,6 @@ def replace_tokens(str, hash) #:nodoc: # { :uri => uri } # Can now use :uri within :format option # end # - class ProgressBar ## # Creates a new progress bar. @@ -438,7 +421,6 @@ class ProgressBar # :time_elapsed # :time_remaining # - def initialize(total, options = {}) @total_steps, @step, @start_time = total, 0, Time.now @title = options.fetch :title, 'Progress' @@ -452,7 +434,6 @@ def initialize(total, options = {}) ## # Completion percentage. - def percent_complete if @total_steps.zero? 100 @@ -463,35 +444,30 @@ def percent_complete ## # Time that has elapsed since the operation started. - def time_elapsed Time.now - @start_time end ## # Estimated time remaining. - def time_remaining (time_elapsed / @step) * steps_remaining end ## # Number of steps left. - def steps_remaining @total_steps - @step end ## # Formatted progress bar. - def progress_bar (@progress_str * (@width * percent_complete / 100)).ljust @width, @incomplete_str end ## # Generates tokens for this step. - def generate_tokens { title: @title, @@ -507,7 +483,6 @@ def generate_tokens ## # Output the progress bar. - def show return if finished? @@ -521,14 +496,12 @@ def show ## # Whether or not the operation is complete, and we have finished. - def finished? @step == @total_steps + 1 end ## # Whether or not the operation has completed. - def completed? @step == @total_steps end @@ -536,7 +509,6 @@ def completed? ## # Increment progress. Optionally pass _tokens_ which # can be displayed in the output format. - def increment(tokens = {}) @step += 1 @tokens.merge! tokens if tokens.is_a? Hash @@ -545,7 +517,6 @@ def increment(tokens = {}) ## # Erase previous terminal line. - def erase_line # highline does not expose the output stream HighLine.default_instance.instance_variable_get('@output').print "\r\e[K" diff --git a/spec/command_spec.rb b/spec/command_spec.rb index 8ccd0578..c193820a 100644 --- a/spec/command_spec.rb +++ b/spec/command_spec.rb @@ -26,6 +26,22 @@ @options.send = 'foo' expect(@options.__send__(:send)).to eq('foo') end + + it 'should expose the underlying table via #__hash__' do + @options.send = 'foo' + expect(@options.__hash__).to eq(send: 'foo') + end + + it 'should describe itself via #inspect' do + @options.send = 'foo' + expect(@options.inspect).to eq('') + end + end + + describe '#inspect' do + it 'describes the command by name' do + expect(@command.inspect).to eq('') + end end describe '#option' do @@ -73,6 +89,27 @@ @command.run 'foo' end + it 'instantiating a class and calling #call by default' do + klass = Class.new do + def initialize(args, options) + @args, @options = args, options + end + end + expect(klass).to receive(:new).once.and_call_original + @command.when_called klass + @command.run 'foo' + end + + it 'instantiating a class and calling an arbitrary method' do + klass = Class.new do + def foo(args, _options) + args + end + end + @command.when_called klass, :foo + expect(@command.run('foo')).to eq(['foo']) + end + it 'should raise an error when no handler is present' do expect { @command.when_called }.to raise_error(ArgumentError) end diff --git a/spec/delegates_spec.rb b/spec/delegates_spec.rb new file mode 100644 index 00000000..53d99e50 --- /dev/null +++ b/spec/delegates_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Commander::Delegates do + include Commander::Methods + + before :each do + mock_terminal + new_command_runner + end + + describe '#defined_commands' do + it 'delegates to the runner instance and returns its commands' do + expect(defined_commands).to eq(Commander::Runner.instance.commands) + end + end +end diff --git a/spec/methods_spec.rb b/spec/methods_spec.rb index d7292991..d3f10f68 100644 --- a/spec/methods_spec.rb +++ b/spec/methods_spec.rb @@ -36,6 +36,11 @@ expect(subject.respond_to?(:ask_for_float)).to be_truthy end + it 'implements the common "ask_for_*" methods by asking HighLine for the class' do + expect(terminal).to receive(:ask).with('Favorite number: ', Float) + subject.ask_for_float('Favorite number: ') + end + it 'responds to "ask_for_*" methods for classes that implement #parse' do expect(subject.respond_to?(:ask_for_datetime)).to be_truthy end @@ -50,6 +55,16 @@ expect(terminal).to receive(:ask) subject.ask_for_datetime('hi') end + + it 'falls through to the default #method_missing when no matching class is found' do + expect do + subject.ask_for_not_a_real_class('hi') + end.to raise_error(NoMethodError) + end + + it 'falls through to the default #method_missing for unrelated methods' do + expect { subject.not_an_ask_for_method }.to raise_error(NoMethodError) + end end end @@ -60,4 +75,24 @@ it 'does not change the Object ancestors' do expect(Object.ancestors).not_to include(Commander::UI) end + + describe 'terminal wrapping' do + # Both scenarios live in a single example, loading the module file + # (rather than requiring it) so the tty-dependent code at the bottom + # of commander/methods.rb re-runs against a stubbed $stdin each time. + it 'only sets HighLine wrap_at when connected to a wide enough terminal' do + methods_file = File.expand_path('../lib/commander/methods.rb', __dir__) + terminal = HighLine.new(StringIO.new, StringIO.new) + allow(terminal).to receive(:output_cols).and_return(80) + HighLine.default_instance = terminal + + allow($stdin).to receive(:tty?).and_return(false) + load methods_file + expect(terminal.wrap_at).to be_nil + + allow($stdin).to receive(:tty?).and_return(true) + load methods_file + expect(terminal.wrap_at).to eq(75) + end + end end diff --git a/spec/platform_spec.rb b/spec/platform_spec.rb new file mode 100644 index 00000000..d4ce4b03 --- /dev/null +++ b/spec/platform_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Commander::Platform do + describe '.jruby?' do + it 'reflects whether the current engine is JRuby' do + expect(Commander::Platform.jruby?).to eq(RUBY_ENGINE == 'jruby') + end + end +end diff --git a/spec/runner_spec.rb b/spec/runner_spec.rb index b64bc326..70156765 100644 --- a/spec/runner_spec.rb +++ b/spec/runner_spec.rb @@ -32,6 +32,16 @@ program :help_formatter, :compact expect(program(:help_formatter)).to eq(Commander::HelpFormatter::TerminalCompact) end + + it 'should preserve a single falsy value rather than wrapping it in an array' do + program :help_paging, false + expect(program(:help_paging)).to eq(false) + end + + it 'should store a block for later invocation' do + program(:int_block) { :interrupted } + expect(program(:int_block).call).to eq(:interrupted) + end end describe '#command' do @@ -44,6 +54,16 @@ end end + describe '#command_exists?' do + it 'should be truthy when the command exists' do + expect(command_runner.command_exists?(:test)).to be_truthy + end + + it 'should be falsy when the command does not exist' do + expect(command_runner.command_exists?(:im_not_real)).to be_falsy + end + end + describe '#separate_switches_from_description' do it 'should seperate switches and description returning both' do switches, description = *Commander::Runner.separate_switches_from_description('-h', '--help', 'display help') diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 97ee4f18..9218e4e1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,7 +4,17 @@ require 'stringio' require 'simplecov' SimpleCov.start do - add_filter '/spec/' + # simplecov 1.x renamed `add_filter` to `skip`; the 0.x line used on + # Ruby 3.1 only has `add_filter`. Use whichever the installed version + # provides so both resolve without deprecation warnings or errors. + if respond_to?(:skip) + skip '/spec/' + else + add_filter '/spec/' + end + # JRuby's coverage measurement is inaccurate, so only enforce the + # threshold on MRI/CRuby where it's reliable. + minimum_coverage 100 unless RUBY_ENGINE == 'jruby' end # Unshift so that local files load instead of something in gems @@ -23,7 +33,9 @@ def mock_terminal end # Stub Kernel.abort -TestSystemExit = Class.new(RuntimeError) +class TestSystemExit < RuntimeError +end + module Commander class Runner def abort(message) diff --git a/spec/ui_spec.rb b/spec/ui_spec.rb index 6ad2cac3..614fcee6 100644 --- a/spec/ui_spec.rb +++ b/spec/ui_spec.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true require 'spec_helper' +require 'tempfile' describe Commander::UI do include Commander::Methods + before :each do + mock_terminal + end + describe '.replace_tokens' do it 'should replace tokens within a string, with hash values' do result = Commander::UI.replace_tokens 'Welcome :name, enjoy your :object', name: 'TJ', object: 'cookie' @@ -12,6 +17,226 @@ end end + describe '#password' do + # HighLine's masked-echo prompting requires a real console (it sets + # `echo=` on the input stream), which a StringIO can't provide, so the + # underlying #ask call is stubbed here rather than driven through a + # mocked terminal. + it 'asks for a password using the given message and mask, and returns the answer' do + allow(self).to receive(:ask).and_return('secret') + expect(password('Enter password: ', '#')).to eq('secret') + expect(self).to have_received(:ask).with('Enter password: ') + end + + it 'retries with the same prompt until a non-empty answer is given' do + allow(self).to receive(:ask).and_return('', 'secret') + expect(password('Enter password: ')).to eq('secret') + expect(self).to have_received(:ask).with('Enter password: ').twice + end + end + + describe '#choose' do + it 'says the message and then delegates to HighLine choose' do + @input << "2\n" + @input.rewind + expect(choose('Pick one:', 'a', 'b')).to eq('b') + expect(@output.string).to include('Pick one:') + end + end + + describe '#log' do + it 'formats and says the action with its arguments' do + log 'create', 'path/to/file.rb' + expect(@output.string).to eq(" create path/to/file.rb\n") + end + end + + describe '#say_ok' do + it 'says each argument in the OK color' do + say_ok 'all good' + expect(@output.string).to include('all good') + end + end + + describe '#say_warning' do + it 'says each argument in the warning color' do + say_warning 'be careful' + expect(@output.string).to include('be careful') + end + end + + describe '#say_error' do + it 'says each argument in the error color' do + say_error 'oh no' + expect(@output.string).to include('oh no') + end + end + + describe '#color' do + it 'says the given text using the requested color' do + color 'I am blue', :blue + expect(@output.string).to include('I am blue') + end + end + + describe '#speak' do + it 'runs the applescript in a background thread' do + allow(self).to receive(:applescript) + speak('hello there').join + expect(self).to have_received(:applescript).with('say "hello there" using "Alex" speaking rate 175') + end + end + + describe '#converse' do + it 'builds an applescript listen statement and symbolizes the response' do + allow(self).to receive(:applescript).and_return(" cookies \n") + result = converse('Best food?', cookies: 'Cookies', unknown: 'Nothing') + expect(result).to eq(:cookies) + expect(self).to have_received(:applescript) do |script| + expect(script).to include('SpeechRecognitionServer') + expect(script).to include('if response is "Cookies" then') + expect(script).to include('else if response is "Nothing" then') + end + end + end + + describe '#applescript' do + it 'shells out to osascript with the given script' do + allow(Commander::UI).to receive(:`).and_return("done\n") + expect(Commander::UI.applescript('display dialog "hi"')).to eq("done\n") + expect(Commander::UI).to have_received(:`).with(/display dialog/) + end + end + + describe '#io' do + it 'redirects $stdin/$stdout to files for the duration of the block' do + input_file = Tempfile.new('commander-io-in') + output_file = Tempfile.new('commander-io-out') + begin + input_file.write('hello') + input_file.rewind + + orig_stdin, orig_stdout = $stdin, $stdout + result = nil + io(input_file.path, output_file.path) { result = $stdin.gets } + + expect(result).to eq('hello') + expect($stdin).to equal(orig_stdin) + expect($stdout).to equal(orig_stdout) + ensure + input_file.close! + output_file.close! + end + end + + it 'restores the original streams even if the block raises' do + orig_stdin, orig_stdout = $stdin, $stdout + + expect { io { fail 'boom' } }.to raise_error(RuntimeError, 'boom') + + expect($stdin).to equal(orig_stdin) + expect($stdout).to equal(orig_stdout) + end + + it 'leaves the streams redirected when no block is given' do + output_file = Tempfile.new('commander-io-out') + orig_stdout = $stdout + begin + io(nil, output_file.path) + expect($stdout).not_to equal(orig_stdout) + ensure + $stdout = orig_stdout + output_file.close! + end + end + end + + describe '.available_editor' do + it 'should not fail on available editors with shell arguments' do + expect(Commander::UI.available_editor('sh -c')).to eq('sh -c') + end + + it 'falls back through EDITOR and common editors when no preferred editor is given' do + allow(ENV).to receive(:fetch).with('EDITOR', nil).and_return(nil) + expect(Commander::UI.available_editor).to eq('vim').or(be_a(String)).or(be_nil) + end + end + + describe '#ask_editor' do + it 'writes the given input to a temp file and returns its contents when the editor succeeds' do + result = ask_editor('draft text', 'true') + expect(result).to eq('draft text') + end + + it 'returns nil when the editor exits unsuccessfully' do + result = ask_editor('draft text', 'false') + expect(result).to be_nil + end + end + + describe '.enable_paging' do + # spec_helper stubs Commander::UI.enable_paging globally (so other specs + # don't page real output); restore the real implementation here since + # this block tests it directly. + before do + allow(Commander::UI).to receive(:enable_paging).and_call_original + # On platforms without fork (e.g. JRuby) enable_paging returns early + # before the forking logic these specs exercise; pretend fork exists + # so the behavior under test is reached everywhere. + allow(Process).to receive(:respond_to?).and_call_original + allow(Process).to receive(:respond_to?).with(:fork).and_return(true) + end + + it 'does nothing when stdout is not a tty' do + allow($stdout).to receive(:tty?).and_return(false) + expect(IO).not_to receive(:pipe) + Commander::UI.enable_paging + end + + it 'reopens stdout/stderr into the write end of the pipe in the forked subprocess' do + allow($stdout).to receive(:tty?).and_return(true) + allow($stderr).to receive(:tty?).and_return(true) + read_io = instance_double(IO, close: nil, closed?: false) + write_io = instance_double(IO, close: nil, closed?: false) + allow(IO).to receive(:pipe).and_return([read_io, write_io]) + allow(Kernel).to receive(:fork).and_return(nil) + allow($stdout).to receive(:reopen) + allow($stderr).to receive(:reopen) + + Commander::UI.enable_paging + + expect($stdout).to have_received(:reopen).with(write_io) + expect($stderr).to have_received(:reopen).with(write_io) + end + + it 'reopens stdin from the pipe and execs the pager in the parent process' do + allow($stdout).to receive(:tty?).and_return(true) + read_io = instance_double(IO, close: nil, closed?: false) + write_io = instance_double(IO, close: nil, closed?: false) + allow(IO).to receive(:pipe).and_return([read_io, write_io]) + allow(Kernel).to receive(:fork).and_return(4242) + allow($stdin).to receive(:reopen) + allow(Kernel).to receive(:select) + allow(Commander::UI).to receive(:exec) + original_less = ENV.delete('LESS') + + begin + Commander::UI.enable_paging + + expect($stdin).to have_received(:reopen).with(read_io) + expect(Kernel).to have_received(:select).with([$stdin]) + expect(ENV.fetch('LESS', nil)).to eq('FSRX') + expect(Commander::UI).to have_received(:exec) + ensure + if original_less + ENV['LESS'] = original_less + else + ENV.delete('LESS') + end + end + end + end + describe 'progress' do it 'should not die on an empty list' do exception = false @@ -22,11 +247,15 @@ end expect(exception).not_to be true end - end - describe '.available_editor' do - it 'should not fail on available editors with shell arguments' do - expect(Commander::UI.available_editor('sh -c')).to eq('sh -c') + it 'shows interim progress and a completion message while walking a list' do + visited = [] + progress(%w(a b)) do |v| + visited << v + {} + end + expect(visited).to eq(%w(a b)) + expect(@output.string).to include('Process complete') end end end