├── .rspec ├── lib ├── boson │ ├── version.rb │ ├── runner_library.rb │ ├── bare_runner.rb │ ├── runner.rb │ ├── method_inspector.rb │ ├── loader.rb │ ├── bin_runner.rb │ ├── inspector.rb │ ├── util.rb │ ├── library.rb │ ├── scientist.rb │ ├── manager.rb │ ├── option_command.rb │ ├── options.rb │ ├── command.rb │ └── option_parser.rb └── boson.rb ├── bin └── boson ├── CONTRIBUTING.md ├── .travis.yml ├── test ├── runner_library_test.rb ├── command_test.rb ├── loader_test.rb ├── util_test.rb ├── method_inspector_test.rb ├── bin_runner_test.rb ├── manager_test.rb ├── test_helper.rb ├── options_test.rb ├── scientist_test.rb ├── runner_test.rb └── option_parser_test.rb ├── Rakefile ├── Upgrading.md ├── LICENSE.txt ├── .gemspec ├── CHANGELOG.rdoc └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --default_path test 2 | --pattern test/**_test.rb 3 | -------------------------------------------------------------------------------- /lib/boson/version.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | VERSION = '1.3.0' 3 | end 4 | -------------------------------------------------------------------------------- /bin/boson: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'boson' 4 | require 'boson/bin_runner' 5 | 6 | Boson::BinRunner.start 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thanks for trying out this project! [See here for contribution guidelines.](http://tagaholic.me/contributing.html) 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: bundle init --gemspec=.gemspec 2 | script: bacon -q -Ilib -I. test/*_test.rb 3 | rvm: 4 | - 1.9.2 5 | - 1.9.3 6 | - 2.0.0 7 | - 2.1.0 8 | - rbx-2.1.1 9 | - jruby-19mode 10 | matrix: 11 | # until jruby + bacon issue fixed 12 | allow_failures: 13 | - rvm: jruby-19mode 14 | -------------------------------------------------------------------------------- /test/runner_library_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "RunnerLibrary" do 4 | before { reset } 5 | 6 | it "creates a library with correct commands" do 7 | Manager.load create_runner(:blah) 8 | library('blarg').commands.should == ['blah'] 9 | end 10 | 11 | it "can coexist with another runner library" do 12 | Manager.load create_runner(:blah) 13 | should_not_raise { Manager.load create_runner(:blih, library: :Blih) } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/command_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Command" do 4 | describe ".find" do 5 | before do 6 | reset_boson 7 | @top_level_command = create_command(:name=>'blah', :lib=>'bling') 8 | end 9 | 10 | it 'finds correct command when a subcommand of the same name exists' do 11 | Command.find('blah').should == @top_level_command 12 | end 13 | 14 | it 'finds nothing given nil' do 15 | Command.find(nil).should == nil 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'fileutils' 3 | 4 | def gemspec 5 | @gemspec ||= eval(File.read('.gemspec'), binding, '.gemspec') 6 | end 7 | 8 | desc "Build the gem" 9 | task :gem=>:gemspec do 10 | sh "gem build .gemspec" 11 | FileUtils.mkdir_p 'pkg' 12 | FileUtils.mv "#{gemspec.name}-#{gemspec.version}.gem", 'pkg' 13 | end 14 | 15 | desc "Install the gem locally" 16 | task :install => :gem do 17 | sh %{gem install pkg/#{gemspec.name}-#{gemspec.version}} 18 | end 19 | 20 | desc "Generate the gemspec" 21 | task :generate do 22 | puts gemspec.to_ruby 23 | end 24 | 25 | desc "Validate the gemspec" 26 | task :gemspec do 27 | gemspec.validate 28 | end 29 | 30 | desc 'Run tests' 31 | task :test do |t| 32 | sh 'bacon -q -Ilib -I. test/*_test.rb' 33 | end 34 | 35 | task :default => :test 36 | -------------------------------------------------------------------------------- /Upgrading.md: -------------------------------------------------------------------------------- 1 | ## Using Old Boson 2 | 3 | Any version before 1.0 is considered the old boson. Although I will accept bug 4 | fixes for it (branched from 5 | [old_boson](http://github.com/cldwalker/boson/tree/old_boson)), I will *not* 6 | accept any new features. Since the new boson supports almost all of [boson's 7 | origin functionality](http://tagaholic.me/blog.html#gem:name=boson) via plugins, 8 | there is little reason to hang onto this version. 9 | 10 | ## Using New Boson 11 | 12 | To enjoy the same experience you've had with the old boson, you'll need to 13 | also install boson-more and create a ~/.bosonrc: 14 | 15 | $ gem install boson boson-more 16 | $ echo "require 'boson/more'" > ~/.bosonrc 17 | 18 | Your old boson config and libraries should just work. Please file issues with 19 | your libraries or any upgrade issues at 20 | [boson-more](http://github.com/cldwalker/boson-more). 21 | 22 | If you've written custom plugins for the old Boson, you most likely have to 23 | upgrade to the new API. 24 | -------------------------------------------------------------------------------- /lib/boson/runner_library.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Library created by Runner 3 | class RunnerLibrary < Library 4 | handles {|source| 5 | source.is_a?(Module) && defined?(Runner) && source.ancestors.include?(Runner) 6 | } 7 | 8 | def self.delegate_runner_methods(runner, mod) 9 | mod.module_eval do 10 | runner.public_instance_methods(false).each do |meth| 11 | define_method(meth) do |*args, &block| 12 | runner.new.send(meth, *args, &block) 13 | end 14 | end 15 | end 16 | end 17 | 18 | def set_name(runner) 19 | @runner = runner #reference needed elsewhere 20 | @runner.to_s[/[^:]+$/].downcase 21 | end 22 | 23 | # Since Boson expects libraries to be modules, creates a temporary module 24 | # and delegates its methods to it 25 | def load_source_and_set_module 26 | @module = Util.create_module Boson::Commands, @name 27 | MethodInspector.instance.rename_store_key @runner, @module 28 | self.class.delegate_runner_methods @runner, @module 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT LICENSE 2 | 3 | Copyright (c) 2010 Gabriel Horner 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 | -------------------------------------------------------------------------------- /test/loader_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Loader" do 4 | describe "load" do 5 | before { reset } 6 | 7 | it "prints error for method conflicts with main_object method" do 8 | runner = create_runner :require 9 | manager_load runner 10 | stderr.should =~ /Unable to load library Blarg.*conflict.*commands: require/ 11 | end 12 | 13 | it "prints error for method conflicts between libraries" do 14 | create_runner :whoops 15 | create_runner :whoops, library: :Blorg 16 | Manager.load Blarg 17 | manager_load Blorg 18 | stderr.should =~ /^Unable to load library Blorg.*conflict.*commands: whoops/ 19 | end 20 | 21 | it "sets loaded to true after loading a library" do 22 | Manager.load create_runner 23 | library('blarg').loaded.should == true 24 | end 25 | 26 | it "loads and strips aliases from a library's commands" do 27 | with_config(:command_aliases=>{"blah"=>'b'}) do 28 | runner = create_runner do 29 | def blah; end 30 | alias :b :blah 31 | end 32 | Manager.load runner 33 | library('blarg').commands.should == ['blah'] 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require 'rubygems' unless Object.const_defined?(:Gem) 3 | require File.dirname(__FILE__) + "/lib/boson/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "boson" 7 | s.version = Boson::VERSION 8 | s.authors = ["Gabriel Horner"] 9 | s.email = "gabriel.horner@gmail.com" 10 | s.homepage = "http://tagaholic.me/boson/" 11 | s.summary = "A command/task framework similar to rake and thor that opens your ruby universe to the commandline and irb." 12 | s.description = "Boson is a modular command/task framework. Thanks to its rich set of plugins, it differentiates itself from rake and thor by being usable from irb and the commandline, having optional automated views generated by hirb and allowing libraries to be written as plain ruby. Works with ruby >= 1.9.2" 13 | s.required_rubygems_version = ">= 1.3.6" 14 | s.executables = ['boson'] 15 | s.add_development_dependency 'mocha', '~> 0.12.0' 16 | s.add_development_dependency 'bacon', '>= 1.1.0' 17 | s.add_development_dependency 'mocha-on-bacon', '~> 0.2.1' 18 | s.add_development_dependency 'bacon-bits' 19 | s.add_development_dependency 'bahia', '>= 0.5.0' 20 | s.files = Dir.glob(%w[{lib,test}/**/*.rb bin/* [A-Z]*.{txt,rdoc,md} ext/**/*.{rb,c}]) + %w{Rakefile .gemspec .travis.yml} 21 | s.files += ['.rspec'] 22 | s.extra_rdoc_files = ["README.md", "LICENSE.txt", "Upgrading.md"] 23 | s.license = 'MIT' 24 | end 25 | -------------------------------------------------------------------------------- /test/util_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Util" do 4 | it "underscore converts camelcase to underscore" do 5 | Util.underscore('Boson::MethodInspector').should == 'boson/method_inspector' 6 | end 7 | 8 | it "constantize converts string to class" do 9 | Util.constantize("Boson").should == ::Boson 10 | end 11 | 12 | describe "underscore_search" do 13 | def search(query, list) 14 | Util.underscore_search(query, list).sort {|a,b| a.to_s <=> b.to_s } 15 | end 16 | 17 | def first_search(query, list) 18 | Util.underscore_search(query, list, true) 19 | end 20 | 21 | it "matches non underscore strings" do 22 | search('som', %w{some words match sometimes}).should == %w{some sometimes} 23 | end 24 | 25 | it "matches first non underscore string" do 26 | first_search('wo', %w{some work wobbles}).should == 'work' 27 | end 28 | 29 | it "matches non underscore symbols" do 30 | search(:som, [:some, :words, :match, :sometimes]).should == [:some, :sometimes] 31 | search('som', [:some, :words, :match, :sometimes]).should == [:some, :sometimes] 32 | end 33 | 34 | it "matches underscore strings" do 35 | search('s_l', %w{some_long some_short some_lame}).should == %w{some_lame some_long} 36 | end 37 | 38 | it "matches first underscore string" do 39 | first_search('s_l', %w{some_long some_short some_lame}).should == 'some_long' 40 | end 41 | 42 | it "matches underscore symbols" do 43 | search(:s_l, [:some_long, :some_short, :some_lame]).should == [:some_lame, :some_long] 44 | search('s_l', [:some_long, :some_short, :some_lame]).should == [:some_lame, :some_long] 45 | end 46 | 47 | it "matches full underscore string" do 48 | search('some_long_name', %w{some_long_name some_short some_lame}).should == %w{some_long_name} 49 | end 50 | 51 | it "only matches exact match if multiple matches that start with exact match" do 52 | search('bl', %w{bl blang bling}).should == ['bl'] 53 | first_search('bl', %w{bl blang bling}).should == 'bl' 54 | end 55 | end 56 | end -------------------------------------------------------------------------------- /test/method_inspector_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "MethodInspector" do 4 | before { MethodInspector.instance = nil } 5 | 6 | def method_inspector 7 | MethodInspector.instance 8 | end 9 | 10 | it "non commands module can't set anything" do 11 | remove_constant :Blah 12 | eval "module Blah; end" 13 | method_inspector.current_module = Blah 14 | Inspector.enable 15 | Blah.module_eval("desc 'test'; def test; end; options :a=>1; def test2; end") 16 | Inspector.disable 17 | method_inspector.store[:desc].empty?.should == true 18 | method_inspector.store[:options].empty?.should == true 19 | end 20 | 21 | it "handles anonymous classes" do 22 | Inspector.enable 23 | Class.new.module_eval "def blah; end" 24 | Inspector.disable 25 | method_inspector.store.should == nil 26 | end 27 | 28 | describe "commands module with" do 29 | def parse(string) 30 | Inspector.enable 31 | ::Boson::Commands::Zzz.module_eval(string) 32 | Inspector.disable 33 | method_inspector.store 34 | end 35 | 36 | before_all { eval "module ::Boson::Commands::Zzz; end" } 37 | 38 | it "desc sets descriptions" do 39 | parsed = parse "desc 'test'; def m1; end; desc 'one'; desc 'more'; def m2; end" 40 | parsed[:desc].should == {"m1"=>"test", "m2"=>"more"} 41 | end 42 | 43 | it "options sets options" do 44 | parse("options :z=>'b'; def zee; end")[:options].should == {"zee"=>{:z=>'b'}} 45 | end 46 | 47 | it "option sets options" do 48 | parse("option :z, 'b'; option :y, :boolean; def zee; end")[:options].should == 49 | {"zee"=>{:z=>'b', :y=>:boolean}} 50 | end 51 | 52 | it "option(s) sets options" do 53 | parse("options :z=>'b'; option :y, :string; def zee; end")[:options].should == 54 | {"zee"=>{:z=>'b', :y=>:string}} 55 | end 56 | 57 | it "option(s) option overrides options" do 58 | parse("options :z=>'b'; option :z, :string; def zee; end")[:options].should == 59 | {"zee"=>{:z=>:string}} 60 | end 61 | 62 | it "config sets config" do 63 | parse("config :z=>true; def zee; end")[:config].should == {"zee"=>{:z=>true}} 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/bin_runner_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | require 'boson/bin_runner' 3 | BinRunner = Boson::BinRunner 4 | 5 | describe "BinRunner" do 6 | def aborts_with(regex) 7 | BinRunner.expects(:abort).with {|e| e[regex] } 8 | yield 9 | end 10 | 11 | unless ENV['FAST'] 12 | it "prints usage with no arguments" do 13 | boson 14 | stdout.should =~ /^boson/ 15 | end 16 | 17 | it "prints usage with --help" do 18 | %w{-h --help}.each do |option| 19 | boson option 20 | stdout.should =~ /^boson/ 21 | end 22 | end 23 | 24 | it 'prints version with --version' do 25 | boson '--version' 26 | stdout.chomp.should == "boson #{Boson::VERSION}" 27 | end 28 | 29 | it "executes string with --execute" do 30 | %w{--execute -e}.each do |option| 31 | boson "#{option} 'print 1 + 1'" 32 | stdout.should == '2' 33 | end 34 | end 35 | 36 | it "sets $DEBUG with --ruby-debug" do 37 | %w{--ruby_debug -D}.each do |option| 38 | boson "#{option} -e 'print $DEBUG'" 39 | stdout.should == 'true' 40 | end 41 | end 42 | 43 | it "sets Boson.debug with --debug" do 44 | boson "--debug -e 'print Boson.debug'" 45 | stdout.should == 'true' 46 | end 47 | 48 | it "prepends to $: with --load_path" do 49 | %w{--load_path -I}.each do |option| 50 | boson "#{option}=lib -e 'print $:[0]'" 51 | stdout.should == 'lib' 52 | end 53 | end 54 | 55 | it "prints error for unexpected error" do 56 | boson %[-e 'raise "blarg"'] 57 | stderr.chomp.should == "Error: blarg" 58 | end 59 | 60 | it "prints error for too many arguments" do 61 | with_command('dude') do 62 | boson "dude 1 2 3" 63 | stderr.should =~ /^'dude' was called incorrectly/ 64 | process.success?.should == false 65 | end 66 | end 67 | 68 | it "prints error for invalid command" do 69 | boson 'blarg' 70 | stderr.chomp.should == %[Could not find command "blarg"] 71 | process.success?.should == false 72 | end 73 | end 74 | 75 | it ".parse_args only translates options before command" do 76 | BinRunner.send(:parse_args, ['-d', 'com', '-v']).should == ["com", {debug: true}, ['-v']] 77 | BinRunner.send(:parse_args, ['com', '-v']).should == ["com", {}, ['-v']] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/boson/bare_runner.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Base class for runners. 3 | class BareRunner 4 | DEFAULT_LIBRARIES = [] 5 | # Default options for parse_args 6 | GLOBAL_OPTIONS = { 7 | help: { type: :boolean, desc: "Displays this help message" } 8 | } 9 | 10 | module API 11 | # Loads rc 12 | def start(*) 13 | @options ||= {} 14 | load_rc 15 | end 16 | 17 | # Default libraries loaded by init 18 | def default_libraries 19 | DEFAULT_LIBRARIES 20 | end 21 | 22 | def all_libraries 23 | default_libraries 24 | end 25 | 26 | # Loads default libraries 27 | def init 28 | Manager.load default_libraries, load_options 29 | end 30 | 31 | # Wrapper around abort 32 | def abort_with(message) 33 | abort message 34 | end 35 | end 36 | 37 | class< err 47 | index = RUBY_ENGINE == 'rbx' ? 1 : 0 48 | raise if !err.backtrace[index].include?('`full_invoke') 49 | no_command_error cmd 50 | end 51 | 52 | # Use to abort when no command found 53 | def no_command_error(cmd) 54 | abort_with %[Could not find command "#{cmd}"] 55 | end 56 | 57 | # Determines if a user command argument error or an internal Boson one 58 | def allowed_argument_error?(err, cmd, args) 59 | msg = RUBY_ENGINE == 'rbx' && err.class == ArgumentError ? 60 | /given \d+, expected \d+/ : /wrong number of arguments/ 61 | err.message[msg] && (cmd_obj = Command.find(cmd)) && 62 | cmd_obj.incorrect_arg_size?(args) 63 | end 64 | 65 | def option_parser 66 | @option_parser ||= OptionParser.new(self::GLOBAL_OPTIONS) 67 | end 68 | 69 | private 70 | def parse_args(args) 71 | options = option_parser.parse(args.dup, :opts_before_args=>true) 72 | new_args = option_parser.non_opts 73 | [new_args[0], options, new_args[1..-1]] 74 | end 75 | 76 | def load_rc 77 | rc = ENV['BOSONRC'] || '~/.bosonrc' 78 | load(rc) if !rc.empty? && File.exists?(File.expand_path(rc)) 79 | rescue StandardError, SyntaxError, LoadError => err 80 | warn "Error while loading #{rc}:\n"+ 81 | "#{err.class}: #{err.message}\n #{err.backtrace.join("\n ")}" 82 | end 83 | 84 | def load_options 85 | {} 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/boson.rb: -------------------------------------------------------------------------------- 1 | require 'boson/bare_runner' 2 | require 'boson/manager' 3 | require 'boson/loader' 4 | require 'boson/inspector' 5 | require 'boson/library' 6 | require 'boson/method_inspector' 7 | require 'boson/runner_library' 8 | require 'boson/command' 9 | require 'boson/util' 10 | require 'boson/option_parser' 11 | require 'boson/options' 12 | require 'boson/scientist' 13 | require 'boson/option_command' 14 | require 'boson/version' 15 | 16 | # This module stores the libraries, commands and the main_object. 17 | # 18 | # Useful documentation links: 19 | # * Boson::Library - All about libraries 20 | # * Boson::Loader - Explains library module callbacks 21 | # * Boson::OptionParser - All about options 22 | module Boson 23 | extend self 24 | 25 | # Module which is extended by Boson.main_object to give it command functionality. 26 | module Universe; end 27 | # Module under which most library modules are evaluated. 28 | module Commands; end 29 | 30 | # Default config 31 | CONFIG = {libraries: {}, command_aliases: {}, option_underscore_search: true} 32 | 33 | # The object which holds and executes all command functionality 34 | attr_accessor :main_object 35 | alias_method :higgs, :main_object 36 | 37 | attr_accessor :commands, :libraries, :config 38 | # Prints debugging info when set 39 | attr_accessor :debug 40 | # Returns true if commands are being executed from a non-ruby shell i.e. bash 41 | # Returns nil/false if in a ruby shell i.e. irb. 42 | attr_accessor :in_shell 43 | # Returns true if in commandline with verbose flag or if set explicitly. 44 | # Plugins should use this to display more info. 45 | attr_accessor :verbose 46 | 47 | # Array of loaded Boson::Library objects. 48 | def libraries 49 | @libraries ||= Array.new 50 | end 51 | 52 | # Array of loaded Boson::Command objects. 53 | def commands 54 | @commands ||= Array.new 55 | end 56 | 57 | # Global config used by most classes 58 | def config 59 | @config ||= CONFIG 60 | end 61 | 62 | # Sets main_object and extends it with commands from Universe 63 | def main_object=(value) 64 | @main_object = value.extend(Universe) 65 | end 66 | 67 | # Finds first library that has a value of attribute 68 | def library(query, attribute='name') 69 | libraries.find {|e| e.send(attribute) == query } 70 | end 71 | 72 | # Invoke an action on the main object. 73 | def invoke(*args, &block) 74 | main_object.send(*args, &block) 75 | end 76 | 77 | # Similar to invoke but accepts args as an array 78 | def full_invoke(cmd, args) 79 | main_object.send(cmd, *args) 80 | end 81 | 82 | # Boolean indicating if the main object can invoke the given method/command. 83 | def can_invoke?(meth, priv=true) 84 | Boson.main_object.respond_to? meth, priv 85 | end 86 | end 87 | 88 | Boson.main_object = self 89 | -------------------------------------------------------------------------------- /lib/boson/runner.rb: -------------------------------------------------------------------------------- 1 | require 'boson' 2 | 3 | module Boson 4 | # Defines a RunnerLibrary for use by executables as a simple way to map 5 | # methods to subcommands 6 | class Runner < BareRunner 7 | # Stores currently started Runner subclass 8 | class < err 37 | abort_with err.message 38 | end 39 | 40 | def self.display_command_help(cmd) 41 | puts "Usage: #{app_name} #{cmd.name} #{cmd.basic_usage}".rstrip, "" 42 | if cmd.options 43 | puts "Options:" 44 | cmd.option_parser.print_usage_table(no_headers: true) 45 | puts "" 46 | end 47 | puts "Description:\n #{cmd.desc || 'TODO'}" 48 | end 49 | 50 | def self.display_help 51 | commands = Boson.commands.sort_by(&:name).map {|c| [c.name, c.desc.to_s] } 52 | puts "Usage: #{app_name} [OPTIONS] COMMAND [ARGS]", "", "Available commands:", 53 | Util.format_table(commands), "", "Options:" 54 | option_parser.print_usage_table(no_headers: true) 55 | end 56 | 57 | def self.app_name 58 | File.basename($0).split(' ').first 59 | end 60 | 61 | def self.abort_with(msg) 62 | super "#{app_name}: #{msg}" 63 | end 64 | 65 | private 66 | def self.load_options 67 | {force: true} 68 | end 69 | 70 | def self.add_command_help 71 | Scientist.extend(ScientistExtension) 72 | Command.extend(CommandExtension) 73 | true # Ensure this method is only called once 74 | end 75 | 76 | module ScientistExtension 77 | # Overrides Scientist' default help 78 | def run_help_option(cmd) 79 | Boson::Runner.current.display_command_help(cmd) 80 | end 81 | end 82 | 83 | module CommandExtension 84 | # Ensure all commands have -h 85 | def new_attributes(name, library) 86 | super.update(option_command: true) 87 | end 88 | end 89 | end 90 | 91 | # Defines default commands that are available to executables i.e. Runner.start 92 | class DefaultCommandsRunner < Runner 93 | desc "Displays help for a command" 94 | def help(cmd = nil) 95 | if cmd.nil? 96 | Runner.current.display_help 97 | else 98 | (cmd_obj = Command.find(cmd)) ? Runner.current.display_command_help(cmd_obj) : 99 | self.class.no_command_error(cmd) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/boson/method_inspector.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Gathers method attributes by redefining method_added and capturing method 3 | # calls before a method. 4 | class MethodInspector 5 | METHODS = [:config, :desc, :options] 6 | SCRAPEABLE_METHODS = [:options] 7 | METHOD_CLASSES = {:config=>Hash, :desc=>String, :options=>Hash} 8 | ALL_METHODS = METHODS + [:option] 9 | 10 | def self.safe_new_method_added(mod, meth) 11 | return unless mod.to_s[/^Boson::Commands::/] 12 | new_method_added(mod, meth) 13 | end 14 | 15 | def self.new_method_added(mod, meth) 16 | instance.new_method_added(mod, meth) 17 | end 18 | 19 | class << self; attr_accessor :instance end 20 | 21 | def self.instance 22 | @instance ||= new 23 | end 24 | 25 | (METHODS + [:option, :mod_store]).each do |meth| 26 | define_singleton_method(meth) do |*args| 27 | instance.send(meth, *args) 28 | end 29 | end 30 | 31 | attr_accessor :current_module, :mod_store 32 | def initialize 33 | @mod_store = {} 34 | end 35 | 36 | # The method_added used while scraping method attributes. 37 | def new_method_added(mod, meth) 38 | self.current_module = mod 39 | 40 | store[:temp] ||= {} 41 | METHODS.each do |e| 42 | store[e][meth.to_s] = store[:temp][e] if store[:temp][e] 43 | end 44 | if store[:temp][:option] 45 | (store[:options][meth.to_s] ||= {}).merge! store[:temp][:option] 46 | end 47 | during_new_method_added mod, meth 48 | store[:temp] = {} 49 | 50 | if SCRAPEABLE_METHODS.any? {|m| has_inspector_method?(meth, m) } 51 | set_arguments(mod, meth) 52 | end 53 | end 54 | 55 | METHODS.each do |e| 56 | define_method(e) do |mod, val| 57 | (@mod_store[mod] ||= {})[e] ||= {} 58 | (store(mod)[:temp] ||= {})[e] = val 59 | end 60 | end 61 | 62 | # Scrapes option 63 | def option(mod, name, value) 64 | (@mod_store[mod] ||= {})[:options] ||= {} 65 | (store(mod)[:temp] ||= {})[:option] ||= {} 66 | (store(mod)[:temp] ||= {})[:option][name] = value 67 | end 68 | 69 | # Hash of a module's method attributes i.e. descriptions, options by method 70 | # and then attribute 71 | def store(mod=@current_module) 72 | @mod_store[mod] 73 | end 74 | 75 | # Renames store key from old to new name 76 | def rename_store_key(old, new) 77 | mod_store[new] = mod_store.delete old 78 | end 79 | 80 | # Sets current module 81 | def current_module=(mod) 82 | @current_module = mod 83 | @mod_store[mod] ||= {} 84 | end 85 | 86 | module API 87 | # Method hook called during new_method_added 88 | def during_new_method_added(mod, meth); end 89 | 90 | def set_arguments(mod, meth) 91 | store[:args] ||= {} 92 | 93 | args = mod.instance_method(meth).parameters.map do|(type, name)| 94 | case type 95 | when :rest then ["*#{name}"] 96 | when :req then [name.to_s] 97 | when :opt then [name.to_s, ''] 98 | else nil 99 | end 100 | end.compact 101 | 102 | store[:args][meth.to_s] = args 103 | end 104 | 105 | # Determines if method's arguments should be scraped 106 | def has_inspector_method?(meth, inspector) 107 | true 108 | end 109 | end 110 | include API 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/manager_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Manager" do 4 | def manager 5 | Manager.instance 6 | end 7 | 8 | before do 9 | reset_boson 10 | Manager.instance = nil 11 | end 12 | 13 | describe ".load" do 14 | def load_library(hash={}) 15 | meths = hash.delete(:commands) || [] 16 | manager_load create_runner(*(meths + [{library: :Blah}])), hash 17 | end 18 | 19 | it "loads basic library" do 20 | load_library 21 | library_loaded? 'blah' 22 | end 23 | 24 | it "loads basic library with verbose" do 25 | capture_stdout { 26 | load_library verbose: true 27 | }.chomp.should == 'Loaded library blah' 28 | library_loaded? 'blah' 29 | end 30 | 31 | it "loads library with commands" do 32 | load_library :commands=>['frylock','meatwad'] 33 | library_loaded? 'blah' 34 | library_has_command 'blah', 'frylock' 35 | library_has_command 'blah', 'meatwad' 36 | end 37 | 38 | it "prints error if library does not load" do 39 | RunnerLibrary.any_instance.expects(:load).returns false 40 | load_library 41 | stderr.should == "Library blah did not load successfully." 42 | end 43 | 44 | [SyntaxError, StandardError, LoaderError].each do |klass| 45 | it "prints error if library fails with #{klass}" do 46 | RunnerLibrary.expects(:new).raises(klass) 47 | load_library 48 | stderr.should == "Unable to load library Blah. Reason: #{klass}" 49 | manager.failed_libraries.should == [Blah] 50 | end 51 | end 52 | 53 | [SyntaxError, StandardError].each do |klass| 54 | it "with verbose prints verbose error if library fails with #{klass}" do 55 | RunnerLibrary.expects(:new).raises(klass) 56 | load_library verbose: true 57 | stderr.should =~ /^Unable to load library Blah. Reason: #{klass}\n\s*\// 58 | manager.failed_libraries.should == [Blah] 59 | end 60 | end 61 | 62 | it "prints error if no library is found" do 63 | manager_load 'dude' 64 | stderr.should == 65 | 'Unable to load library dude. Reason: Library dude not found.' 66 | end 67 | 68 | it "prints error for library that's already loaded" do 69 | runner = create_runner 70 | Manager.load runner 71 | manager_load runner, verbose: true 72 | stderr.should == "Library blarg already exists." 73 | end 74 | 75 | it "merges with existing created library" do 76 | create_library(name: 'blah') 77 | load_library 78 | library_loaded? 'blah' 79 | Boson.libraries.size.should == 1 80 | end 81 | end 82 | 83 | describe ".redefine_commands" do 84 | before do 85 | @library = create_library(:name=>'blah', :commands=>['foo', 'bar']) 86 | @foo = create_command(name: 'foo', lib: 'blah', options: {fool: :string}, 87 | args: '*') 88 | create_command(name: 'bar', lib: 'blah', options: {bah: :string}) 89 | end 90 | 91 | it "only redefines commands with args" do 92 | Scientist.expects(:redefine_command).with(anything, @foo) 93 | manager.redefine_commands(@library, @library.commands) 94 | end 95 | 96 | it "with verbose only redefines commands with args and prints rejected" do 97 | Manager.instance.verbose = true 98 | Scientist.expects(:redefine_command).with(anything, @foo) 99 | capture_stdout { 100 | manager.redefine_commands(@library, @library.commands) 101 | }.should =~ /cannot have options.*bar/ 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/boson/loader.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Raised if a library has methods which conflict with existing methods 3 | class MethodConflictError < LoaderError 4 | MESSAGE = "The following commands conflict with existing commands: %s" 5 | def initialize(conflicts) 6 | super MESSAGE % conflicts.join(', ') 7 | end 8 | end 9 | 10 | # This module is mixed into Library to give it load() functionality. When 11 | # creating your own Library subclass, you should at least override 12 | # load_source_and_set_module. 13 | module Loader 14 | # Loads a library and its dependencies and returns true if library loads 15 | # correctly. 16 | def load 17 | load_source_and_set_module 18 | module_callbacks if @module 19 | yield if block_given? # load dependencies 20 | detect_additions { load_commands } if load_commands? 21 | set_library_commands 22 | loaded_correctly? && (@loaded = true) 23 | end 24 | 25 | # Method hook at the beginning of #load. This method should load the source 26 | # and set instance variables necessary to make a library valid i.e. @module. 27 | def load_source_and_set_module; end 28 | 29 | # Method hook for @module before loading 30 | def module_callbacks; end 31 | 32 | # Determines if load_commands should be called 33 | def load_commands? 34 | @module 35 | end 36 | 37 | # Wraps around module loading for unexpected additions 38 | def detect_additions(options={}, &block) 39 | Util.detect(options, &block).tap do |detected| 40 | @commands.concat detected[:methods].map(&:to_s) 41 | end 42 | end 43 | 44 | # Prepares for command loading, loads commands and rescues certain errors. 45 | def load_commands 46 | @module = @module ? Util.constantize(@module) : 47 | Util.create_module(Boson::Commands, clean_name) 48 | before_load_commands 49 | check_for_method_conflicts unless @force 50 | actual_load_commands 51 | rescue MethodConflictError => err 52 | handle_method_conflict_error err 53 | end 54 | 55 | # Boolean which indicates if library loaded correctly. 56 | def loaded_correctly? 57 | !!@module 58 | end 59 | 60 | # Method hook for @module after it's been included 61 | def after_include; end 62 | 63 | # called when MethodConflictError is rescued 64 | def handle_method_conflict_error(err) 65 | raise err 66 | end 67 | 68 | # Method hook called after @module has been created 69 | def before_load_commands; end 70 | 71 | # Actually includes module and its commands 72 | def actual_load_commands 73 | include_in_universe 74 | end 75 | 76 | # Returns array of method conflicts 77 | def method_conflicts 78 | (@module.instance_methods + @module.private_instance_methods) & 79 | (Boson.main_object.methods + Boson.main_object.private_methods) 80 | end 81 | 82 | # Handles setting and cleaning @commands 83 | def set_library_commands 84 | clean_library_commands 85 | end 86 | 87 | # Cleans @commands from set_library_commands 88 | def clean_library_commands 89 | aliases = @commands_hash.select {|k,v| @commands.include?(k) }. 90 | map {|k,v| v[:alias] }.compact 91 | @commands -= aliases 92 | @commands.uniq! 93 | end 94 | 95 | private 96 | def include_in_universe(lib_module=@module) 97 | Boson::Universe.send :include, lib_module 98 | after_include 99 | Boson::Universe.send :extend_object, Boson.main_object 100 | end 101 | 102 | def check_for_method_conflicts 103 | conflicts = method_conflicts 104 | raise MethodConflictError.new(conflicts) unless conflicts.empty? 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/boson/bin_runner.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # This class handles the boson executable. 3 | # 4 | # Usage for the boson shell command looks like this: 5 | # boson [GLOBAL OPTIONS] [COMMAND] [ARGS] [COMMAND OPTIONS] 6 | # 7 | # The boson executable comes with several global options: :version, :execute, 8 | # :ruby_debug, :debug, and :load_path. 9 | class BinRunner < BareRunner 10 | GLOBAL_OPTIONS.update( 11 | version: {type: :boolean, desc: "Prints the current version"}, 12 | execute: {type: :string, 13 | desc: "Executes given arguments as a one line script"}, 14 | ruby_debug: {type: :boolean, desc: "Sets $DEBUG", alias: 'D'}, 15 | debug: {type: :boolean, desc: "Prints debug info for boson"}, 16 | load_path: {type: :string, desc: "Add to front of $LOAD_PATH", alias: 'I'} 17 | ) 18 | 19 | module API 20 | attr_accessor :command 21 | 22 | # Executes functionality from either an option or a command 23 | def execute_option_or_command(options, command, args) 24 | options[:execute] ? eval_execute_option(options[:execute]) : 25 | execute_command(command, args) 26 | end 27 | 28 | # Evaluates :execute option. 29 | def eval_execute_option(str) 30 | Boson.main_object.instance_eval str 31 | end 32 | 33 | # Returns true if an option does something and exits early 34 | def early_option?(args) 35 | if @options[:version] 36 | puts("boson #{Boson::VERSION}") 37 | true 38 | elsif args.empty? || (@command.nil? && !@options[:execute]) 39 | print_usage 40 | true 41 | else 42 | false 43 | end 44 | end 45 | 46 | # Determines verbosity of this class 47 | def verbose 48 | false 49 | end 50 | 51 | # Handles no method errors 52 | def no_method_error_message(err) 53 | @command = @command.to_s 54 | if err.backtrace.grep(/`(invoke|full_invoke)'$/).empty? || 55 | !err.message[/undefined method `(\w+\.)?#{command_name(@command)}'/] 56 | default_error_message($!) 57 | else 58 | command_not_found?(@command) ? 59 | "Error: Command '#{@command}' not found" : default_error_message(err) 60 | end 61 | end 62 | 63 | # Determine command name given full command name. Overridden by namespaces 64 | def command_name(cmd) 65 | cmd 66 | end 67 | 68 | # Determines if a NoMethodError is a command not found error 69 | def command_not_found?(cmd) 70 | cmd[/\w+/] 71 | end 72 | 73 | # Constructs error message 74 | def default_error_message(err) 75 | "Error: #{err.message}" 76 | end 77 | 78 | def print_usage_header 79 | puts "boson [GLOBAL OPTIONS] [COMMAND] [ARGS] [COMMAND OPTIONS]\n\n" 80 | end 81 | 82 | # prints full usage 83 | def print_usage 84 | print_usage_header 85 | @option_parser.print_usage_table 86 | end 87 | end 88 | extend API 89 | 90 | # Starts, processes and ends a commandline request. 91 | def self.start(args=ARGV) 92 | super 93 | @command, @options, @args = parse_args(args) 94 | 95 | $:.unshift(*options[:load_path].split(":")) if options[:load_path] 96 | Boson.debug = true if options[:debug] 97 | $DEBUG = true if options[:ruby_debug] 98 | return if early_option?(args) 99 | Boson.in_shell = true 100 | 101 | init 102 | execute_option_or_command(@options, @command, @args) 103 | rescue NoMethodError 104 | abort_with no_method_error_message($!) 105 | rescue 106 | abort_with default_error_message($!) 107 | end 108 | 109 | # Hash of global options passed in from commandline 110 | def self.options 111 | @options ||= {} 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/boson/inspector.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Uses method decorators to scrape, process and hand off method attributes as 3 | # data to Library objects. 4 | # 5 | # === Method Decorators 6 | # Method decorators refer to methods placed before a command's method in a 7 | # library: 8 | # class SomeRunner < Boson::Runner 9 | # options :verbose=>:boolean 10 | # option :count, :numeric 11 | # # Something descriptive perhaps 12 | # def some_method(opts) 13 | # # ... 14 | # end 15 | # end 16 | # 17 | # Method decorators serve as configuration for a method's command. All 18 | # decorators should only be called once per method except for option. 19 | # Available method decorators: 20 | # * config: Hash to define any command attributes (see Command.new). 21 | # * desc: String to define a command's description for a command. Defaults to 22 | # first commented line above a method. 23 | # * options: Hash to define an OptionParser object for a command's options. 24 | # * option: Option name and value to be merged in with options. See 25 | # OptionParser for what an option value can be. 26 | class Inspector 27 | class << self; attr_reader :enabled; end 28 | 29 | # Enable scraping by overridding method_added to snoop on a library while 30 | # it's loading its methods. 31 | def self.enable(options = {}) 32 | method_inspector_meth = options[:all_classes] ? 33 | :new_method_added : :safe_new_method_added 34 | klass = options[:module] || ::Module 35 | @enabled = true unless options[:module] 36 | 37 | body = MethodInspector::ALL_METHODS.map {|e| 38 | %[def #{e}(*args) 39 | Boson::MethodInspector.#{e}(self, *args) 40 | end] 41 | }.join("\n") + 42 | %[ 43 | def new_method_added(method) 44 | Boson::MethodInspector.#{method_inspector_meth}(self, method) 45 | end 46 | 47 | alias_method :_old_method_added, :method_added 48 | alias_method :method_added, :new_method_added 49 | ] 50 | klass.module_eval body 51 | end 52 | 53 | # Disable scraping method data. 54 | def self.disable 55 | ::Module.module_eval %[ 56 | Boson::MethodInspector::ALL_METHODS.each {|e| remove_method e } 57 | alias_method :method_added, :_old_method_added 58 | ] 59 | @enabled = false 60 | end 61 | 62 | # Adds method attributes to the library's commands 63 | def self.add_method_data_to_library(library) 64 | new(library).add_data 65 | end 66 | 67 | def initialize(library) 68 | @commands_hash = library.commands_hash 69 | @library_file = library.library_file 70 | MethodInspector.instance.current_module = library.module 71 | @store = MethodInspector.instance.store 72 | end 73 | 74 | module API 75 | # Adds scraped data from all inspectors 76 | def add_data 77 | add_method_scraped_data 78 | end 79 | end 80 | include API 81 | 82 | private 83 | def add_method_scraped_data 84 | (MethodInspector::METHODS + [:args]).each do |key| 85 | (@store[key] || []).each do |cmd, val| 86 | @commands_hash[cmd] ||= {} 87 | add_valid_data_to_config(key, val, cmd) 88 | end 89 | end 90 | end 91 | 92 | def add_valid_data_to_config(key, value, cmd) 93 | if valid_attr_value?(key, value) 94 | add_scraped_data_to_config(key, value, cmd) 95 | else 96 | if Boson.debug 97 | warn "DEBUG: Command '#{cmd}' has #{key.inspect} attribute with " + 98 | "invalid value '#{value.inspect}'" 99 | end 100 | end 101 | end 102 | 103 | def add_scraped_data_to_config(key, value, cmd) 104 | if value.is_a?(Hash) 105 | if key == :config 106 | @commands_hash[cmd] = Util.recursive_hash_merge value, @commands_hash[cmd] 107 | else 108 | @commands_hash[cmd][key] = Util.recursive_hash_merge value, 109 | @commands_hash[cmd][key] || {} 110 | end 111 | else 112 | @commands_hash[cmd][key] ||= value 113 | end 114 | end 115 | 116 | def valid_attr_value?(key, value) 117 | return true if (klass = MethodInspector::METHOD_CLASSES[key]).nil? 118 | value.is_a?(klass) || value.nil? 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/boson/util.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Collection of utility methods used throughout Boson. 3 | module Util 4 | extend self 5 | # From ActiveSupport, converts a camelcased string to an underscored string: 6 | # 'Boson::MethodInspector' -> 'boson/method_inspector' 7 | def underscore(camel_cased_word) 8 | camel_cased_word.to_s.gsub(/::/, '/'). 9 | gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). 10 | gsub(/([a-z\d])([A-Z])/,'\1_\2'). 11 | tr("-", "_"). 12 | downcase 13 | end 14 | 15 | # From ActiveSupport, does the reverse of underscore: 16 | # 'boson/method_inspector' -> 'Boson::MethodInspector' 17 | def camelize(string) 18 | string.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }. 19 | gsub(/(?:^|_)(.)/) { $1.upcase } 20 | end 21 | 22 | # Converts a module/class string to the actual constant. 23 | # Returns nil if not found. 24 | def constantize(string) 25 | any_const_get(camelize(string)) 26 | end 27 | 28 | # Returns a constant like const_get() no matter what namespace it's nested 29 | # in. Returns nil if the constant is not found. 30 | def any_const_get(name) 31 | return name if name.is_a?(Module) 32 | klass = Object 33 | name.split('::').each {|e| 34 | klass = klass.const_get(e) 35 | } 36 | klass 37 | rescue 38 | nil 39 | end 40 | 41 | # Detects new object/kernel methods, gems and modules created within a 42 | # block. Returns a hash of what's detected. Valid options and possible 43 | # returned keys are :methods, :object_methods, :modules, :gems. 44 | def detect(options={}, &block) 45 | options = {methods: true}.merge!(options) 46 | original_gems = defined?(Gem) ? Gem.loaded_specs.keys : [] 47 | original_object_methods = Object.instance_methods 48 | original_instance_methods = Boson.main_object.singleton_class.instance_methods 49 | original_modules = modules if options[:modules] 50 | 51 | block.call 52 | 53 | detected = {} 54 | detected[:methods] = options[:methods] ? 55 | (Boson.main_object.singleton_class.instance_methods - 56 | original_instance_methods) : [] 57 | unless options[:object_methods] 58 | detected[:methods] -= (Object.instance_methods - original_object_methods) 59 | end 60 | detected[:gems] = Gem.loaded_specs.keys - original_gems if defined? Gem 61 | detected[:modules] = modules - original_modules if options[:modules] 62 | detected 63 | end 64 | 65 | # Returns all modules that currently exist. 66 | def modules 67 | all_modules = [] 68 | ObjectSpace.each_object(Module) {|e| all_modules << e} 69 | all_modules 70 | end 71 | 72 | # Creates a module under a given base module and possible name. If the 73 | # module already exists, it attempts to create one with a number appended to 74 | # the name. 75 | def create_module(base_module, name) 76 | desired_class = camelize(name) 77 | possible_suffixes = [''] + %w{1 2 3 4 5 6 7 8 9 10} 78 | if suffix = possible_suffixes.find {|e| 79 | !base_module.const_defined?(desired_class+e) } 80 | base_module.const_set(desired_class+suffix, Module.new) 81 | end 82 | end 83 | 84 | # Recursively merge hash1 with hash2. 85 | def recursive_hash_merge(hash1, hash2) 86 | hash1.merge(hash2) {|k,o,n| (o.is_a?(Hash)) ? recursive_hash_merge(o,n) : n} 87 | end 88 | 89 | # Regular expression search of a list with underscore anchoring of words. 90 | # For example 'some_dang_long_word' can be specified as 's_d_l_w'. 91 | def underscore_search(input, list, first_match=false) 92 | meth = first_match ? :find : :select 93 | return (first_match ? input : [input]) if list.include?(input) 94 | input = input.to_s 95 | if input.include?("_") 96 | underscore_regex = input.split('_').map {|e| 97 | Regexp.escape(e) }.join("([^_]+)?_") 98 | list.send(meth) {|e| e.to_s =~ /^#{underscore_regex}/ } 99 | else 100 | escaped_input = Regexp.escape(input) 101 | list.send(meth) {|e| e.to_s =~ /^#{escaped_input}/ } 102 | end 103 | end 104 | 105 | def format_table(arr_of_arr) 106 | name_max = arr_of_arr.map {|arr| arr[0].length }.max 107 | desc_max = arr_of_arr.map {|arr| arr[1].length }.max 108 | 109 | arr_of_arr.map do |name, desc| 110 | (" %-*s %-*s" % [name_max, name, desc_max, desc]).rstrip 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'mocha_standalone' 2 | require 'boson' 3 | require 'fileutils' 4 | require 'boson/runner' 5 | require 'bahia' 6 | 7 | ENV['RSPEC'] = '1' if $0[/rspec/] 8 | unless ENV['RSPEC'] 9 | require 'bacon' 10 | require 'bacon/bits' 11 | require 'mocha-on-bacon' 12 | end 13 | 14 | Object.send :remove_const, :OptionParser 15 | Boson.constants.each {|e| Object.const_set(e, Boson.const_get(e)) unless Object.const_defined?(e) } 16 | ENV['BOSONRC'] = File.dirname(__FILE__) + '/.bosonrc' 17 | 18 | module TestHelpers 19 | ## Misc 20 | def assert_error(error, message=nil) 21 | yield 22 | rescue error=>e 23 | e.class.should == error 24 | e.message.should =~ Regexp.new(message) if message 25 | else 26 | nil.should == error 27 | end 28 | 29 | def remove_constant(name, mod=Object) 30 | mod.send(:remove_const, name) if mod.const_defined?(name, false) 31 | end 32 | 33 | def with_config(options) 34 | old_config = Boson.config 35 | Boson.config = Boson.config.merge(options) 36 | yield 37 | Boson.config = old_config 38 | end 39 | 40 | # for use with executables 41 | def with_command(cmd) 42 | old = ENV['BOSONRC'] 43 | ENV['BOSONRC'] = File.dirname(__FILE__) + '/.bosonrc.temp' 44 | File.open(ENV['BOSONRC'], 'w') {|f| 45 | f.puts <<-STR 46 | require 'boson/runner' 47 | class SomeRunner < Boson::Runner 48 | def #{cmd} 49 | end 50 | end 51 | Boson::Manager.load SomeRunner 52 | STR 53 | } 54 | 55 | yield 56 | 57 | FileUtils.rm_f ENV['BOSONRC'] 58 | ENV['BOSONRC'] = old 59 | end 60 | 61 | def manager_load(lib, options={}) 62 | @stderr = capture_stderr { Manager.load(lib, options) } 63 | end 64 | 65 | def stderr 66 | @stderr.chomp 67 | end 68 | 69 | ## Reset 70 | def reset 71 | reset_main_object 72 | reset_boson 73 | end 74 | 75 | def reset_main_object 76 | Boson.send :remove_const, "Universe" 77 | eval "module ::Boson::Universe; end" 78 | remove_constant "Blah", Boson::Commands 79 | Boson.main_object = Object.new 80 | end 81 | 82 | def reset_boson 83 | reset_libraries 84 | Boson.instance_eval("@commands = nil") 85 | end 86 | 87 | def reset_libraries 88 | Boson.instance_eval("@libraries = nil") 89 | end 90 | 91 | ## Library 92 | def library_loaded?(name, bool=true) 93 | Manager.loaded?(name).should == bool 94 | end 95 | 96 | def library(name) 97 | Boson.library(name) 98 | end 99 | 100 | def library_has_command(lib, command, bool=true) 101 | (lib = library(lib)) && lib.commands.include?(command).should == bool 102 | end 103 | 104 | ## Factories 105 | def create_runner(*methods, &block) 106 | options = methods[-1].is_a?(Hash) ? methods.pop : {} 107 | library = options[:library] || :Blarg 108 | remove_constant library 109 | 110 | Object.const_set(library, Class.new(Boson::Runner)).tap do |klass| 111 | if block 112 | klass.module_eval(&block) 113 | else 114 | methods.each do |meth| 115 | klass.send(:define_method, meth) { } 116 | end 117 | end 118 | end 119 | end 120 | 121 | def create_library(hash) 122 | Library.new(hash).tap {|lib| Manager.add_library lib } 123 | end 124 | 125 | def create_command(hash) 126 | Command.new(hash).tap {|cmd| Boson.commands << cmd } 127 | end 128 | 129 | ## Capture 130 | def capture_stdout(&block) 131 | original_stdout = $stdout 132 | $stdout = fake = StringIO.new 133 | begin 134 | yield 135 | ensure 136 | $stdout = original_stdout 137 | end 138 | fake.string 139 | end 140 | 141 | def capture_stderr(&block) 142 | original_stderr = $stderr 143 | $stderr = fake = StringIO.new 144 | begin 145 | yield 146 | ensure 147 | $stderr = original_stderr 148 | end 149 | fake.string 150 | end 151 | 152 | if ENV['RSPEC'] 153 | def should_not_raise(&block) 154 | block.should_not raise_error 155 | end 156 | else 157 | # Since rspec doesn't allow should != or should.not 158 | Object.send(:define_method, :should_not) {|*args, &block| 159 | should.not(*args, &block) 160 | } 161 | def should_not_raise(&block) 162 | should.not.raise &block 163 | end 164 | end 165 | end 166 | 167 | if ENV['RSPEC'] 168 | module RspecBits 169 | def before_all(&block) 170 | before(:all, &block) 171 | end 172 | 173 | def after_all(&block) 174 | after(:all, &block) 175 | end 176 | end 177 | 178 | RSpec.configure {|c| 179 | c.mock_with :mocha 180 | c.extend RspecBits 181 | c.include TestHelpers, Bahia 182 | } 183 | else 184 | Bacon::Context.send :include, Bahia 185 | Bacon::Context.send :include, TestHelpers 186 | end 187 | -------------------------------------------------------------------------------- /lib/boson/library.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # A library is a group of commands (Command objects) usually grouped together 3 | # by a module. Libraries are loaded from different sources depending on the 4 | # library subclass. 5 | # 6 | # === Creating Your Own Library 7 | # To create your own subclass you need to define what sources the subclass can 8 | # handle with handles(). See Loader to see what instance methods to override 9 | # for a subclass. 10 | class Library 11 | include Loader 12 | class <{'commands'=>{:desc=>'Lists commands', :alias=>'com'}} 38 | # @option hash [Boolean] :force Forces a library to ignore when a library's 39 | # methods are overriding existing ones. Use with caution. Default is false. 40 | def initialize(hash) 41 | before_initialize 42 | @name = set_name(hash.delete(:name)) or 43 | raise ArgumentError, "Library missing required key :name" 44 | @loaded = false 45 | @commands_hash = {} 46 | @commands = [] 47 | set_config (config[:libraries][@name] || {}).merge(hash), true 48 | set_command_aliases(config[:command_aliases]) 49 | end 50 | 51 | # A concise symbol version of a library type i.e. FileLibrary -> :file. 52 | def library_type 53 | str = self.class.to_s[/::(\w+)Library$/, 1] || 'library' 54 | str.downcase.to_sym 55 | end 56 | 57 | # handles names under directories 58 | def clean_name 59 | @name[/\w+$/] 60 | end 61 | 62 | # sets name 63 | def set_name(name) 64 | name.to_s 65 | end 66 | 67 | module API 68 | # The object a library uses for executing its commands. 69 | def namespace_object 70 | @namespace_object ||= Boson.main_object 71 | end 72 | 73 | # Method hook called at the beginning of initialize 74 | def before_initialize 75 | end 76 | 77 | # Determines if library is local i.e. scoped to current directory/project 78 | def local? 79 | false 80 | end 81 | 82 | # @return [Hash] Attributes used internally by a library. Defaults to 83 | # using Boson.config but can be overridden to be library-specific. 84 | def config 85 | Boson.config 86 | end 87 | end 88 | include API 89 | 90 | # Command objects of library's commands 91 | def command_objects(names=self.commands, command_array=Boson.commands) 92 | command_array.select {|e| names.include?(e.name) && e.lib == self.name } 93 | end 94 | 95 | # Command object for given command name 96 | def command_object(name) 97 | command_objects([name])[0] 98 | end 99 | 100 | private 101 | def set_attributes(hash, force=false) 102 | hash.each do |k,v| 103 | if instance_variable_get("@#{k}").nil? || force 104 | instance_variable_set("@#{k}", v) 105 | end 106 | end 107 | end 108 | 109 | def set_config(config, force=false) 110 | if (commands = config.delete(:commands)) 111 | if commands.is_a?(Array) 112 | @commands += commands 113 | @pre_defined_commands = true 114 | elsif commands.is_a?(Hash) 115 | @commands += commands.keys 116 | @commands_hash = Util.recursive_hash_merge commands, @commands_hash 117 | end 118 | end 119 | set_command_aliases config.delete(:command_aliases) if config[:command_aliases] 120 | set_attributes config, force 121 | end 122 | 123 | def set_command_aliases(command_aliases) 124 | (command_aliases || {}).each do |cmd, cmd_alias| 125 | @commands_hash[cmd] ||= {} 126 | @commands_hash[cmd][:alias] ||= cmd_alias 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /CHANGELOG.rdoc: -------------------------------------------------------------------------------- 1 | == 1.3.0 2 | * Fix to support ruby 2.X 3 | 4 | == 1.2.4 5 | * Remove repo_index which has moved to boson-more 6 | 7 | == 1.2.3 8 | * Fix help command with no args 9 | 10 | == 1.2.2 11 | * Fix required arg command failing hard with no args 12 | * Fix bug with Command#option_command? not being available to plugins 13 | 14 | == 1.2.1 15 | * Fix only option being invalid and not deleted 16 | * Fix handling option parse errors in Runner 17 | * Fix argument error handling for commands with optional args 18 | 19 | == 1.2.0 20 | * Add help subcommand for executables 21 | * Allow Runner help methods to be extended 22 | * Fix arg and no method error handling on rbx 23 | * Fix OptionParser#delete_invalid_opts bug 24 | * Prefix Runner executable errors with executable name 25 | * Fix pending tests 26 | * Rename Runner help methods to Runner.display_help and Runner.display_command_help 27 | 28 | == 1.1.1 29 | * Fix bug for command with one argument containing quoted arguments 30 | 31 | == 1.1.0 32 | * Add Runner.execute 33 | * Allow Runner to define commands with Kernel method names 34 | * Fix Runner.load_options 35 | 36 | == 1.0.1 37 | * Fix RunnerLibrary not parsing options correctly 38 | 39 | == 1.0.0 40 | * A new slim boson! 41 | 42 | == 0.4.0 43 | * Add file lock for concurrent processes 44 | 45 | == 0.3.4 46 | * Handle rubygems deprecation (#28) 47 | * 1.9 Fixes 48 | 49 | == 0.3.3 50 | * Fix install command for https (#22) 51 | 52 | == 0.3.2 53 | * Add commandline backtrace option (#18) 54 | 55 | == 0.3.1 56 | * Fixed MethodInspector handling anonymous classes on 1.9.2 (#16) 57 | * Fixed get and install commands on 1.9.2 (#17) 58 | * Fixed failed loading of Bosonfile 59 | * Fixed RequireLibrary indicating correct load status 60 | 61 | == 0.3.0 62 | * Added --debug to executable with multiple debug hooks 63 | * Added --ruby_debug and -I to executable to change $LOAD_PATH and $DEBUG 64 | * Added @option method attribute as a more readable complement to @options 65 | * Added proper exit code for failed commands (#12) 66 | * Added friendlier errors for libraries with SyntaxError or LoaderError 67 | * Added validation to method attributes 68 | * Improved RequireLibrary to more robustly handle gems like httparty 69 | * Fixed 1.9.2-rc2 bugs including #14 70 | * Fixed finding commands with same names 71 | * Fixed --console for ruby >= 1.8.7 72 | * Fixed --help for namespaced commands 73 | 74 | == 0.2.5 75 | * Fixed critical gemspec error 76 | 77 | == 0.2.4 78 | * Tests use bacon and pass on all major ruby versions 79 | * Fixed bug for 1.8.7 and super (#10) 80 | * Added commandline pipes with '+' 81 | * Fixed bug when requiring rubygems in a library 82 | * Fixed bug in sort pipe for 1.9.2 83 | * Got rid of jeweler in Rakefile and $LOAD_PATH meddling 84 | * Refactored BinRunner's error handling 85 | 86 | == 0.2.3 87 | * Added improved support and additional attributes for user pipe options 88 | * Added :pipes global command option 89 | * Added json + yaml parsing to get() 90 | * Added underscore searching to option values 91 | * Added build_install() command 92 | * Added :usage_options commandline option 93 | 94 | == 0.2.2 95 | * Renamed Boson::Command#description to #desc. Delete your index at ~/.boson/config/index.marshal. 96 | * Renamed Boson::Command#global_options to #option_command. Update your configs. 97 | * Bug fix for Windows and indexing (#4) 98 | * Added system wide Boson commands at /etc/boson (#2) 99 | * Added Boson::Command#config for plugins to set/get via @config 100 | * Added option_command and unload options to BinRunner 101 | * Added special option parsing characters: - and -- 102 | * Added special :output_class key for global render_options 103 | * Added :delete_options global option 104 | * Fixed --no variant for single letter booleans 105 | * Fixed MethodInspector parsing arguments with special characters 106 | * Allow global -p to work even in failures 107 | * Allow -hv to default to verbose help 108 | * Boson::OptionParser tweaks 109 | 110 | == 0.2.1 111 | * Added local libraries: Bosonfile and under local repositories 112 | * Added config method attribute. 113 | * Added default_option and global_options command attributes. 114 | * Added OptionParser.parse and OptionParser.usage for scripting use. 115 | * Improved auto-rendering from commandline. 116 | * Removed library reload. 117 | * Better docs. 118 | 119 | == 0.2.0 120 | * Command options 121 | ** Added custom global and render options for commands. 122 | ** Added pipe and filter option commands. 123 | ** Add global query option. 124 | * Options 125 | ** Users can define custom option types. 126 | ** Added hash option type. 127 | ** Any option can be a boolean with :bool_default attribute. 128 | ** Adding * aliasing to relevant options. 129 | * Made Boson::Scientist.commandify for use outside Boson. 130 | * Any command can have a default option. 131 | * Directories are namespaced automatically. 132 | * Solidified library module callback methods. 133 | * Added support for Windows home. 134 | * Improved ModuleLibrary to handle class or module class methods. 135 | * Better search and sort integration with Hirb. 136 | * Better docs. 137 | * Fixed number of bugs. 138 | * query_fields option for searching libraries and commands is deprecated. Specifying query 139 | fields is now done by prefixing a query with ':'. For example: 140 | bash> boson commands library_type:gem 141 | # instead of 142 | bash> boson commands gem --query_fields=library_type 143 | 144 | == 0.1.0 145 | * First real release 146 | * Plenty of fixes to make it ruby 1.9 ready. 147 | * Added more documentation 148 | * BinRunner tweaks and bug fixes 149 | * Other miscellaneous bug fixes 150 | 151 | == 0.0.1 152 | * An initial release for others to play with. 153 | -------------------------------------------------------------------------------- /lib/boson/scientist.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Scientist wraps around and redefines an object's method to give it the 3 | # following features: 4 | # * Methods can take shell command input with options or receive its normal 5 | # arguments. See the Commandification section. 6 | # * Methods have a slew of global options available. See OptionCommand for an 7 | # explanation of basic global options. 8 | # 9 | # The main methods Scientist provides are redefine_command() for redefining an 10 | # object's method with a Command object and commandify() for redefining with a 11 | # hash of method attributes. Note that for an object's method to be redefined 12 | # correctly, its last argument _must_ expect a hash. 13 | # 14 | # === Commandification 15 | # Take for example this basic method/command with an options definition: 16 | # options :level=>:numeric, :verbose=>:boolean 17 | # def foo(*args) 18 | # args 19 | # end 20 | # 21 | # When Scientist wraps around foo(), it can take arguments normally or as a 22 | # shell command: 23 | # foo 'one', 'two', :verbose=>true # normal call 24 | # foo 'one two -v' # commandline call 25 | # 26 | # Both calls return: ['one', 'two', {:verbose=>true}] 27 | # 28 | # Non-string arguments can be passed as well: 29 | # foo Object, 'two', :level=>1 30 | # foo Object, 'two -l1' 31 | # 32 | # Both calls return: [Object, 'two', {:level=>1}] 33 | module Scientist 34 | extend self 35 | # Handles all Scientist errors. 36 | class Error < StandardError; end 37 | 38 | attr_accessor :global_options 39 | @no_option_commands ||= [] 40 | @option_commands ||= {} 41 | @object_methods = {} 42 | 43 | # Redefines an object's method with a Command of the same name. 44 | def redefine_command(obj, command) 45 | cmd_block = redefine_command_block(obj, command) 46 | @no_option_commands << command if command.options.nil? 47 | [command.name, command.alias].compact.each {|e| 48 | obj.singleton_class.send(:define_method, e, cmd_block) 49 | } 50 | rescue Error 51 | warn "Error: #{$!.message}" 52 | end 53 | 54 | # A wrapper around redefine_command that doesn't depend on a Command object. 55 | # Rather you simply pass a hash of command attributes (see Command.new) or 56 | # command methods and let OpenStruct mock a command. The only required 57 | # attribute is :name, though to get any real use you should define :options 58 | # and :arg_size (default is '*'). Example: 59 | # >> def checkit(*args); args; end 60 | # => nil 61 | # >> Boson::Scientist.commandify(self, :name=>'checkit', :options=>{:verbose=>:boolean, :num=>:numeric}) 62 | # => ['checkit'] 63 | # # regular ruby method 64 | # >> checkit 'one', 'two', :num=>13, :verbose=>true 65 | # => ["one", "two", {:num=>13, :verbose=>true}] 66 | # # commandline ruby method 67 | # >> checkit 'one two -v -n=13' 68 | # => ["one", "two", {:num=>13, :verbose=>true}] 69 | def commandify(obj, hash) 70 | raise ArgumentError, ":name required" unless hash[:name] 71 | hash[:arg_size] ||= '*' 72 | hash[:has_splat_args?] = true if hash[:arg_size] == '*' 73 | fake_cmd = OpenStruct.new(hash) 74 | fake_cmd.option_parser ||= OptionParser.new(fake_cmd.options || {}) 75 | redefine_command(obj, fake_cmd) 76 | end 77 | 78 | # The actual method which redefines a command's original method 79 | def redefine_command_block(obj, command) 80 | object_methods(obj)[command.name] ||= begin 81 | obj.method(command.name) 82 | rescue NameError 83 | raise Error, "No method exists to redefine command '#{command.name}'." 84 | end 85 | lambda {|*args| 86 | Scientist.analyze(obj, command, args) {|args| 87 | Scientist.object_methods(obj)[command.name].call(*args) 88 | } 89 | } 90 | end 91 | 92 | # Returns hash of methods for an object 93 | def object_methods(obj) 94 | @object_methods[obj] ||= {} 95 | end 96 | 97 | # option command for given command 98 | def option_command(cmd=@command) 99 | @option_commands[cmd] ||= OptionCommand.new(cmd) 100 | end 101 | 102 | # Runs a command given its object and arguments 103 | def analyze(obj, command, args, &block) 104 | @global_options, @command, @original_args = {}, command, args.dup 105 | @args = translate_args(obj, args) 106 | return run_help_option(@command) if @global_options[:help] 107 | during_analyze(&block) 108 | rescue OptionParser::Error, Error 109 | raise if Boson.in_shell 110 | warn "Error: #{$!}" 111 | end 112 | 113 | # Overridable method called during analyze 114 | def during_analyze(&block) 115 | process_result call_original_command(@args, &block) 116 | end 117 | 118 | # Hook method available after parse in translate_args 119 | def after_parse; end 120 | 121 | private 122 | def call_original_command(args, &block) 123 | block.call(args) 124 | end 125 | 126 | def translate_args(obj, args) 127 | option_command.modify_args(args) 128 | @global_options, @current_options, args = option_command.parse(args) 129 | return if @global_options[:help] 130 | after_parse 131 | 132 | if @current_options 133 | option_command.add_default_args(args, obj) 134 | return args if @no_option_commands.include?(@command) 135 | args << @current_options 136 | option_command.check_argument_size(args) 137 | end 138 | args 139 | end 140 | 141 | def run_help_option(cmd) 142 | puts "#{cmd.full_name} #{cmd.usage}".rstrip 143 | end 144 | 145 | def process_result(result) 146 | result 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/boson/manager.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # Base class for library loading errors. Raised mostly in Boson::Loader and 3 | # rescued by Boson::Manager. 4 | class LoaderError < StandardError; end 5 | 6 | # Handles loading of libraries and commands. 7 | class Manager 8 | # Loads a library or an array of libraries with options. Manager loads the 9 | # first library subclass to return true for Library#handles. Any options 10 | # that aren't listed here are passed as library attributes to the libraries 11 | # (see Library.new) 12 | # 13 | # @param [Hash] options 14 | # @option options [Boolean] :verbose Prints each library's loaded status 15 | # along with more verbose errors. Default is false. 16 | # @example Manager.load MyRunner 17 | def self.load(libraries, options={}) 18 | instance.load(libraries, options) 19 | end 20 | 21 | class < 0 70 | puts "Following commands cannot have options until their arguments " + 71 | "are configured: " + rejected.map {|e| e.name}.join(', ') 72 | end 73 | accepted.each {|cmd| Scientist.redefine_command(lib.namespace_object, cmd) } 74 | end 75 | 76 | module API 77 | # Method hook for loading dependencies or anything else before loading 78 | # a library 79 | def load_dependencies(lib, options); end 80 | 81 | # Method hook in middle of after_load 82 | def during_after_load; end 83 | 84 | # Method hook called before create_commands 85 | def before_create_commands(lib) 86 | if lib.is_a?(RunnerLibrary) && lib.module 87 | Inspector.add_method_data_to_library(lib) 88 | end 89 | end 90 | 91 | # Method hook called after create_commands 92 | def after_create_commands(lib, commands); end 93 | 94 | # Handles an error from a load action 95 | def handle_load_action_error(library, load_method, err) 96 | case err 97 | when LoaderError 98 | add_failed_library library 99 | warn "Unable to #{load_method} library #{library}. Reason: #{err.message}" 100 | else 101 | add_failed_library library 102 | message = "Unable to #{load_method} library #{library}. Reason: #{err}" 103 | if Boson.debug 104 | message << "\n" + err.backtrace.map {|e| " " + e }.join("\n") 105 | elsif verbose 106 | message << "\n" + err.backtrace.slice(0,3).map {|e| " " + e }.join("\n") 107 | end 108 | warn message 109 | end 110 | end 111 | end 112 | include API 113 | 114 | private 115 | def call_load_action(library, load_method) 116 | yield 117 | rescue StandardError, SyntaxError, LoadError => err 118 | handle_load_action_error(library, load_method, err) 119 | ensure 120 | Inspector.disable if Inspector.enabled 121 | end 122 | 123 | def load_once(source, options={}) 124 | self.verbose = options[:verbose] 125 | 126 | call_load_action(source, :load) do 127 | lib = loader_create(source, options) 128 | if self.class.loaded?(lib.name) 129 | if verbose && !options[:dependency] 130 | warn "Library #{lib.name} already exists." 131 | end 132 | false 133 | else 134 | actual_load_once lib, options 135 | end 136 | end 137 | end 138 | 139 | def actual_load_once(lib, options) 140 | if lib.load { load_dependencies(lib, options) } 141 | lib 142 | else 143 | if !options[:dependency] 144 | warn "Library #{lib.name} did not load successfully." 145 | end 146 | warn " "+lib.inspect if Boson.debug 147 | false 148 | end 149 | end 150 | 151 | def loader_create(source, options) 152 | options = options.dup.tap {|h| h.delete(:verbose) } 153 | lib_class = Library.handle_blocks.find {|k,v| v.call(source) } or 154 | raise(LoaderError, "Library #{source} not found.") 155 | lib_class[0].new(options.merge(name: source)) 156 | end 157 | 158 | def create_commands(lib, commands=lib.commands) 159 | before_create_commands(lib) 160 | commands.each {|e| Boson.commands << Command.create(e, lib)} 161 | after_create_commands(lib, commands) 162 | redefine_commands(lib, commands) 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/boson/option_command.rb: -------------------------------------------------------------------------------- 1 | require 'shellwords' 2 | module Boson 3 | # A class used by Scientist to wrap around Command objects. It's main purpose 4 | # is to parse a command's global and local options. As the names imply, 5 | # global options are available to all commands while local options are 6 | # specific to a command. When passing options to commands, global ones _must_ 7 | # be passed first, then local ones. Also, options _must_ all be passed either 8 | # before or after arguments. 9 | # 10 | # === Basic Global Options 11 | # Any command with options comes with basic global options. For example '-h' 12 | # on an option command prints help. If a global option conflicts with a local 13 | # option, the local option takes precedence. 14 | class OptionCommand 15 | # ArgumentError specific to @command's arguments 16 | class CommandArgumentError < ::ArgumentError; end 17 | 18 | # default global options 19 | BASIC_OPTIONS = { 20 | :help=>{:type=>:boolean, :desc=>"Display a command's help"}, 21 | } 22 | 23 | class < 1 && args[-1].is_a?(String) 49 | temp_args = Boson.in_shell ? args : Shellwords.shellwords(args.pop) 50 | global_opt, parsed_options, new_args = parse_options temp_args 51 | Boson.in_shell ? args = new_args : args += new_args 52 | # add default options 53 | elsif @command.options.nil? || @command.options.empty? || 54 | (@command.numerical_arg_size? && args.size <= (@command.arg_size - 1).abs) || 55 | (@command.has_splat_args? && !args[-1].is_a?(Hash)) 56 | global_opt, parsed_options = parse_options([])[0,2] 57 | # merge default options with given hash of options 58 | elsif (@command.has_splat_args? || (args.size == @command.arg_size)) && 59 | args[-1].is_a?(Hash) 60 | global_opt, parsed_options = parse_options([])[0,2] 61 | parsed_options.merge!(args.pop) 62 | end 63 | [global_opt || {}, parsed_options, args] 64 | end 65 | 66 | def parse_global_options(args) 67 | option_parser.parse args 68 | end 69 | 70 | module API 71 | # option parser just for option command 72 | def option_parser 73 | @option_parser ||= self.class.default_option_parser 74 | end 75 | end 76 | include API 77 | 78 | # modifies args for edge cases 79 | def modify_args(args) 80 | if @command.default_option && @command.numerical_arg_size? && 81 | @command.arg_size <= 1 && 82 | !args[0].is_a?(Hash) && args[0].to_s[/./] != '-' && !args.join.empty? 83 | args[0] = "--#{@command.default_option}=#{args[0]}" 84 | end 85 | end 86 | 87 | # raises CommandArgumentError if argument size is incorrect for given args 88 | def check_argument_size(args) 89 | if @command.numerical_arg_size? && args.size != @command.arg_size 90 | command_size, args_size = args.size > @command.arg_size ? 91 | [@command.arg_size, args.size] : 92 | [@command.arg_size - 1, args.size - 1] 93 | raise CommandArgumentError, 94 | "wrong number of arguments (#{args_size} for #{command_size})" 95 | end 96 | end 97 | 98 | # Adds default args as original method would 99 | def add_default_args(args, obj) 100 | if @command.args && args.size < @command.arg_size - 1 101 | # leave off last arg since its an option 102 | @command.args.slice(0..-2).each_with_index {|arr,i| 103 | next if args.size >= i + 1 # only fill in once args run out 104 | break if arr.size != 2 # a default arg value must exist 105 | begin 106 | args[i] = @command.file_parsed_args ? obj.instance_eval(arr[1]) : arr[1] 107 | rescue Exception 108 | raise Scientist::Error, "Unable to set default argument at " + 109 | "position #{i+1}.\nReason: #{$!.message}" 110 | end 111 | } 112 | end 113 | end 114 | 115 | private 116 | def parse_options(args) 117 | parsed_options = @command.option_parser.parse(args, delete_invalid_opts: true) 118 | trailing, unparseable = split_trailing 119 | global_options = parse_global_options @command.option_parser.leading_non_opts + 120 | trailing 121 | 122 | # delete invalid options not deleted since no other options present 123 | if @command.numerical_arg_size? && 124 | @command.option_parser.leading_non_opts.size > @command.arg_size - 1 125 | option_parser.delete_leading_invalid_opts 126 | end 127 | 128 | new_args = option_parser.non_opts.dup + unparseable 129 | [global_options, parsed_options, new_args] 130 | rescue OptionParser::Error 131 | global_options = parse_global_options @command.option_parser.leading_non_opts + 132 | split_trailing[0] 133 | global_options[:help] ? [global_options, nil, []] : raise 134 | end 135 | 136 | def split_trailing 137 | trailing = @command.option_parser.trailing_non_opts 138 | if trailing[0] == '--' 139 | trailing.shift 140 | [ [], trailing ] 141 | else 142 | trailing.shift if trailing[0] == '-' 143 | [ trailing, [] ] 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/boson/options.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # This module contains the methods used to define the default option types. 3 | # 4 | # === Creating Your Own Option Type 5 | # Defining your own option type simply requires one method (create_@type) to 6 | # parse the option value and create the desired object. To create an option 7 | # type :date, you could create the following create_date method: 8 | # # Drop this in ~/.bosonrc 9 | # module Boson::Options::Date 10 | # def create_date(value) 11 | # # value should be mm/dd 12 | # Date.parse(value + "/#{Date.today.year}") 13 | # end 14 | # end 15 | # Boson::OptionParser.send :include, Boson::Options::Date 16 | # 17 | # In a Library, we could then use this new option: 18 | # class Calendar < Boson::Runner 19 | # options :day=>:date 20 | # def appointments(options={}) 21 | # # ... 22 | # end 23 | # end 24 | # # >> appointments '-d 10/10' -> {:day=># } 25 | # As you can see, a date object is created from the :date option's value and 26 | # passed into appointments(). 27 | # 28 | # Some additional tips on the create_* method: 29 | # * The argument passed to the method is the option value from the user. 30 | # * To access the current option name use @current_option. 31 | # * To access the hash of attributes the current option has use 32 | # OptionParser.current_attributes. See OptionParser.new for more about 33 | # option attributes. 34 | # 35 | # There are two optional methods per option type: validate_@type and 36 | # usage_for_@type i.e. validate_date and usage_for_date. Like create_@type, 37 | # validate_@type takes the option's value. If the value validation fails, 38 | # raise an OptionParser::Error with a proper message. All user-defined option 39 | # types automatically validate for an option value's existence. The 40 | # usage_for_* method takes an option's name (i.e. --day) and returns a usage 41 | # string to be wrapped in '[ ]'. If no usage is defined the default would look 42 | # like '[--day=:date]'. Consider using the OptionParser#default_usage helper 43 | # method for your usage. 44 | module Options 45 | # Parse/create methods 46 | def create_string(value) 47 | if (values = current_attributes[:values]) && 48 | (values = values.sort_by {|e| e.to_s}) 49 | value = auto_alias_value(values, value) 50 | validate_enum_values(values, value) 51 | end 52 | value 53 | end 54 | 55 | def create_boolean(value) 56 | if (!@opt_types.key?(dasherize(@current_option)) && 57 | @current_option =~ /^no-(\w+)$/) 58 | opt = (opt = original_no_opt($1)) ? undasherize(opt) : $1 59 | (@current_option.replace(opt) && false) 60 | else 61 | true 62 | end 63 | end 64 | 65 | def create_numeric(value) 66 | value.index('.') ? value.to_f : value.to_i 67 | end 68 | 69 | def create_array(value) 70 | splitter = current_attributes[:split] || ',' 71 | array = value.split(splitter) 72 | if (values = current_attributes[:values]) && 73 | (values = values.sort_by {|e| e.to_s }) 74 | if current_attributes[:regexp] 75 | array = array.map {|e| 76 | (new_values = values.grep(/#{e}/)).empty? ? e : new_values 77 | }.compact.flatten.uniq 78 | else 79 | array.each {|e| array.delete(e) && array += values if e == '*'} 80 | array.map! {|e| auto_alias_value(values, e) } 81 | end 82 | validate_enum_values(values, array) 83 | end 84 | array 85 | end 86 | 87 | def create_hash(value) 88 | (keys = current_attributes[:keys]) && keys = keys.sort_by {|e| e.to_s } 89 | hash = parse_hash(value, keys) 90 | if keys 91 | hash = hash.inject({}) {|h,(k,v)| 92 | h[auto_alias_value(keys, k)] = v; h 93 | } 94 | validate_enum_values(keys, hash.keys) 95 | end 96 | hash 97 | end 98 | 99 | def parse_hash(value, keys) 100 | splitter = current_attributes[:split] || ',' 101 | if !value.include?(':') && current_attributes[:default_keys] 102 | value = current_attributes[:default_keys].to_s + ":#{value}" 103 | end 104 | 105 | # Creates array pairs, grouping array of keys with a value 106 | aoa = Hash[*value.split( 107 | /(?::)([^#{Regexp.quote(splitter)}]+)#{Regexp.quote(splitter)}?/ 108 | )].to_a 109 | if keys 110 | aoa.each_with_index {|(k,v),i| aoa[i][0] = keys.join(splitter) if k == '*' } 111 | end 112 | aoa.inject({}) {|t,(k,v)| k.split(splitter).each {|e| t[e] = v }; t } 113 | end 114 | 115 | # Validation methods 116 | def validate_string(value) 117 | if valid?(value) 118 | raise OptionParser::Error, 119 | "cannot pass '#{value}' as an argument to option '#{@current_option}'" 120 | end 121 | end 122 | 123 | def validate_numeric(value) 124 | unless value =~ OptionParser::NUMERIC and $& == value 125 | raise OptionParser::Error, 126 | "expected numeric value for option '#{@current_option}'; got " + 127 | value.inspect 128 | end 129 | end 130 | 131 | def validate_hash(value) 132 | if !value.include?(':') && !current_attributes[:default_keys] 133 | raise OptionParser::Error, 134 | "invalid key:value pair for option '#{@current_option}'" 135 | end 136 | end 137 | 138 | # Usage methods 139 | def usage_for_boolean(opt) 140 | opt 141 | end 142 | 143 | def usage_for_string(opt) 144 | default_usage(opt, undasherize(opt).upcase) 145 | end 146 | 147 | def usage_for_numeric(opt) 148 | default_usage opt, "N" 149 | end 150 | 151 | def usage_for_array(opt) 152 | default_usage opt, "A,B,C" 153 | end 154 | 155 | def usage_for_hash(opt) 156 | default_usage opt, "A:B,C:D" 157 | end 158 | end 159 | end 160 | Boson::OptionParser.send :include, Boson::Options 161 | -------------------------------------------------------------------------------- /lib/boson/command.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # A command starts with the functionality of a ruby method and adds benefits 3 | # with options, etc. 4 | class Command 5 | module APIClassMethods 6 | # Creates a command given its name and a library. 7 | def create(name, library) 8 | new(new_attributes(name, library)) 9 | end 10 | 11 | # Attributes passed to commands from its library 12 | def library_attributes(library) 13 | {lib: library.name} 14 | end 15 | 16 | # Finds a command, aliased or not. If found returns the command object, 17 | # otherwise returns nil. 18 | def find(command, commands=Boson.commands) 19 | command && commands.find {|e| [e.name, e.alias].include?(command) } 20 | end 21 | 22 | # Generates a command's initial attributes when creating a command object 23 | def new_attributes(name, library) 24 | (library.commands_hash[name] || {}).merge(name: name). 25 | update(library_attributes(library)) 26 | end 27 | end 28 | extend APIClassMethods 29 | 30 | # One line usage for a command if it exists 31 | def self.usage(command) 32 | (cmd = find(command)) ? "#{command} #{cmd.usage}" : '' 33 | end 34 | 35 | # Attributes that are defined as accessors 36 | ATTRIBUTES = [:name, :lib, :alias, :desc, :options, :args, :config] 37 | attr_accessor *(ATTRIBUTES + [:default_option]) 38 | # Attributes that can be passed in at initialization 39 | INIT_ATTRIBUTES = [:alias, :desc, :options, :default_option, :option_command] 40 | attr_reader :file_parsed_args 41 | 42 | # Takes a hash of attributes which map to instance variables and values. 43 | # :name and :lib are required keys. 44 | # 45 | # @param [Hash] attributes 46 | # @option attributes [String] :desc Description that shows up once in 47 | # command listings 48 | # @option attributes [String] :alias Alternative name for command 49 | # @option attributes [Hash] :options Options passed to OptionParser 50 | # @option attributes [Array,Integer,String] :args Should only be set if 51 | # not automatically set. This attribute is only important for commands 52 | # that have options. Its value can be an array, a number representing 53 | # the number of arguments or '*' if the command has a variable number of 54 | # arguments. 55 | # @option attributes [String] :default_option Only for an option command 56 | # that has one or zero arguments. This treats the given option as an optional 57 | # first argument. Example: 58 | # # For a command with default option 'query' and options --query and -v 59 | # 'some -v' -> '--query=some -v' 60 | # '-v' -> '-v' 61 | # @option attributes [Hash] :config used by third party libraries to get and 62 | # set custom command attributes. 63 | # @option attributes [Boolean] :option_command Wraps a command with an 64 | # OptionCommand object i.e. allow commands to have options. 65 | def initialize(attributes) 66 | hash = attributes.dup 67 | @name = hash.delete(:name) or raise ArgumentError 68 | @lib = hash.delete(:lib) or raise ArgumentError 69 | # since MethodInspector scrapes arguments from file by default 70 | @file_parsed_args = true 71 | INIT_ATTRIBUTES.each do |e| 72 | instance_variable_set("@#{e}", hash.delete(e)) if hash.key?(e) 73 | end 74 | 75 | after_initialize(hash) 76 | 77 | if (args = hash.delete(:args)) 78 | if args.is_a?(Array) 79 | @args = args 80 | elsif args.to_s[/^\d+/] 81 | @arg_size = args.to_i 82 | elsif args == '*' 83 | @args = [['*args']] 84 | end 85 | end 86 | @config = Util.recursive_hash_merge hash, hash.delete(:config) || {} 87 | end 88 | 89 | module API 90 | # Called after initialize 91 | def after_initialize(hash) 92 | end 93 | 94 | # Alias for a name but plugins may use it to give a more descriptive name 95 | def full_name 96 | name 97 | end 98 | 99 | # One-line usage of args 100 | def basic_usage 101 | return '' if args.nil? 102 | usage_args.map {|e| 103 | (e.size < 2) ? e[0].upcase : "[#{e[0].upcase}]" 104 | }.join(' ') 105 | end 106 | alias_method :usage, :basic_usage 107 | 108 | # Indicates if an OptionCommand 109 | def option_command? 110 | options || @option_command 111 | end 112 | end 113 | include API 114 | 115 | # Library object a command belongs to. 116 | def library 117 | @library ||= Boson.library(@lib) 118 | end 119 | 120 | def args(lib=library) 121 | @args 122 | end 123 | 124 | # Option parser for command as defined by @options. 125 | def option_parser 126 | @option_parser ||= OptionParser.new(@options || {}) 127 | end 128 | 129 | # until @config is consistent in index + actual loading 130 | def config 131 | @config ||= {} 132 | end 133 | 134 | # Indicates if any arg has a splat 135 | def has_splat_args? 136 | !!(args && @args[-1] && @args[-1][0][/^\*/]) 137 | end 138 | 139 | # Indicates if arg size can handle a numerical comparison 140 | def numerical_arg_size? 141 | !has_splat_args? && arg_size 142 | end 143 | 144 | # Determines if incorrect # of args given i.e. too little or too much 145 | def incorrect_arg_size?(args) 146 | return false if has_splat_args? 147 | required_arg_size = @args.take_while {|e| e[1].nil? }.size 148 | args.size < required_arg_size || args.size > required_arg_size 149 | end 150 | 151 | # Number of arguments 152 | def arg_size 153 | unless instance_variable_defined?("@arg_size") 154 | @arg_size = args ? args.size : nil 155 | end 156 | @arg_size 157 | end 158 | 159 | private 160 | def usage_args 161 | args && @options && !has_splat_args? ? 162 | (@default_option ? 163 | [[@default_option.to_s, @file_parsed_args ? ''.inspect : '']] + 164 | args[0..-2] : args[0..-2]) 165 | : args 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/options_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Options" do 4 | def create(opts) 5 | @opt = OptionParser.new(opts) 6 | end 7 | 8 | def parse(*args) 9 | @non_opts = [] 10 | @opt.parse(args.flatten) 11 | end 12 | 13 | describe ":string type" do 14 | before { 15 | create "--foo" => :string, "--bar" => :string, :blah=>{:type=>:string, :default=>:huh} 16 | } 17 | 18 | it "doesn't set nonexistant options" do 19 | parse("--bling")[:bar].should == nil 20 | end 21 | 22 | it "sets values correctly" do 23 | parse("--foo", "12")[:foo].should == "12" 24 | parse("--bar", "12")[:bar].should == "12" 25 | end 26 | 27 | it "raises error if passed another valid option" do 28 | assert_error(OptionParser::Error, "cannot pass.*'foo'") { parse("--foo", "--bar") } 29 | end 30 | 31 | it "raises error if not passed a value" do 32 | assert_error(OptionParser::Error, "no value.*'foo'") { parse("--foo") } 33 | end 34 | 35 | it "overwrites earlier values with later values" do 36 | parse("--foo", "12", "--foo", "13")[:foo].should == "13" 37 | end 38 | 39 | it "can have symbolic default value" do 40 | parse('--blah','ok')[:blah].should == 'ok' 41 | end 42 | end 43 | 44 | describe ":string type with :values attribute" do 45 | before_all { create :foo=>{:type=>:string, :values=>%w{angola abu abib}} } 46 | it "auto aliases if a match exists" do 47 | parse("-f", "an")[:foo].should == 'angola' 48 | end 49 | 50 | it "auto aliases first sorted match" do 51 | parse("-f", "a")[:foo].should == 'abib' 52 | end 53 | 54 | it "raises error if option doesn't auto alias or match given values" do 55 | assert_error(OptionParser::Error, "invalid.*'z'") { parse("-f", "z") } 56 | end 57 | 58 | it "doesn't raise error for a nonmatch if enum is false" do 59 | create :foo=>{:type=>:string, :values=>%w{angola abu abib}, :enum=>false} 60 | parse("-f", "z")[:foo].should == 'z' 61 | end 62 | end 63 | 64 | describe ":string type with default value" do 65 | before { create "--branch" => "master" } 66 | 67 | it "should get the specified value" do 68 | parse("--branch", "bugfix").should == { :branch => "bugfix" } 69 | end 70 | 71 | it "should get the default value when not specified" do 72 | parse.should == { :branch => "master" } 73 | end 74 | end 75 | 76 | describe ":numeric type" do 77 | before { create "n" => :numeric, "m" => 5 } 78 | 79 | it "supports numeric defaults" do 80 | parse["m"].should == 5 81 | end 82 | 83 | it "converts values to numeric types" do 84 | parse("-n", "3", "-m", ".5").should == {:n => 3, :m => 0.5} 85 | end 86 | 87 | it "raises error when value isn't numeric" do 88 | assert_error(OptionParser::Error, "expected numeric value for.*'n'") { parse("-n", "foo") } 89 | end 90 | 91 | it "raises error when opt is present without value" do 92 | assert_error(OptionParser::Error, "no value.*'n'") { parse("-n") } 93 | end 94 | end 95 | 96 | describe ":array type" do 97 | before_all { 98 | create :a=>:array, :b=>[1,2,3], :c=>{:type=>:array, :values=>%w{foo fa bar zebra}, :enum=>false}, 99 | :d=>{:type=>:array, :split=>" ", :values=>[:ab, :bc, :cd], :enum=>false}, 100 | :e=>{:type=>:array, :values=>%w{some so silly}, :regexp=>true} 101 | } 102 | 103 | it "supports array defaults" do 104 | parse[:b].should == [1,2,3] 105 | end 106 | 107 | it "converts comma delimited values to an array" do 108 | parse("-a","1,2,5")[:a].should == %w{1 2 5} 109 | end 110 | 111 | it "raises error when option has no value" do 112 | assert_error(OptionParser::Error, "no value.*'a'") { parse("-a") } 113 | end 114 | 115 | it "auto aliases :values attribute" do 116 | parse("-c","f,b")[:c].should == %w{fa bar} 117 | end 118 | 119 | it "auto aliases symbolic :values" do 120 | parse("-d","a c")[:d].should == [:ab,:cd] 121 | end 122 | 123 | it "supports a configurable splitter" do 124 | parse("-d", "yogi berra")[:d].should == %w{yogi berra} 125 | end 126 | 127 | it "aliases * to all values" do 128 | parse("-c", '*')[:c].sort.should == %w{bar fa foo zebra} 129 | parse("-c", '*,ok')[:c].sort.should == %w{bar fa foo ok zebra} 130 | end 131 | 132 | it "aliases correctly with :regexp on" do 133 | parse("-e", 'so')[:e].sort.should == %w{so some} 134 | end 135 | end 136 | 137 | describe ":hash type" do 138 | before_all { 139 | create :a=>:hash, :b=>{:default=>{:a=>'b'}}, :c=>{:type=>:hash, :keys=>%w{one two three}}, 140 | :e=>{:type=>:hash, :keys=>[:one, :two, :three], :default_keys=>:three}, 141 | :d=>{:type=>:hash, :split=>" "} 142 | } 143 | 144 | it "converts comma delimited pairs to hash" do 145 | parse("-a", "f:3,g:4")[:a].should == {'f'=>'3', 'g'=>'4'} 146 | end 147 | 148 | it "supports hash defaults" do 149 | parse[:b].should == {:a=>'b'} 150 | end 151 | 152 | it "raises error when option has no value" do 153 | assert_error(OptionParser::Error, "no value.*'a'") { parse("-a") } 154 | end 155 | 156 | it "raises error if invalid key-value pair given for unknown keys" do 157 | assert_error(OptionParser::Error, "invalid.*pair.*'a'") { parse("-a", 'b') } 158 | end 159 | 160 | it "auto aliases :keys attribute" do 161 | parse("-c","t:3,o:1")[:c].should == {'three'=>'3', 'one'=>'1'} 162 | end 163 | 164 | it "adds in explicit default keys with value only argument" do 165 | parse('-e', 'whoop')[:e].should == {:three=>'whoop'} 166 | end 167 | 168 | it "adds in default keys from known :keys with value only argument" do 169 | parse("-c","okay")[:c].should == {'one'=>'okay'} 170 | end 171 | 172 | it "auto aliases symbolic :keys" do 173 | parse("-e","t:3,o:1")[:e].should == {:three=>'3', :one=>'1'} 174 | end 175 | 176 | it "supports a configurable splitter" do 177 | parse("-d","a:ab b:bc")[:d].should == {'a'=>'ab', 'b'=>'bc'} 178 | end 179 | 180 | it "supports grouping keys" do 181 | parse("-c", "t,tw:foo,o:bar")[:c].should == {'three'=>'foo','two'=>'foo', 'one'=>'bar'} 182 | end 183 | 184 | it "aliases * to all keys" do 185 | parse("-c", "*:foo")[:c].should == {'three'=>'foo', 'two'=>'foo', 'one'=>'foo'} 186 | parse('-a', '*:foo')[:a].should == {'*'=>'foo'} 187 | end 188 | end 189 | end -------------------------------------------------------------------------------- /test/scientist_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "Scientist" do 4 | before_all { 5 | Boson.in_shell = nil 6 | eval <<-EOF 7 | module Blah 8 | def blah(arg1, options={}) 9 | [arg1, options] 10 | end 11 | def splat_blah(*args) 12 | args 13 | end 14 | def default_blah(arg1, arg2=default, options={}) 15 | [arg1, arg2, options] 16 | end 17 | def default; 'some default'; end 18 | def default_option(options={}) 19 | options 20 | end 21 | end 22 | EOF 23 | @opt_cmd = Object.new.extend Blah 24 | } 25 | 26 | def command(hash, args) 27 | hash = {:name=>'blah', :lib=>'bling', :options=>{:force=>:boolean, :level=>2}}.merge(hash) 28 | @cmd = Command.new hash 29 | @cmd.instance_variable_set("@file_parsed_args", true) if hash[:file_parsed_args] 30 | Scientist.redefine_command(@opt_cmd, @cmd) 31 | @opt_cmd.send(hash[:name], *args) 32 | end 33 | 34 | def command_with_arg_size(*args) 35 | command({:args=>2}, args) 36 | end 37 | 38 | def command_with_args(*args) 39 | command({:args=>[['arg1'],['options', {}]]}, args) 40 | end 41 | 42 | def basic_command(hash, args) 43 | command({:name=>'splat_blah', :args=>'*'}.merge(hash), args) 44 | end 45 | 46 | def command_with_splat_args(*args) 47 | command({:name=>'splat_blah', :args=>'*'}, args) 48 | end 49 | 50 | def command_with_arg_defaults(*args) 51 | arg_defaults = [%w{arg1}, %w{arg2 default}, %w{options {}}] 52 | command({:name=>'default_blah', :file_parsed_args=>true, :args=>arg_defaults}, args) 53 | end 54 | 55 | def args_are_equal(args, array) 56 | command_with_args(*args).should == array 57 | command_with_arg_size(*args).should == array 58 | command_with_splat_args(*args).should == array 59 | end 60 | 61 | def all_commands 62 | [:command_with_args, :command_with_arg_size, :command_with_splat_args] 63 | end 64 | 65 | describe "all commands" do 66 | it "translate arg and options as one string" do 67 | args_are_equal ['a1 -f'], ['a1', {:force=>true, :level=>2}] 68 | end 69 | 70 | it "translate arg and stringified options" do 71 | args_are_equal [:cool, '-l3'], [:cool, {:level=>3}] 72 | end 73 | 74 | it "translate arg and normal hash options" do 75 | args_are_equal [:cool, {:ok=>true}], [:cool, {:ok=>true, :level=>2}] 76 | end 77 | 78 | it "translate stringified arg without options sets default options" do 79 | args_are_equal ['cool'], ['cool', {:level=>2}] 80 | end 81 | 82 | it "translate arg without options sets default options" do 83 | args_are_equal [:cool], [:cool, {:level=>2}] 84 | end 85 | 86 | it "with invalid options print errors and delete them" do 87 | all_commands.each do |cmd| 88 | capture_stderr { 89 | send(cmd, 'cool -f -z').should == ['cool', {:force=>true, :level=>2}] 90 | }.should =~/invalid.*z/ 91 | end 92 | end 93 | end 94 | 95 | describe "command" do 96 | describe "with arg defaults" do 97 | it "sets defaults with stringified args" do 98 | command_with_arg_defaults('1').should == ["1", "some default", {:level=>2}] 99 | end 100 | 101 | it "sets defaults with normal args" do 102 | command_with_arg_defaults(1).should == [1, "some default", {:level=>2}] 103 | end 104 | 105 | it "sets default if optional arg is a valid option" do 106 | command_with_arg_defaults("cool -f").should == ["cool", "some default", {:level=>2, :force=>true}] 107 | end 108 | 109 | it "doesn't set defaults if not needed" do 110 | command_with_arg_defaults(1, 'nada').should == [1, "nada", {:level=>2}] 111 | end 112 | 113 | it "prints error for invalid defaults" do 114 | arg_defaults = [%w{arg1}, %w{arg2 invalidzzz}, %w{options {}}] 115 | capture_stderr { 116 | command({:name=>'default_blah', :file_parsed_args=>true, :args=>arg_defaults}, [1]) 117 | }.should =~ /Error.*position 2/ 118 | end 119 | end 120 | 121 | describe "prints error" do 122 | it "with option error" do 123 | capture_stderr { command_with_args('a1 -l') }.should =~ /Error.*level/ 124 | end 125 | 126 | it "with no argument defined for options" do 127 | assert_error(OptionCommand::CommandArgumentError, '2 for 1') { command({:args=>1}, 'ok') } 128 | end 129 | end 130 | 131 | it "translates stringfied args + options starting at second arg" do 132 | command_with_arg_defaults(1, 'nada -l3').should == [1, "nada", {:level=>3}] 133 | end 134 | 135 | it "with leading option-like args are translated as arguments" do 136 | command_with_args('-z -f').should == ["-z", {:force=>true, :level=>2}] 137 | command_with_args('--blah -f').should == ['--blah', {:force=>true, :level=>2}] 138 | end 139 | 140 | it "with splat args does not raise error for too few or many args" do 141 | [[], [''], [1,2,3], ['1 2 3']].each do |args| 142 | should_not_raise { command_with_splat_args *args } 143 | end 144 | end 145 | 146 | it "with not enough args raises CommandArgumentError" do 147 | args = [OptionCommand::CommandArgumentError, '0 for 1'] 148 | assert_error(*args) { command_with_args } 149 | assert_error(*args) { command_with_args '' } 150 | assert_error(*args) { command_with_arg_size } 151 | assert_error(*args) { command_with_arg_size '' } 152 | end 153 | 154 | it "with too many args raises CommandArgumentError" do 155 | args3 = RUBY_ENGINE == 'rbx' ? [ArgumentError, 'given 3, expected 2'] : 156 | RUBY_VERSION >= '2.0.0' ? [ArgumentError, "3 for 1..2"] : [ArgumentError, '3 for 2'] 157 | args4 = [OptionCommand::CommandArgumentError, '4 for 2'] 158 | assert_error(*args3) { command_with_args 1,2,3 } 159 | assert_error(*args4) { command_with_args '1 2 3' } 160 | assert_error(*args3) { command_with_arg_size 1,2,3 } 161 | assert_error(*args4) { command_with_arg_size '1 2 3' } 162 | end 163 | end 164 | 165 | describe "command with default option" do 166 | before_all { @cmd_attributes = {:name=>'default_option', :default_option=>'level', :args=>1} } 167 | 168 | it "parses normally from irb" do 169 | command(@cmd_attributes, '-f --level=3').should == {:level=>3, :force=>true} 170 | end 171 | 172 | it "parses normally from cmdline" do 173 | Boson.expects(:in_shell).times(2).returns true 174 | command(@cmd_attributes, ['--force', '--level=3']).should == {:level=>3, :force=>true} 175 | end 176 | 177 | it "parses no arguments normally" do 178 | command(@cmd_attributes, '').should == {:level=>2} 179 | end 180 | 181 | it "parses ruby arguments normally" do 182 | command(@cmd_attributes, [{:force=>true, :level=>10}]).should == {:level=>10, :force=>true} 183 | end 184 | 185 | it "prepends correctly from irb" do 186 | command(@cmd_attributes, '3 -f').should == {:level=>3, :force=>true} 187 | end 188 | 189 | it "prepends correctly from cmdline" do 190 | Boson.expects(:in_shell).times(2).returns true 191 | command(@cmd_attributes, ['3','-f']).should == {:level=>3, :force=>true} 192 | end 193 | end 194 | 195 | it "redefine_command prints error for command with nonexistant method" do 196 | capture_stderr { 197 | Scientist.redefine_command Object.new, Command.new(:name=>'blah', :lib=>'blah') 198 | }.should =~ /Error: No method.*'blah'/ 199 | end 200 | 201 | after_all { Boson.in_shell = false } 202 | end 203 | -------------------------------------------------------------------------------- /test/runner_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/test_helper' 2 | require 'shellwords' 3 | 4 | # hack required to re-add default_commands_runner methods 5 | $".delete_if {|e| e[%r{boson/runner.rb$}] } 6 | Boson.send(:remove_const, :Runner) 7 | Boson.send(:remove_const, :DefaultCommandsRunner) 8 | require 'boson/runner' 9 | 10 | # remove side effects from other tests 11 | Boson::Runner::GLOBAL_OPTIONS.delete_if {|k,v| k != :help } 12 | 13 | class MyRunner < Boson::Runner 14 | desc "This is a small" 15 | def small(*args) 16 | p args 17 | end 18 | 19 | option :tags, :type => :array 20 | option :blurg, :type => :boolean, :required => true 21 | desc 'This is splot' 22 | def splot(*args) 23 | p args 24 | end 25 | 26 | option :spicy, type: :boolean, desc: 'hot' 27 | desc "This is a medium" 28 | def medium(arg=nil, opts={}) 29 | p [arg, opts] 30 | end 31 | 32 | desc "This is a mini" 33 | def mini(me) 34 | end 35 | 36 | def quiet 37 | end 38 | 39 | def explode(arg=nil) 40 | {}.update 41 | end 42 | 43 | def boom 44 | nil.boom 45 | end 46 | 47 | def broken 48 | raise ArgumentError 49 | end 50 | 51 | def test 52 | puts "TEST" 53 | end 54 | 55 | private 56 | def no_run 57 | end 58 | end 59 | 60 | class ExtendedRunner < Boson::Runner 61 | def self.execute(command, args, options) 62 | options[:version] ? puts("Version 1000.0") : super 63 | end 64 | 65 | def self.display_command_help(cmd) 66 | super 67 | puts "And don't forget to eat BAACCCONN" 68 | end 69 | end 70 | 71 | describe "Runner" do 72 | before_all { reset } 73 | 74 | def my_command(cmd='') 75 | $0 = 'my_command' 76 | capture_stdout do 77 | MyRunner.start Shellwords.split(cmd) 78 | end 79 | end 80 | 81 | def extended_command(cmd='') 82 | $0 = 'extended_command' 83 | capture_stdout do 84 | ExtendedRunner.start Shellwords.split(cmd) 85 | end 86 | end 87 | 88 | def default_usage 89 | <<-STR 90 | Usage: my_command [OPTIONS] COMMAND [ARGS] 91 | 92 | Available commands: 93 | boom 94 | broken 95 | explode 96 | help Displays help for a command 97 | medium This is a medium 98 | mini This is a mini 99 | quiet 100 | small This is a small 101 | splot This is splot 102 | test 103 | 104 | Options: 105 | -h, --help Displays this help message 106 | STR 107 | end 108 | 109 | it "prints sorted commands by default" do 110 | my_command.should == default_usage 111 | end 112 | 113 | it "prints default usage for -h and --help" do 114 | my_command('-h').should == default_usage 115 | my_command('--help').should == default_usage 116 | end 117 | 118 | describe "for help COMMAND" do 119 | it 'prints help for valid command' do 120 | my_command('help quiet').should ==<<-STR 121 | Usage: my_command quiet 122 | 123 | Description: 124 | TODO 125 | STR 126 | end 127 | 128 | it 'prints error for invalid command' do 129 | Boson::DefaultCommandsRunner.expects(:abort). 130 | with("my_command: Could not find command \"invalid\"") 131 | my_command('help invalid') 132 | end 133 | 134 | it 'prints general help if no command' do 135 | my_command('help').should == default_usage 136 | end 137 | end 138 | 139 | describe "for COMMAND -h" do 140 | it "prints help for descriptionless command" do 141 | my_command('quiet -h').should == <<-STR 142 | Usage: my_command quiet 143 | 144 | Description: 145 | TODO 146 | STR 147 | end 148 | 149 | it "prints help for optionless command with splat args" do 150 | my_command('small -h').should == <<-STR 151 | Usage: my_command small *ARGS 152 | 153 | Description: 154 | This is a small 155 | STR 156 | end 157 | 158 | it "prints help for optionless command with required args" do 159 | my_command('mini -h').should == <<-STR 160 | Usage: my_command mini ME 161 | 162 | Description: 163 | This is a mini 164 | STR 165 | end 166 | 167 | it "prints help for command with options and optional args" do 168 | my_command('medium -h').should == <<-STR 169 | Usage: my_command medium [ARG] 170 | 171 | Options: 172 | -s, --spicy hot 173 | 174 | Description: 175 | This is a medium 176 | STR 177 | end 178 | end 179 | 180 | it "handles command with default arguments correctly" do 181 | my_command('medium').chomp.should == '[nil, {}]' 182 | end 183 | 184 | it "calls command with options correctly" do 185 | my_command('medium 1 --spicy').chomp.should == '["1", {:spicy=>true}]' 186 | end 187 | 188 | it "calls command with additional invalid option" do 189 | capture_stderr { 190 | my_command('medium 1 -z').chomp.should == '["1", {}]' 191 | }.should == "Deleted invalid option '-z'\n" 192 | end 193 | 194 | it "calls command with quoted arguments correctly" do 195 | my_command("medium '1 2'").chomp.should == '["1 2", {}]' 196 | end 197 | 198 | it "calls optionless command correctly" do 199 | my_command('small 1 2').chomp.should == '["1", "2"]' 200 | end 201 | 202 | it "calls command with too many args" do 203 | MyRunner.expects(:abort).with <<-STR.chomp 204 | my_command: 'medium' was called incorrectly. 205 | Usage: medium [ARG] 206 | STR 207 | my_command('medium 1 2 3') 208 | end 209 | 210 | it "calls command with splat args and multiple options correctly" do 211 | Boson.in_shell = true 212 | my_command('splot 1 2 -b --tags=1,2').chomp.should == 213 | '["1", "2", {:blurg=>true, :tags=>["1", "2"]}]' 214 | Boson.in_shell = nil 215 | end 216 | 217 | it "prints error for command with option parse error" do 218 | MyRunner.expects(:abort).with <<-STR.chomp 219 | my_command: no value provided for required option 'blurg' 220 | STR 221 | my_command('splot 1') 222 | end 223 | 224 | it "executes custom global option" do 225 | # setup goes here to avoid coupling to other runner 226 | ExtendedRunner::GLOBAL_OPTIONS[:version] = { 227 | type: :boolean, :desc => 'Print version' 228 | } 229 | 230 | extended_command('-v').chomp.should == 'Version 1000.0' 231 | end 232 | 233 | it "allows Kernel-method command names" do 234 | my_command('test').chomp.should == 'TEST' 235 | end 236 | 237 | it "prints error message for internal public method" do 238 | MyRunner.expects(:abort).with %[my_command: Could not find command "to_s"] 239 | my_command('to_s').should == '' 240 | end 241 | 242 | it "prints error message for nonexistant command" do 243 | MyRunner.expects(:abort).with %[my_command: Could not find command "blarg"] 244 | my_command('blarg').should == '' 245 | end 246 | 247 | it 'prints error message for command missing required args' do 248 | MyRunner.expects(:abort).with <<-STR.chomp 249 | my_command: 'mini' was called incorrectly. 250 | Usage: mini ME 251 | STR 252 | my_command('mini').should == '' 253 | end 254 | it "allows no method error in command" do 255 | assert_error(NoMethodError) { my_command('boom') } 256 | end 257 | 258 | it "allows argument error in command" do 259 | assert_error(ArgumentError) { my_command('broken') } 260 | end 261 | 262 | it "allows argument error in command with optional args" do 263 | assert_error(ArgumentError) { my_command('explode') } 264 | end 265 | 266 | it "prints error message for private method" do 267 | MyRunner.expects(:abort).with %[my_command: Could not find command "no_run"] 268 | my_command('no_run').should == '' 269 | end 270 | 271 | describe "$BOSONRC" do 272 | before { ENV.delete('BOSONRC') } 273 | 274 | it "is not loaded by default" do 275 | MyRunner.expects(:load).never 276 | my_command('quiet').should == '' 277 | end 278 | 279 | it "is loaded if set" do 280 | ENV['BOSONRC'] = 'whoop' 281 | File.expects(:exists?).returns(true) 282 | MyRunner.expects(:load).with('whoop') 283 | my_command('quiet') 284 | end 285 | 286 | after_all { ENV['BOSONRC'] = File.dirname(__FILE__) + '/.bosonrc' } 287 | end 288 | 289 | describe "extend Runner" do 290 | it "can extend help" do 291 | extended_command('help help').should == <<-STR 292 | Usage: extended_command help [CMD] 293 | 294 | Description: 295 | Displays help for a command 296 | And don't forget to eat BAACCCONN 297 | STR 298 | end 299 | 300 | it "can extend a command's --help" do 301 | extended_command('help -h').should == <<-STR 302 | Usage: extended_command help [CMD] 303 | 304 | Description: 305 | Displays help for a command 306 | And don't forget to eat BAACCCONN 307 | STR 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Boson is a modular command/task framework. Thanks to its rich set of plugins, 4 | it differentiates itself from rake and thor by being usable from irb and the 5 | commandline, having automated views generated by hirb and allowing libraries to 6 | be written as plain ruby. Works with on all major rubies for ruby >= 1.9.2 7 | 8 | ## New Boson 9 | 10 | Starting with 1.0, boson has changed significantly. Please read [the upgrading 11 | doc](https://github.com/cldwalker/boson/blob/master/Upgrading.md) if you have an 12 | older version or if your [reading about 13 | boson](http://tagaholic.me/blog.html#gem:name=boson) predates 2012. 14 | 15 | Boson has been rewritten to have a smaller core (no dependencies) with optional 16 | plugins to hook into its various features. The major focus of 1.0 has been to 17 | provide an easy way for third-party gems to create their executable and define 18 | subcommands with options. 19 | 20 | ## Docs 21 | 22 | Nicely formatted docs are available 23 | [here](http://rdoc.info/gems/boson/file/README.md). 24 | 25 | ## Example Executable 26 | 27 | For a gem with an executable, bin/cow: 28 | 29 | ```ruby 30 | #!/usr/bin/env ruby 31 | require 'boson/runner' 32 | 33 | class CowRunner < Boson::Runner 34 | option :urgent, type: :boolean 35 | def say(text, options={}) 36 | text.capitalize! if options[:urgent] 37 | puts text 38 | end 39 | 40 | def moo 41 | puts "MOOOO" 42 | end 43 | end 44 | 45 | CowRunner.start 46 | ``` 47 | 48 | You can now execute cow with say and moo subcommands: 49 | 50 | $ cow say hungry 51 | hungry 52 | $ cow moo 53 | MOOOO 54 | # use say's urgent option 55 | $ cow say hungry -urgent 56 | HUNGRY 57 | 58 | You'll notice that this syntax is powerful and concise and is very similar to 59 | thor's API. Subcommands map to ruby methods and the class represents the executable. 60 | 61 | For some examples of executables see 62 | [vimdb](https://github.com/cldwalker/vimdb/blob/master/lib/vimdb/runner.rb) 63 | or [tag](https://github.com/cldwalker/tag/blob/master/lib/tag/runner.rb). 64 | 65 | ## Comparison to Thor 66 | 67 | Since boson and it's rewrite are both heavily inspired by [thor](http://github.com/wycats/thor), it 68 | makes sense to compare them. 69 | 70 | First, what I consider pros boson has over thor. Boson 71 | 72 | * is designed to handle plugins. This means it core parts are extendable by 73 | modules and core components like commands can have arbitrary metadata 74 | associated with them. 75 | * has a rich set of plugins. See [boson-more](http://github.com/cldwalker/boson-more). 76 | * has commands that are easily testable. Whereas thor has options that automagically 77 | appear in command methods, boson explicitly passes options to its command 78 | method as a hash i.e. `MyRunner.new.subcommand(arg, verbose: true)`. This 79 | also allows commands to just be called as ruby, with no magic to consider. 80 | * supports custom-user option types i.e. creating a Date option type. See 81 | Boson::Options. 82 | * supports custom method decorators i.e. methods like desc that add functionality 83 | to a command. While boson supports option, options, desc and config out of the box, 84 | users can create their own. 85 | * automatically creates usage for your subcommand. With thor you need to 86 | manually define your usage with desc: `desc "SOME USAGE", "SOME DESCRIPTION"` 87 | * is lenient about descriptions. Describe commands at your leisure. With thor 88 | you must define a desc. 89 | * has no blacklist for command names while thor has a 90 | [blacklist](https://github.com/wycats/thor/blob/a24b6697a37d9bc0c0ea94ef9bf2cdbb33b8abb9/lib/thor/base.rb#L18-19) 91 | due to its design. With boson you can even name commands after Kernel method 92 | names but tread with caution in your own Runner class. 93 | * allows for user-defined default global options (i.e. --help) and commands 94 | (i.e. help). This means that with a plugin you could have your own additional 95 | default options and commands shared across executables. See the extending 96 | section below. 97 | * allows default help and command help to be overridden/extended by 98 | subclassing Runner.display_help and Runner.display_command_help respectively. 99 | * provides an optional custom rc file for your executable. Simply set 100 | ENV['BOSONRC'] to a path i.e. ~/.myprogramrc. This rc file loads before any 101 | command processing is done, allowing for users to extend your executable 102 | easily i.e. to add more subcommands. For an example, see 103 | [vimdb](http://github.com/cldwalker/vimdb). 104 | 105 | Now for pros thor has over boson. Thor 106 | 107 | * is widely used and thus has been community QAed thoroughly. 108 | * supports generators as a major feature. 109 | * is more stable as its feature set is mostly frozen. 110 | * is used by rails and thus is guaranteed support for some time. 111 | * supports ruby 1.8.7. 112 | * can conveniently define an option across commands using class_option. 113 | boson may add this later. 114 | * TODO: I'm sure there's more 115 | 116 | ## Converting From Thor 117 | 118 | * Change your requires and subclass from Boson::Runner instead of Thor. 119 | * Delete the first argument from `desc`. Usage is automatically created in boson. 120 | * Rename `method_option` to `option` 121 | * For options with a type option, make sure it maps to a symbol i.e. :array or :boolean. 122 | If left a string, the option will be interpreted to be a string option with that 123 | string as a default. 124 | * `class_option` doesn't exist yet but you can emulate it for now by defining 125 | your class option in a class method and then calling your class method before 126 | every command. See [vimdb](http://github.com/cldwalker/vimdb) for an example. 127 | 128 | ## Writing Plugins 129 | 130 | A Boson plugin is a third-party library that extends Boson through its extension 131 | API. Any Boson class/module that includes or extends a module named API or 132 | APIClassMethods provides an extension API. Examples of such classes are 133 | Boson::BareRunner, Boson::Command, Boson::Inspector and Boson::Library. As an 134 | example, let us extend what any boson-based executable does first, extend 135 | Boson::BareRunner.start: 136 | 137 | ```ruby 138 | module Boson 139 | module CustomStartUp 140 | def start(*) 141 | super 142 | # additional startup 143 | end 144 | end 145 | end 146 | 147 | Boson::BareRunner.extend Boson::CustomStartUp 148 | ``` 149 | 150 | Notice that `extend` was used to extend a class method. To extend an instance 151 | method you would use `include`. Also notice that you use `super` in an 152 | overridden method to call original functionality. If you don't, you're 153 | possibly overridden existing functionality, which is fine as long as you know 154 | what you are overriding. 155 | 156 | If you want to gemify your plugin, name it boson-plugin_name and put it under 157 | lib/boson/plugin_name. The previous example would go in 158 | lib/boson/custom_startup.rb. To use your plugin, a user can simply require your 159 | plugin in their executable. 160 | 161 | For many plugin examples, see 162 | [boson-more](http://github.com/cldwalker/boson-more). 163 | 164 | ## Using a Plugin 165 | 166 | To use a plugin, just require it. For an executable: 167 | 168 | ```ruby 169 | require 'boson/runner' 170 | require 'boson/my_plugin' 171 | 172 | MyRunner.start 173 | ``` 174 | 175 | For the boson executable, just require the plugins in ~/.bosonrc. 176 | 177 | ## Extending Your Executables 178 | 179 | Boson allows for custom default options and commands. This means you can 180 | add your own defaults in a plugin and use them across your executables. 181 | 182 | To add a custom default command, simply reopen Boson::DefaultCommandsRunner: 183 | 184 | ```ruby 185 | class Boson::DefaultCommandsRunner 186 | desc "whoomp" 187 | def whoomp 188 | puts "WHOOMP there it is!" 189 | end 190 | end 191 | ``` 192 | 193 | To add a custom global option, add to Boson::Runner::GLOBAL_OPTIONS: 194 | 195 | ```ruby 196 | Boson::Runner::GLOBAL_OPTIONS.update( 197 | verbose: {type: :boolean, desc: "Verbose description of loading libraries"} 198 | ) 199 | ``` 200 | 201 | Custom global options are defined in the same format as options for a command. 202 | 203 | ## Bugs/Issues 204 | 205 | Please report them [on github](http://github.com/cldwalker/boson/issues). 206 | If the issue is about upgrading from old boson, please file it in 207 | [boson-more](http://github.com/cldwalker/boson-more/issues). 208 | 209 | ## Contributing 210 | [See here](http://tagaholic.me/contributing.html) 211 | 212 | ## Motiviation 213 | 214 | Motivation for the new boson is all the damn executables I'm making. 215 | 216 | ## Credits 217 | Boson stands on the shoulders of these people and their ideas: 218 | 219 | * Contributors: @mirell, @martinos, @celldee, @zhando 220 | * Yehuda Katz for Thor and its awesome option parser (Boson::OptionParser). 221 | * Daniel Berger for his original work on thor's option parser. 222 | * Chris Wanstrath for inspiring Boson's libraries with Rip's packages. 223 | -------------------------------------------------------------------------------- /test/option_parser_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | describe "OptionParser" do 4 | def create(opts) 5 | @opt = OptionParser.new(opts) 6 | end 7 | 8 | def opt; @opt; end 9 | 10 | def parse(*args) 11 | @non_opts = [] 12 | opt.parse(args.flatten) 13 | end 14 | 15 | describe "#indifferent_hash" do 16 | before { 17 | @hash = OptionParser.new({}).indifferent_hash 18 | @hash.update foo: 'bar' 19 | } 20 | 21 | it "can access values indifferently" do 22 | @hash['foo'].should == 'bar' 23 | @hash[:foo].should == 'bar' 24 | end 25 | 26 | it "cannot set values indifferently" do 27 | @hash['foo'] = 'barred' 28 | @hash['foo'].should == 'barred' 29 | @hash[:foo].should_not == 'barred' 30 | @hash[:foo].should == 'bar' 31 | end 32 | end 33 | 34 | describe "naming" do 35 | it "automatically aliases long options with their first letter" do 36 | create "--foo" => true 37 | parse("-f")["foo"].should == true 38 | end 39 | 40 | it "automatically aliases two options with same first letters by aliasing alphabetical first with lowercase and second with uppercase" do 41 | create :verbose=>:boolean, :vertical=>:string, :verz=>:boolean 42 | parse('-v', '-V','2').should == {:verbose=>true, :vertical=>'2'} 43 | end 44 | 45 | it "doesn't auto-alias options that have multiple names given" do 46 | create ["--foo", "--bar"] => :boolean 47 | parse("-f")["foo"].should == nil 48 | end 49 | 50 | it "allows aliases to be symbols or strings" do 51 | create [:foo, :bar, 'baz'] =>:string 52 | parse("--foo", "12")[:foo].should == "12" 53 | parse("--bar", "12")[:foo].should == "12" 54 | parse("--baz", "12")[:foo].should == "12" 55 | end 56 | 57 | it "allows multiple aliases for a given opt" do 58 | create ["--foo", "--bar", "--baz"] => :string 59 | parse("--foo", "12")["foo"].should == "12" 60 | parse("--bar", "12")["foo"].should == "12" 61 | parse("--baz", "12")["foo"].should == "12" 62 | end 63 | 64 | it "allows custom short names" do 65 | create "-f" => :string 66 | parse("-f", "12").should == {:f => "12"} 67 | end 68 | 69 | it "allows capital short names" do 70 | create :A => :boolean 71 | parse("-A")[:A].should == true 72 | end 73 | 74 | it "allows capital short aliases" do 75 | create [:awesome, :A] => :string 76 | parse("--awesome", "bar")[:awesome].should == 'bar' 77 | parse("-A", "bar")[:awesome].should == 'bar' 78 | end 79 | 80 | it "allows custom short aliases" do 81 | create ["--bar", "-f"] => :string 82 | parse("-f", "12").should == {:bar => "12"} 83 | end 84 | 85 | it "allows humanized opt name" do 86 | create 'foo' => :string, :bar => :string 87 | parse("-f", "1", "-b", "2").should == {:foo => "1", :bar => "2"} 88 | end 89 | 90 | it "allows humanized symbol opt name" do 91 | create :foo=>:string 92 | parse('-f','1').should == {:foo=>'1'} 93 | end 94 | 95 | it "doesn't allow alias to override another option" do 96 | create :foo=>:string, [:bar, :foo]=>:boolean 97 | parse("--foo", "boo")[:foo].should == 'boo' 98 | end 99 | 100 | it "doesn't recognize long opt format for a opt that is originally short" do 101 | create 'f' => :string 102 | parse("-f", "1").should == {:f => "1"} 103 | parse("--f", "1").should == {} 104 | end 105 | 106 | it "accepts --[no-]opt variant for booleans, setting false for value" do 107 | create "--foo" => :boolean 108 | parse("--no-foo")["foo"].should == false 109 | parse("--no-f")["foo"].should == false 110 | parse("--foo")["foo"].should == true 111 | end 112 | 113 | it "accepts --[no-]opt variant for single letter booleans" do 114 | create :e=>true 115 | parse("--no-e")[:e].should == false 116 | end 117 | 118 | it "will prefer 'no-opt' variant over inverting 'opt' if explicitly set" do 119 | create "--no-foo" => true 120 | parse("--no-foo")["no-foo"].should == true 121 | end 122 | 123 | end 124 | 125 | describe "option values can be set with" do 126 | it "a opt= assignment" do 127 | create :foo => :string 128 | parse("--foo=12")["foo"].should == "12" 129 | parse("-f=12")["foo"].should == "12" 130 | parse("--foo=bar=baz")["foo"].should == "bar=baz" 131 | parse("--foo=sentence with spaces")["foo"].should == "sentence with spaces" 132 | end 133 | 134 | it "a -nXY assignment" do 135 | create "--num" => :numeric 136 | parse("-n12")["num"].should == 12 137 | end 138 | 139 | it "conjoined short options" do 140 | create "--foo" => true, "--bar" => true, "--app" => true 141 | opts = parse "-fba" 142 | opts["foo"].should == true 143 | opts["bar"].should == true 144 | opts["app"].should == true 145 | end 146 | 147 | it "conjoined short options with argument" do 148 | create "--foo" => true, "--bar" => true, "--app" => :numeric 149 | opts = parse "-fba", "12" 150 | opts["foo"].should == true 151 | opts["bar"].should == true 152 | opts["app"].should == 12 153 | end 154 | end 155 | 156 | describe "#parse" do 157 | it "extracts non-option arguments" do 158 | create "--foo" => :string, "--bar" => true 159 | parse("foo", "bar", "--baz", "--foo", "12", "--bar", "-T", "bang").should == { 160 | :foo => "12", :bar => true 161 | } 162 | opt.leading_non_opts.should == ["foo", "bar", "--baz"] 163 | opt.trailing_non_opts.should == ["-T", "bang"] 164 | opt.non_opts.should == ["foo", "bar", "--baz", "-T", "bang"] 165 | end 166 | 167 | it "stopped by --" do 168 | create :foo=>:boolean, :dude=>:boolean 169 | parse("foo", "bar", "--", "-f").should == {} 170 | opt.leading_non_opts.should == %w{foo bar} 171 | opt.trailing_non_opts.should == %w{-- -f} 172 | end 173 | 174 | describe "with parse flag" do 175 | it ":delete_invalid_opts deletes and warns of invalid options" do 176 | create(:foo=>:boolean) 177 | capture_stderr { 178 | opt.parse(%w{-f -d ok}, :delete_invalid_opts=>true) 179 | }.should =~ /Deleted invalid option '-d'/ 180 | opt.non_opts.should == ['ok'] 181 | end 182 | 183 | it ":delete_invalid_opts deletes until - or --" do 184 | create(:foo=>:boolean, :bar=>:boolean) 185 | %w{- --}.each do |stop_char| 186 | capture_stderr { 187 | opt.parse(%w{ok -b -d} << stop_char << '-f', :delete_invalid_opts=>true) 188 | }.should =~ /'-d'/ 189 | opt.non_opts.should == %w{ok} << stop_char << '-f' 190 | end 191 | end 192 | 193 | it ":opts_before_args only allows options before args" do 194 | create(:foo=>:boolean) 195 | opt.parse(%w{ok -f}, :opts_before_args=>true).should == {} 196 | opt.parse(%w{-f ok}, :opts_before_args=>true).should == {:foo=>true} 197 | end 198 | end 199 | 200 | describe "with no arguments" do 201 | it "and no options returns an empty hash" do 202 | create({}) 203 | parse.should == {} 204 | end 205 | 206 | it "and several options returns an empty hash" do 207 | create "--foo" => :boolean, "--bar" => :string 208 | parse.should == {} 209 | end 210 | end 211 | end 212 | 213 | describe "option hashes" do 214 | it "make hash keys available as symbols as well" do 215 | create "--foo" => :string 216 | parse("--foo", "12")[:foo].should == "12" 217 | end 218 | 219 | it "don't set nonexistant options" do 220 | create "--foo" => :boolean 221 | parse("--foo")["bar"].should == nil 222 | opts = parse 223 | opts["foo"].should == nil 224 | end 225 | end 226 | 227 | describe ":required option attribute" do 228 | before_all { 229 | create "--foo" => {:type=>:string, :required=>true}, :bar => {:type=>:hash, :required=>true} 230 | } 231 | 232 | it "raises an error if string option isn't given" do 233 | assert_error(OptionParser::Error, 'no value.*required.*foo') { parse("--bar", "str:ok") } 234 | end 235 | 236 | it "raises an error if non-string option isn't given" do 237 | assert_error(OptionParser::Error, 'no value.*required.*bar') { parse("--foo", "yup") } 238 | end 239 | 240 | it "raises no error when given arguments" do 241 | parse("--foo", "yup", "--bar","ok:dude").should == {:foo=>'yup', :bar=>{'ok'=>'dude'}} 242 | end 243 | end 244 | 245 | describe ":bool_default option attribute" do 246 | before_all { 247 | create :foo=>{:type=>:string, :bool_default=>'whoop'}, :bar=>{:type=>:array, :bool_default=>'1'}, 248 | :verbose=>:boolean, :yep=>{:type=>:string, :bool_default=>true} 249 | } 250 | 251 | it "sets default boolean" do 252 | parse('--foo', '--bar', '1')[:foo].should == 'whoop' 253 | parse('--foo', 'ok', 'dokay')[:foo].should == 'whoop' 254 | end 255 | 256 | it "sets options normally" do 257 | parse('--foo=boo', '--bar=har').should == {:foo=>'boo', :bar=>['har']} 258 | end 259 | 260 | it "sets default boolean for array" do 261 | parse("--bar", '--foo', '2')[:bar].should == ['1'] 262 | end 263 | 264 | it "sets default boolean for non-string value" do 265 | parse('--yep', '--foo=2')[:yep].should == true 266 | end 267 | 268 | it "default booleans can be joined with boolean options" do 269 | parse('-fbv').should == {:verbose=>true, :bar=>['1'], :foo=>'whoop'} 270 | end 271 | end 272 | 273 | describe "option with attributes" do 274 | it "can get type from :type" do 275 | create :foo=>{:type=>:numeric} 276 | parse("-f", '3')[:foo].should == 3 277 | end 278 | 279 | it "can get type and default from :default" do 280 | create :foo=>{:default=>[]} 281 | parse("-f", "1")[:foo].should == ['1'] 282 | parse[:foo].should == [] 283 | end 284 | 285 | it "assumes :boolean type if no type found" do 286 | create :foo=>{:some=>'params'} 287 | parse('-f')[:foo].should == true 288 | end 289 | end 290 | 291 | def usage 292 | opt.formatted_usage.split(" ").sort 293 | end 294 | 295 | describe "#formatted_usage" do 296 | it "outputs string args with sample values" do 297 | create "--repo" => :string, "--branch" => "bugfix", "-n" => 6 298 | usage.should == %w([--branch=bugfix] [--repo=REPO] [-n=6]) 299 | end 300 | 301 | it "outputs numeric args with 'N' as sample value" do 302 | create "--iter" => :numeric 303 | usage.should == ["[--iter=N]"] 304 | end 305 | 306 | it "outputs array args with sample value" do 307 | create "--libs" => :array 308 | usage.should == ["[--libs=A,B,C]"] 309 | end 310 | 311 | it "outputs hash args with sample value" do 312 | create '--paths' => :hash 313 | usage.should == ["[--paths=A:B,C:D]"] 314 | end 315 | end 316 | 317 | describe "#render_table" do 318 | it "renders normal options with desc correctly" do 319 | create regexp: {type: :string, desc: 'A regex, ya know'}, 320 | all: {type: :boolean, desc: 'Search all fields'} 321 | capture_stdout { opt.print_usage_table no_headers: true }.should == <<-STR 322 | -a, --all Search all fields 323 | -r, --regexp A regex, ya know 324 | STR 325 | end 326 | 327 | it "renders an option without an alias correctly" do 328 | create regexp: :string, reverse_sort: :boolean, reload: :boolean 329 | capture_stdout { opt.print_usage_table no_headers: true }.should == <<-STR 330 | -r, --regexp 331 | -R, --reload 332 | --reverse_sort 333 | STR 334 | end 335 | end 336 | 337 | describe "user defined option class" do 338 | before_all { 339 | ::FooBoo = Struct.new(:name) 340 | module Options::FooBoo 341 | def create_foo_boo(value) 342 | ::FooBoo.new(value) 343 | end 344 | def validate_foo_boo(value); end 345 | end 346 | ::OptionParser.send :include, Options::FooBoo 347 | create :a=>:foo_boo, :b=>::FooBoo.new('blah'), :c=>:blah_blah, 348 | :d=>{:type=>:foo_boo, :type=>::FooBoo.new('bling')} 349 | } 350 | 351 | it "created from symbol" do 352 | (obj = parse('-a', 'whoop')[:a]).class.should == ::FooBoo 353 | obj.name.should == 'whoop' 354 | end 355 | 356 | it "created from default" do 357 | (obj = parse[:b]).class.should == ::FooBoo 358 | obj.name.should == 'blah' 359 | end 360 | 361 | it "created from type attribute" do 362 | (obj = parse('-d', 'whoop')[:d]).class.should == ::FooBoo 363 | obj.name.should == 'whoop' 364 | end 365 | 366 | it "has its validation called" do 367 | opt.expects(:validate_foo_boo) 368 | parse("-a", 'blah') 369 | end 370 | 371 | it "has default usage" do 372 | usage[0].should == "[-a=:foo_boo]" 373 | end 374 | 375 | it "when nonexistant raises error" do 376 | assert_error(OptionParser::Error, "invalid.*:blah_blah") { parse("-c", 'ok') } 377 | end 378 | end 379 | end 380 | -------------------------------------------------------------------------------- /lib/boson/option_parser.rb: -------------------------------------------------------------------------------- 1 | module Boson 2 | # This class concisely defines commandline options that when parsed produce a 3 | # Hash of option keys and values. 4 | # Additional points: 5 | # * Setting option values should follow conventions in *nix environments. 6 | # See examples below. 7 | # * By default, there are 5 option types, each which produce different 8 | # objects for option values. 9 | # * The default option types can produce objects for one or more of the following 10 | # Ruby classes: String, Integer, Float, Array, Hash, FalseClass, TrueClass. 11 | # * Users can define their own option types which create objects for _any_ 12 | # Ruby class. See Options. 13 | # * Each option type can have attributes to enable more features (see 14 | # OptionParser.new). 15 | # * When options are parsed by parse(), an indifferent access hash is returned. 16 | # * Options are also called switches, parameters, flags etc. 17 | # * Option parsing stops when it comes across a '--'. 18 | # 19 | # Default option types: 20 | # [*:boolean*] This option has no passed value. To toogle a boolean, prepend 21 | # with '--no-'. Multiple booleans can be joined together. 22 | # '--debug' -> {:debug=>true} 23 | # '--no-debug' -> {:debug=>false} 24 | # '--no-d' -> {:debug=>false} 25 | # '-d -f -t' same as '-dft' 26 | # [*:string*] Sets values by separating name from value with space or '='. 27 | # '--color red' -> {:color=>'red'} 28 | # '--color=red' -> {:color=>'red'} 29 | # '--color "gotta love spaces"' -> {:color=>'gotta love spaces'} 30 | # [*:numeric*] Sets values as :string does or by appending number right after 31 | # aliased name. Shortened form can be appended to joined booleans. 32 | # '-n3' -> {:num=>3} 33 | # '-dn3' -> {:debug=>true, :num=>3} 34 | # [*:array*] Sets values as :string does. Multiple values are split by a 35 | # configurable character Default is ',' (see OptionParser.new). 36 | # Passing '*' refers to all known :values. 37 | # '--fields 1,2,3' -> {:fields=>['1','2','3']} 38 | # '--fields *' -> {:fields=>['1','2','3']} 39 | # [*:hash*] Sets values as :string does. Key-value pairs are split by ':' and 40 | # pairs are split by a configurable character (default ','). 41 | # Multiple keys can be joined to one value. Passing '*' as a key 42 | # refers to all known :keys. 43 | # '--fields a:b,c:d' -> {:fields=>{'a'=>'b', 'c'=>'d'} } 44 | # '--fields a,b:d' -> {:fields=>{'a'=>'d', 'b'=>'d'} } 45 | # '--fields *:d' -> {:fields=>{'a'=>'d', 'b'=>'d', 'c'=>'d'} } 46 | # 47 | # This is a modified version of Yehuda Katz's Thor::Options class which is a 48 | # modified version of Daniel Berger's Getopt::Long class (Ruby license). 49 | class OptionParser 50 | # Raised for all OptionParser errors 51 | class Error < StandardError; end 52 | 53 | NUMERIC = /(\d*\.\d+|\d+)/ 54 | LONG_RE = /^(--\w+[-\w+]*)$/ 55 | SHORT_RE = /^(-[a-zA-Z])$/i 56 | EQ_RE = /^(--\w+[-\w+]*|-[a-zA-Z])=(.*)$/i 57 | # Allow either -x -v or -xv style for single char args 58 | SHORT_SQ_RE = /^-([a-zA-Z]{2,})$/i 59 | SHORT_NUM = /^(-[a-zA-Z])#{NUMERIC}$/i 60 | STOP_STRINGS = %w{-- -} 61 | 62 | attr_reader :leading_non_opts, :trailing_non_opts, :opt_aliases 63 | 64 | # Array of arguments left after defined options have been parsed out by parse. 65 | def non_opts 66 | leading_non_opts + trailing_non_opts 67 | end 68 | 69 | # Takes a hash of options. Each option, a key-value pair, must provide the 70 | # option's name and type. Names longer than one character are accessed with 71 | # '--' while one character names are accessed with '-'. Names can be 72 | # symbols, strings or even dasherized strings: 73 | # 74 | # Boson::OptionParser.new :debug=>:boolean, 'level'=>:numeric, 75 | # '--fields'=>:array 76 | # 77 | # Options can have default values and implicit types simply by changing the 78 | # option type for the default value: 79 | # 80 | # Boson::OptionParser.new :debug=>true, 'level'=>3.1, :fields=>%w{f1 f2} 81 | # 82 | # By default every option name longer than one character is given an alias, 83 | # the first character from its name. For example, the --fields option has -f 84 | # as its alias. You can override the default alias by providing your own 85 | # option aliases as an array in the option's key. 86 | # 87 | # Boson::OptionParser.new [:debug, :damnit, :D]=>true 88 | # 89 | # Note that aliases are accessed the same way as option names. For the 90 | # above, --debug, --damnit and -D all refer to the same option. 91 | # 92 | # Options can have additional attributes by passing a hash to the option 93 | # value instead of a type or default: 94 | # 95 | # Boson::OptionParser.new :fields=>{:type=>:array, :values=>%w{f1 f2 f3}, 96 | # :enum=>false} 97 | # 98 | # These attributes are available when an option is parsed via 99 | # current_attributes(). Here are the available option attributes for the 100 | # default option types: 101 | # 102 | # [*:type*] This or :default is required. Available types are :string, 103 | # :boolean, :array, :numeric, :hash. 104 | # [*:default*] This or :type is required. This is the default value an 105 | # option has when not passed. 106 | # [*:bool_default*] This is the value an option has when passed as a 107 | # boolean. However, by enabling this an option can only 108 | # have explicit values with '=' i.e. '--index=alias' and 109 | # no '--index alias'. If this value is a string, it is 110 | # parsed as any option value would be. Otherwise, the 111 | # value is passed directly without parsing. 112 | # [*:required*] Boolean indicating if option is required. Option parses 113 | # raises error if value not given. Default is false. 114 | # [*:alias*] Alternative way to define option aliases with an option name 115 | # or an array of them. Useful in yaml files. Setting to false 116 | # will prevent creating an automatic alias. 117 | # [*:values*] An array of values an option can have. Available for :array 118 | # and :string options. Values here can be aliased by typing a 119 | # unique string it starts with or underscore aliasing (see 120 | # Util.underscore_search). For example, for values foo, odd and 121 | # obnoxiously_long, f refers to foo, od to odd and o_l to 122 | # obnoxiously_long. 123 | # [*:enum*] Boolean indicating if an option enforces values in :values or 124 | # :keys. Default is true. For :array, :hash and :string options. 125 | # [*:split*] For :array and :hash options. A string or regular expression 126 | # on which an array value splits to produce an array of values. 127 | # Default is ','. 128 | # [*:keys*] :hash option only. An array of values a hash option's keys can 129 | # have. Keys can be aliased just like :values. 130 | # [*:default_keys*] For :hash option only. Default keys to assume when only 131 | # a value is given. Multiple keys can be joined by the 132 | # :split character. Defaults to first key of :keys if 133 | # :keys given. 134 | # [*:regexp*] For :array option with a :values attribute. Boolean indicating 135 | # that each option value does a regular expression search of 136 | # :values. If there are values that match, they replace the 137 | # original option value. If none, then the original option 138 | # value is used. 139 | def initialize(opts) 140 | @defaults = {} 141 | @opt_aliases = {} 142 | @leading_non_opts, @trailing_non_opts = [], [] 143 | 144 | # build hash of dashed options to option types 145 | # type can be a hash of opt attributes, a default value or a type symbol 146 | @opt_types = opts.inject({}) do |mem, (name, type)| 147 | name, *aliases = name if name.is_a?(Array) 148 | name = name.to_s 149 | # we need both nice and dasherized form of option name 150 | if name.index('-') == 0 151 | nice_name = undasherize name 152 | else 153 | nice_name = name 154 | name = dasherize name 155 | end 156 | # store for later 157 | @opt_aliases[nice_name] = aliases || [] 158 | 159 | if type.is_a?(Hash) 160 | @option_attributes ||= {} 161 | @option_attributes[nice_name] = type 162 | @opt_aliases[nice_name] = Array(type[:alias]) if type.key?(:alias) 163 | @defaults[nice_name] = type[:default] if type[:default] 164 | if (type.key?(:values) || type.key?(:keys)) && !type.key?(:enum) 165 | @option_attributes[nice_name][:enum] = true 166 | end 167 | if type.key?(:keys) 168 | @option_attributes[nice_name][:default_keys] ||= type[:keys][0] 169 | end 170 | type = type[:type] || (!type[:default].nil? ? 171 | determine_option_type(type[:default]) : :boolean) 172 | end 173 | 174 | # set defaults 175 | case type 176 | when TrueClass then @defaults[nice_name] = true 177 | when FalseClass then @defaults[nice_name] = false 178 | else @defaults[nice_name] = type unless type.is_a?(Symbol) 179 | end 180 | mem[name] = !type.nil? ? determine_option_type(type) : type 181 | mem 182 | end 183 | 184 | # generate hash of dashed aliases to dashed options 185 | @opt_aliases = @opt_aliases.sort.inject({}) {|h, (nice_name, aliases)| 186 | name = dasherize nice_name 187 | # allow for aliases as symbols 188 | aliases.map! {|e| 189 | e.to_s.index('-') == 0 || e == false ? e : dasherize(e.to_s) } 190 | 191 | if aliases.empty? and nice_name.length > 1 192 | opt_alias = nice_name[0,1] 193 | opt_alias = h.key?("-"+opt_alias) ? "-"+opt_alias.capitalize : 194 | "-"+opt_alias 195 | h[opt_alias] ||= name unless @opt_types.key?(opt_alias) 196 | else 197 | aliases.each {|e| h[e] = name if !@opt_types.key?(e) && e != false } 198 | end 199 | h 200 | } 201 | end 202 | 203 | # Parses an array of arguments for defined options to return an indifferent 204 | # access hash. Once the parser recognizes a valid option, it continues to 205 | # parse until an non option argument is detected. 206 | # @param [Hash] flags 207 | # @option flags [Boolean] :opts_before_args When true options must come 208 | # before arguments. Default is false. 209 | # @option flags [Boolean] :delete_invalid_opts When true deletes any 210 | # invalid options left after parsing. Will stop deleting if it comes 211 | # across - or --. Default is false. 212 | def parse(args, flags={}) 213 | @args = args 214 | # start with symbolized defaults 215 | hash = Hash[@defaults.map {|k,v| [k.to_sym, v] }] 216 | 217 | @leading_non_opts = [] 218 | unless flags[:opts_before_args] 219 | @leading_non_opts << shift until current_is_option? || @args.empty? || 220 | STOP_STRINGS.include?(peek) 221 | end 222 | 223 | while current_is_option? 224 | case @original_current_option = shift 225 | when SHORT_SQ_RE 226 | unshift $1.split('').map { |f| "-#{f}" } 227 | next 228 | when EQ_RE, SHORT_NUM 229 | unshift $2 230 | option = $1 231 | when LONG_RE, SHORT_RE 232 | option = $1 233 | end 234 | 235 | dashed_option = normalize_option(option) 236 | @current_option = undasherize(dashed_option) 237 | type = option_type(dashed_option) 238 | validate_option_value(type) 239 | value = create_option_value(type) 240 | # set on different line since current_option may change 241 | hash[@current_option.to_sym] = value 242 | end 243 | 244 | @trailing_non_opts = @args 245 | check_required! hash 246 | delete_invalid_opts if flags[:delete_invalid_opts] 247 | indifferent_hash.tap {|h| h.update hash } 248 | end 249 | 250 | # Helper method to generate usage. Takes a dashed option and a string value 251 | # indicating an option value's format. 252 | def default_usage(opt, val) 253 | opt + "=" + (@defaults[undasherize(opt)] || val).to_s 254 | end 255 | 256 | # Generates one-line usage of all options. 257 | def formatted_usage 258 | return "" if @opt_types.empty? 259 | @opt_types.map do |opt, type| 260 | val = respond_to?("usage_for_#{type}", true) ? 261 | send("usage_for_#{type}", opt) : "#{opt}=:#{type}" 262 | "[" + val + "]" 263 | end.join(" ") 264 | end 265 | 266 | alias :to_s :formatted_usage 267 | 268 | # More verbose option help in the form of a table. 269 | def print_usage_table(options={}) 270 | fields = get_usage_fields options[:fields] 271 | fields, opts = get_fields_and_options(fields, options) 272 | render_table(fields, opts, options) 273 | end 274 | 275 | module API 276 | def get_fields_and_options(fields, options) 277 | opts = all_options_with_fields fields 278 | [fields, opts] 279 | end 280 | 281 | def render_table(fields, arr, options) 282 | headers = options[:no_headers] ? [] : [['Name', 'Desc'], ['----', '----']] 283 | arr_of_arr = headers + arr.map do |row| 284 | [ row.values_at(:alias, :name).compact.join(', '), row[:desc].to_s ] 285 | end 286 | puts Util.format_table(arr_of_arr) 287 | end 288 | end 289 | include API 290 | 291 | # Hash of option names mapped to hash of its external attributes 292 | def option_attributes 293 | @option_attributes || {} 294 | end 295 | 296 | # Hash of option attributes for the currently parsed option. _Any_ hash keys 297 | # passed to an option are available here. This means that an option type can 298 | # have any user-defined attributes available during option parsing and 299 | # object creation. 300 | def current_attributes 301 | @option_attributes && @option_attributes[@current_option] || {} 302 | end 303 | 304 | # Removes dashes from a dashed option i.e. '--date' -> 'date' and '-d' -> 'd'. 305 | def undasherize(str) 306 | str.sub(/^-{1,2}/, '') 307 | end 308 | 309 | # Adds dashes to an option name i.e. 'date' -> '--date' and 'd' -> '-d'. 310 | def dasherize(str) 311 | (str.length > 1 ? "--" : "-") + str 312 | end 313 | 314 | # List of option types 315 | def types 316 | @opt_types.values 317 | end 318 | 319 | # List of option names 320 | def names 321 | @opt_types.keys.map {|e| undasherize e } 322 | end 323 | 324 | # List of option aliases 325 | def aliases 326 | @opt_aliases.keys.map {|e| undasherize e } 327 | end 328 | 329 | # Creates a Hash with indifferent access 330 | def indifferent_hash 331 | Hash.new {|hash,key| hash[key.to_sym] if String === key } 332 | end 333 | 334 | def delete_leading_invalid_opts 335 | delete_invalid_opts @leading_non_opts 336 | end 337 | 338 | private 339 | def all_options_with_fields(fields) 340 | aliases = @opt_aliases.invert 341 | @opt_types.keys.sort.inject([]) {|t,e| 342 | nice_name = undasherize(e) 343 | h = {:name=>e, :type=>@opt_types[e], :alias=>aliases[e] || nil } 344 | h[:default] = @defaults[nice_name] if fields.include?(:default) 345 | (fields - h.keys).each {|f| 346 | h[f] = (option_attributes[nice_name] || {})[f] 347 | } 348 | t << h 349 | } 350 | end 351 | 352 | def get_usage_fields(fields) 353 | fields || ([:name, :alias, :type] + [:desc, :values, :keys].select {|e| 354 | option_attributes.values.any? {|f| f.key?(e) } }).uniq 355 | end 356 | 357 | def option_type(opt) 358 | if opt =~ /^--no-(\w+)$/ 359 | @opt_types[opt] || @opt_types[dasherize($1)] || 360 | @opt_types[original_no_opt($1)] 361 | else 362 | @opt_types[opt] 363 | end 364 | end 365 | 366 | def determine_option_type(value) 367 | return value if value.is_a?(Symbol) 368 | case value 369 | when TrueClass, FalseClass then :boolean 370 | when Numeric then :numeric 371 | else Util.underscore(value.class.to_s).to_sym 372 | end 373 | end 374 | 375 | def value_shift 376 | return shift if !current_attributes.key?(:bool_default) 377 | return shift if @original_current_option =~ EQ_RE 378 | current_attributes[:bool_default] 379 | end 380 | 381 | def create_option_value(type) 382 | if current_attributes.key?(:bool_default) && 383 | (@original_current_option !~ EQ_RE) && 384 | !(bool_default = current_attributes[:bool_default]).is_a?(String) 385 | bool_default 386 | else 387 | respond_to?("create_#{type}", true) ? 388 | send("create_#{type}", type != :boolean ? value_shift : nil) : 389 | raise(Error, "Option '#{@current_option}' is invalid option type " + 390 | "#{type.inspect}.") 391 | end 392 | end 393 | 394 | def auto_alias_value(values, possible_value) 395 | if Boson.config[:option_underscore_search] 396 | self.class.send(:define_method, :auto_alias_value) {|values, possible_val| 397 | Util.underscore_search(possible_val, values, true) || possible_val 398 | } 399 | else 400 | self.class.send(:define_method, :auto_alias_value) {|values, possible_val| 401 | values.find {|v| v.to_s =~ /^#{possible_val}/ } || possible_val 402 | } 403 | end 404 | auto_alias_value(values, possible_value) 405 | end 406 | 407 | def validate_enum_values(values, possible_values) 408 | if current_attributes[:enum] 409 | Array(possible_values).each {|e| 410 | if !values.include?(e) 411 | raise(Error, "invalid value '#{e}' for option '#{@current_option}'") 412 | end 413 | } 414 | end 415 | end 416 | 417 | def validate_option_value(type) 418 | return if current_attributes.key?(:bool_default) 419 | if type != :boolean && peek.nil? 420 | raise Error, "no value provided for option '#{@current_option}'" 421 | end 422 | send("validate_#{type}", peek) if respond_to?("validate_#{type}", true) 423 | end 424 | 425 | def delete_invalid_opts(arr=@trailing_non_opts) 426 | stop = nil 427 | arr.delete_if do |e| 428 | stop ||= STOP_STRINGS.include?(e) 429 | invalid = e.start_with?('-') && !stop 430 | warn "Deleted invalid option '#{e}'" if invalid 431 | invalid 432 | end 433 | end 434 | 435 | def peek 436 | @args.first 437 | end 438 | 439 | def shift 440 | @args.shift 441 | end 442 | 443 | def unshift(arg) 444 | unless arg.kind_of?(Array) 445 | @args.unshift(arg) 446 | else 447 | @args = arg + @args 448 | end 449 | end 450 | 451 | def valid?(arg) 452 | if arg.to_s =~ /^--no-(\w+)$/ 453 | @opt_types.key?(arg) or (@opt_types[dasherize($1)] == :boolean) or 454 | (@opt_types[original_no_opt($1)] == :boolean) 455 | else 456 | @opt_types.key?(arg) or @opt_aliases.key?(arg) 457 | end 458 | end 459 | 460 | def current_is_option? 461 | case peek 462 | when LONG_RE, SHORT_RE, EQ_RE, SHORT_NUM 463 | valid?($1) 464 | when SHORT_SQ_RE 465 | $1.split('').any? { |f| valid?("-#{f}") } 466 | end 467 | end 468 | 469 | def normalize_option(opt) 470 | @opt_aliases.key?(opt) ? @opt_aliases[opt] : opt 471 | end 472 | 473 | def original_no_opt(opt) 474 | @opt_aliases[dasherize(opt)] 475 | end 476 | 477 | def check_required!(hash) 478 | for name, type in @opt_types 479 | @current_option = undasherize(name) 480 | if current_attributes[:required] && !hash.key?(@current_option.to_sym) 481 | raise Error, "no value provided for required option '#{@current_option}'" 482 | end 483 | end 484 | end 485 | end 486 | end 487 | --------------------------------------------------------------------------------