├── .gitignore ├── Gemfile ├── .travis.yml ├── TODO ├── lib ├── choice │ ├── version.rb │ ├── option.rb │ ├── writer.rb │ └── parser.rb └── choice.rb ├── LICENSE ├── choice.gemspec ├── examples ├── gamble.rb └── ftpd.rb ├── CHANGELOG ├── Rakefile ├── test ├── test_writer.rb ├── test_option.rb ├── test_choice.rb └── test_parser.rb └── README.rdoc /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1 6 | - rbx-2 7 | - jruby 8 | before_install: 9 | - gem install bundler 10 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | 1. Add support for defining the number of arguments an option takes. 2 | 2. Publish docs similar to how we used to pub to RubyForge 3 | 3. Convert these to Github Issues 4 | -------------------------------------------------------------------------------- /lib/choice/version.rb: -------------------------------------------------------------------------------- 1 | module Choice 2 | module Version #:nodoc: 3 | MAJOR = 0 4 | MINOR = 2 5 | TINY = 0 6 | STRING = [MAJOR, MINOR, TINY] * '.' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006 Chris Wanstrath 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /choice.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'choice/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "choice" 8 | spec.version = Choice::Version::STRING 9 | spec.authors = ["Grant Austin", "Chris Wanstrath"] 10 | spec.email = ["gaustin@gmail.com", "chris@ozmm.org"] 11 | spec.summary = "Choice is a command line option parser." 12 | spec.description = "Choice is a simple little gem for easily defining and parsing command line options with a friendly DSL." 13 | spec.homepage = "http://www.github.com/defunkt/choice" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.7" 22 | spec.add_development_dependency "rake", "~> 10.0" 23 | spec.add_development_dependency "test-unit", "~> 3.0" 24 | end 25 | -------------------------------------------------------------------------------- /examples/gamble.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib" 2 | $:.unshift "lib" 3 | require 'choice' 4 | 5 | suits = %w[clubs diamonds spades hearts] 6 | stringed_numerics = (1..13).to_a.map { |a| a.to_s } 7 | valid_cards = stringed_numerics + %w[jack queen king ace] 8 | cards = {} 9 | stringed_numerics.each { |n| cards[n] = n } 10 | cards.merge!('1' => 'ace', '11' => 'jack', '12' => 'queen', '13' => 'king') 11 | 12 | Choice.options do 13 | header "Gambling is fun again! Pick a card and a suit (or two), then see if you win!" 14 | header "" 15 | header "Options:" 16 | 17 | option :suit, :true do 18 | short '-s' 19 | long '--suit *SUITS' 20 | desc "The suit you wish to choose. Required. You can pass in more than one, even." 21 | desc " Valid suits: #{suits * ' '}" 22 | valid suits 23 | end 24 | 25 | separator '' 26 | 27 | option :card, :true do 28 | short '-c' 29 | long '--card CARD' 30 | desc "The card you wish to gamble on. Required. Only one, please." 31 | desc " Valid cards: 1 - 13, jack, queen, king, ace" 32 | valid valid_cards 33 | cast String 34 | end 35 | end 36 | 37 | suit = suits[rand(suits.size)] 38 | card = cards[(rand(13)+1).to_s] 39 | 40 | puts "I drew the #{card} of #{suit}." 41 | puts "You picked the #{Choice.choices[:card]} of #{Choice.choices[:suit] * ' or '}." 42 | puts "You " << (Choice.choices[:suit].include?(suit) && card == cards[Choice.choices[:card]] ? 'win!' : 'lose :(') 43 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.2.0: 2 | - Removed some version specific hacks. Including 1.8.6 support. 3 | 0.1.7: 4 | - Fix bug related to NilClass#to_h 5 | - Simplified packaging process: Now possible to package with an up-to-date system. 6 | 0.1.6: 7 | - Use Array on a value that can be a string or an array. 8 | - Wrap the banner/footer/header defining method with a method with a default param. 9 | - Fixed expectation that Choice.parse returns an empty hash. It really returns a nil. 10 | 11 | 0.1.4: 12 | - Monkeypatch to Hash for #index deprecation. Only applied on RUBY_VERSION < "1.9" 13 | - Fixed rake file for README filename change 14 | - Fixed a broken test that's been broken since 2009. 15 | 16 | 0.1.3: 17 | - Added args_of method to retrieve the arguments of an option 18 | 19 | 0.1.2: 20 | - Made validate directive accept block and validate against its boolean value. 21 | - Created shorthand format for defining options directly with a hash. 22 | 23 | 0.1.1: 24 | - Fixed test_option.rb to be self sufficient. 25 | - Fix so that long argument with equals sign in it will parse correctly [Justin Bailey] 26 | - Added 'defaultize' deprecation warning. Too much magic can be harmful. 27 | - Made Choice::Writer::puts, print, and printf public, can now possibly be used by other Choice classes. 28 | - Changed UnknownArgument to UnknownOption (more descriptive) 29 | - Added a 'valid' option as per bugtracker request for 'enum.' [Alexis Li] 30 | - Change --long=[ARG] optional format to --long[=ARG] but keep around old format just in case. 31 | - Added list format to options as per bug tracker suggestion in the format of --long=*LONG [Alexis Li] 32 | - Added --long ARG format. Works with --long [ARG] and --long *ARG and --long [*ARG] 33 | - Added :required option which insists an option is present. 34 | - Added gamble.rb card game example. 35 | 36 | 0.1.0: 37 | - First release 38 | -------------------------------------------------------------------------------- /examples/ftpd.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib" 2 | $:.unshift "lib" 3 | require 'choice' 4 | 5 | port = 21 6 | PROGRAM_VERSION = 4 7 | 8 | Choice.options do 9 | #banner "Usage: ftpd.rb [options]" 10 | 11 | header "" 12 | header "Specific options:" 13 | 14 | option :host do 15 | short '-h' 16 | long '--host=HOST' 17 | desc "The hostname or ip of the host to bind to" 18 | desc "(default 127.0.0.1)" 19 | default '127.0.0.1' 20 | end 21 | 22 | option :port do 23 | short '-p' 24 | long '--port=PORT' 25 | desc "The port to listen on (default 21)" 26 | cast Integer 27 | default port 28 | end 29 | 30 | option :clients do 31 | short '-c' 32 | long '--clients=NUM' 33 | cast Integer 34 | desc "The number of connections to allow at once (default 5)" 35 | default 5 36 | end 37 | 38 | option :protocol do 39 | short '-l' 40 | long '--protocol=PROTOCOL' 41 | desc "The protocol to use (default ftp)" 42 | valid %w[ftp sftp] 43 | default 'ftp' 44 | end 45 | 46 | option :yaml_cfg do 47 | long '--config=FILE' 48 | desc 'Load configuration from YAML file' 49 | end 50 | 51 | option :sample do 52 | long '--sample' 53 | desc "See a sample YAML config file" 54 | action do 55 | puts "See!" 56 | exit 57 | end 58 | end 59 | 60 | option :debug do 61 | short '-d' 62 | long '--debug[=LEVEL]' 63 | desc 'Turn on debugging mode' 64 | end 65 | 66 | separator '' 67 | separator 'Common options: ' 68 | 69 | option :help do 70 | long '--help' 71 | desc 'Show this message' 72 | end 73 | 74 | option :version do 75 | short '-v' 76 | long '--version' 77 | desc 'Show version' 78 | action do 79 | puts "ftpd.rb FTP server v#{PROGRAM_VERSION}" 80 | exit 81 | end 82 | end 83 | 84 | end 85 | 86 | print "Choices: " 87 | puts Choice.choices.inspect 88 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rubygems/gem_runner' 3 | require 'rake' 4 | require 'rake/testtask' 5 | require 'rdoc/task' 6 | require './lib/choice/version' 7 | 8 | PACKAGE_VERSION = Choice::Version::STRING 9 | 10 | desc "Default task" 11 | task :default => [ :test ] 12 | 13 | Rake::TestTask.new :test do |t| 14 | t.test_files = [ "test/test*" ] 15 | end 16 | 17 | desc "Clean generated files" 18 | task :clean do 19 | rm_rf "choice-#{PACKAGE_VERSION}.gem" 20 | end 21 | 22 | desc "Prepackage warnings and reminders" 23 | task :prepackage do 24 | unless ENV["OK"] == "yes" 25 | puts "=========================================================" 26 | puts " Please check that the following files have been updated" 27 | puts " in preparation for release #{PACKAGE_VERSION}:" 28 | puts 29 | puts " README.rdoc (with latest info)" 30 | puts " CHANGELOG (with the recent changes)" 31 | puts " lib/choice/version.rb (with current version number)" 32 | puts 33 | puts " Did you remember to 'rake tag'?" 34 | puts 35 | puts " If you are sure these have all been taken care of, re-run" 36 | puts " rake with 'OK=yes'." 37 | puts "=========================================================" 38 | puts 39 | 40 | abort 41 | end 42 | end 43 | 44 | desc "Tag the current trunk with the current release version" 45 | task :tag do 46 | tag = "v#{PACKAGE_VERSION}" 47 | warn "WARNING: this will tag using the tag #{tag} and push the ref to git://www.github.com/defunkt/choice" 48 | warn "If you do not wish to continue, you have 5 seconds to cancel by pressing CTRL-C..." 49 | 5.times { |i| print "#{5-i} "; $stdout.flush; sleep 1 } 50 | system "git tag -a #{tag} -m \"Tagging the #{tag} release\"" 51 | system "git push origin #{tag}" 52 | end 53 | 54 | task :gem do 55 | system "gem build choice.gemspec" 56 | end 57 | 58 | desc "Build all packages" 59 | task :package => [ :prepackage, :test, :gem ] 60 | -------------------------------------------------------------------------------- /test/test_writer.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib:lib" 2 | require 'test/unit' 3 | require 'choice' 4 | 5 | class TestWriter < Test::Unit::TestCase 6 | 7 | def setup 8 | Choice.reset! 9 | end 10 | 11 | HELP_OUT = '' 12 | def test_help 13 | song = Choice::Option.new do 14 | short '-s' 15 | long '--song=SONG' 16 | cast String 17 | desc 'Your favorite GNR song.' 18 | desc '(Default: MyMichelle)' 19 | default 'MyMichelle' 20 | end 21 | dude = Choice::Option.new do 22 | short '-d' 23 | long '--dude=DUDE' 24 | cast String 25 | desc 'Your favorite GNR dude.' 26 | desc '(Default: Slash)' 27 | default 'Slash' 28 | end 29 | 30 | options = [[:song, song], [:dude, dude]] 31 | args = { :banner => "Welcome to the jungle", 32 | :header => [""], 33 | :options => options, 34 | :footer => ["", "Wake up."] } 35 | 36 | help_string = <<-HELP 37 | Welcome to the jungle 38 | 39 | -s, --song=SONG Your favorite GNR song. 40 | (Default: MyMichelle) 41 | -d, --dude=DUDE Your favorite GNR dude. 42 | (Default: Slash) 43 | 44 | Wake up. 45 | HELP 46 | 47 | Choice::Writer.help(args, HELP_OUT, true) 48 | 49 | assert_equal help_string, HELP_OUT 50 | end 51 | 52 | BANNER_OUT = '' 53 | def test_banner 54 | media = Choice::Option.new do 55 | short '-m' 56 | long '--media=MEDIA' 57 | end 58 | rom = Choice::Option.new do 59 | short '-r' 60 | long '--rom=ROM' 61 | end 62 | 63 | options = [[:media, media], [:rom, rom]] 64 | args = { :header => [""], 65 | :options => options } 66 | 67 | help_string = <<-HELP 68 | Usage: #{program} [-mr] 69 | 70 | -m, --media=MEDIA 71 | -r, --rom=ROM 72 | HELP 73 | 74 | Choice::Writer.help(args, BANNER_OUT, true) 75 | 76 | assert_equal help_string, BANNER_OUT 77 | end 78 | 79 | SPILLOVER_OUT = '' 80 | def test_desc_spillover 81 | toolong = Choice::Option.new do 82 | long '--thisisgonnabewaytoolongiswear=STRING' 83 | desc 'Way too long, boy wonder.' 84 | end 85 | 86 | options = [[:toolong, toolong]] 87 | 88 | help_string = <<-HELP 89 | Usage: #{program} 90 | --thisisgonnabewaytoolongiswear=STRING 91 | Way too long, boy wonder. 92 | HELP 93 | 94 | 95 | Choice::Writer.help({:options => options}, SPILLOVER_OUT, true) 96 | 97 | assert_equal help_string, SPILLOVER_OUT 98 | end 99 | 100 | def program 101 | if (/(\/|\\)/ =~ $0) then File.basename($0) else $0 end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/choice/option.rb: -------------------------------------------------------------------------------- 1 | module Choice 2 | 3 | # The Option class parses and stores all the information about a specific 4 | # option. 5 | class Option #:nodoc: all 6 | # You can instantiate an option on its own or by passing it a name and 7 | # a block. If you give it a block, it will eval() the block and set itself 8 | # up nicely. 9 | def initialize(required = false, &block) 10 | # If we got a block, eval it and set everything up. 11 | instance_eval(&block) if block_given? 12 | 13 | # Is this option required? 14 | @required = required 15 | end 16 | 17 | attr_reader :required 18 | 19 | def method_missing(method, *args, &block) 20 | # Mask NoMethodError 21 | # TODO: Remove this, if it doesn't make sense. 22 | raise ParseError, "I don't know `#{method}'" 23 | end 24 | 25 | def short(value=nil) 26 | @short ||= value 27 | end 28 | 29 | def short? 30 | @short 31 | end 32 | 33 | def long(value=nil) 34 | @long ||= value 35 | end 36 | 37 | def long? 38 | @long 39 | end 40 | 41 | def default(value=nil) 42 | @default ||= value 43 | end 44 | 45 | def default? 46 | @default 47 | end 48 | 49 | def cast(value=nil) 50 | @cast ||= value 51 | end 52 | 53 | def cast? 54 | @cast 55 | end 56 | 57 | def valid(value=nil) 58 | @valid ||= value 59 | end 60 | 61 | def valid? 62 | @valid 63 | end 64 | 65 | # TODO: Should this be split into two different validate methods? 66 | def validate(value=nil, &block) 67 | @validate ||= if !value.nil? 68 | value 69 | elsif !block.nil? 70 | block 71 | end 72 | end 73 | 74 | def validate? 75 | @validate 76 | end 77 | 78 | def action(&block) 79 | @action ||= block 80 | end 81 | 82 | def action? 83 | @action 84 | end 85 | 86 | def filter(&block) 87 | @filter ||= block 88 | end 89 | 90 | def filter? 91 | @filter 92 | end 93 | 94 | # The desc method is slightly special: it stores itself as an array and 95 | # each subsequent call adds to that array, rather than overwriting it. 96 | # This is so we can do multi-line descriptions easily. 97 | def desc(string = nil) 98 | return @desc if string.nil? 99 | 100 | @desc ||= [] 101 | @desc.push(string) 102 | end 103 | 104 | # Simple, desc question method. 105 | def desc? 106 | @desc 107 | end 108 | 109 | # Returns Option converted to an array. 110 | def to_a 111 | [ 112 | required, 113 | short, 114 | long, 115 | desc, 116 | default, 117 | filter, 118 | action, 119 | cast, 120 | valid, 121 | validate 122 | ].compact 123 | end 124 | 125 | # Returns Option converted to a hash. 126 | def to_h 127 | { 128 | "required" => required, 129 | "short" => short, 130 | "long" => long, 131 | "desc" => desc, 132 | "default" => default, 133 | "filter" => filter, 134 | "action" => action, 135 | "cast" => cast, 136 | "valid" => valid, 137 | "validate" => validate 138 | }.reject {|k, v| v.nil? } 139 | end 140 | 141 | # In case someone tries to use a method we don't know about in their 142 | # option block. 143 | class ParseError < Exception; end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/test_option.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib:lib" 2 | require 'test/unit' 3 | require 'choice' 4 | require 'choice/option' 5 | 6 | class TestOption < Test::Unit::TestCase 7 | def setup 8 | Choice.reset! 9 | @option = Choice::Option.new 10 | end 11 | 12 | def test_desc 13 | line_one = "This is a description." 14 | line_two = "I can add many lines." 15 | line_three = "There is no limit." 16 | 17 | refute @option.desc? 18 | 19 | @option.desc line_one 20 | @option.desc line_two 21 | 22 | assert @option.desc? 23 | assert_equal [line_one, line_two], @option.desc 24 | 25 | @option.desc line_three 26 | assert @option.desc? 27 | assert_equal [line_one, line_two, line_three], @option.desc 28 | end 29 | 30 | def test_choice 31 | short = "-p" 32 | 33 | refute @option.short? 34 | 35 | @option.short short 36 | 37 | assert @option.short? 38 | assert_equal short, @option.short 39 | end 40 | 41 | def test_no_choice 42 | default = 42 43 | 44 | assert_raise(Choice::Option::ParseError) do 45 | @option.defaut? 46 | end 47 | 48 | assert_raise(Choice::Option::ParseError) do 49 | @option.defaut default 50 | end 51 | 52 | assert_raise(Choice::Option::ParseError) do 53 | @option.defaut 54 | end 55 | 56 | end 57 | 58 | def test_block_choice 59 | refute @option.action? 60 | 61 | @option.action do 62 | 1 + 1 63 | end 64 | 65 | assert @option.action? 66 | assert_block(&@option.action) 67 | end 68 | 69 | def test_format 70 | @option = Choice::Option.new do 71 | validate /^\w+$/ 72 | end 73 | 74 | assert_equal /^\w+$/, @option.validate 75 | 76 | block = proc { |f| File.exists? f } 77 | @option = Choice::Option.new do 78 | validate &block 79 | end 80 | 81 | assert_equal block, @option.validate 82 | end 83 | 84 | def test_dsl 85 | @option = Choice::Option.new do 86 | short "-h" 87 | long "--host=HOST" 88 | cast String 89 | desc "The hostname." 90 | desc "(Alphanumeric only)" 91 | filter do 92 | 5 * 10 93 | end 94 | end 95 | 96 | assert_equal "-h", @option.short 97 | assert_equal "--host=HOST", @option.long 98 | assert_equal String, @option.cast 99 | assert_equal ["The hostname.", "(Alphanumeric only)"], @option.desc 100 | assert_equal proc { 5 * 10 }.call, @option.filter.call 101 | end 102 | 103 | def test_to_a 104 | desc = "This is your description." 105 | short = "-t" 106 | long = "--test=METHOD" 107 | default = :to_a 108 | 109 | @option.desc desc 110 | @option.short short 111 | @option.long long 112 | @option.default default 113 | @option.action { 1 + 1 } 114 | array = @option.to_a 115 | 116 | assert_equal Choice::Option, @option.class 117 | assert_equal Array, array.class 118 | assert array.include? [desc] 119 | assert array.include? short 120 | assert array.include? long 121 | assert array.include? default 122 | assert_equal proc { 1 + 1 }.call, array.select { |a| a.is_a? Proc }.first.call 123 | end 124 | 125 | def test_to_h 126 | desc = "This is your description." 127 | short = "-t" 128 | long = "--test=METHOD" 129 | cast = Integer 130 | 131 | @option.desc desc 132 | @option.short short 133 | @option.long long 134 | @option.cast cast 135 | @option.filter { 2 + 2 } 136 | hash = @option.to_h 137 | 138 | assert_equal Choice::Option, @option.class 139 | assert_equal Hash, hash.class 140 | assert_equal [desc], hash['desc'] 141 | assert_equal short, hash['short'] 142 | assert_equal cast, hash['cast'] 143 | assert_equal proc { 2 + 2 }.call, hash['filter'].call 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/choice.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.dirname(__FILE__) 2 | require 'choice/option' 3 | require 'choice/parser' 4 | require 'choice/writer' 5 | 6 | # 7 | # Usage of this module is lovingly detailed in the README file. 8 | # 9 | module Choice 10 | extend self 11 | 12 | # The main method, which defines the options 13 | def options(hash = {}, &block) 14 | # if we are passing in a hash to define our options, use that straight 15 | options_from_hash(hash) unless hash.empty? 16 | 17 | # Setup all instance variables 18 | reset! if hash.empty? 19 | @@args ||= ARGV 20 | 21 | # Eval the passed block to define the options. 22 | instance_eval(&block) if block_given? 23 | 24 | # Parse what we've got. 25 | parse unless parsed? 26 | end 27 | 28 | # Set options from a hash, shorthand style 29 | def options_from_hash(options_hash) 30 | options_hash.each do |name, definition| 31 | option = Option.new 32 | definition.each do |key, value| 33 | Array(value).each { |hit| option.send(key, hit) } 34 | end 35 | @@options << [name.to_s, option] 36 | end 37 | end 38 | 39 | # Return an array representing the rest of the command line arguments 40 | def rest 41 | @@rest 42 | end 43 | 44 | # Returns a hash representing options passed in via the command line. 45 | def choices 46 | @@choices 47 | end 48 | 49 | # Shortcut access to Choice.choices 50 | def [](choice) 51 | choices[choice] 52 | end 53 | 54 | # Defines an option. 55 | def option(opt, required = false, &block) 56 | # Notice: options is maintained as an array of arrays, the first element 57 | # the option name and the second the option object. 58 | @@options << [opt.to_s, Option.new(required, &block)] 59 | end 60 | 61 | # Separators are text displayed by --help within the options block. 62 | def separator(str) 63 | # We store separators as simple strings in the options array to maintain 64 | # order. They are ignored by the parser. 65 | @@options << str 66 | end 67 | 68 | # Define the banner, header, footer methods. All are just getters/setters 69 | # of class variables. 70 | %w[banner header footer].each do |method| 71 | define_method(method) do |string=nil| 72 | variable = "@@#{method}" 73 | return class_variable_get(variable) if string.nil? 74 | val = class_variable_get(variable) || '' 75 | class_variable_set(variable, val << string) 76 | end 77 | end 78 | 79 | # Parse the provided args against the defined options. 80 | def parse #:nodoc: 81 | # Do nothing if options are not defined. 82 | return unless @@options.size > 0 83 | 84 | # Show help if it's anywhere in the argument list. 85 | if @@args.include?('--help') 86 | help 87 | else 88 | begin 89 | # Delegate parsing to our parser class, passing it our defined 90 | # options and the passed arguments. 91 | @@choices, @@rest = Parser.parse(@@options, @@args) 92 | rescue Choice::Parser::ParseError 93 | # If we get an expected exception, show the help file. 94 | help 95 | end 96 | end 97 | end 98 | 99 | # Did we already parse the arguments? 100 | def parsed? #:nodoc: 101 | @@choices ||= false 102 | end 103 | 104 | # Print the help screen by calling our Writer object 105 | def help #:nodoc: 106 | Writer.help( { :banner => @@banner, :header => @@header, 107 | :options => @@options, :footer => @@footer }, 108 | output_to, exit_on_help? ) 109 | end 110 | 111 | # Set the args, potentially to something other than ARGV. 112 | def args=(args) #:nodoc: 113 | @@args = args.dup.map { |a| a + '' } 114 | parse if parsed? 115 | end 116 | 117 | # Return the args. 118 | def args #:nodoc: 119 | @@args 120 | end 121 | 122 | # Returns the arguments that follow an argument 123 | def args_of(opt) 124 | args_of_opt = [] 125 | 126 | # Return an array of the arguments between opt and the next option, 127 | # which all start with "-" 128 | @@args.slice(@@args.index(opt)+1, @@args.length).select do |arg| 129 | if arg[0].chr != "-" 130 | args_of_opt << arg 131 | else 132 | break 133 | end 134 | end 135 | args_of_opt 136 | end 137 | 138 | # You can choose to not kill the script after the help screen is printed. 139 | def dont_exit_on_help=(val) #:nodoc: 140 | @@exit = true 141 | end 142 | 143 | # Do we want to exit on help? 144 | def exit_on_help? #:nodoc: 145 | @@exit rescue false 146 | end 147 | 148 | # If we want to write to somewhere other than STDOUT. 149 | def output_to(target = nil) #:nodoc: 150 | @@output_to ||= STDOUT 151 | return @@output_to if target.nil? 152 | @@output_to = target 153 | end 154 | 155 | # Reset all the class variables. 156 | def reset! #:nodoc: 157 | @@args = false 158 | @@banner = false 159 | @@header = Array.new 160 | @@options = Array.new 161 | @@footer = Array.new 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/choice/writer.rb: -------------------------------------------------------------------------------- 1 | module Choice 2 | # This module writes to the screen. As of now, its only real use is writing 3 | # the help screen. 4 | module Writer #:nodoc: all 5 | 6 | # Some constants used for printing and line widths 7 | SHORT_LENGTH = 6 8 | SHORT_BREAK_LENGTH = 2 9 | LONG_LENGTH = 29 10 | PRE_DESC_LENGTH = SHORT_LENGTH + SHORT_BREAK_LENGTH + LONG_LENGTH 11 | 12 | 13 | # The main method. Takes a hash of arguments with the following possible 14 | # keys, running them through the appropriate method: 15 | # banner, header, options, footer 16 | # 17 | # Can also be told where to print (default STDOUT) and not to exit after 18 | # printing the help screen, which it does by default. 19 | def self.help(args, target = STDOUT, dont_exit = false) 20 | # Set our printing target. 21 | self.target = target 22 | 23 | # The banner method needs to know about the passed options if it's going 24 | # to do its magic. Only really needs :options if :banner is nil. 25 | banner(args[:banner], args[:options]) 26 | 27 | # Run these three methods, passing in the appropriate hash element. 28 | %w[header options footer].each do |meth| 29 | send(meth, args[meth.to_sym]) 30 | end 31 | 32 | # Exit. Unless you don't want to. 33 | exit unless dont_exit 34 | end 35 | 36 | class < 0 53 | header.each { |line| puts line } 54 | end 55 | end 56 | 57 | # Print out the options block by going through each option and printing 58 | # it as a line (or more). Expects an array. 59 | def options(options) 60 | # Do nothing if there's nothing to do. 61 | return if options.nil? || !options.size 62 | 63 | # If the option is a hash, run it through option_line. Otherwise 64 | # just print it out as is. 65 | options.each do |name, option| 66 | if !option.nil? && option.respond_to?(:to_h) 67 | option_line(option.to_h) 68 | else 69 | puts name 70 | end 71 | end 72 | end 73 | 74 | # The heavy lifting: print a line for an option. Has intimate knowledge 75 | # of what keys are expected. 76 | def option_line(option) 77 | # Expect a hash 78 | return unless option.is_a?(Hash) 79 | 80 | # Make this easier on us 81 | short = option['short'] 82 | long = option['long'] 83 | line = '' 84 | 85 | # Get the short part. 86 | line << sprintf("%#{SHORT_LENGTH}s", short) 87 | line << sprintf("%-#{SHORT_BREAK_LENGTH}s", (',' if short && long)) 88 | 89 | # Get the long part. 90 | line << sprintf("%-#{LONG_LENGTH}s", long) 91 | 92 | # Print what we have so far 93 | print line 94 | 95 | # If there's a desc, print it. 96 | if option['desc'] 97 | # If the line is too long, spill over to the next line 98 | if line.length > PRE_DESC_LENGTH 99 | puts 100 | print " " * PRE_DESC_LENGTH 101 | end 102 | 103 | puts option['desc'].shift 104 | 105 | # If there is more than one desc line, print each one in succession 106 | # as separate lines. 107 | option['desc'].each do |desc| 108 | puts ' '*37 + desc 109 | end 110 | 111 | else 112 | # No desc, just print a newline. 113 | puts 114 | 115 | end 116 | end 117 | 118 | # Expects an array, prints each element as a line. 119 | def footer(footer) 120 | footer.each { |line| puts line } unless footer.nil? 121 | end 122 | 123 | # Prints the usage statement, e.g. Usage prog.rb [-abc] 124 | # Expects an array. 125 | def usage(options) 126 | # Really we just need an enumerable. 127 | return unless options.respond_to?(:each) 128 | 129 | # Start off the options with a dash. 130 | opts = '-' 131 | 132 | # Figure out the option shorts. 133 | options.dup.each do |option| 134 | # We really need an array here. 135 | next unless option.is_a?(Array) 136 | 137 | # Grab the hash of the last element, which should be the second 138 | # element. 139 | option = option.last.to_h 140 | 141 | # Add the short to the options string. 142 | opts << option['short'].sub('-','') if option['short'] 143 | end 144 | 145 | # Figure out if we actually got any options. 146 | opts = if opts =~ /^-(.+)/ 147 | " [#{opts}]" 148 | end.to_s 149 | 150 | # Print it out, with our newly aquired options string. 151 | puts "Usage: #{program}" << opts 152 | end 153 | 154 | # Figure out the name of this program based on what was run. 155 | def program 156 | (/(\/|\\)/ =~ $0) ? File.basename($0) : $0 157 | end 158 | 159 | # Set where we print. 160 | def target=(target) 161 | @@target = target 162 | end 163 | 164 | # Where do we print? 165 | def target 166 | @@target 167 | end 168 | 169 | public 170 | # Fake puts 171 | def puts(str = nil) 172 | str = '' if str.nil? 173 | print(str + "\n") 174 | end 175 | 176 | # Fake printf 177 | def printf(format, *args) 178 | print(sprintf(format, *args)) 179 | end 180 | 181 | # Fake print -- just add to target, which may not be STDOUT. 182 | def print(str) 183 | target << str 184 | end 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/test_choice.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib:lib" 2 | require 'test/unit' 3 | require 'choice' 4 | 5 | $VERBOSE = nil 6 | 7 | class TestChoice < Test::Unit::TestCase 8 | 9 | def setup 10 | Choice.reset! 11 | Choice.dont_exit_on_help = true 12 | Choice.send(:class_variable_set, '@@choices', {}) 13 | end 14 | 15 | def test_choices 16 | Choice.options do 17 | header "Tell me about yourself?" 18 | header "" 19 | option :band do 20 | short "-b" 21 | long "--band=BAND" 22 | cast String 23 | desc "Your favorite band." 24 | validate /\w+/ 25 | end 26 | option :animal do 27 | short "-a" 28 | long "--animal=ANIMAL" 29 | cast String 30 | desc "Your favorite animal." 31 | end 32 | footer "" 33 | footer "--help This message" 34 | end 35 | 36 | band = 'LedZeppelin' 37 | animal = 'Reindeer' 38 | 39 | args = ['-b', band, "--animal=#{animal}"] 40 | Choice.args = args 41 | 42 | assert_equal band, Choice.choices[:band] 43 | assert_equal animal, Choice.choices[:animal] 44 | assert_equal ["Tell me about yourself?", ""], Choice.header 45 | assert_equal ["", "--help This message"], Choice.footer 46 | 47 | assert_equal Choice.choices[:band], Choice[:band] 48 | assert_equal Choice.choices[:animal], Choice[:animal] 49 | end 50 | 51 | def test_failed_parse 52 | assert_equal nil, Choice.parse 53 | end 54 | 55 | HELP_STRING = '' 56 | def test_help 57 | Choice.output_to(HELP_STRING) 58 | 59 | Choice.options do 60 | banner "Usage: choice [-mu]" 61 | header "" 62 | option :meal do 63 | short '-m' 64 | desc 'Your favorite meal.' 65 | end 66 | 67 | separator "" 68 | separator "And you eat it with..." 69 | 70 | option :utencil do 71 | short "-u" 72 | long "--utencil[=UTENCIL]" 73 | desc "Your favorite eating utencil." 74 | end 75 | end 76 | 77 | Choice.args = ['-m', 'lunch', '--help'] 78 | 79 | help_string = <<-HELP 80 | Usage: choice [-mu] 81 | 82 | -m Your favorite meal. 83 | 84 | And you eat it with... 85 | -u, --utencil[=UTENCIL] Your favorite eating utencil. 86 | HELP 87 | 88 | assert_equal help_string, HELP_STRING 89 | end 90 | 91 | UNKNOWN_STRING = '' 92 | def test_unknown_argument 93 | Choice.output_to(UNKNOWN_STRING) 94 | 95 | Choice.options do 96 | banner "Usage: choice [-mu]" 97 | header "" 98 | option :meal do 99 | short '-m' 100 | desc 'Your favorite meal.' 101 | end 102 | 103 | separator "" 104 | separator "And you eat it with..." 105 | 106 | option :utencil do 107 | short "-u" 108 | long "--utencil[=UTENCIL]" 109 | desc "Your favorite eating utencil." 110 | end 111 | end 112 | 113 | Choice.args = ['-m', 'lunch', '--motorcycles'] 114 | 115 | help_string = <<-HELP 116 | Usage: choice [-mu] 117 | 118 | -m Your favorite meal. 119 | 120 | And you eat it with... 121 | -u, --utencil[=UTENCIL] Your favorite eating utencil. 122 | HELP 123 | 124 | assert_equal help_string, UNKNOWN_STRING 125 | end 126 | 127 | REQUIRED_STRING = '' 128 | def test_required_argument 129 | Choice.output_to(REQUIRED_STRING) 130 | 131 | Choice.options do 132 | banner "Usage: choice [-mu]" 133 | header "" 134 | option :meal, :true do 135 | short '-m' 136 | desc 'Your favorite meal.' 137 | end 138 | 139 | separator "" 140 | separator "And you eat it with..." 141 | 142 | option :utencil do 143 | short "-u" 144 | long "--utencil[=UTENCIL]" 145 | desc "Your favorite eating utencil." 146 | end 147 | end 148 | 149 | Choice.args = ['-u', 'spork'] 150 | 151 | help_string = <<-HELP 152 | Usage: choice [-mu] 153 | 154 | -m Your favorite meal. 155 | 156 | And you eat it with... 157 | -u, --utencil[=UTENCIL] Your favorite eating utencil. 158 | HELP 159 | 160 | assert_equal help_string, REQUIRED_STRING 161 | end 162 | 163 | def test_shorthand_choices 164 | Choice.options do 165 | header "Tell me about yourself?" 166 | header "" 167 | options :band => { :short => "-b", :long => "--band=BAND", :cast => String, :desc => ["Your favorite band.", "Something cool."], 168 | :validate => /\w+/ }, 169 | :animal => { :short => "-a", :long => "--animal=ANIMAL", :cast => String, :desc => "Your favorite animal." } 170 | 171 | footer "" 172 | footer "--help This message" 173 | end 174 | 175 | band = 'LedZeppelin' 176 | animal = 'Reindeer' 177 | 178 | args = ['-b', band, "--animal=#{animal}"] 179 | Choice.args = args 180 | 181 | assert_equal band, Choice.choices[:band] 182 | assert_equal animal, Choice.choices[:animal] 183 | assert_equal ["Tell me about yourself?", ""], Choice.header 184 | assert_equal ["", "--help This message"], Choice.footer 185 | end 186 | 187 | def test_args_of 188 | suits = %w[clubs diamonds spades hearts] 189 | stringed_numerics = (1..13).to_a.map { |a| a.to_s } 190 | valid_cards = stringed_numerics + %w[jack queen king ace] 191 | cards = {} 192 | stringed_numerics.each { |n| cards[n] = n } 193 | cards.merge!('1' => 'ace', '11' => 'jack', '12' => 'queen', '13' => 'king') 194 | 195 | Choice.options do 196 | header "Gambling is fun again! Pick a card and a suit (or two), then see if you win!" 197 | header "" 198 | header "Options:" 199 | 200 | option :suit, :true do 201 | short '-s' 202 | long '--suit *SUITS' 203 | desc "The suit you wish to choose. Required. You can pass in more than one, even." 204 | desc " Valid suits: #{suits * ' '}" 205 | valid suits 206 | end 207 | 208 | separator '' 209 | 210 | option :card, :true do 211 | short '-c' 212 | long '--card CARD' 213 | desc "The card you wish to gamble on. Required. Only one, please." 214 | desc " Valid cards: 1 - 13, jack, queen, king, ace" 215 | valid valid_cards 216 | cast String 217 | end 218 | 219 | #cheat! to test --option= 220 | option :autowin do 221 | short '-a' 222 | long '--autowin=PLAYER' 223 | desc 'The person who should automatically win every time' 224 | desc 'Beware: raises the suspitions of other players' 225 | end 226 | end 227 | 228 | args = ["-c", "king", "--suit", "clubs", "diamonds", "spades", "hearts", "--autowin", "Grant"] 229 | Choice.args = args 230 | assert_equal ["king"], Choice.args_of("-c") 231 | assert_equal ["clubs", "diamonds", "spades", "hearts"], Choice.args_of("--suit") 232 | assert_equal ["Grant"], Choice.args_of("--autowin") 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/choice/parser.rb: -------------------------------------------------------------------------------- 1 | module Choice 2 | 3 | # The parser takes our option definitions and our arguments and produces 4 | # a hash of values. 5 | module Parser #:nodoc: all 6 | extend self 7 | 8 | # What method to call on an object for each given 'cast' value. 9 | CAST_METHODS = { Integer => :to_i, String => :to_s, Float => :to_f, 10 | Symbol => :to_sym } 11 | 12 | # Perhaps this method does too much. It is, however, a parser. 13 | # You pass it an array of arrays, the first element of each element being 14 | # the option's name and the second element being a hash of the option's 15 | # info. You also pass in your current arguments, so it knows what to 16 | # check against. 17 | def parse(options, args) 18 | # Return empty hash if the parsing adventure would be fruitless. 19 | return {} if options.nil? || !options || args.nil? || !args.is_a?(Array) 20 | 21 | # Operate on a copy of the inputs 22 | args = args.dup 23 | 24 | # If we are passed an array, make the best of it by converting it 25 | # to a hash. 26 | options = options.inject({}) do |hash, value| 27 | value.is_a?(Array) ? hash.merge(value.first => value[1]) : hash 28 | end if options.is_a? Array 29 | 30 | # Define local hashes we're going to use. choices is where we store 31 | # the actual values we've pulled from the argument list. 32 | hashes, longs, required, validators, choices, arrayed = {}, {}, {}, {}, {}, {} 33 | hard_required = {} 34 | 35 | # We can define these on the fly because they are all so similar. 36 | params = %w[short cast filter action default valid] 37 | params.each { |param| hashes["#{param}s"] = {} } 38 | 39 | # Inspect each option and move its info into our local hashes. 40 | options.each do |name, obj| 41 | name = name.to_sym 42 | 43 | # Only take hashes or hash-like duck objects. 44 | raise HashExpectedForOption unless obj.respond_to? :to_h 45 | obj = obj.to_h 46 | 47 | # Is this option required? 48 | hard_required[name] = true if obj['required'] 49 | 50 | # Set the local hashes if the value exists on this option object. 51 | params.each { |param| hashes["#{param}s"][name] = obj[param] if obj[param] } 52 | 53 | # If there is a validate statement, make it a regex or proc. 54 | validators[name] = make_validation(obj['validate']) if obj['validate'] 55 | 56 | # Parse the long option. If it contains a =, figure out if the 57 | # argument is required or optional. Optional arguments are formed 58 | # like [=ARG], whereas required are just ARG (in --long=ARG style). 59 | if obj['long'] && obj['long'] =~ /(=|\[| )/ 60 | # Save the separator we used, as we're gonna need it, then split 61 | sep = $1 62 | option, *argument = obj['long'].split(sep) 63 | 64 | # The actual name of the long switch 65 | longs[name] = option 66 | 67 | # Preserve the original argument, as it may contain [ or =, 68 | # by joining with the character we split on. Add a [ in front if 69 | # we split on that. 70 | argument = (sep == '[' ? '[' : '') << Array(argument).join(sep) 71 | 72 | # Do we expect multiple arguments which get turned into an array? 73 | arrayed[name] = true if argument =~ /^\[?=?\*(.+)\]?$/ 74 | 75 | # Is this long required or optional? 76 | required[name] = true unless argument =~ /^\[=?\*?(.+)\]$/ 77 | elsif obj['long'] 78 | # We can't have a long as a switch when valid is set -- die. 79 | raise ArgumentRequiredWithValid if obj['valid'] 80 | 81 | # Set without any checking if it's just --long 82 | longs[name] = obj['long'] 83 | end 84 | 85 | # If we were given a list of valid arguments with 'valid,' this option 86 | # is definitely required. 87 | required[name] = true if obj['valid'] 88 | end 89 | 90 | rest = [] 91 | 92 | # Go through the arguments and try to figure out whom they belong to 93 | # at this point. 94 | while arg = args.shift 95 | if hashes['shorts'].value?(arg) 96 | # Set the value to the next element in the args array since 97 | # this is a short. 98 | 99 | # If the next argument isn't a value, set this value to true 100 | if args.empty? || args.first.match(/^-/) 101 | value = true 102 | else 103 | value = args.shift 104 | end 105 | 106 | # Add this value to the choices hash with the key of the option's 107 | # name. If we expect an array, tack this argument on. 108 | name = hashes['shorts'].key(arg) 109 | if arrayed[name] 110 | choices[name] ||= [] 111 | choices[name] << value unless value.nil? 112 | choices[name] += arrayize_arguments(args) 113 | else 114 | choices[name] = value 115 | end 116 | 117 | elsif (m = arg.match(/^(--[^=]+)=?/)) && longs.value?(m[1]) 118 | # The joke here is we always accept both --long=VALUE and --long VALUE. 119 | 120 | # Grab values from --long=VALUE format 121 | name, value = arg.split('=', 2) 122 | name = longs.key(name) 123 | 124 | if value.nil? && args.first !~ /^-/ 125 | # Grab value otherwise if not in --long=VALUE format. Assume --long VALUE. 126 | # Value is nil if we don't have a = and the next argument is no good 127 | value = args.shift 128 | end 129 | 130 | # If we expect an array, tack this argument on. 131 | if arrayed[name] 132 | # If this is arrayed and the value isn't nil, set it. 133 | choices[name] ||= [] 134 | choices[name] << value unless value.nil? 135 | choices[name] += arrayize_arguments(args) 136 | else 137 | # If we set the value to nil, that means nothing was set and we 138 | # need to set the value to true. We'll find out later if that's 139 | # acceptable or not. 140 | choices[name] = value.nil? ? true : value 141 | end 142 | 143 | else 144 | # If we're here, we have no idea what the passed argument is. Die. 145 | if arg =~ /^-/ 146 | raise UnknownOption 147 | else 148 | rest << arg 149 | end 150 | end 151 | end 152 | 153 | # Okay, we got all the choices. Now go through and run any filters or 154 | # whatever on them. 155 | choices.each do |name, value| 156 | # Check to make sure we have all the required arguments. 157 | raise ArgumentRequired if required[name] && value === true 158 | 159 | # Validate the argument if we need to, against a regexp or a block. 160 | if validators[name] 161 | if validators[name].is_a?(Regexp) && validators[name] =~ value 162 | elsif validators[name].is_a?(Proc) && validators[name].call(value) 163 | else raise ArgumentValidationFails 164 | end 165 | end 166 | 167 | # Make sure the argument is valid 168 | raise InvalidArgument unless Array(value).all? { |v| hashes['valids'][name].include?(v) } if hashes['valids'][name] 169 | 170 | # Cast the argument using the method defined in the constant hash. 171 | value = value.send(CAST_METHODS[hashes['casts'][name]]) if hashes['casts'].include?(name) 172 | 173 | # Run the value through a filter and re-set it with the return. 174 | value = hashes['filters'][name].call(value) if hashes['filters'].include?(name) 175 | 176 | # Run an action block if there is one associated. 177 | hashes['actions'][name].call(value) if hashes['actions'].include?(name) 178 | 179 | # Now that we've done all that, re-set the element of the choice hash 180 | # with the (potentially) new value. 181 | if arrayed[name] && choices[name].empty? 182 | choices[name] = true 183 | else 184 | choices[name] = value 185 | end 186 | end 187 | 188 | # Die if we're missing any required arguments 189 | hard_required.each do |name, value| 190 | raise ArgumentRequired unless choices[name] 191 | end 192 | 193 | # Home stretch. Go through all the defaults defined and if a choice 194 | # does not exist in our choices hash, set its value to the requested 195 | # default. 196 | hashes['defaults'].each do |name, value| 197 | choices[name] = value unless choices[name] 198 | end 199 | 200 | # Return the choices hash and the rest of the args 201 | [ choices, rest ] 202 | end 203 | 204 | private 205 | # Turns trailing command line arguments into an array for an arrayed value 206 | def arrayize_arguments(args) 207 | # Go through trailing arguments and suck them in if they don't seem 208 | # to have an owner. 209 | array = [] 210 | until args.empty? || args.first.match(/^-/) 211 | array << args.shift 212 | end 213 | array 214 | end 215 | 216 | def make_validation(validation) 217 | case validation 218 | when Proc then 219 | validation 220 | when Regexp, String then 221 | Regexp.new(validation.to_s) 222 | else 223 | raise ValidateExpectsRegexpOrBlock 224 | end 225 | end 226 | 227 | # All the possible exceptions this module can raise. 228 | class ParseError < Exception; end 229 | class HashExpectedForOption < Exception; end 230 | class UnknownOption < ParseError; end 231 | class ArgumentRequired < ParseError; end 232 | class ValidateExpectsRegexpOrBlock < ParseError; end 233 | class ArgumentValidationFails < ParseError; end 234 | class InvalidArgument < ParseError; end 235 | class ArgumentRequiredWithValid < ParseError; end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /test/test_parser.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "../lib:lib" 2 | require 'test/unit' 3 | require 'choice/option' 4 | require 'choice/parser' 5 | 6 | class TestParser < Test::Unit::TestCase 7 | def setup 8 | @options = {} 9 | end 10 | 11 | def test_parse_options 12 | @options['band'] = Choice::Option.new do 13 | short '-b' 14 | long '--band=BAND' 15 | cast String 16 | desc 'Your favorite band.' 17 | end 18 | @options['animal'] = Choice::Option.new do 19 | short '-a' 20 | long '--animal=ANIMAL' 21 | cast String 22 | desc 'Your favorite animal.' 23 | end 24 | band = 'Led Zeppelin' 25 | animal = 'Reindeer' 26 | 27 | args = ['-b', band, "--animal=#{animal}"] 28 | 29 | choices, rest = Choice::Parser.parse(@options, args) 30 | 31 | assert_equal band, choices[:band] 32 | assert_equal animal, choices[:animal] 33 | end 34 | 35 | def test_parse_no_options 36 | assert_equal Hash.new, Choice::Parser.parse(nil, nil) 37 | end 38 | 39 | def test_parse_default 40 | @options['soda'] = Choice::Option.new do 41 | short '-s' 42 | long '--soda=SODA' 43 | default 'PibbJr' 44 | end 45 | 46 | args = [] 47 | 48 | choices, rest = Choice::Parser.parse(@options, args) 49 | 50 | assert_equal 'PibbJr', choices[:soda] 51 | end 52 | 53 | def test_parse_options_with_filters 54 | @options['host'] = Choice::Option.new do 55 | short '-h' 56 | filter do |opt| 57 | opt.gsub!(/[^\w]/, '') 58 | opt = opt.sub(/k/, 'c') 59 | end 60 | end 61 | host = 'de.fun.kt' 62 | args = ['-h', host] 63 | choices, rest = Choice::Parser.parse(@options, args) 64 | 65 | assert_equal 'defunct', choices[:host] 66 | end 67 | 68 | def test_casting 69 | @options['port'] = Choice::Option.new do 70 | short '-p' 71 | cast Integer 72 | end 73 | 74 | port = '3000' 75 | args = ['-p', port] 76 | choices, rest = Choice::Parser.parse(@options, args) 77 | 78 | assert_equal port.to_i, choices[:port] 79 | end 80 | 81 | def test_text_required 82 | @options['name'] = Choice::Option.new do 83 | short '-n' 84 | long '--name=NAME' 85 | end 86 | @options['age'] = Choice::Option.new do 87 | short '-a' 88 | long 'age[=AGE]' 89 | cast Integer 90 | end 91 | 92 | args = ['-n', '-a', '21'] 93 | 94 | assert_raise(Choice::Parser::ArgumentRequired) do 95 | choices, rest = Choice::Parser.parse(@options, args) 96 | end 97 | end 98 | 99 | def test_text_optional 100 | @options['color'] = Choice::Option.new do 101 | short '-c' 102 | long '--color[=COLOR]' 103 | end 104 | 105 | args = ['-c'] 106 | choices, rest = Choice::Parser.parse(@options, args) 107 | 108 | assert choices[:color] 109 | 110 | color = 'ladyblue' 111 | args = ['-c', color] 112 | choices, rest = Choice::Parser.parse(@options, args) 113 | 114 | assert_equal color, choices[:color] 115 | end 116 | 117 | def test_text_optional_deprecated 118 | @options['color'] = Choice::Option.new do 119 | short '-c' 120 | long '--color=[COLOR]' 121 | end 122 | 123 | args = ['-c'] 124 | choices, rest = Choice::Parser.parse(@options, args) 125 | 126 | assert choices[:color] 127 | 128 | color = 'ladyblue' 129 | args = ['-c', color] 130 | choices, rest = Choice::Parser.parse(@options, args) 131 | 132 | assert_equal color, choices[:color] 133 | end 134 | 135 | def test_ignore_separator 136 | options = [] 137 | options << ['keyboard', Choice::Option.new do 138 | short '-k' 139 | long '--keyboard=BOARD' 140 | end] 141 | 142 | options << ['mouse', Choice::Option.new do 143 | short '-m' 144 | long '--mouse=MOUSE' 145 | end] 146 | 147 | args = ['-m', 'onebutton'] 148 | choices, rest = Choice::Parser.parse([options.first, '----', options.last], args) 149 | 150 | assert choices[:mouse] 151 | assert_equal 1, choices.size 152 | end 153 | 154 | def test_long_as_switch 155 | @options['chunky'] = Choice::Option.new do 156 | short '-b' 157 | long '--bacon' 158 | end 159 | 160 | args = ['--bacon'] 161 | choices, rest = Choice::Parser.parse(@options, args) 162 | 163 | assert choices[:chunky] 164 | end 165 | 166 | def test_validate_regexp 167 | @options['email'] = Choice::Option.new do 168 | short '-e' 169 | long '--email=EMAIL' 170 | desc 'Your valid email addy.' 171 | validate /^[a-z0-9_.-]+@[a-z0-9_.-]+\.[a-z]{2,4}$/i 172 | end 173 | 174 | email_bad = 'this will@neverwork' 175 | email_good = 'chris@ozmm.org' 176 | 177 | args = ['-e', email_bad] 178 | assert_raise(Choice::Parser::ArgumentValidationFails) do 179 | choices, rest = Choice::Parser.parse(@options, args) 180 | end 181 | 182 | args = ['-e', email_good] 183 | choices, rest = Choice::Parser.parse(@options, args) 184 | 185 | assert_equal email_good, choices[:email] 186 | end 187 | 188 | def test_validate_block 189 | @options['file'] = Choice::Option.new do 190 | short '-f' 191 | long '--file=FILE' 192 | desc 'Your valid email addy.' 193 | validate do |arg| 194 | File.exists? arg 195 | end 196 | end 197 | 198 | file_bad = 'not_a_file.rb' 199 | file_good = __FILE__ 200 | 201 | args = ['-f', file_bad] 202 | assert_raise(Choice::Parser::ArgumentValidationFails) do 203 | choices, rest = Choice::Parser.parse(@options, args) 204 | end 205 | 206 | args = ['-f', file_good] 207 | choices, rest = Choice::Parser.parse(@options, args) 208 | 209 | assert_equal file_good, choices[:file] 210 | end 211 | 212 | def test_unknown_argument 213 | @options['cd'] = Choice::Option.new do 214 | short '-c' 215 | long '--cd=CD' 216 | desc 'A CD you like.' 217 | end 218 | 219 | args = ['-c', 'BestOfYanni', '--grace'] 220 | assert_raise(Choice::Parser::UnknownOption) do 221 | choices, rest = Choice::Parser.parse(@options, args) 222 | end 223 | end 224 | 225 | def test_valid 226 | @options['suit'] = Choice::Option.new do 227 | short '-s' 228 | long '--suit=SUIT' 229 | valid %w[club diamond spade heart] 230 | desc "The suit of your card, sir." 231 | end 232 | 233 | suit_good = 'club' 234 | suit_bad = 'joker' 235 | 236 | args = ['-s', suit_bad] 237 | assert_raise(Choice::Parser::InvalidArgument) do 238 | choices, rest = Choice::Parser.parse(@options, args) 239 | end 240 | 241 | args = ['-s', suit_good] 242 | choices, rest = Choice::Parser.parse(@options, args) 243 | 244 | assert_equal suit_good, choices[:suit] 245 | end 246 | 247 | def test_valid_needs_argument 248 | @options['pants'] = Choice::Option.new do 249 | short '-p' 250 | long '--pants' 251 | valid %w[jeans slacks trunks boxers] 252 | desc "Your preferred type of pants." 253 | end 254 | 255 | args = ['-p'] 256 | assert_raise(Choice::Parser::ArgumentRequiredWithValid) do 257 | choices, rest = Choice::Parser.parse(@options, args) 258 | end 259 | end 260 | 261 | def test_long_as_array 262 | @options['medium'] = Choice::Option.new do 263 | short '-m' 264 | long '--medium=*MEDIUM' 265 | desc "The medium(s) you like best." 266 | end 267 | 268 | mediums = %w[canvas stone steel] 269 | 270 | args = ['-m', mediums.first, '-m', mediums[1], '-m', mediums.last] 271 | choices, rest = Choice::Parser.parse(@options, args) 272 | assert_equal mediums, choices[:medium] 273 | 274 | args = ['-m', mediums.first, mediums[1], mediums.last] 275 | choices, rest = Choice::Parser.parse(@options, args) 276 | assert_equal mediums, choices[:medium] 277 | 278 | args = ["--medium=#{mediums.first}", "--medium=#{mediums[1]}", "--medium=#{mediums.last}"] 279 | choices, rest = Choice::Parser.parse(@options, args) 280 | assert_equal mediums, choices[:medium] 281 | 282 | args = ["--medium=#{mediums.first}", mediums[1], mediums.last] 283 | choices, rest = Choice::Parser.parse(@options, args) 284 | assert_equal mediums, choices[:medium] 285 | end 286 | 287 | def test_long_as_array_optional 288 | @options['instruments'] = Choice::Option.new do 289 | short '-i' 290 | long '--instruments[=*INSTRUMENTS]' 291 | desc "Do you like instruments? Which ones do you like best?" 292 | end 293 | 294 | instruments = %w[xylophone guitar piano] 295 | 296 | args = ["--instruments=#{instruments.first}", "--instruments=#{instruments[1]}", 297 | "--instruments=#{instruments.last}"] 298 | choices, rest = Choice::Parser.parse(@options, args) 299 | assert_equal instruments, choices[:instruments] 300 | 301 | args = %w[--instruments] 302 | choices, rest = Choice::Parser.parse(@options, args) 303 | assert_equal true, choices[:instruments] 304 | end 305 | 306 | def test_long_as_array_with_valid 307 | @options['suits'] = Choice::Option.new do 308 | short '-s' 309 | long '--suits=*SUITS' 310 | valid %w[club diamond spade heart] 311 | desc "The suits of your deck, sir." 312 | end 313 | 314 | suits = %w[spade heart] 315 | 316 | args = ['-s', suits.first, suits.last] 317 | choices, rest = Choice::Parser.parse(@options, args) 318 | 319 | assert_equal suits, choices[:suits] 320 | 321 | args = ['-s', suits.first, 'notasuit'] 322 | assert_raise(Choice::Parser::InvalidArgument) do 323 | choices, rest = Choice::Parser.parse(@options, args) 324 | end 325 | end 326 | 327 | def test_long_with_spaces 328 | @options['donut'] = Choice::Option.new do 329 | short '-d' 330 | long '--donut DONUT' 331 | desc "Your favorite donut style." 332 | end 333 | 334 | donut = 'long-john' 335 | 336 | args = ['--donut', donut] 337 | choices, rest = Choice::Parser.parse(@options, args) 338 | 339 | assert_equal donut, choices[:donut] 340 | end 341 | 342 | def test_optional_long_with_spaces 343 | @options['donut'] = Choice::Option.new do 344 | short '-d' 345 | long '--donut [DONUT]' 346 | desc "Your favorite donut style." 347 | end 348 | 349 | donut = 'chocolate' 350 | 351 | args = ['--donut', donut] 352 | choices, rest = Choice::Parser.parse(@options, args) 353 | assert_equal donut, choices[:donut] 354 | 355 | args = ['--donut'] 356 | choices, rest = Choice::Parser.parse(@options, args) 357 | assert_equal true, choices[:donut] 358 | end 359 | 360 | def test_long_with_spaces_arrayed 361 | @options['donuts'] = Choice::Option.new do 362 | short '-d' 363 | long '--donuts *DONUTS' 364 | desc "Your favorite donut styles." 365 | end 366 | 367 | donuts = %w[glazed cream-filled] 368 | 369 | args = ['--donuts', donuts.first, donuts.last] 370 | choices, rest = Choice::Parser.parse(@options, args) 371 | assert_equal donuts, choices[:donuts] 372 | end 373 | 374 | def test_long_with_rest 375 | @options['donut'] = Choice::Option.new do 376 | short '-d' 377 | long '--donut [DONUT]' 378 | desc "Your favorite donut style." 379 | end 380 | 381 | donut = 'chocolate' 382 | 383 | args = ['eat', '--donut', donut] 384 | choices, rest = Choice::Parser.parse(@options, args) 385 | assert_equal donut, choices[:donut] 386 | assert_equal ['eat'], rest 387 | end 388 | end 389 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | {}[https://travis-ci.org/defunkt/choice] 2 | 3 | = Welcome to Choice 4 | 5 | Choice is a small library for defining and parsing command line options. It 6 | works awesomely with Highline[https://github.com/JEG2/highline] or other command 7 | line interface libraries. 8 | 9 | Choice was written by Chris Wanstrath as an exercise in test driving development 10 | of a DSL. This project is still an infant: bugs are expected and tattling on them 11 | is appreciated. 12 | 13 | Installing is easy, with RubyGems. Give it a shot: 14 | $ gem install choice 15 | 16 | E-mail inquiries can be directed to mailto:chris[at]ozmm[dot]org. 17 | 18 | Of course, Choice is licensed under the MIT License, which you can find included 19 | in the LICENSE file or by surfing your World Wide Web browser of choice towards 20 | http://www.opensource.org/licenses/mit-license.php. 21 | 22 | == Using Choice 23 | 24 | An +examples+ directory is included with Choice, in which some contrived Ruby 25 | programs utilizing the library have been placed. Here's a snippet: 26 | 27 | === ftpd.rb 28 | 29 | require 'choice' 30 | 31 | PROGRAM_VERSION = 4 32 | 33 | Choice.options do 34 | header '' 35 | header 'Specific options:' 36 | 37 | option :host do 38 | short '-h' 39 | long '--host=HOST' 40 | desc 'The hostname or ip of the host to bind to (default 127.0.0.1)' 41 | default '127.0.0.1' 42 | end 43 | 44 | option :port do 45 | short '-p' 46 | long '--port=PORT' 47 | desc 'The port to listen on (default 21)' 48 | cast Integer 49 | default 21 50 | end 51 | 52 | separator '' 53 | separator 'Common options: ' 54 | 55 | option :help do 56 | long '--help' 57 | desc 'Show this message' 58 | end 59 | 60 | option :version do 61 | short '-v' 62 | long '--version' 63 | desc 'Show version' 64 | action do 65 | puts "ftpd.rb FTP server v#{PROGRAM_VERSION}" 66 | exit 67 | end 68 | end 69 | end 70 | 71 | puts 'port: ' + Choice[:port] 72 | 73 | Notice the last line. For free, you will be given a Choice.choices 74 | hash which contain, at runtime, the options found and their values. 75 | 76 | Choice[:key] is a shortcut for Choice.choices[:key]. 77 | 78 | Because we gave option :port a default of 21, 79 | Choice[:port] should be 21 if we run ftpd.rb with no options. 80 | Let's see. 81 | 82 | $ ruby ftpd.rb 83 | port: 21 84 | 85 | Cool. On our system, port 21 is reserved. Let's use another port. 86 | 87 | $ ruby ftpd.rb -p 2100 88 | port: 2100 89 | 90 | Alright. And, of course, there is the hard way of doing things. 91 | 92 | $ ruby ftpd.rb --port=2100 93 | port: 2100 94 | 95 | That :version option looks pretty interesting, huh? I wonder what it 96 | does... 97 | 98 | $ ruby ftpd.rb -v 99 | ftpd.rb FTP server v4 100 | 101 | That's not all, though. We also get a --help option for free. 102 | 103 | $ ruby ftpd.rb --help 104 | Usage: ftpd.rb [-hpv] 105 | 106 | Specific options: 107 | -h, --host=HOST The hostname or ip of the host to bind to (default 127.0.0.1) 108 | -p, --port=PORT The port to listen on (default 21) 109 | 110 | Common options: 111 | --help Show this message 112 | -v, --version Show version 113 | 114 | 115 | == The Choice.choices hash 116 | 117 | Keep in mind that your option's key in the Choice.choices hash is 118 | defined by the first parameter passed to option statement. This is perfectly 119 | legit, albeit somewhat confusing: 120 | 121 | option :name do 122 | short '-h' 123 | long '--handle=NAME' 124 | desc "Your handle." 125 | end 126 | 127 | You can access this option by using Choice.choices[:name], not 128 | :handle. 129 | 130 | == Option options 131 | 132 | Obviously, Choice revolves around the option statement, which receives 133 | a block. Here are all the, er, options +option+ accepts. None of them are 134 | required but +short+ or +long+ must be present for Choice to know what to do. 135 | 136 | Options must be defined in the context of a Choice.options block, as 137 | seen above. This context is assumed for the following explanations. 138 | 139 | For the quick learners, here's the list: 140 | * short 141 | * long 142 | * default 143 | * desc 144 | * cast 145 | * valid (takes array) 146 | * validate (takes regex) 147 | * filter (takes a block) 148 | * action (ditto) 149 | 150 | You can define these within your option in any order which pleases you. 151 | 152 | === short 153 | 154 | Defines the short switch for an option. Expected to be a dash and a single 155 | character. 156 | 157 | short '-s' 158 | 159 | === long 160 | 161 | Defines the long switch for an option. Expected to be a double dash followed by 162 | a string, an equal sign (or a space), and another string. 163 | 164 | There are two variants: longs where a parameter is required and longs where a 165 | parameter is optional, in which case the value will be +true+ if the option is 166 | present. 167 | 168 | *Optional*: 169 | long '--debug=[LEVEL]' 170 | 171 | Assuming our program defines Choices and ends with this line: 172 | puts 'debug: ' + Choice.choices[:debug] 173 | 174 | we can do this: 175 | 176 | $ ruby ftpd.rb --debug 177 | debug: true 178 | 179 | $ ruby ftpd.rb --debug=1 180 | debug: 1 181 | 182 | $ ruby ftpd.rb --debug 1 183 | debug: 1 184 | 185 | *Required*: 186 | long '--debug=LEVEL' 187 | 188 | Assuming the same as above: 189 | 190 | $ ruby ftpd.rb --debug 1 191 | debug: 1 192 | 193 | $ ruby ftpd.rb --debug 194 | 195 | 196 | === long as array 197 | 198 | Often you may wish to allow users the ability to pass in multiple arguments and have 199 | them all combined into an array. You can accomplish this by defining a +long+ and 200 | setting the caps-argument to *ARG. Like this: 201 | 202 | long '--suit *SUITS' 203 | 204 | Choice.choices.suits will now return an array. Here's an example of usage: 205 | 206 | $ ruby --suit hearts clubs 207 | suit: ['hearts', 'clubs'] 208 | 209 | Check out examples/gamble.rb for more information on this cool feature. 210 | 211 | === default 212 | 213 | You can define a default value for your option, if you'd like. If the option 214 | is not present in the argument list, the default will be returned when trying 215 | to access that element of the Choice.choices hash. 216 | 217 | As with the above, assume our program prints Choice.choices[:debug]: 218 | 219 | default 'info' 220 | 221 | If we don't pass in --debug, the :debug element of our hash 222 | will be 'info.' 223 | 224 | $ ftpd.rb 225 | debug: info 226 | 227 | $ ftpd.rb --debug warn 228 | debug: warn 229 | 230 | === desc 231 | 232 | The description of this option. Fairly straightforward, with one little trick: 233 | multiple +desc+ statements in a single option will be considered new desc lines. 234 | The desc lines will be printed in the order they are defined. Like this: 235 | 236 | desc "Your hostname." 237 | desc "(default 'localhost')" 238 | 239 | A snippet from your --help might then look like this: 240 | 241 | -h, --host=HOST Your hostname. 242 | (default 127.0.0.1) 243 | 244 | 245 | === cast 246 | 247 | By default, all members of the Choice.choices hash are strings. If 248 | you want something different, like an Integer for a port number, you can use 249 | the +cast+ statement. 250 | 251 | cast Integer 252 | 253 | Currently support +cast+ options: 254 | 255 | * Integer 256 | * String 257 | * Float 258 | * Symbol 259 | 260 | We'll probably add Date, Time, and DateTime in the future, if people want them. 261 | 262 | === valid 263 | 264 | Giving +valid+ an array creates a whitelist of acceptable arguments. 265 | 266 | valid %w[clubs hearts spades diamonds] 267 | 268 | If our option is passed anything other than one of the four card suits, the help 269 | screen will be printed. It might be a good idea to include acceptable arguments in 270 | your option's "desc" value. 271 | 272 | $ ruby gamble.rb -s clubs 273 | suit: clubs 274 | 275 | $ ruby gamble.rb -s joker 276 | 277 | 278 | === validate 279 | 280 | The +validate+ statement accepts a regular expression which it will test 281 | against the value passed. If the test fails, the --help screen will 282 | be printed. I love ports, so let's stick with that example: 283 | 284 | validate /^\d+$/ 285 | 286 | Of course, 2100 matches this: 287 | 288 | $ ruby ftpd.rb -p 2100 289 | port: 2100 290 | 291 | I like dogs. I wish dogs could be ports. Alas, Choice knows better (once 292 | I've told it so): 293 | 294 | $ ruby ftpd.rb -p labradoodle 295 | 296 | 297 | === filter 298 | 299 | The +filter+ statement lets you play with a value before it goes into the 300 | Choice.choices hash. If you use +cast+, this will occur post-casting. 301 | 302 | In this program we're defining a :name option and saying we don't want any 303 | crazy characters in it, then printing that element of the 304 | Choice.choices+ hash: 305 | 306 | filter do |value| 307 | value = value.gsub(/[^\w]/, '') 308 | end 309 | 310 | Now: 311 | 312 | $ ruby ftpd.rb --name=c.hr.is 313 | name: chris 314 | 315 | You can probably think of better uses. 316 | 317 | === action 318 | 319 | A block passed to the +action+ statement will be run if that particular option 320 | is passed. See the --version example earlier. 321 | 322 | === required options 323 | 324 | You can specify an option as being required by passing :required => true to the 325 | option definition. Choice will then print the help screen if this option is 326 | not present. Please let your dear users know which options are required. 327 | 328 | For example: 329 | 330 | option :card, :required => true do 331 | short '-c' 332 | long '--card CARD' 333 | desc "The card you wish to gamble on. Required. Only one, please." 334 | end 335 | 336 | Then: 337 | 338 | $ ruby gamble.rb 339 | 340 | 341 | == Other options 342 | 343 | These statements are purely aesthetic, used to help make your --help 344 | screen a little more digestible. 345 | 346 | Passing an empty string to any of these options will print a newline. 347 | 348 | === banner 349 | 350 | The banner is the first line printed when your program is called with 351 | --help. By default, it will be something like this, based on the 352 | options defined: 353 | 354 | Usage: ftpd.rb [-hpv] 355 | 356 | You can pass any string to the +banner+ statement to override what prints. This 357 | might be useful if you're into ascii art. 358 | 359 | banner "Usage: ftpd.rb" 360 | 361 | === header 362 | 363 | The header is what shows up after the banner but before your option definitions 364 | are printed. Each header call is a newline. Check out the example above. 365 | 366 | header "ftp is a harsh and unforgiving protocol." 367 | 368 | === separator 369 | 370 | As in the example above, you can put separators between options to help display 371 | the logical groupings of your options. Or whatever. 372 | 373 | separator "----" 374 | 375 | To get a blank line, rock an empty string: 376 | 377 | separator '' 378 | 379 | === footer 380 | 381 | The footer is displayed after all your options are displayed. Nothing new 382 | here, works like the other options above. 383 | 384 | footer "That's all there is to it!" 385 | 386 | == Shorthand 387 | 388 | Now that you've gone through all the hard stuff, here's the easy stuff: Choice 389 | options can be defined with a simple hash if you'd like. Here's an example, 390 | from the tests: 391 | 392 | Choice.options do 393 | header "Tell me about yourself?" 394 | header "" 395 | options :band => { :short => "-b", :long => "--band=BAND", :cast => String, :desc => "Your favorite band.", 396 | :validate => /\w+/ }, 397 | :animal => { :short => "-a", :long => "--animal=ANIMAL", :cast => String, :desc => "Your favorite animal." } 398 | 399 | footer "" 400 | footer "--help This message" 401 | end 402 | 403 | How's that tickle you? Real nice. 404 | 405 | == It looks like poetry 406 | 407 | That's it. Not much, I know. Maybe this will make handling your command 408 | line options a bit easier. You can always use the option parser in the standard 409 | Ruby library, but DSLs are just so cool. As one of my non-programmer friends 410 | said of a Ruby DSL: "It looks like poetry." 411 | 412 | == It's totally broken 413 | 414 | Okay, I knew this would happen. Do me a favor, if you have time: run +rake+ 415 | from the Choice directory and send me the output (mailto:chris[at]ozmm[dot]org). 416 | This'll run the unit tests. Also, if you would, send me a bit of information 417 | on your platform. Choice was tested on OS X and RHEL with a 2.4 kernel but who 418 | knows. Thanks a lot. 419 | 420 | == Thanks to 421 | 422 | For bug reports, patches, and ideas I'd be honored to thank the following: 423 | 424 | - Justin Bailey 425 | - Alexis Li 426 | --------------------------------------------------------------------------------