├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── Gemfile ├── History.markdown ├── LICENSE.txt ├── README.md ├── Rakefile ├── examples ├── help_dialogue.rb ├── logging.rb └── trace.rb ├── lib ├── mercenary.rb └── mercenary │ ├── command.rb │ ├── option.rb │ ├── presenter.rb │ ├── program.rb │ └── version.rb ├── mercenary.gemspec ├── script ├── bootstrap ├── cibuild ├── console ├── examples ├── fmt └── release └── spec ├── command_spec.rb ├── option_spec.rb ├── presenter_spec.rb ├── program_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | if: "!contains(github.event.commits[0].message, '[ci skip]')" 14 | name: 'Ruby ${{ matrix.ruby_version }}' 15 | runs-on: 'ubuntu-latest' 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby_version: 20 | - 2.7 21 | - 3.3 22 | - 3.4 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 5 27 | - name: "Set up Ruby ${{ matrix.ruby_version }}" 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby_version }} 31 | bundler-cache: true 32 | - name: Execute Unit tests 33 | run: script/cibuild 34 | 35 | style_check: 36 | if: "!contains(github.event.commits[0].message, '[ci skip]')" 37 | name: 'Code Style Check' 38 | runs-on: 'ubuntu-latest' 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | ruby_version: 43 | - 2.7 44 | steps: 45 | - uses: actions/checkout@v4 46 | with: 47 | fetch-depth: 5 48 | - name: "Set up Ruby ${{ matrix.ruby_version }}" 49 | uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ${{ matrix.ruby_version }} 52 | bundler-cache: true 53 | - name: Run RuboCop 54 | run: bash script/fmt 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Gem 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "lib/**/version.rb" 9 | 10 | jobs: 11 | release: 12 | if: "github.repository_owner == 'jekyll'" 13 | name: "Release Gem (Ruby ${{ matrix.ruby_version }})" 14 | runs-on: "ubuntu-latest" 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | ruby_version: 19 | - 2.7 20 | steps: 21 | - name: Checkout Repository 22 | uses: actions/checkout@v4 23 | - name: "Set up Ruby ${{ matrix.ruby_version }}" 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby_version }} 27 | bundler-cache: true 28 | - name: Build and Publish Gem 29 | uses: ashmaroli/release-gem@dist 30 | with: 31 | gemspec_name: mercenary 32 | env: 33 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_GEM_PUSH_API_KEY }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | vendor/bundle 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: rubocop-jekyll 4 | inherit_gem: 5 | rubocop-jekyll: .rubocop.yml 6 | 7 | AllCops: 8 | TargetRubyVersion: 2.7 9 | NewCops: enable 10 | Exclude: 11 | - vendor/**/* 12 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2024-12-29 16:56:48 UTC using RuboCop version 1.57.2. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | Jekyll/NoPAllowed: 11 | Exclude: 12 | - 'examples/help_dialogue.rb' 13 | 14 | # Offense count: 2 15 | Jekyll/NoPutsAllowed: 16 | Exclude: 17 | - 'lib/mercenary/command.rb' 18 | 19 | # Offense count: 7 20 | # This cop supports safe autocorrection (--autocorrect). 21 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. 22 | # URISchemes: http, https 23 | Layout/LineLength: 24 | Max: 319 25 | 26 | # Offense count: 4 27 | # This cop supports unsafe autocorrection (--autocorrect-all). 28 | # Configuration parameters: AllowSafeAssignment. 29 | Lint/AssignmentInCondition: 30 | Exclude: 31 | - 'lib/mercenary/command.rb' 32 | - 'lib/mercenary/option.rb' 33 | - 'lib/mercenary/presenter.rb' 34 | 35 | # Offense count: 2 36 | Lint/DuplicateMethods: 37 | Exclude: 38 | - 'lib/mercenary/command.rb' 39 | 40 | # Offense count: 1 41 | # Configuration parameters: AllowComments, AllowEmptyLambdas. 42 | Lint/EmptyBlock: 43 | Exclude: 44 | - 'spec/command_spec.rb' 45 | 46 | # Offense count: 1 47 | # Configuration parameters: AllowComments, AllowNil. 48 | Lint/SuppressedException: 49 | Exclude: 50 | - 'lib/mercenary/option.rb' 51 | 52 | # Offense count: 1 53 | # This cop supports unsafe autocorrection (--autocorrect-all). 54 | Lint/UselessAssignment: 55 | Exclude: 56 | - 'lib/mercenary/presenter.rb' 57 | 58 | # Offense count: 1 59 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 60 | Metrics/AbcSize: 61 | Max: 26 62 | 63 | # Offense count: 1 64 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 65 | # AllowedMethods: refine 66 | Metrics/BlockLength: 67 | Max: 28 68 | 69 | # Offense count: 1 70 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 71 | Metrics/MethodLength: 72 | Max: 24 73 | 74 | # Offense count: 1 75 | # Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros. 76 | # NamePrefix: is_, has_, have_ 77 | # ForbiddenPrefixes: is_, has_, have_ 78 | # AllowedMethods: is_a? 79 | # MethodDefinitionMacros: define_method, define_singleton_method 80 | Naming/PredicateName: 81 | Exclude: 82 | - 'spec/**/*' 83 | - 'lib/mercenary/command.rb' 84 | 85 | # Offense count: 3 86 | Performance/ChainArrayAllocation: 87 | Exclude: 88 | - 'lib/mercenary/option.rb' 89 | - 'lib/mercenary/presenter.rb' 90 | - 'spec/option_spec.rb' 91 | 92 | # Offense count: 1 93 | # This cop supports safe autocorrection (--autocorrect). 94 | Performance/StringIdentifierArgument: 95 | Exclude: 96 | - 'lib/mercenary/presenter.rb' 97 | 98 | # Offense count: 12 99 | # This cop supports safe autocorrection (--autocorrect). 100 | # Configuration parameters: EnforcedStyle. 101 | # SupportedStyles: separated, grouped 102 | Style/AccessorGrouping: 103 | Exclude: 104 | - 'lib/mercenary/command.rb' 105 | - 'lib/mercenary/program.rb' 106 | 107 | # Offense count: 1 108 | # This cop supports unsafe autocorrection (--autocorrect-all). 109 | # Configuration parameters: AllowedReceivers. 110 | Style/CollectionCompact: 111 | Exclude: 112 | - 'spec/option_spec.rb' 113 | 114 | # Offense count: 1 115 | # This cop supports unsafe autocorrection (--autocorrect-all). 116 | Style/GlobalStdStream: 117 | Exclude: 118 | - 'lib/mercenary/command.rb' 119 | 120 | # Offense count: 1 121 | # This cop supports safe autocorrection (--autocorrect). 122 | Style/IfUnlessModifier: 123 | Exclude: 124 | - 'lib/mercenary/command.rb' 125 | 126 | # Offense count: 1 127 | Style/MissingRespondToMissing: 128 | Exclude: 129 | - 'lib/mercenary/presenter.rb' 130 | 131 | # Offense count: 2 132 | # This cop supports safe autocorrection (--autocorrect). 133 | Style/RedundantRegexpArgument: 134 | Exclude: 135 | - 'lib/mercenary/option.rb' 136 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | gem "rspec", "~> 3.0" 9 | gem "rubocop-jekyll", "~> 0.14.0" 10 | -------------------------------------------------------------------------------- /History.markdown: -------------------------------------------------------------------------------- 1 | ## HEAD 2 | 3 | ### Development Fixes 4 | 5 | * Add GitHub Actions & Dependabot configurations (#60) 6 | * Update rubocop-jekyll requirement from ~> 0.10.0 to ~> 0.11.0 (#61) 7 | * Bump `rubocop-jekyll` to v0.12.x (#63) 8 | * Remove superfluous items from gem package (#66) 9 | * Move development dependencies to Gemfile (#67) 10 | * Remove unnecessary Travis CI configuration file (#71) 11 | * Bump `actions/checkout` to v4 (#70) 12 | * Bump RuboCop version to v1.57 (#73) 13 | * Update CI workflow to test with Ruby 3.4 (#69) 14 | 15 | ### Minor Enhancements 16 | 17 | * Bump minimum required Ruby version to 2.7 (#72) 18 | * Add gem `logger` as a runtime dependency (#68) 19 | 20 | ## 0.4.0 / 2020-01-18 21 | 22 | ### Major Enhancements 23 | 24 | * Drop Ruby 2.3 support 25 | 26 | ### Minor Enhancements 27 | 28 | * Remove parent command's flags from subcommand usage (#44) 29 | 30 | ### Development Fixes 31 | 32 | * Adopt Jekyll's rubocop config for consistency 33 | 34 | ### Documentation 35 | 36 | * fixes the readme (#52) 37 | 38 | ## 0.3.6 / 2016-04-07 39 | 40 | ### Bug Fixes 41 | 42 | * Presenter: Options should include those from parent command (#42) 43 | 44 | ## 0.3.5 / 2014-11-12 45 | 46 | ### Bug Fixes 47 | 48 | * Capture `OptionsParser::InvalidOption` and show a nice error message (#38) 49 | * Absolute paths for requires and autoloads (#39) 50 | 51 | ### Development Fixes 52 | 53 | * Bump to RSpec 3 (#40) 54 | 55 | ## 0.3.4 / 2014-07-11 56 | 57 | ### Bug Fixes 58 | 59 | * Use option object as key in the command's `@map` hash (#35) 60 | 61 | ## 0.3.3 / 2014-05-07 62 | 63 | ### Bug Fixes 64 | 65 | * The `--version` flag should not exit with code 1, but instead code 0. (#33) 66 | 67 | ## 0.3.2 / 2014-03-18 68 | 69 | ### Bug Fixes 70 | 71 | * Remove duplicate commands from help output; show aliases w/command names (#29) 72 | 73 | ## 0.3.1 / 2014-02-21 74 | 75 | ### Minor Enhancements 76 | 77 | * Add `-t/--trace` to list of options in help message (#19) 78 | 79 | ### Bug Fixes 80 | 81 | * `Mercenary::Option` now accepts return values in the form of Class constants (#22) 82 | 83 | ## 0.3.0 / 2014-02-20 84 | 85 | ### Major Enhancements 86 | 87 | * Officially drop 1.8.7 support (#14) 88 | * Allow Commands to set their own versions (#17) 89 | * Show subcommands, options and usage in help and attach to all commands (#18) 90 | * Add `-t, --trace` to allow full exception backtrace to print, otherwise print just the error message (#19) 91 | 92 | ### Minor Enhancements 93 | 94 | * Logging state is maintained throughout process (#12) 95 | * Tidy up Command#logger output (#21) 96 | 97 | ### Development Fixes 98 | 99 | * Added specs for `Program` (#13) 100 | 101 | ## 0.2.1 / 2013-12-25 102 | 103 | ### Bug Fixes 104 | 105 | * Added missing comma to fix '-v' and '--version' options (#9) 106 | 107 | ## 0.2.0 / 2013-11-30 108 | 109 | ### Major Enhancements 110 | 111 | * Add `Command#default_command` to specify a default command if none is given by the user at runtime (#7) 112 | 113 | ### Minor Enhancements 114 | 115 | * Add `Command#execute` to execute the actions of a command (#6) 116 | 117 | ### Development Fixes 118 | 119 | * Add standard GitHub bootstrap and cibuild scripts to `script/` (#2) 120 | 121 | ## 0.1.0 / 2013-11-08 122 | 123 | ### Major Enhancements 124 | 125 | * It works! 126 | 127 | ### Minor Enhancements 128 | 129 | * Add a logger to `Command` 130 | * Add `--version` switch to all programs 131 | 132 | ### Bug Fixes 133 | 134 | * Fix `Command#syntax` and `Command#description`'s handing of setting vs getting 135 | * Fix load path problem in `lib/mercenary.rb` 136 | 137 | ### Development Fixes 138 | 139 | * Add TomDoc to everything 140 | * Add a couple starter specs 141 | * Add TravisCI badge 142 | * Add Travis configuration 143 | 144 | ## 0.0.1 / 2013-11-06 145 | 146 | * Birthday! 147 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-present Parker Moore and the mercenary contributors 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mercenary 2 | 3 | Lightweight and flexible library for writing command-line apps in Ruby. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'mercenary' 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install mercenary 18 | 19 | **Note: Mercenary may not work with Ruby < 1.9.3.** 20 | 21 | ## Usage 22 | 23 | Creating programs and commands with Mercenary is easy: 24 | 25 | ```ruby 26 | Mercenary.program(:jekyll) do |p| 27 | p.version Jekyll::VERSION 28 | p.description 'Jekyll is a blog-aware, static site generator in Ruby' 29 | p.syntax "jekyll [options]" 30 | 31 | p.command(:new) do |c| 32 | c.syntax "new PATH" # do not include the program name or super commands 33 | c.description "Creates a new Jekyll site scaffold in PATH" 34 | c.option 'blank', '--blank', 'Initialize the new site without any content.' 35 | 36 | c.action do |args, options| 37 | Jekyll::Commands::New.process(args, blank: options['blank']) 38 | end 39 | end 40 | 41 | p.command(:build) do |c| 42 | c.syntax "build [options]" 43 | c.description "Builds your Jekyll site" 44 | 45 | c.option 'safe', '--safe', 'Run in safe mode' 46 | c.option 'source', '--source DIR', 'From where to collect the source files' 47 | c.option 'destination', '--dest DIR', 'To where the compiled files should be written' 48 | 49 | c.action do |_, options| 50 | Jekyll::Commands::Build.process(options) 51 | end 52 | end 53 | 54 | # Bring in command bundled in external gem 55 | begin 56 | require "jekyll-import" 57 | JekyllImport.init_with_program(p) 58 | rescue LoadError 59 | end 60 | 61 | p.default_command(:build) 62 | end 63 | ``` 64 | 65 | All commands have the following default options: 66 | 67 | - `-h/--help` - show a help message 68 | - `-v/--version` - show the program version 69 | - `-t/--trace` - show the full backtrace when an error occurs 70 | 71 | ## API 72 | 73 | ### `Mercenary` 74 | 75 | #### `.program` 76 | 77 | Creates and executes a program. Accepts two arguments: 78 | 79 | - `name` - program name as a Symbol 80 | - `block` - the specification for the program, passed the program instance as an 81 | argument. 82 | 83 | Example is above, under the heading [Usage](#usage). 84 | 85 | ### `Program` 86 | 87 | `Program` is a subclass of `Command`, so it has all of the methods documented 88 | below as well as those for `Command`. 89 | 90 | #### `#config` 91 | 92 | Fetches the program configuration hash. 93 | 94 | ### `Command` 95 | 96 | #### `#new` 97 | 98 | Create a new command. Accepts one argument: 99 | 100 | - `name` - the name of your command, as a symbol 101 | 102 | #### `#version` 103 | 104 | Sets or gets the version of the command. Accepts an optional argument: 105 | 106 | - `version` - (optional) the version to set for the command. If present, this 107 | becomes the new version for the command and persists. 108 | 109 | #### `#syntax` 110 | 111 | Sets or gets the syntax of the command. Built on parent syntaxes if a parent 112 | exists. Accepts one optional argument: 113 | 114 | - `syntax` - (optional) the syntax to set for the command. Will inherit from the 115 | parent commands or program. Usually in the form of 116 | `"command_name [OPTIONS]"` 117 | 118 | When a parent command exists, say `supercommand`, with syntax set as 119 | `supercommand [OPTIONS]`, the syntax of the command in question 120 | will be `supercommand command_name [OPTIONS]` with both 121 | `` and `[OPTIONS]` stripped out. Any text between `<` and `>` or 122 | between `[` and `]` will be stripped from parent command syntaxes. The purpose 123 | of this chaining is to reduce redundancy. 124 | 125 | #### `#description` 126 | 127 | Sets or gets the description of the command. Accepts one optional argument: 128 | 129 | - `desc` - (optional) the description to set for the command. If 130 | provided, will override any previous description set for the command. 131 | 132 | #### `#default_command` 133 | 134 | Sets or gets the default subcommand of the command to execute in the event no 135 | subcommand is passed during execution. Accepts one optional argument: 136 | 137 | - `command_name` - (optional) the `Symbol` name of the subcommand to be 138 | executed. Raises an `ArgumentError` if the subcommand doesn't exist. 139 | Overwrites previously-set default commands. 140 | 141 | #### `#option` 142 | 143 | Adds a new option to the command. Accepts many arguments: 144 | 145 | - `config_key` - the configuration key that the value of this option maps to. 146 | - `*options` - all the options, globbed, to be passed to `OptionParser`, namely the 147 | switches and the option description. Usually in the format 148 | `"-s", "--switch", "Sets the 'switch' flag"`. 149 | 150 | Valid option calls: 151 | 152 | ```ruby 153 | cmd.option 'config_key', '-c', 'Sets the "config" flag' 154 | cmd.option 'config_key', '--config', 'Sets the "config" flag' 155 | cmd.option 'config_key', '-c', '--config', 'Sets the "config" flag.' 156 | cmd.option 'config_key', '-c FILE', '--config FILE', 'The config file.' 157 | cmd.option 'config_key', '-c FILE1[,FILE2[,FILE3...]]', '--config FILE1[,FILE2[,FILE3...]]', Array, 'The config files.' 158 | ``` 159 | 160 | Notice that you can specify either a short switch, a long switch, or both. If 161 | you want to accept an argument, you have to specify it in the switch strings. 162 | The class of the argument defaults to `String`, but you can optionally set a 163 | different class to create, e.g. `Array`, if you are expecting a particular class 164 | in your code from this option's value. The description is also optional, but 165 | it's highly recommended to include a description. 166 | 167 | #### `#alias` 168 | 169 | Specifies an alias for this command such that the alias may be used in place of 170 | the command during execution. Accepts one argument: 171 | 172 | - `cmd_name` - the alias name for this command as a `Symbol` 173 | 174 | Example: 175 | 176 | ```ruby 177 | cmd.alias(:my_alias) 178 | # Now `cmd` is now also executable via "my_alias" 179 | ``` 180 | 181 | #### `#action` 182 | 183 | Specifies a block to be executed in the event the command is specified at 184 | runtime. The block is given two arguments: 185 | 186 | - `args` - the non-switch arguments given from the command-line 187 | - `options` - the options hash built via the switches passed 188 | 189 | **Note that actions are additive**, meaning any new call to `#action` will 190 | result in another action to be executed at runtime. Actions will be executed in 191 | the order they are specified in. 192 | 193 | Example: 194 | 195 | ```ruby 196 | cmd.action do |args, options| 197 | # do something! 198 | end 199 | ``` 200 | 201 | #### `#logger` 202 | 203 | Access the logger for this command. Useful for outputting information to STDOUT. 204 | Accepts one optional argument: 205 | 206 | - `level` - (optional) the severity threshold at which to begin logging. Uses 207 | Ruby's built-in 208 | [`Logger`](http://www.ruby-doc.org/stdlib-2.1.0/libdoc/logger/rdoc/Logger.html) 209 | levels. 210 | 211 | Log level defaults to `Logger::INFO`. 212 | 213 | Examples: 214 | 215 | ```ruby 216 | cmd.logger(Logger::DEBUG) 217 | cmd.logger.debug "My debug message." 218 | cmd.logger.info "My informative message." 219 | cmd.logger.warn "ACHTUNG!!" 220 | cmd.logger.error "Something terrible has happened." 221 | cmd.logger.fatal "I can't continue doing what I'm doing." 222 | ``` 223 | 224 | #### `#command` 225 | 226 | Creates a new subcommand for the current command. Accepts two arguments: 227 | 228 | - `cmd_name` - the command name, as a Symbol 229 | - `block` - the specification of the subcommand in a block 230 | 231 | Example: 232 | 233 | ```ruby 234 | my_command.command(:my_subcommand) do |subcmd| 235 | subcmd.description 'My subcommand' 236 | subcmd.syntax 'my_subcommand [OPTIONS]' 237 | # ... 238 | end 239 | ``` 240 | 241 | ## Contributing 242 | 243 | 1. Fork it 244 | 2. Create your feature branch (`git checkout -b my-new-feature`) 245 | 3. Commit your changes (`git commit -am 'Add some feature'`) 246 | 4. Push to the branch (`git push origin my-new-feature`) 247 | 5. Create new Pull Request 248 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | -------------------------------------------------------------------------------- /examples/help_dialogue.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.join(__dir__, "..", "lib") 5 | 6 | require "mercenary" 7 | 8 | # This example sets the logging mode of mercenary to 9 | # debug. Logging messages from "p.logger.debug" will 10 | # be output to STDOUT. 11 | 12 | Mercenary.program(:help_dialogue) do |p| 13 | p.version "2.0.1" 14 | p.description "An example of the help dialogue in Mercenary" 15 | p.syntax "help_dialogue " 16 | 17 | p.command(:some_subcommand) do |c| 18 | c.version "1.4.2" 19 | c.syntax "some_subcommand [options]" 20 | c.description "Some subcommand to do something" 21 | c.option "an_option", "-o", "--option", "Some option" 22 | c.alias(:blah) 23 | 24 | c.command(:yet_another_sub) do |f| 25 | f.syntax "yet_another_sub [options]" 26 | f.description "Do amazing things" 27 | f.option "blah", "-b", "--blah", "Trigger blah flag" 28 | f.option "heh", "-H ARG", "--heh ARG", "Give a heh" 29 | 30 | f.action do |args, options| 31 | print "Args: " 32 | p args 33 | print "Opts: " 34 | p options 35 | end 36 | end 37 | end 38 | 39 | p.command(:another_subcommand) do |c| 40 | c.syntax "another_subcommand [options]" 41 | c.description "Another subcommand to do something different." 42 | c.option "an_option", "-O", "--option", "Some option" 43 | c.option "another_options", "--pluginzzz", "Set where the plugins should be found from" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /examples/logging.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.join(__dir__, "..", "lib") 5 | 6 | require "mercenary" 7 | 8 | # This example sets the logging mode of mercenary to 9 | # debug. Logging messages from "p.logger.debug" will 10 | # be output to STDOUT. 11 | 12 | Mercenary.program(:logger_output) do |p| 13 | p.version "5.2.6" 14 | p.description "An example of turning on logging for Mercenary." 15 | p.syntax "logger_output" 16 | 17 | p.logger.info "The default log level is INFO. So this will output." 18 | p.logger.debug "Since DEBUG is below INFO, this will not output." 19 | 20 | p.logger(Logger::DEBUG) 21 | p.logger.debug "Logger level now set to DEBUG. So everything will output." 22 | 23 | p.logger.debug "Example of DEBUG level message." 24 | p.logger.info "Example of INFO level message." 25 | p.logger.warn "Example of WARN level message." 26 | p.logger.error "Example of ERROR level message." 27 | p.logger.fatal "Example of FATAL level message." 28 | p.logger.unknown "Example of UNKNOWN level message." 29 | 30 | p.action do |_args, _options| 31 | p.logger(Logger::INFO) 32 | p.logger.debug "Logger level back to INFO. This line will not output." 33 | p.logger.info "This INFO message will output." 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /examples/trace.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.join(__dir__, "..", "lib") 5 | 6 | require "mercenary" 7 | 8 | # This example sets the logging mode of mercenary to 9 | # debug. Logging messages from "p.logger.debug" will 10 | # be output to STDOUT. 11 | 12 | Mercenary.program(:trace) do |p| 13 | p.version "2.0.1" 14 | p.description "An example of traces in Mercenary" 15 | p.syntax "trace " 16 | 17 | p.action do |_, _| 18 | raise ArgumentError, "YOU DID SOMETHING TERRIBLE YOU BUFFOON" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/mercenary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("mercenary/version", __dir__) 4 | require "optparse" 5 | require "logger" 6 | 7 | module Mercenary 8 | autoload :Command, File.expand_path("mercenary/command", __dir__) 9 | autoload :Option, File.expand_path("mercenary/option", __dir__) 10 | autoload :Presenter, File.expand_path("mercenary/presenter", __dir__) 11 | autoload :Program, File.expand_path("mercenary/program", __dir__) 12 | 13 | # Public: Instantiate a new program and execute. 14 | # 15 | # name - the name of your program 16 | # 17 | # Returns nothing. 18 | def self.program(name) 19 | program = Program.new(name) 20 | yield program 21 | program.go(ARGV) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mercenary/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module Mercenary 4 | class Command 5 | attr_reader :name 6 | attr_reader :description 7 | attr_reader :syntax 8 | attr_accessor :options 9 | attr_accessor :commands 10 | attr_accessor :actions 11 | attr_reader :map 12 | attr_accessor :parent 13 | attr_reader :trace 14 | attr_reader :aliases 15 | 16 | # Public: Creates a new Command 17 | # 18 | # name - the name of the command 19 | # parent - (optional) the instancce of Mercenary::Command which you wish to 20 | # be the parent of this command 21 | # 22 | # Returns nothing 23 | def initialize(name, parent = nil) 24 | @name = name 25 | @options = [] 26 | @commands = {} 27 | @actions = [] 28 | @map = {} 29 | @parent = parent 30 | @trace = false 31 | @aliases = [] 32 | end 33 | 34 | # Public: Sets or gets the command version 35 | # 36 | # version - the command version (optional) 37 | # 38 | # Returns the version and sets it if an argument is non-nil 39 | def version(version = nil) 40 | @version = version if version 41 | @version 42 | end 43 | 44 | # Public: Sets or gets the syntax string 45 | # 46 | # syntax - the string which describes this command's usage syntax (optional) 47 | # 48 | # Returns the syntax string and sets it if an argument is present 49 | def syntax(syntax = nil) 50 | @syntax = syntax if syntax 51 | syntax_list = [] 52 | syntax_list << parent.syntax.to_s.gsub(%r!<[\w\s-]+>!, "").gsub(%r!\[[\w\s-]+\]!, "").strip if parent 53 | syntax_list << (@syntax || name.to_s) 54 | syntax_list.join(" ") 55 | end 56 | 57 | # Public: Sets or gets the command description 58 | # 59 | # description - the description of what the command does (optional) 60 | # 61 | # Returns the description and sets it if an argument is present 62 | def description(desc = nil) 63 | @description = desc if desc 64 | @description 65 | end 66 | 67 | # Public: Sets the default command 68 | # 69 | # command_name - the command name to be executed in the event no args are 70 | # present 71 | # 72 | # Returns the default command if there is one, `nil` otherwise 73 | def default_command(command_name = nil) 74 | if command_name 75 | if commands.key?(command_name) 76 | @default_command = commands[command_name] if command_name 77 | @default_command 78 | else 79 | raise ArgumentError, "'#{command_name}' couldn't be found in this command's list of commands." 80 | end 81 | else 82 | @default_command 83 | end 84 | end 85 | 86 | # Public: Adds an option switch 87 | # 88 | # sym - the variable key which is used to identify the value of the switch 89 | # at runtime in the options hash 90 | # 91 | # Returns nothing 92 | def option(sym, *options) 93 | new_option = Option.new(sym, options) 94 | @options << new_option 95 | @map[new_option] = sym 96 | end 97 | 98 | # Public: Adds a subcommand 99 | # 100 | # cmd_name - the name of the command 101 | # block - a block accepting the new instance of Mercenary::Command to be 102 | # modified (optional) 103 | # 104 | # Returns nothing 105 | def command(cmd_name) 106 | cmd = Command.new(cmd_name, self) 107 | yield cmd 108 | @commands[cmd_name] = cmd 109 | end 110 | 111 | # Public: Add an alias for this command's name to be attached to the parent 112 | # 113 | # cmd_name - the name of the alias 114 | # 115 | # Returns nothing 116 | def alias(cmd_name) 117 | logger.debug "adding alias to parent for self: '#{cmd_name}'" 118 | aliases << cmd_name 119 | @parent.commands[cmd_name] = self 120 | end 121 | 122 | # Public: Add an action Proc to be executed at runtime 123 | # 124 | # block - the Proc to be executed at runtime 125 | # 126 | # Returns nothing 127 | def action(&block) 128 | @actions << block 129 | end 130 | 131 | # Public: Fetch a Logger (stdlib) 132 | # 133 | # level - the logger level (a Logger constant, see docs for more info) 134 | # 135 | # Returns the instance of Logger 136 | 137 | def logger(level = nil) 138 | unless @logger 139 | @logger = Logger.new(STDOUT) 140 | @logger.level = level || Logger::INFO 141 | @logger.formatter = proc do |severity, _datetime, _progname, msg| 142 | "#{identity} | " << "#{severity.downcase.capitalize}:".ljust(7) << " #{msg}\n" 143 | end 144 | end 145 | 146 | @logger.level = level unless level.nil? 147 | @logger 148 | end 149 | 150 | # Public: Run the command 151 | # 152 | # argv - an array of string args 153 | # opts - the instance of OptionParser 154 | # config - the output config hash 155 | # 156 | # Returns the command to be executed 157 | def go(argv, opts, config) 158 | opts.banner = "Usage: #{syntax}" 159 | process_options(opts, config) 160 | add_default_options(opts) 161 | 162 | if argv[0] && cmd = commands[argv[0].to_sym] 163 | logger.debug "Found subcommand '#{cmd.name}'" 164 | argv.shift 165 | cmd.go(argv, opts, config) 166 | else 167 | logger.debug "No additional command found, time to exec" 168 | self 169 | end 170 | end 171 | 172 | # Public: Add this command's options to OptionParser and set a default 173 | # action of setting the value of the option to the inputted hash 174 | # 175 | # opts - instance of OptionParser 176 | # config - the Hash in which the option values should be placed 177 | # 178 | # Returns nothing 179 | def process_options(opts, config) 180 | options.each do |option| 181 | opts.on(*option.for_option_parser) do |x| 182 | config[map[option]] = x 183 | end 184 | end 185 | end 186 | 187 | # Public: Add version and help options to the command 188 | # 189 | # opts - instance of OptionParser 190 | # 191 | # Returns nothing 192 | def add_default_options(opts) 193 | option "show_help", "-h", "--help", "Show this message" 194 | option "show_version", "-v", "--version", "Print the name and version" 195 | option "show_backtrace", "-t", "--trace", "Show the full backtrace when an error occurs" 196 | opts.on("-v", "--version", "Print the version") do 197 | puts "#{name} #{version}" 198 | exit(0) 199 | end 200 | 201 | opts.on("-t", "--trace", "Show full backtrace if an error occurs") do 202 | @trace = true 203 | end 204 | 205 | opts.on_tail("-h", "--help", "Show this message") do 206 | puts self 207 | exit 208 | end 209 | end 210 | 211 | # Public: Execute all actions given the inputted args and options 212 | # 213 | # argv - (optional) command-line args (sans opts) 214 | # config - (optional) the Hash configuration of string key to value 215 | # 216 | # Returns nothing 217 | def execute(argv = [], config = {}) 218 | if actions.empty? && !default_command.nil? 219 | default_command.execute 220 | else 221 | actions.each { |a| a.call(argv, config) } 222 | end 223 | end 224 | 225 | # Public: Check if this command has a subcommand 226 | # 227 | # sub_command - the name of the subcommand 228 | # 229 | # Returns true if this command is the parent of a command of name 230 | # 'sub_command' and false otherwise 231 | def has_command?(sub_command) 232 | commands.key?(sub_command) 233 | end 234 | 235 | # Public: Identify this command 236 | # 237 | # Returns a string which identifies this command 238 | def ident 239 | "" 240 | end 241 | 242 | # Public: Get the full identity (name & version) of this command 243 | # 244 | # Returns a string containing the name and version if it exists 245 | def identity 246 | "#{full_name} #{version}".strip 247 | end 248 | 249 | # Public: Get the name of the current command plus that of 250 | # its parent commands 251 | # 252 | # Returns the full name of the command 253 | def full_name 254 | the_name = [] 255 | the_name << parent.full_name if parent&.full_name 256 | the_name << name 257 | the_name.join(" ") 258 | end 259 | 260 | # Public: Return all the names and aliases for this command. 261 | # 262 | # Returns a comma-separated String list of the name followed by its aliases 263 | def names_and_aliases 264 | ([name.to_s] + aliases).compact.join(", ") 265 | end 266 | 267 | # Public: Build a string containing a summary of the command 268 | # 269 | # Returns a one-line summary of the command. 270 | def summarize 271 | " #{names_and_aliases.ljust(20)} #{description}" 272 | end 273 | 274 | # Public: Build a string containing the command name, options and any subcommands 275 | # 276 | # Returns the string identifying this command, its options and its subcommands 277 | def to_s 278 | Presenter.new(self).print_command 279 | end 280 | end 281 | end 282 | -------------------------------------------------------------------------------- /lib/mercenary/option.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mercenary 4 | class Option 5 | attr_reader :config_key, :description, :short, :long, :return_type 6 | 7 | # Public: Create a new Option 8 | # 9 | # config_key - the key in the config hash to which the value of this option 10 | # will map 11 | # info - an array containing first the switches, then an optional 12 | # return type (e.g. Array), then a description of the option 13 | # 14 | # Returns nothing 15 | def initialize(config_key, info) 16 | @config_key = config_key 17 | while arg = info.shift 18 | begin 19 | @return_type = Object.const_get(arg.to_s) 20 | next 21 | rescue NameError 22 | end 23 | if arg.start_with?("-") 24 | if arg.start_with?("--") 25 | @long = arg 26 | else 27 | @short = arg 28 | end 29 | next 30 | end 31 | @description = arg 32 | end 33 | end 34 | 35 | # Public: Fetch the array containing the info OptionParser is interested in 36 | # 37 | # Returns the array which OptionParser#on wants 38 | def for_option_parser 39 | [short, long, return_type, description].flatten.reject { |o| o.to_s.empty? } 40 | end 41 | 42 | # Public: Build a string representation of this option including the 43 | # switches and description 44 | # 45 | # Returns a string representation of this option 46 | def to_s 47 | "#{formatted_switches} #{description}" 48 | end 49 | 50 | # Public: Build a beautifully-formatted string representation of the switches 51 | # 52 | # Returns a formatted string representation of the switches 53 | def formatted_switches 54 | [ 55 | switches.first.rjust(10), 56 | switches.last.ljust(13), 57 | ].join(", ").gsub(%r! , !, " ").gsub(%r!, !, " ") 58 | end 59 | 60 | # Public: Hash based on the hash value of instance variables 61 | # 62 | # Returns a Fixnum which is unique to this Option based on the instance variables 63 | def hash 64 | instance_variables.map do |var| 65 | instance_variable_get(var).hash 66 | end.reduce(:^) 67 | end 68 | 69 | # Public: Check equivalence of two Options based on equivalence of their 70 | # instance variables 71 | # 72 | # Returns true if all the instance variables are equal, false otherwise 73 | def eql?(other) 74 | return false unless self.class.eql?(other.class) 75 | 76 | instance_variables.map do |var| 77 | instance_variable_get(var).eql?(other.instance_variable_get(var)) 78 | end.all? 79 | end 80 | 81 | # Public: Fetch an array of switches, including the short and long versions 82 | # 83 | # Returns an array of two strings. An empty string represents no switch in 84 | # that position. 85 | def switches 86 | [short, long].map(&:to_s) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/mercenary/presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mercenary 4 | class Presenter 5 | attr_accessor :command 6 | 7 | # Public: Make a new Presenter 8 | # 9 | # command - a Mercenary::Command to present 10 | # 11 | # Returns nothing 12 | def initialize(command) 13 | @command = command 14 | end 15 | 16 | # Public: Builds a string representation of the command usage 17 | # 18 | # Returns the string representation of the command usage 19 | def usage_presentation 20 | " #{command.syntax}" 21 | end 22 | 23 | # Public: Builds a string representation of the options 24 | # 25 | # Returns the string representation of the options 26 | def options_presentation 27 | return nil unless command_options_presentation || parent_command_options_presentation 28 | 29 | [command_options_presentation, parent_command_options_presentation].compact.join("\n") 30 | end 31 | 32 | def command_options_presentation 33 | return nil if command.options.empty? 34 | 35 | options = command.options 36 | options -= command.parent.options if command.parent 37 | options.map(&:to_s).join("\n") 38 | end 39 | 40 | # Public: Builds a string representation of the options for parent 41 | # commands 42 | # 43 | # Returns the string representation of the options for parent commands 44 | def parent_command_options_presentation 45 | return nil unless command.parent 46 | 47 | Presenter.new(command.parent).options_presentation 48 | end 49 | 50 | # Public: Builds a string representation of the subcommands 51 | # 52 | # Returns the string representation of the subcommands 53 | def subcommands_presentation 54 | return nil if command.commands.empty? 55 | 56 | command.commands.values.uniq.map(&:summarize).join("\n") 57 | end 58 | 59 | # Public: Builds the command header, including the command identity and description 60 | # 61 | # Returns the command header as a String 62 | def command_header 63 | header = command.identity.to_s 64 | header << " -- #{command.description}" if command.description 65 | header 66 | end 67 | 68 | # Public: Builds a string representation of the whole command 69 | # 70 | # Returns the string representation of the whole command 71 | def command_presentation 72 | msg = [] 73 | msg << command_header 74 | msg << "Usage:" 75 | msg << usage_presentation 76 | 77 | if opts = options_presentation 78 | msg << "Options:\n#{opts}" 79 | end 80 | if subcommands = subcommands_presentation 81 | msg << "Subcommands:\n#{subcommands_presentation}" 82 | end 83 | msg.join("\n\n") 84 | end 85 | 86 | # Public: Turn a print_* into a *_presentation or freak out 87 | # 88 | # meth - the method being called 89 | # args - an array of arguments passed to the missing method 90 | # block - the block passed to the missing method 91 | # 92 | # Returns the value of whatever function is called 93 | def method_missing(meth, *args, &block) 94 | if meth.to_s =~ %r!^print_(.+)$! 95 | send("#{Regexp.last_match(1).downcase}_presentation") 96 | else 97 | # You *must* call super if you don't handle the method, 98 | # otherwise you'll mess up Ruby's method lookup. 99 | super 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/mercenary/program.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mercenary 4 | class Program < Command 5 | attr_reader :optparse 6 | attr_reader :config 7 | 8 | # Public: Creates a new Program 9 | # 10 | # name - the name of the program 11 | # 12 | # Returns nothing 13 | def initialize(name) 14 | @config = {} 15 | super(name) 16 | end 17 | 18 | # Public: Run the program 19 | # 20 | # argv - an array of string args (usually ARGV) 21 | # 22 | # Returns nothing 23 | def go(argv) 24 | logger.debug("Using args passed in: #{argv.inspect}") 25 | 26 | cmd = nil 27 | 28 | @optparse = OptionParser.new do |opts| 29 | cmd = super(argv, opts, @config) 30 | end 31 | 32 | begin 33 | @optparse.parse!(argv) 34 | rescue OptionParser::InvalidOption => e 35 | logger.error "Whoops, we can't understand your command." 36 | logger.error e.message.to_s 37 | logger.error "Run your command again with the --help switch to see available options." 38 | abort 39 | end 40 | 41 | logger.debug("Parsed config: #{@config.inspect}") 42 | 43 | begin 44 | cmd.execute(argv, @config) 45 | rescue StandardError => e 46 | if cmd.trace 47 | raise e 48 | else 49 | logger.error e.message 50 | abort 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/mercenary/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mercenary 4 | VERSION = "0.4.0" 5 | end 6 | -------------------------------------------------------------------------------- /mercenary.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "mercenary/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "mercenary" 9 | spec.version = Mercenary::VERSION 10 | spec.authors = ["Tom Preston-Werner", "Parker Moore"] 11 | spec.email = ["tom@mojombo.com", "parkrmoore@gmail.com"] 12 | spec.description = "Lightweight and flexible library for writing command-line apps in Ruby." 13 | spec.summary = "Lightweight and flexible library for writing command-line apps in Ruby." 14 | spec.homepage = "https://github.com/jekyll/mercenary" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files lib History.markdown LICENSE.txt README.md`.split($INPUT_RECORD_SEPARATOR) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.required_ruby_version = ">= 2.7.0" 21 | 22 | spec.add_dependency "logger", "~> 1.6" 23 | end 24 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | set -e 4 | 5 | echo "Time to get set up." 6 | bundle install 7 | echo "Boom." 8 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | set -ex 4 | 5 | bundle exec rspec 6 | script/fmt 7 | script/examples 8 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | irb -r./lib/mercenary.rb 4 | -------------------------------------------------------------------------------- /script/examples: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | 5 | function run () { 6 | echo "+ ruby ./examples/$@" 7 | ruby -e "puts '=' * 79" 8 | ruby ./examples/$@ 9 | ruby -e "puts '=' * 79" 10 | } 11 | 12 | run logging.rb 13 | run logging.rb -v 14 | run help_dialogue.rb -h 15 | run help_dialogue.rb some_subcommand -h 16 | run help_dialogue.rb another_subcommand -h 17 | run help_dialogue.rb some_subcommand yet_another_sub -h 18 | run help_dialogue.rb some_subcommand yet_another_sub -b 19 | -------------------------------------------------------------------------------- /script/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "RuboCop $(bundle exec rubocop --version)" 4 | bundle exec rubocop -D --disable-pending-cops $@ 5 | success=$? 6 | if ((success != 0)); then 7 | echo -e "\nTry running \`script/fmt -a\` to automatically fix errors" 8 | fi 9 | exit $success 10 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Tag and push a release. 3 | 4 | set -e 5 | 6 | script/cibuild 7 | bundle exec rake release 8 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe(Mercenary::Command) do 6 | context "a basic command" do 7 | let(:command) { Mercenary::Command.new(:my_name) } 8 | let(:parent) { Mercenary::Command.new(:my_parent) } 9 | let(:with_sub) do 10 | c = Mercenary::Command.new(:i_have_subcommand) 11 | add_sub.call(c) 12 | c 13 | end 14 | let(:command_with_parent) do 15 | Mercenary::Command.new( 16 | :i_have_parent, 17 | parent 18 | ) 19 | end 20 | let(:add_sub) do 21 | proc do |c| 22 | c.command(:sub_command) { |p| } 23 | end 24 | end 25 | 26 | it "can be created with just a name" do 27 | expect(command.name).to eql(:my_name) 28 | end 29 | 30 | it "can hold a parent command" do 31 | expect(command_with_parent.parent).to eql(parent) 32 | end 33 | 34 | it "can create subcommands" do 35 | expect(add_sub.call(command)).to be_a(Mercenary::Command) 36 | expect(add_sub.call(command).parent).to eq(command) 37 | end 38 | 39 | it "can set its version" do 40 | version = "1.4.2" 41 | command.version version 42 | expect(command.version).to eq(version) 43 | end 44 | 45 | it "can set its syntax" do 46 | syntax_string = "my_name [options]" 47 | cmd = described_class.new(:my_name) 48 | cmd.syntax syntax_string 49 | expect(cmd.syntax).to eq(syntax_string) 50 | end 51 | 52 | it "can set its description" do 53 | desc = "run all the things" 54 | command.description desc 55 | expect(command.description).to eq(desc) 56 | end 57 | 58 | it "can set its options" do 59 | name = "show_drafts" 60 | opts = ["--drafts", "Render posts in the _drafts folder"] 61 | option = Mercenary::Option.new(name, opts) 62 | command.option name, *opts 63 | expect(command.options).to eql([option]) 64 | expect(command.map.values).to include(name) 65 | end 66 | 67 | it "knows its full name" do 68 | expect(command_with_parent.full_name).to eql("my_parent i_have_parent") 69 | end 70 | 71 | it "knows its identity" do 72 | command_with_parent.version "1.8.7" 73 | expect(command_with_parent.identity).to eql("my_parent i_have_parent 1.8.7") 74 | end 75 | 76 | it "raises an ArgumentError if I specify a default_command that isn't there" do 77 | c = command # some weird NameError with the block below? 78 | expect { c.default_command(:nope) }.to raise_error(ArgumentError) 79 | end 80 | 81 | it "sets the default_command" do 82 | expect(with_sub.default_command(:sub_command).name).to eq(:sub_command) 83 | end 84 | 85 | context "with an alias" do 86 | before(:each) do 87 | command_with_parent.alias(:an_alias) 88 | end 89 | it "shows the alias in the summary" do 90 | expect(command_with_parent.summarize).to eql(" i_have_parent, an_alias ") 91 | end 92 | 93 | it "its names_and_aliases method reports both the name and alias" do 94 | expect(command_with_parent.names_and_aliases).to eql("i_have_parent, an_alias") 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/option_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe(Mercenary::Option) do 6 | let(:config_key) { "largo" } 7 | let(:description) { "This is a description" } 8 | let(:switches) { ["-l", "--largo"] } 9 | let(:option) { described_class.new(config_key, [switches, description].flatten.reject(&:nil?)) } 10 | 11 | it "knows its config key" do 12 | expect(option.config_key).to eql(config_key) 13 | end 14 | 15 | it "knows its description" do 16 | expect(option.description).to eql(description) 17 | end 18 | 19 | it "knows its switches" do 20 | expect(option.switches).to eql(switches) 21 | end 22 | 23 | it "knows how to present itself" do 24 | expect(option.to_s).to eql(" -l, --largo #{description}") 25 | end 26 | 27 | it "has an OptionParser representation" do 28 | expect(option.for_option_parser).to eql([switches, description].flatten) 29 | end 30 | 31 | it "compares itself with other options well" do 32 | new_option = described_class.new(config_key, ["-l", "--largo", description]) 33 | expect(option.eql?(new_option)).to be(true) 34 | expect(option.hash.eql?(new_option.hash)).to be(true) 35 | end 36 | 37 | it "has a custom #hash" do 38 | expect(option.hash.to_s).to match(%r!\d+!) 39 | end 40 | 41 | context "with just the long switch" do 42 | let(:switches) { ["--largo"] } 43 | 44 | it "adds an empty string in place of the short switch" do 45 | expect(option.switches).to eql(["", "--largo"]) 46 | end 47 | 48 | it "sets its description properly" do 49 | expect(option.description).to eql(description) 50 | end 51 | 52 | it "knows how to present the switch" do 53 | expect(option.formatted_switches).to eql(" --largo ") 54 | end 55 | end 56 | 57 | context "with just the short switch" do 58 | let(:switches) { ["-l"] } 59 | 60 | it "adds an empty string in place of the long switch" do 61 | expect(option.switches).to eql(["-l", ""]) 62 | end 63 | 64 | it "sets its description properly" do 65 | expect(option.description).to eql(description) 66 | end 67 | 68 | it "knows how to present the switch" do 69 | expect(option.formatted_switches).to eql(" -l ") 70 | end 71 | end 72 | 73 | context "without a description" do 74 | let(:description) { nil } 75 | 76 | it "knows there is no description" do 77 | expect(option.description).to be(nil) 78 | end 79 | 80 | it "knows both inputs are switches" do 81 | expect(option.switches).to eql(switches) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/presenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe(Mercenary::Presenter) do 6 | let(:supercommand) { Mercenary::Command.new(:script_name) } 7 | let(:command) { Mercenary::Command.new(:subcommand, supercommand) } 8 | let(:presenter) { described_class.new(command) } 9 | 10 | before(:each) do 11 | supercommand.option "version", "-v", "--version", "Show version" 12 | supercommand.option "help", "-h", "--help", "Help!" 13 | 14 | command.version "1.4.2" 15 | command.description "Do all the things." 16 | command.option "help", "-h", "--help", "Help!" 17 | command.option "one", "-1", "--one", "The first option" 18 | command.option "two", "-2", "--two", "The second option" 19 | command.alias :cmd 20 | supercommand.commands[command.name] = command 21 | end 22 | 23 | it "knows how to present the command" do 24 | expect(presenter.command_presentation).to eql("script_name subcommand 1.4.2 -- Do all the things.\n\nUsage:\n\n script_name subcommand\n\nOptions:\n -1, --one The first option\n -2, --two The second option\n -v, --version Show version\n -h, --help Help!") 25 | end 26 | 27 | it "knows how to present the subcommands, without duplicates for aliases" do 28 | expect(described_class.new(supercommand).subcommands_presentation).to eql(" subcommand, cmd Do all the things.") 29 | end 30 | 31 | it "knows how to present the usage" do 32 | expect(presenter.usage_presentation).to eql(" script_name subcommand") 33 | end 34 | 35 | it "knows how to present the options" do 36 | expect(presenter.options_presentation).to eql(" -1, --one The first option\n -2, --two The second option\n -v, --version Show version\n -h, --help Help!") 37 | end 38 | 39 | it "allows you to say print_* instead of *_presentation" do 40 | expect(presenter.print_usage).to eql(presenter.usage_presentation) 41 | expect(presenter.print_subcommands).to eql(presenter.subcommands_presentation) 42 | expect(presenter.print_options).to eql(presenter.options_presentation) 43 | expect(presenter.print_command).to eql(presenter.command_presentation) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/program_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe(Mercenary::Program) do 6 | context "a basic program" do 7 | let(:program) { Mercenary::Program.new(:my_name) } 8 | 9 | it "can be created with just a name" do 10 | expect(program.name).to eql(:my_name) 11 | end 12 | 13 | it "can set its version" do 14 | version = Mercenary::VERSION 15 | program.version version 16 | expect(program.version).to eq(version) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "mercenary" 6 | 7 | RSpec.configure do |config| 8 | config.run_all_when_everything_filtered = true 9 | config.filter_run :focus 10 | 11 | # Run specs in random order to surface order dependencies. If you find an 12 | # order dependency and want to debug it, you can fix the order by providing 13 | # the seed, which is printed after each run. 14 | # --seed 1234 15 | config.order = "random" 16 | end 17 | --------------------------------------------------------------------------------