├── test ├── apps │ ├── todo │ │ ├── Gemfile │ │ ├── lib │ │ │ └── todo │ │ │ │ ├── version.rb │ │ │ │ └── commands │ │ │ │ ├── ls.rb │ │ │ │ ├── create.rb │ │ │ │ ├── make.rb │ │ │ │ └── list.rb │ │ ├── README.rdoc │ │ ├── todo.rdoc │ │ ├── test │ │ │ └── tc_nothing.rb │ │ ├── Rakefile │ │ ├── todo.gemspec │ │ └── bin │ │ │ └── todo │ ├── todo_legacy │ │ ├── Gemfile │ │ ├── lib │ │ │ └── todo │ │ │ │ ├── version.rb │ │ │ │ └── commands │ │ │ │ ├── create.rb │ │ │ │ ├── ls.rb │ │ │ │ └── list.rb │ │ ├── README.rdoc │ │ ├── todo.rdoc │ │ ├── test │ │ │ └── tc_nothing.rb │ │ ├── Rakefile │ │ ├── todo.gemspec │ │ └── bin │ │ │ └── todo │ ├── README.md │ └── todo_plugins │ │ └── commands │ │ └── third.rb ├── unit │ ├── init_simplecov.rb │ ├── support │ │ ├── gli_test_config.yml │ │ └── fake_std_out.rb │ ├── test_helper.rb │ ├── compound_command_test.rb │ ├── verbatim_wrapper_test.rb │ ├── options_test.rb │ ├── switch_test.rb │ ├── command_finder_test.rb │ ├── flag_test.rb │ ├── terminal_test.rb │ ├── subcommands_test.rb │ ├── doc_test.rb │ └── subcommand_parsing_test.rb └── integration │ ├── scaffold_test.rb │ ├── test_helper.rb │ ├── gli_powered_app_test.rb │ └── gli_cli_test.rb ├── object-model.png ├── lib ├── gli │ ├── version.rb │ ├── argument.rb │ ├── commands │ │ ├── help_modules │ │ │ ├── compact_synopsis_formatter.rb │ │ │ ├── verbatim_wrapper.rb │ │ │ ├── one_line_wrapper.rb │ │ │ ├── terminal_synopsis_formatter.rb │ │ │ ├── list_formatter.rb │ │ │ ├── tty_only_wrapper.rb │ │ │ ├── help_completion_format.rb │ │ │ ├── arg_name_formatter.rb │ │ │ ├── text_wrapper.rb │ │ │ ├── options_formatter.rb │ │ │ ├── command_finder.rb │ │ │ ├── global_help_format.rb │ │ │ ├── command_help_format.rb │ │ │ └── full_synopsis_formatter.rb │ │ ├── compound_command.rb │ │ ├── initconfig.rb │ │ ├── rdoc_document_listener.rb │ │ ├── help.rb │ │ └── doc.rb │ ├── options.rb │ ├── option_parsing_result.rb │ ├── command_line_option.rb │ ├── switch.rb │ ├── command_line_token.rb │ ├── command_finder.rb │ ├── option_parser_factory.rb │ ├── gli_option_block_parser.rb │ ├── flag.rb │ ├── terminal.rb │ ├── exceptions.rb │ ├── command_support.rb │ ├── command.rb │ ├── app_support.rb │ └── gli_option_parser.rb └── gli.rb ├── Gemfile ├── dx ├── docker-compose.env ├── prune ├── dx.sh.lib ├── stop ├── start ├── show-help-in-app-container-then-wait.sh ├── exec ├── build └── setupkit.sh.lib ├── .gitignore ├── bin ├── setup ├── ci ├── gli └── rake ├── Dockerfile.dx ├── object-model.dot ├── gli.rdoc ├── gli.gemspec ├── exe └── gli ├── CONTRIBUTING.md ├── gli.cheat ├── Rakefile └── README.rdoc /test/apps/todo/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /test/apps/todo/lib/todo/version.rb: -------------------------------------------------------------------------------- 1 | module Todo 2 | VERSION = '0.0.1' 3 | end 4 | -------------------------------------------------------------------------------- /object-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davetron5000/gli/HEAD/object-model.png -------------------------------------------------------------------------------- /test/apps/todo_legacy/lib/todo/version.rb: -------------------------------------------------------------------------------- 1 | module Todo 2 | VERSION = '0.0.1' 3 | end 4 | -------------------------------------------------------------------------------- /test/apps/todo/README.rdoc: -------------------------------------------------------------------------------- 1 | = todo 2 | 3 | Describe your project here 4 | 5 | :include:todo.rdoc 6 | 7 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/README.rdoc: -------------------------------------------------------------------------------- 1 | = todo 2 | 3 | Describe your project here 4 | 5 | :include:todo.rdoc 6 | 7 | -------------------------------------------------------------------------------- /lib/gli/version.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | unless const_defined? :VERSION 3 | VERSION = '2.22.1' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/apps/README.md: -------------------------------------------------------------------------------- 1 | Special-built applications for use in tests that will hopefully reveal 2 | various features and edge cases 3 | -------------------------------------------------------------------------------- /test/apps/todo/todo.rdoc: -------------------------------------------------------------------------------- 1 | = todo 2 | 3 | Generate this with 4 | todo rdoc 5 | After you have described your command line interface -------------------------------------------------------------------------------- /test/apps/todo_legacy/todo.rdoc: -------------------------------------------------------------------------------- 1 | = todo 2 | 3 | Generate this with 4 | todo rdoc 5 | After you have described your command line interface -------------------------------------------------------------------------------- /test/apps/todo_plugins/commands/third.rb: -------------------------------------------------------------------------------- 1 | class App 2 | command :third do |c| c.action { |g,o,a| puts "third: #{a.join(',')}" } end 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem "simplecov", "~> 0.6.4", :platforms => :mri_19 6 | gem "psych", :platforms => :mri_19 7 | -------------------------------------------------------------------------------- /test/unit/init_simplecov.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'simplecov' 3 | SimpleCov.start do 4 | add_filter "/test" 5 | end 6 | rescue LoadError 7 | # Don't care 8 | end 9 | -------------------------------------------------------------------------------- /dx/docker-compose.env: -------------------------------------------------------------------------------- 1 | # This array must include the oldest Ruby first! 2 | RUBY_VERSIONS=("3.2" "3.3" "3.4") 3 | IMAGE=davetron5000/gli-dev 4 | PROJECT_NAME=gli 5 | # vim: ft=bash 6 | -------------------------------------------------------------------------------- /test/unit/support/gli_test_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | commands: 3 | :command: 4 | :g: bar 5 | :s: true 6 | :other_command: 7 | :g: foobar 8 | :f: barfoo 9 | :f: foo 10 | :bleorgh: true 11 | :t: false 12 | -------------------------------------------------------------------------------- /test/apps/todo/test/tc_nothing.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | 3 | class TC_testNothing < Test::Unit::TestCase 4 | 5 | def setup 6 | end 7 | 8 | def teardown 9 | end 10 | 11 | def test_the_truth 12 | assert true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/test/tc_nothing.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | 3 | class TC_testNothing < Test::Unit::TestCase 4 | 5 | def setup 6 | end 7 | 8 | def teardown 9 | end 10 | 11 | def test_the_truth 12 | assert true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | html 3 | *.sw? 4 | pkg 5 | coverage 6 | .bundle 7 | tmp 8 | Gemfile.lock 9 | *.gem 10 | results.html 11 | .DS_Store 12 | Session.vim 13 | scaffold_test 14 | dx/docker-compose.local.env 15 | docker-compose.dx.yml 16 | .tool-versions 17 | .ruby-version 18 | -------------------------------------------------------------------------------- /test/unit/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "gli" 3 | require "pp" 4 | 5 | module TestHelper 6 | class CLIApp 7 | include GLI::App 8 | 9 | def reset 10 | super 11 | @subcommand_option_handling_strategy = :normal 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | if [ Gemfile.lock -ot gli.gemspec ] ; then 5 | echo "[ bin/setup ] gli.gemspec has been modified - deleting Gemfile.lock" 6 | rm Gemfile.lock 7 | bundle install 8 | else 9 | echo "[ bin/setup ] checking bundle and updating as necessary" 10 | bundle check || bundle install 11 | fi 12 | -------------------------------------------------------------------------------- /lib/gli/argument.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | class Argument #:nodoc: 3 | 4 | attr_reader :name 5 | attr_reader :options 6 | 7 | def initialize(name,options = []) 8 | @name = name 9 | @options = options 10 | end 11 | 12 | def optional? 13 | @options.include? :optional 14 | end 15 | 16 | def multiple? 17 | @options.include? :multiple 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/compact_synopsis_formatter.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | class CompactSynopsisFormatter < FullSynopsisFormatter 5 | 6 | protected 7 | 8 | def sub_options_doc(sub_options) 9 | if sub_options.empty? 10 | '' 11 | else 12 | '[subcommand options]' 13 | end 14 | end 15 | 16 | 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /bin/ci: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | DX_DIR="${SCRIPT_DIR}/../dx" 7 | 8 | . "${DX_DIR}/docker-compose.env" 9 | . "${DX_DIR}/setupkit.sh.lib" 10 | 11 | for ruby_version in ${RUBY_VERSIONS[@]}; do 12 | log "Setting up for Ruby version '${ruby_version}'" 13 | dx/exec -v ${ruby_version} bin/setup 14 | log "Running tests for Ruby version '${ruby_version}'" 15 | dx/exec -v ${ruby_version} bin/rake 16 | done 17 | 18 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/verbatim_wrapper.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Leaves text formatting exactly as it was received. Doesn't strip anything. 5 | class VerbatimWrapper 6 | # Args are ignored entirely; this keeps it consistent with the TextWrapper interface 7 | def initialize(width,indent) 8 | end 9 | 10 | def wrap(text) 11 | return String(text) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /dx/prune: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | 7 | . "${SCRIPT_DIR}/dx.sh.lib" 8 | require_command "docker" 9 | load_docker_compose_env 10 | 11 | usage_on_help "Prune containers for this repo" "" "" "" "${@}" 12 | 13 | for container_id in $(docker container ls -a -f "name=^${PROJECT_NAME}-.*-1$" --format="{{.ID}}"); do 14 | log "🗑" "Removing container with id '${container_id}'" 15 | docker container rm "${container_id}" 16 | done 17 | echo "🧼" "Containers removed" 18 | 19 | # vim: ft=bash 20 | -------------------------------------------------------------------------------- /dx/dx.sh.lib: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | . "${SCRIPT_DIR}/setupkit.sh.lib" 4 | 5 | require_command "realpath" 6 | require_command "cat" 7 | 8 | ENV_FILE=$(realpath "${SCRIPT_DIR}")/docker-compose.env 9 | 10 | load_docker_compose_env() { 11 | . "${ENV_FILE}" 12 | } 13 | 14 | exec_hook_if_exists() { 15 | script_name=$1 16 | shift 17 | if [ -x "${SCRIPT_DIR}"/"${script_name}" ]; then 18 | log "🪝" "${script_name} exists - executing" 19 | "${SCRIPT_DIR}"/"${script_name}" "${@}" 20 | else 21 | debug "${script_name} does not exist" 22 | fi 23 | } 24 | # vim: ft=bash 25 | -------------------------------------------------------------------------------- /dx/stop: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | 7 | . "${SCRIPT_DIR}/dx.sh.lib" 8 | require_command "docker" 9 | load_docker_compose_env 10 | 11 | usage_on_help "Stops all services, the container in which to run your app and removes any volumes" "" "" "" "${@}" 12 | 13 | log "🚀" "Stopping docker-compose.dx.yml" 14 | 15 | docker \ 16 | compose \ 17 | --file docker-compose.dx.yml \ 18 | --project-name "${PROJECT_NAME}" \ 19 | --env-file "${ENV_FILE}" \ 20 | down \ 21 | --volumes 22 | 23 | # vim: ft=bash 24 | -------------------------------------------------------------------------------- /test/apps/todo/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/clean' 2 | require 'rubygems' 3 | require 'rake/gempackagetask' 4 | require 'rdoc/task' 5 | 6 | Rake::RDocTask.new do |rd| 7 | rd.main = "README.rdoc" 8 | rd.rdoc_files.include("README.rdoc","lib/**/*.rb","bin/**/*") 9 | rd.title = 'Your application title' 10 | end 11 | 12 | spec = eval(File.read('todo.gemspec')) 13 | 14 | Rake::GemPackageTask.new(spec) do |pkg| 15 | end 16 | 17 | require 'rake/testtask' 18 | Rake::TestTask.new do |t| 19 | t.libs << "test" 20 | t.test_files = FileList['test/tc_*.rb'] 21 | end 22 | 23 | task :default => :test 24 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/clean' 2 | require 'rubygems' 3 | require 'rake/gempackagetask' 4 | require 'rdoc/task' 5 | 6 | Rake::RDocTask.new do |rd| 7 | rd.main = "README.rdoc" 8 | rd.rdoc_files.include("README.rdoc","lib/**/*.rb","bin/**/*") 9 | rd.title = 'Your application title' 10 | end 11 | 12 | spec = eval(File.read('todo.gemspec')) 13 | 14 | Rake::GemPackageTask.new(spec) do |pkg| 15 | end 16 | 17 | require 'rake/testtask' 18 | Rake::TestTask.new do |t| 19 | t.libs << "test" 20 | t.test_files = FileList['test/tc_*.rb'] 21 | end 22 | 23 | task :default => :test 24 | -------------------------------------------------------------------------------- /test/unit/support/fake_std_out.rb: -------------------------------------------------------------------------------- 1 | class FakeStdOut 2 | attr_reader :strings 3 | 4 | def initialize 5 | @strings = [] 6 | end 7 | 8 | def puts(string=nil) 9 | @strings << string unless string.nil? 10 | end 11 | 12 | def write(x) 13 | puts(x) 14 | end 15 | 16 | def printf(*args) 17 | puts(Kernel.printf(*args)) 18 | end 19 | 20 | # Returns true if the regexp matches anything in the output 21 | def contained?(regexp) 22 | strings.find{ |x| x =~ regexp } 23 | end 24 | 25 | def flush; end 26 | 27 | def to_s 28 | @strings.join("\n") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/gli/options.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module GLI 4 | # Subclass of +OpenStruct+ that provides hash-like methods for #[] and #[]=. Note that is is *not* a Hash. 5 | # By using GLI::App#use_openstruct, your options will be coerced into one of these. 6 | class Options < OpenStruct 7 | 8 | # Return the value of an attribute 9 | def[](k) 10 | self.send(k.to_sym) 11 | end 12 | 13 | # Set the value of an attribute 14 | def[]=(k, v) 15 | self.send("#{k.to_sym}=",v) 16 | end 17 | 18 | def map(&block) 19 | @table.map(&block) 20 | end 21 | end 22 | end 23 | 24 | -------------------------------------------------------------------------------- /lib/gli/option_parsing_result.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | class OptionParsingResult 3 | attr_accessor :global_options 4 | attr_accessor :command 5 | attr_accessor :command_options 6 | attr_accessor :arguments 7 | 8 | def convert_to_openstruct! 9 | @global_options = Options.new(@global_options) 10 | @command_options = Options.new(@command_options) 11 | self 12 | end 13 | 14 | # Allows us to splat this object into blocks and methods expecting parameters in this order 15 | def to_a 16 | [@global_options,@command,@command_options,@arguments] 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/unit/compound_command_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class CompoundCommandFinderTest < Minitest::Test 4 | include TestHelper 5 | 6 | def test_exception_for_missing_commands 7 | @name = "new" 8 | @unknown_name = "create" 9 | @existing_command = OpenStruct.new(:name => @name) 10 | @base = OpenStruct.new( :commands => { @name => @existing_command }) 11 | 12 | @code = lambda { GLI::Commands::CompoundCommand.new(@base,{:foo => [@name,@unknown_name]}) } 13 | 14 | ex = assert_raises(RuntimeError,&@code) 15 | assert_match /#{Regexp.escape(@unknown_name)}/,ex.message 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/lib/todo/commands/create.rb: -------------------------------------------------------------------------------- 1 | desc "Create a new task or context" 2 | command [:create,:new] do |c| 3 | c.desc "Make a new task" 4 | c.arg_name 'task_name', :multiple 5 | c.command :tasks do |tasks| 6 | tasks.action do |global,options,args| 7 | puts "#{args}" 8 | end 9 | end 10 | 11 | c.desc "Make a new context" 12 | c.arg_name 'context_name', :optional 13 | c.command :contexts do |contexts| 14 | contexts.action do |global,options,args| 15 | puts "#{args}" 16 | end 17 | end 18 | 19 | c.default_desc "Makes a new task" 20 | c.action do 21 | puts "default action" 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/one_line_wrapper.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Formats text in one line, stripping newlines and NOT wrapping 5 | class OneLineWrapper 6 | # Args are ignored entirely; this keeps it consistent with the TextWrapper interface 7 | def initialize(width,indent) 8 | end 9 | 10 | # Return a wrapped version of text, assuming that the first line has already been 11 | # indented by @indent characters. Resulting text does NOT have a newline in it. 12 | def wrap(text) 13 | return String(text).gsub(/\n+/,' ').strip 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /dx/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | 7 | . "${SCRIPT_DIR}/dx.sh.lib" 8 | require_command "docker" 9 | load_docker_compose_env 10 | 11 | usage_on_help "Starts all services, including a container in which to run your app" "" "" "" "${@}" 12 | 13 | log "🚀" "Starting docker-compose.dx.yml" 14 | 15 | BUILD=--build 16 | if [ "${1}" == "--no-build" ]; then 17 | BUILD= 18 | fi 19 | 20 | docker \ 21 | compose \ 22 | --file docker-compose.dx.yml \ 23 | --project-name "${PROJECT_NAME}" \ 24 | --env-file "${ENV_FILE}" \ 25 | up \ 26 | "${BUILD}" \ 27 | --timestamps \ 28 | --force-recreate 29 | 30 | # vim: ft=bash 31 | -------------------------------------------------------------------------------- /test/unit/verbatim_wrapper_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class TerminalTest < Minitest::Test 4 | include TestHelper 5 | 6 | def test_handles_nil 7 | @wrapper = GLI::Commands::HelpModules::VerbatimWrapper.new(rand(100),rand(100)) 8 | @result = @wrapper.wrap(nil) 9 | assert_equal '',@result 10 | end 11 | 12 | def test_does_not_touch_input 13 | @wrapper = GLI::Commands::HelpModules::VerbatimWrapper.new(rand(100),rand(100)) 14 | @input = < Terminal.instance.size[0] } 13 | CompactSynopsisFormatter.new(@app,@flags_and_switches).synopses_for_command(command) 14 | 15 | else 16 | synopses 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/list_formatter.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Given a list of two-element lists, formats on the terminal 5 | class ListFormatter 6 | def initialize(list,wrapper_class=TextWrapper) 7 | @list = list 8 | @wrapper_class = wrapper_class 9 | end 10 | 11 | # Output the list to the output_device 12 | def output(output_device) 13 | return if @list.empty? 14 | max_width = @list.map { |_| _[0].length }.max 15 | wrapper = @wrapper_class.new(Terminal.instance.size[0],4 + max_width + 3) 16 | @list.each do |(name,description)| 17 | output_device.printf(" %-#{max_width}s - %s\n",name,wrapper.wrap(String(description).strip)) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/tty_only_wrapper.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Formats text in one line, stripping newlines and NOT wrapping 5 | class TTYOnlyWrapper 6 | # Args are ignored entirely; this keeps it consistent with the TextWrapper interface 7 | def initialize(width,indent) 8 | @proxy = if STDOUT.tty? 9 | TextWrapper.new(width,indent) 10 | else 11 | OneLineWrapper.new(width,indent) 12 | end 13 | end 14 | 15 | # Return a wrapped version of text, assuming that the first line has already been 16 | # indented by @indent characters. Resulting text does NOT have a newline in it. 17 | def wrap(text) 18 | @proxy.wrap(text) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/apps/todo/todo.gemspec: -------------------------------------------------------------------------------- 1 | # Ensure we require the local version and not one we might have installed already 2 | require File.join([File.dirname(__FILE__),'lib','todo','version.rb']) 3 | spec = Gem::Specification.new do |s| 4 | s.name = 'todo' 5 | s.version = Todo::VERSION 6 | s.author = 'Your Name Here' 7 | s.email = 'your@email.address.com' 8 | s.homepage = 'http://your.website.com' 9 | s.platform = Gem::Platform::RUBY 10 | s.summary = 'A description of your project' 11 | # Add your other files here if you make them 12 | s.files = %w( 13 | bin/todo 14 | ) 15 | s.require_paths << 'lib' 16 | s.extra_rdoc_files = ['README.rdoc','todo.rdoc'] 17 | s.rdoc_options << '--title' << 'todo' << '--main' << 'README.rdoc' << '-ri' 18 | s.bindir = 'bin' 19 | s.executables << 'todo' 20 | s.add_development_dependency('rake') 21 | s.add_development_dependency('rdoc') 22 | end 23 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/todo.gemspec: -------------------------------------------------------------------------------- 1 | # Ensure we require the local version and not one we might have installed already 2 | require File.join([File.dirname(__FILE__),'lib','todo','version.rb']) 3 | spec = Gem::Specification.new do |s| 4 | s.name = 'todo' 5 | s.version = Todo::VERSION 6 | s.author = 'Your Name Here' 7 | s.email = 'your@email.address.com' 8 | s.homepage = 'http://your.website.com' 9 | s.platform = Gem::Platform::RUBY 10 | s.summary = 'A description of your project' 11 | # Add your other files here if you make them 12 | s.files = %w( 13 | bin/todo 14 | ) 15 | s.require_paths << 'lib' 16 | s.extra_rdoc_files = ['README.rdoc','todo.rdoc'] 17 | s.rdoc_options << '--title' << 'todo' << '--main' << 'README.rdoc' << '-ri' 18 | s.bindir = 'bin' 19 | s.executables << 'todo' 20 | s.add_development_dependency('rake') 21 | s.add_development_dependency('rdoc') 22 | end 23 | -------------------------------------------------------------------------------- /bin/gli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'gli' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("gli", "gli") 30 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rake", "rake") 30 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/help_completion_format.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | class HelpCompletionFormat 5 | def initialize(app,command_finder,args) 6 | @app = app 7 | @command_finder = command_finder 8 | @command_finder.squelch_stderr = true 9 | @args = args 10 | end 11 | 12 | def format 13 | name = @args.shift 14 | 15 | base = @command_finder.find_command(name) 16 | base = @command_finder.last_found_command if base.nil? 17 | base = @app if base.nil? 18 | 19 | prefix_to_match = @command_finder.last_unknown_command 20 | 21 | base.commands.values.map { |command| 22 | [command.name,command.aliases] 23 | }.flatten.compact.map(&:to_s).sort.select { |command_name| 24 | prefix_to_match.nil? || command_name =~ /^#{prefix_to_match}/ 25 | }.join("\n") 26 | end 27 | 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /Dockerfile.dx: -------------------------------------------------------------------------------- 1 | ARG RUBY_VERSION 2 | FROM ruby:${RUBY_VERSION} 3 | 4 | ENV DEBIAN_FRONTEND noninteractive 5 | RUN apt-get -y update 6 | 7 | 8 | # dx.snippet.start=templates/snippets/bundler/latest__bundler.dockerfile-snippet 9 | # Based on documentation at https://guides.rubygems.org/command-reference/#gem-update 10 | # based on the vendor's documentation 11 | RUN echo "gem: --no-document" >> ~/.gemrc && \ 12 | gem update --system && \ 13 | gem install bundler 14 | 15 | # dx.snippet.end=templates/snippets/bundler/latest__bundler.dockerfile-snippet 16 | 17 | 18 | # dx.snippet.start=templates/snippets/vim/bullseye_vim.dockerfile-snippet 19 | # Based on documentation at https://packages.debian.org/search?keywords=vim 20 | # based on the vendor's documentation 21 | ENV EDITOR=vim 22 | RUN apt-get install -y vim && \ 23 | echo "set -o vi" >> /root/.bashrc 24 | # dx.snippet.end=templates/snippets/vim/bullseye_vim.dockerfile-snippet 25 | 26 | # This entrypoint produces a nice help message and waits around for you to do 27 | # something with the container. 28 | COPY dx/show-help-in-app-container-then-wait.sh /root 29 | -------------------------------------------------------------------------------- /test/integration/scaffold_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "open3" 3 | 4 | class ScaffoldCommandTest < Minitest::Test 5 | include TestHelper 6 | 7 | def test_scaffolded_app_has_reasonable_setup 8 | FileUtils.rm_rf "scaffold_test" 9 | run_gli("init scaffold_test") 10 | assert Dir.exist? "scaffold_test" 11 | FileUtils.chdir "scaffold_test" do 12 | run_command("bundle install", "", return_err_and_status: false, expect_failure: false) 13 | 14 | scaffold_lib = "lib:../lib" 15 | 16 | # help works 17 | out = run_command("bin/scaffold_test","--help", return_err_and_status: false, expect_failure: false, rubylib: scaffold_lib) 18 | assert_match /SYNOPSIS/,out 19 | assert_match /GLOBAL OPTIONS/,out 20 | assert_match /COMMANDS/,out 21 | 22 | # can run unit tests 23 | out = run_command("bundle exec ","rake test", return_err_and_status: false, expect_failure: false, rubylib: scaffold_lib) 24 | assert_match /0 failures/,out 25 | assert_match /0 errors/,out 26 | assert_match /0 skips/,out 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /test/unit/options_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class OptiosnTest < Minitest::Test 4 | include TestHelper 5 | 6 | def test_by_method 7 | o = GLI::Options.new 8 | o.name = 'verbose' 9 | assert_equal 'verbose', o.name 10 | assert_equal 'verbose', o[:name] 11 | assert_equal 'verbose', o['name'] 12 | end 13 | 14 | def test_by_string 15 | o = GLI::Options.new 16 | o['name'] = 'verbose' 17 | assert_equal 'verbose', o.name 18 | assert_equal 'verbose', o[:name] 19 | assert_equal 'verbose', o['name'] 20 | end 21 | 22 | def test_by_symbol 23 | o = GLI::Options.new 24 | o[:name] = 'verbose' 25 | assert_equal 'verbose', o.name 26 | assert_equal 'verbose', o[:name] 27 | assert_equal 'verbose', o['name'] 28 | end 29 | 30 | def test_map_defers_to_underlying_map 31 | o = GLI::Options.new 32 | o[:foo] = 'bar' 33 | o[:blah] = 'crud' 34 | 35 | result = Hash[o.map { |k,v| 36 | [k,v.upcase] 37 | }] 38 | assert_equal 2,result.size 39 | assert_equal "BAR",result[:foo] 40 | assert_equal "CRUD",result[:blah] 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /lib/gli/command_line_option.rb: -------------------------------------------------------------------------------- 1 | require 'gli/command_line_token.rb' 2 | 3 | module GLI 4 | # An option, not a command or argument, on the command line 5 | class CommandLineOption < CommandLineToken #:nodoc: 6 | 7 | attr_accessor :default_value 8 | # Command to which this option "belongs", nil if it's a global option 9 | attr_accessor :associated_command 10 | 11 | # Creates a new option 12 | # 13 | # names - Array of symbols or strings representing the names of this switch 14 | # options - hash of options: 15 | # :desc - the short description 16 | # :long_desc - the long description 17 | # :default_value - the default value of this option 18 | def initialize(names,options = {}) 19 | super(names,options[:desc],options[:long_desc]) 20 | @default_value = options[:default_value] 21 | end 22 | 23 | def self.name_as_string(name,negatable=true) 24 | string = name.to_s 25 | if string.length == 1 26 | "-#{string}" 27 | elsif negatable 28 | "--[no-]#{string}" 29 | else 30 | "--#{string}" 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/gli.rb: -------------------------------------------------------------------------------- 1 | require 'gli/command_finder.rb' 2 | require 'gli/gli_option_block_parser.rb' 3 | require 'gli/option_parser_factory.rb' 4 | require 'gli/option_parsing_result.rb' 5 | require 'gli/gli_option_parser.rb' 6 | require 'gli/app_support.rb' 7 | require 'gli/app.rb' 8 | require 'gli/command_support.rb' 9 | require 'gli/command.rb' 10 | require 'gli/command_line_token.rb' 11 | require 'gli/command_line_option.rb' 12 | require 'gli/exceptions.rb' 13 | require 'gli/flag.rb' 14 | require 'gli/options.rb' 15 | require 'gli/switch.rb' 16 | require 'gli/argument.rb' 17 | require 'gli/dsl.rb' 18 | require 'gli/version.rb' 19 | require 'gli/commands/help' 20 | require 'gli/commands/compound_command' 21 | require 'gli/commands/initconfig' 22 | require 'gli/commands/rdoc_document_listener' 23 | require 'gli/commands/doc' 24 | 25 | module GLI 26 | include GLI::App 27 | 28 | def self.included(klass) 29 | warn "You should include GLI::App instead" 30 | end 31 | 32 | def self.run(*args) 33 | warn "GLI.run no longer works for GLI-2, you must just call `run(ARGV)' instead" 34 | warn "either fix your app, or use the latest GLI in the 1.x family" 35 | 1 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /dx/show-help-in-app-container-then-wait.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Ideally, the message below is shown after everything starts up. We can't 6 | # achieve this using healtchecks because the interval for a healtcheck is 7 | # also an initial delay, and we don't really want to do healthchecks on 8 | # our DB or Redis every 2 seconds. So, we sleep just a bit to let 9 | # the other containers start up and vomit out their output first. 10 | sleep 2 11 | # Output some helpful messaging when invoking `dx/start` (which itself is 12 | # a convenience script for `docker compose up`. 13 | # 14 | # Adding this to work around the mild inconvenience of the `app` container's 15 | # entrypoint generating no output. 16 | # 17 | cat <<-'PROMPT' 18 | 19 | 20 | 21 | 🎉 Dev Environment Initialized! 🎉 22 | 23 | ℹ️ To use this environment, open a new terminal and run 24 | 25 | dx/exec bash 26 | 27 | 🕹 Use `ctrl-c` to exit. 28 | 29 | 30 | 31 | PROMPT 32 | 33 | # Using `sleep infinity` instead of `tail -f /dev/null`. This may be a 34 | # performance improvement based on the conversation on a semi-related 35 | # StackOverflow page. 36 | # 37 | # @see https://stackoverflow.com/a/41655546 38 | sleep infinity 39 | -------------------------------------------------------------------------------- /lib/gli/switch.rb: -------------------------------------------------------------------------------- 1 | require 'gli/command_line_option.rb' 2 | 3 | module GLI 4 | # Defines a command line switch 5 | class Switch < CommandLineOption #:nodoc: 6 | 7 | attr_accessor :default_value 8 | attr_reader :negatable 9 | 10 | # Creates a new switch 11 | # 12 | # names - Array of symbols or strings representing the names of this switch 13 | # options - hash of options: 14 | # :desc - the short description 15 | # :long_desc - the long description 16 | # :negatable - true or false if this switch is negatable; defaults to true 17 | # :default_value - default value if the switch is omitted 18 | def initialize(names,options = {}) 19 | super(names,options) 20 | @default_value = false if options[:default_value].nil? 21 | @negatable = options[:negatable].nil? ? true : options[:negatable] 22 | if @default_value != false && @negatable == false 23 | raise "A switch with default #{@default_value} that isn't negatable is useless" 24 | end 25 | end 26 | 27 | def arguments_for_option_parser 28 | all_forms_a 29 | end 30 | 31 | def negatable? 32 | @negatable 33 | end 34 | 35 | def required? 36 | false 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /object-model.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | rankdir="BT" 4 | nodesep=0.5 5 | 6 | node[shape=record fontname=courier fontsize=18] 7 | edge[fontname=avenir fontsize=12] 8 | 9 | CommandLineToken [ label="{ CommandLineToken | #name\l | #description\l | #long_description\l | #aliases\l}"] 10 | CommandLineOption [ label="{ CommandLineOption | #default_value \l }"] 11 | DSL 12 | Command 13 | Flag [ label="{ Flag | #argument_name\l }"] 14 | Switch 15 | App 16 | TopLevel [ label="top level?" shape=diamond fontname=avenir fontsize=12] 17 | 18 | Command -> DSL [ arrowhead=oarrow label=" includes" minlen=3] 19 | Command -> CommandLineToken [ arrowhead=oarrow label="inherits"] 20 | CommandLineOption -> CommandLineToken [ arrowhead=oarrow label="inherits"] 21 | Flag -> CommandLineOption [ arrowhead=oarrow label="inherits"] 22 | Switch -> CommandLineOption [ arrowhead=oarrow label="inherits"] 23 | Command -> TopLevel [ arrowhead=none label="parent" style=dotted] 24 | TopLevel -> App [ arrowhead=odiamond label="YES" style=dotted ] 25 | TopLevel -> Command [ arrowhead=odiamond label="NO" style=dotted ] 26 | CommandLineOption -> Command [ arrowhead=odiamond style=dotted label="associated_command"] 27 | 28 | { rank=same; DSL; App } 29 | } 30 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/lib/todo/commands/ls.rb: -------------------------------------------------------------------------------- 1 | # This is copied so I can have two slightly different versions of the same thing 2 | desc "LS things, such as tasks or contexts" 3 | long_desc %( 4 | List a whole lot of things that you might be keeping track of 5 | in your overall todo list. 6 | 7 | This is your go-to place or finding all of the things that you 8 | might have 9 | stored in 10 | your todo databases. 11 | ) 12 | command [:ls] do |c| 13 | c.desc "Show long form" 14 | c.switch [:l,:long] 15 | 16 | c.desc "List tasks" 17 | c.long_desc %( 18 | Lists all of your tasks that you have, in varying orders, and 19 | all that stuff. Yes, this is long, but I need a long description. 20 | ) 21 | c.command :tasks do |tasks| 22 | tasks.desc "blah blah crud x whatever" 23 | tasks.flag [:x] 24 | tasks.action do |global,options,args| 25 | puts "ls tasks: #{args.join(',')}" 26 | end 27 | end 28 | 29 | c.desc "List contexts" 30 | c.long_desc %( 31 | Lists all of your contexts, which are places you might be 32 | where you can do stuff and all that. 33 | ) 34 | c.command :contexts do |contexts| 35 | 36 | contexts.desc "Foobar" 37 | contexts.switch [:f,'foobar'] 38 | 39 | contexts.desc "Blah" 40 | contexts.switch [:b] 41 | 42 | contexts.action do |global,options,args| 43 | puts "ls contexts: #{args.join(',')}" 44 | end 45 | end 46 | end 47 | 48 | -------------------------------------------------------------------------------- /test/unit/switch_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class SwitchTest < Minitest::Test 4 | include TestHelper 5 | 6 | def test_basics_simple 7 | switch_with_names(:filename) 8 | attributes_should_be_set 9 | assert_equal(:filename,@cli_option.name) 10 | assert_nil @cli_option.aliases 11 | end 12 | 13 | def test_basics_kinda_complex 14 | switch_with_names([:f]) 15 | attributes_should_be_set 16 | assert_equal(:f,@cli_option.name) 17 | assert_nil @cli_option.aliases 18 | end 19 | 20 | def test_basics_complex 21 | switch_with_names([:f,:file,:filename]) 22 | attributes_should_be_set 23 | assert_equal(:f,@cli_option.name) 24 | assert_equal([:file,:filename],@cli_option.aliases) 25 | assert_equal ["-f","--[no-]file","--[no-]filename"],@switch.arguments_for_option_parser 26 | end 27 | 28 | def test_includes_negatable 29 | assert_equal '-a',GLI::Switch.name_as_string('a') 30 | assert_equal '--[no-]foo',GLI::Switch.name_as_string('foo') 31 | end 32 | 33 | private 34 | 35 | def switch_with_names(names) 36 | @options = { 37 | :desc => 'Filename', 38 | :long_desc => 'The Filename', 39 | } 40 | @switch = GLI::Switch.new(names,@options) 41 | @cli_option = @switch 42 | end 43 | 44 | def attributes_should_be_set 45 | assert_equal(@options[:desc],@switch.description) 46 | assert_equal(@options[:long_desc],@switch.long_description) 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /test/apps/todo/lib/todo/commands/ls.rb: -------------------------------------------------------------------------------- 1 | class App 2 | # This is copied so I can have two slightly different versions of the same thing 3 | desc "LS things, such as tasks or contexts" 4 | long_desc %( 5 | List a whole lot of things that you might be keeping track of 6 | in your overall todo list. 7 | 8 | This is your go-to place or finding all of the things that you 9 | might have 10 | stored in 11 | your todo databases. 12 | ) 13 | command [:ls] do |c| 14 | c.desc "Show long form" 15 | c.switch [:l,:long] 16 | 17 | c.desc "List tasks" 18 | c.long_desc %( 19 | Lists all of your tasks that you have, in varying orders, and 20 | all that stuff. Yes, this is long, but I need a long description. 21 | ) 22 | c.command :tasks do |tasks| 23 | tasks.desc "blah blah crud x whatever" 24 | tasks.flag [:x] 25 | tasks.action do |global,options,args| 26 | puts "ls tasks: #{args.join(',')}" 27 | end 28 | end 29 | 30 | c.desc "List contexts" 31 | c.long_desc %( 32 | Lists all of your contexts, which are places you might be 33 | where you can do stuff and all that. 34 | ) 35 | c.command :contexts do |contexts| 36 | 37 | contexts.desc "Foobar" 38 | contexts.switch [:f,'foobar'] 39 | 40 | contexts.desc "Blah" 41 | contexts.switch [:b] 42 | 43 | contexts.action do |global,options,args| 44 | puts "ls contexts: #{args.join(',')}" 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/gli/commands/compound_command.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | # A command that calls other commands in order 4 | class CompoundCommand < Command 5 | # base:: object that respondes to +commands+ 6 | # configuration:: Array of arrays: index 0 is the array of names of this command and index 1 7 | # is the names of the compound commands. 8 | def initialize(base,configuration,options={}) 9 | name = configuration.keys.first 10 | super(options.merge(:names => [name])) 11 | 12 | command_names = configuration[name] 13 | 14 | check_for_unknown_commands!(base,command_names) 15 | 16 | @wrapped_commands = command_names.map { |name| self.class.find_command(base,name) } 17 | end 18 | 19 | def execute(global_options,options,arguments) #:nodoc: 20 | @wrapped_commands.each do |command| 21 | command.execute(global_options,options,arguments) 22 | end 23 | end 24 | 25 | private 26 | 27 | def check_for_unknown_commands!(base,command_names) 28 | known_commands = base.commands.keys.map(&:to_s) 29 | unknown_commands = command_names.map(&:to_s) - known_commands 30 | 31 | unless unknown_commands.empty? 32 | raise "Unknown commands #{unknown_commands.join(',')}" 33 | end 34 | end 35 | 36 | def self.find_command(base,name) 37 | base.commands.values.find { |command| command.name == name } 38 | end 39 | 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/apps/todo/lib/todo/commands/create.rb: -------------------------------------------------------------------------------- 1 | class App 2 | desc "Create a new task or context" 3 | command [:create,:new] do |c| 4 | c.desc "Make a new task" 5 | c.arg_name 'task_name', :multiple 6 | c.arg :should_ignore_this 7 | c.command :tasks do |tasks| 8 | tasks.action do |global,options,args| 9 | puts "#{args}" 10 | end 11 | end 12 | 13 | c.desc "Make a new context" 14 | c.arg :should_ignore_this 15 | c.arg_name 'context_name', :optional 16 | c.command :contexts do |contexts| 17 | contexts.action do |global,options,args| 18 | puts "#{args}" 19 | end 20 | end 21 | 22 | c.default_desc "Makes a new task" 23 | c.action do 24 | puts "default action" 25 | end 26 | 27 | c.arg "first" 28 | c.arg "second" 29 | c.arg "name", :optional 30 | c.command :"relation_1-1" do |remote| 31 | remote.action do |global,options,args| 32 | puts "relation: #{args}" 33 | end 34 | end 35 | 36 | c.arg "first", :multiple 37 | c.arg "second" 38 | c.arg "name", :optional 39 | c.command :"relation_n-1" do |remote| 40 | remote.action do |global,options,args| 41 | puts "relation: #{args}" 42 | end 43 | end 44 | 45 | c.arg "first" 46 | c.arg "second", :multiple 47 | c.arg "name", :optional 48 | c.command :"relation_1-n" do |remote| 49 | remote.action do |global,options,args| 50 | puts "relation: #{args}" 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/arg_name_formatter.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Handles wrapping text 5 | class ArgNameFormatter 6 | def format(arguments_description,arguments_options,arguments) 7 | # Select which format to use: argname or arguments 8 | # Priority to old way: argname 9 | desc = format_argname(arguments_description, arguments_options) 10 | desc = format_arguments(arguments) if desc.strip == '' 11 | desc 12 | end 13 | 14 | def format_arguments(arguments) 15 | return '' if arguments.empty? 16 | desc = "" 17 | 18 | # Go through the arguments, building the description string 19 | arguments.each do |arg| 20 | arg_desc = "#{arg.name}" 21 | if arg.optional? 22 | arg_desc = "[#{arg_desc}]" 23 | end 24 | if arg.multiple? 25 | arg_desc = "#{arg_desc}..." 26 | end 27 | desc = desc + " " + arg_desc 28 | end 29 | 30 | desc 31 | end 32 | 33 | def format_argname(arguments_description,arguments_options) 34 | return '' if String(arguments_description).strip == '' 35 | desc = arguments_description 36 | if arguments_options.include? :optional 37 | desc = "[#{desc}]" 38 | end 39 | if arguments_options.include? :multiple 40 | desc = "#{desc}..." 41 | end 42 | " " + desc 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /dx/exec: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | 7 | . "${SCRIPT_DIR}/dx.sh.lib" 8 | 9 | require_command "docker" 10 | load_docker_compose_env 11 | 12 | usage_description="Execute a command inside the app's container." 13 | usage_args="[-s service] [-v ruby_version] command" 14 | usage_pre="exec.pre" 15 | usage_on_help "${usage_description}" "${usage_args}" "${usage_pre}" "" "${@}" 16 | 17 | LATEST_RUBY=${RUBY_VERSIONS[0]} 18 | DEFAULT_SERVICE=gli-${LATEST_RUBY} 19 | SERVICE="${SERVICE_NAME:-${DEFAULT_SERVICE}}" 20 | while getopts "v:s:" opt "${@}"; do 21 | case ${opt} in 22 | v ) 23 | SERVICE="gli-${OPTARG}" 24 | ;; 25 | s ) 26 | SERVICE="${OPTARG}" 27 | ;; 28 | \? ) 29 | log "🛑" "Unknown option: ${opt}" 30 | usage "${description}" "${usage_args}" "${usage_pre}" 31 | ;; 32 | : ) 33 | log "🛑" "Invalid option: ${opt} requires an argument" 34 | usage "${description}" "${usage_args}" "${usage_pre}" 35 | ;; 36 | esac 37 | done 38 | shift $((OPTIND -1)) 39 | 40 | if [ $# -eq 0 ]; then 41 | log "🛑" "You must provide a command e.g. bash or ls -l" 42 | usage "${description}" "${usage_args}" "${usage_pre}" 43 | fi 44 | 45 | 46 | exec_hook_if_exists "exec.pre" 47 | 48 | log "🚂" "Running '${*}' inside container with service name '${SERVICE}'" 49 | 50 | docker \ 51 | compose \ 52 | --file docker-compose.dx.yaml \ 53 | --project-name "${PROJECT_NAME}" \ 54 | --env-file "${ENV_FILE}" \ 55 | exec \ 56 | "${SERVICE}" \ 57 | "${@}" 58 | 59 | # vim: ft=bash 60 | -------------------------------------------------------------------------------- /gli.rdoc: -------------------------------------------------------------------------------- 1 | == gli - create scaffolding for a GLI-powered application 2 | 3 | v2.19.2 4 | 5 | === Global Options 6 | === -r|--root arg 7 | 8 | Root dir of project 9 | 10 | [Default Value] . 11 | This is the directory where the project''s directory will be made, so if you 12 | specify a project name ''foo'' and the root dir of ''.'', the directory 13 | ''./foo'' will be created' 14 | 15 | === --help 16 | Show this message 17 | 18 | 19 | 20 | === -n 21 | Dry run; dont change the disk 22 | 23 | 24 | 25 | === -v 26 | Be verbose 27 | 28 | 29 | 30 | === --version 31 | Display the program version 32 | 33 | 34 | 35 | === Commands 36 | ==== Command: help command 37 | Shows a list of commands or help for one command 38 | 39 | Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function 40 | ===== Options 41 | ===== -c 42 | List commands one per line, to assist with shell completion 43 | 44 | 45 | 46 | ==== Command: init|scaffold project_name [command_name]... 47 | Create a new GLI-based project 48 | 49 | This will create a scaffold command line project that uses GLI 50 | for command line processing. Specifically, this will create 51 | an executable ready to go, as well as a lib and test directory, all 52 | inside the directory named for your project 53 | ===== Options 54 | ===== -e|--[no-]ext 55 | Create an ext dir 56 | 57 | 58 | 59 | ===== --[no-]force 60 | Overwrite/ignore existing files and directories 61 | 62 | 63 | 64 | ===== --notest 65 | Do not create a test or features dir 66 | 67 | 68 | 69 | ===== --[no-]rvmrc 70 | Create an .rvmrc based on your current RVM setup 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /test/integration/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "pathname" 3 | require "fileutils" 4 | 5 | # Copied from https://github.com/splattael/minitest-around 6 | # so as to avoid an explicit dependency 7 | Minitest::Test.class_eval do 8 | alias_method :run_without_around, :run 9 | def run(*args) 10 | if defined?(around) 11 | result = nil 12 | around { result = run_without_around(*args) } 13 | result 14 | else 15 | run_without_around(*args) 16 | end 17 | end 18 | end 19 | 20 | module TestHelper 21 | def around(&block) 22 | Bundler.with_original_env do 23 | root = Pathname(__FILE__).dirname / ".." / ".." 24 | FileUtils.chdir root do 25 | block.() 26 | end 27 | end 28 | end 29 | 30 | def run_gli(args="", return_err_and_status: false, expect_failure: false) 31 | run_command("bin/gli",args,return_err_and_status:return_err_and_status,expect_failure:expect_failure) 32 | end 33 | 34 | def run_command(command,args,return_err_and_status:,expect_failure:,rubylib:nil) 35 | command_line_invocation = "#{command} #{args}" 36 | env = {} 37 | if !rubylib.nil? 38 | env["RUBYLIB"] = rubylib 39 | end 40 | stdout_string, stderr_string, status = Open3.capture3(env,command_line_invocation) 41 | if expect_failure 42 | refute_equal 0,status.exitstatus,"Expected failure for '#{command_line_invocation}' but it succeeded" 43 | else 44 | assert_equal 0,status.exitstatus,"Expected success for '#{command_line_invocation}' but it failed:\n#{stdout_string}\n\n#{stderr_string}\n\n" 45 | end 46 | if return_err_and_status 47 | [ stdout_string, stderr_string, status ] 48 | else 49 | stdout_string 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/lib/todo/commands/list.rb: -------------------------------------------------------------------------------- 1 | desc "List things, such as tasks or contexts" 2 | long_desc %( 3 | List a whole lot of things that you might be keeping track of 4 | in your overall todo list. 5 | 6 | This is your go-to place or finding all of the things that you 7 | might have 8 | stored in 9 | your todo databases. 10 | ) 11 | command [:list] do |c| 12 | c.default_command :tasks 13 | 14 | c.desc "Show long form" 15 | c.switch [:l,:long] 16 | 17 | c.desc "List tasks" 18 | c.long_desc %( 19 | Lists all of your tasks that you have, in varying orders, and 20 | all that stuff. Yes, this is long, but I need a long description. 21 | ) 22 | c.command :tasks do |tasks| 23 | tasks.desc "blah blah crud x whatever" 24 | tasks.flag [:x], :must_match => Array 25 | 26 | tasks.flag :flag 27 | 28 | tasks.action do |global,options,args| 29 | puts options[:x].inspect 30 | puts "list tasks: #{args.join(',')}" 31 | end 32 | 33 | tasks.desc 'list open tasks' 34 | tasks.command :open do |open| 35 | open.action do |global,options,args| 36 | puts "tasks open" 37 | end 38 | end 39 | 40 | tasks.default_desc 'list all tasks' 41 | end 42 | 43 | c.desc "List contexts" 44 | c.long_desc %( 45 | Lists all of your contexts, which are places you might be 46 | where you can do stuff and all that. 47 | ) 48 | c.command :contexts do |contexts| 49 | 50 | contexts.desc "Foobar" 51 | contexts.switch [:f,'foobar'] 52 | 53 | contexts.desc "Blah" 54 | contexts.switch [:b] 55 | 56 | contexts.flag :otherflag 57 | 58 | contexts.action do |global,options,args| 59 | puts "list contexts: #{args.join(',')}" 60 | end 61 | end 62 | end 63 | 64 | -------------------------------------------------------------------------------- /test/apps/todo/lib/todo/commands/make.rb: -------------------------------------------------------------------------------- 1 | class App 2 | command [:make] do |c| 3 | c.desc "Show long form" 4 | c.flag [:l,:long] 5 | 6 | c.desc 'make a new task' 7 | c.command :task do |task| 8 | task.desc 'make the task a long task' 9 | task.flag [:l,:long] 10 | 11 | task.action do |g,o,a| 12 | puts 'new task' 13 | puts a.join(',') 14 | puts o[:long] 15 | end 16 | 17 | task.desc 'make a bug' 18 | task.arg :argument, [:multiple, :optional] 19 | task.command :bug do |bug| 20 | bug.desc 'make this bug in the legacy system' 21 | bug.flag [:l,:legacy] 22 | 23 | bug.action do |g,o,a| 24 | puts 'new task bug' 25 | puts a.join(',') 26 | # All this .to_s is to make sure 1.8.7/REE don't convert nil to the string "nil" 27 | puts o[:legacy].to_s 28 | puts o[:long].to_s 29 | puts o[:l].to_s 30 | puts o[GLI::Command::PARENT][:l].to_s 31 | puts o[GLI::Command::PARENT][:long].to_s 32 | puts o[GLI::Command::PARENT][:legacy].to_s 33 | puts o[GLI::Command::PARENT][GLI::Command::PARENT][:l].to_s 34 | puts o[GLI::Command::PARENT][GLI::Command::PARENT][:long].to_s 35 | puts o[GLI::Command::PARENT][GLI::Command::PARENT][:legacy].to_s 36 | end 37 | end 38 | end 39 | 40 | c.desc 'make a new context' 41 | c.command :context do |context| 42 | context.desc 'make the context a local context' 43 | context.flag [:l,:local] 44 | 45 | context.action do |g,o,a| 46 | puts 'new context' 47 | puts a.join(',') 48 | puts o[:local].to_s 49 | puts o[:long].to_s 50 | puts o[:l].to_s 51 | end 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/unit/command_finder_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class CommandFinderTest < Minitest::Test 4 | include TestHelper 5 | 6 | def setup 7 | @app = CLIApp.new 8 | [:status, :deployable, :some_command, :some_similar_command].each do |command| 9 | @app.commands[command] = GLI::Command.new(:names => command) 10 | end 11 | end 12 | 13 | def teardown 14 | end 15 | 16 | def test_unknown_command_name 17 | assert_raises(GLI::UnknownCommand) do 18 | GLI::CommandFinder.new(@app.commands, :default_command => :status).find_command(:unfindable_command) 19 | end 20 | end 21 | 22 | def test_no_command_name_without_default 23 | assert_raises(GLI::UnknownCommand) do 24 | GLI::CommandFinder.new(@app.commands).find_command(nil) 25 | end 26 | end 27 | 28 | def test_no_command_name_with_default 29 | actual = GLI::CommandFinder.new(@app.commands, :default_command => :status).find_command(nil) 30 | expected = @app.commands[:status] 31 | 32 | assert_equal(actual, expected) 33 | end 34 | 35 | def test_ambigous_command 36 | assert_raises(GLI::AmbiguousCommand) do 37 | GLI::CommandFinder.new(@app.commands, :default_command => :status).find_command(:some) 38 | end 39 | end 40 | 41 | def test_partial_name_with_autocorrect_enabled 42 | actual = GLI::CommandFinder.new(@app.commands, :default_command => :status).find_command(:deploy) 43 | expected = @app.commands[:deployable] 44 | 45 | assert_equal(actual, expected) 46 | end 47 | 48 | def test_partial_name_with_autocorrect_disabled 49 | assert_raises(GLI::UnknownCommand) do 50 | GLI::CommandFinder.new(@app.commands, :default_command => :status, :autocomplete => false) 51 | .find_command(:deploy) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/integration/gli_powered_app_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "open3" 3 | 4 | class GLIPoweredAppTest < Minitest::Test 5 | include TestHelper 6 | 7 | def teardown 8 | FileUtils.rm_f("todo.rdoc") 9 | end 10 | 11 | def test_help_works 12 | out = run_app("help") 13 | assert_top_level_help(out) 14 | end 15 | 16 | def test_unknown_command_exits_nonzero 17 | out, err, status = run_app("asdfasdfasdf", expect_failure: true, return_err_and_status: true) 18 | assert_match /Unknown command 'asdfasdfasdf'/,err 19 | assert_equal 64, status.exitstatus 20 | assert_top_level_help(out) 21 | end 22 | 23 | def test_unknown_switch_exits_nonzero 24 | out, err, status = run_app("list --foo", expect_failure: true, return_err_and_status: true) 25 | assert_match /Unknown option \-\-foo/,err 26 | assert_equal 64, status.exitstatus 27 | assert_match /COMMAND OPTIONS/, out 28 | end 29 | 30 | def test_missing_args_exits_nonzero 31 | out, err, status = run_app("list", expect_failure: true, return_err_and_status: true) 32 | assert_match /list requires these options: required_flag, required_flag2/,err 33 | assert_equal 64, status.exitstatus 34 | assert_match /COMMAND OPTIONS/, out 35 | end 36 | 37 | def test_doc_generation 38 | out, err, status = run_app("_doc", return_err_and_status: true) 39 | assert File.exist?("todo.rdoc") 40 | end 41 | 42 | private 43 | def assert_top_level_help(out) 44 | assert_match /SYNOPSIS/, out 45 | assert_match /GLOBAL OPTIONS/, out 46 | assert_match /COMMANDS/, out 47 | end 48 | 49 | def run_app(args="", return_err_and_status: false, expect_failure: false) 50 | run_command("test/apps/todo/bin/todo",args,return_err_and_status:return_err_and_status,expect_failure:expect_failure) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /gli.gemspec: -------------------------------------------------------------------------------- 1 | # Make sure we get the gli that's local 2 | require File.join([File.dirname(__FILE__),"lib","gli","version.rb"]) 3 | 4 | spec = Gem::Specification.new do |s| 5 | s.name = "gli" 6 | s.version = GLI::VERSION 7 | s.licenses = ["Apache-2.0"] 8 | s.author = "David Copeland" 9 | s.email = "davidcopeland@naildrivin5.com" 10 | s.homepage = "http://davetron5000.github.io/gli" 11 | s.platform = Gem::Platform::RUBY 12 | s.summary = "Build command-suite CLI apps that are awesome." 13 | s.description = "Build command-suite CLI apps that are awesome. Bootstrap your app, add commands, options and documentation while maintaining a well-tested idiomatic command-line app" 14 | 15 | s.metadata = { 16 | 'bug_tracker_uri' => 'https://github.com/davetron5000/gli/issues', 17 | 'changelog_uri' => 'https://github.com/davetron5000/gli/releases', 18 | 'documentation_uri' => 'https://davetron5000.github.io/gli/rdoc/index.html', 19 | 'homepage_uri' => 'https://davetron5000.github.io/gli/', 20 | 'source_code_uri' => 'https://github.com/davetron5000/gli/', 21 | 'wiki_url' => 'https://github.com/davetron5000/gli/wiki', 22 | } 23 | 24 | s.files = `git ls-files`.split("\n") 25 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 26 | s.require_paths = ["lib"] 27 | 28 | s.extra_rdoc_files = ["README.rdoc", "gli.rdoc"] 29 | s.rdoc_options << "--title" << "Git Like Interface" << "--main" << "README.rdoc" 30 | 31 | s.bindir = "exe" 32 | s.executables = "gli" 33 | 34 | s.add_dependency("ostruct") 35 | 36 | s.add_development_dependency("rake") 37 | s.add_development_dependency("rdoc") 38 | s.add_development_dependency("rainbow", "~> 1.1", "~> 1.1.1") 39 | s.add_development_dependency("sdoc") 40 | s.add_development_dependency("minitest", "~> 5") 41 | end 42 | -------------------------------------------------------------------------------- /test/apps/todo_legacy/bin/todo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # These would not be in a real GLI app; we do this so we can easily run this on the command line 4 | $: << File.expand_path(File.join(File.dirname(__FILE__),'..','..','..','lib')) 5 | $: << File.expand_path(File.join(File.dirname(__FILE__),'..','lib')) 6 | 7 | require 'gli' 8 | require 'todo/version' 9 | 10 | 11 | if ENV['GLI1_COMPATIBILITY'] 12 | include GLI 13 | else 14 | include GLI::App 15 | end 16 | 17 | sort_help (ENV['TODO_SORT_HELP'] || 'alpha').to_sym 18 | wrap_help_text (ENV['TODO_WRAP_HELP_TEXT'] || 'to_terminal').to_sym 19 | 20 | program_desc 'Manages tasks' 21 | program_long_desc "A test program that has a sophisticated UI that can be used to exercise a lot of GLI's power" 22 | 23 | config_file "gli_test_todo.rc" 24 | 25 | flag :flag 26 | switch :switch 27 | switch :otherswitch, :negatable => true 28 | 29 | version Todo::VERSION 30 | 31 | commands_from 'todo/commands' 32 | commands_from File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'todo_plugins', 'commands')) 33 | 34 | command :first do |c| c.action { |g,o,a| puts "first: #{a.join(',')}" } end 35 | command :second do |c| c.action { |g,o,a| puts "second: #{a.join(',')}" } end 36 | 37 | command :chained => [ :first, :second ] 38 | command [:chained2,:ch2] => [ :second, :first ] 39 | 40 | pre do |global,command,options,args| 41 | # Pre logic here 42 | # Return true to proceed; false to abort and not call the 43 | # chosen command 44 | # Use skips_pre before a command to skip this block 45 | # on that command only 46 | true 47 | end 48 | 49 | post do |global,command,options,args| 50 | # Post logic here 51 | # Use skips_post before a command to skip this 52 | # block on that command only 53 | end 54 | 55 | on_error do |exception| 56 | # Error logic here 57 | # return false to skip default error handling 58 | true 59 | end 60 | 61 | exit run(ARGV) 62 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/text_wrapper.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Handles wrapping text 5 | class TextWrapper 6 | # Create a text_wrapper wrapping at the given width, 7 | # and indent. 8 | def initialize(width,indent) 9 | @width = width 10 | @indent = indent 11 | end 12 | 13 | # Return a wrapped version of text, assuming that the first line has already been 14 | # indented by @indent characters. Resulting text does NOT have a newline in it. 15 | def wrap(text) 16 | return text if text.nil? 17 | wrapped_text = '' 18 | current_graf = '' 19 | 20 | paragraphs = text.split(/\n\n+/) 21 | paragraphs.each do |graf| 22 | current_line = '' 23 | current_line_length = @indent 24 | 25 | words = graf.split(/\s+/) 26 | current_line = words.shift || '' 27 | current_line_length += current_line.length 28 | 29 | words.each do |word| 30 | if current_line_length + word.length + 1 > @width 31 | current_graf << current_line << "\n" 32 | current_line = '' 33 | current_line << ' ' * @indent << word 34 | current_line_length = @indent + word.length 35 | else 36 | if current_line == '' 37 | current_line << word 38 | else 39 | current_line << ' ' << word 40 | end 41 | current_line_length += (word.length + 1) 42 | end 43 | end 44 | current_graf << current_line 45 | wrapped_text << current_graf << "\n\n" << ' ' * @indent 46 | current_graf = '' 47 | end 48 | wrapped_text.gsub(/[\n\s]*\Z/,'') 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/flag_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class FlagTest < Minitest::Test 4 | include TestHelper 5 | 6 | def test_basics_simple 7 | setup_for_flag_with_names(:f) 8 | assert_attributes_set 9 | assert_equal(:f,@cli_option.name) 10 | assert_nil @cli_option.aliases 11 | end 12 | 13 | def test_basics_kinda_complex 14 | setup_for_flag_with_names([:f]) 15 | assert_attributes_set 16 | assert_equal(:f,@cli_option.name) 17 | assert_nil @cli_option.aliases 18 | end 19 | 20 | def test_basics_complex 21 | setup_for_flag_with_names([:f,:file,:filename]) 22 | assert_attributes_set 23 | assert_equal(:f,@cli_option.name) 24 | assert_equal [:file,:filename], @cli_option.aliases 25 | assert_equal ["-f VAL","--file VAL","--filename VAL",/foobar/,Float],@flag.arguments_for_option_parser 26 | end 27 | 28 | def test_flag_can_mask_its_value 29 | setup_for_flag_with_names(:password, :mask => true) 30 | assert_attributes_set(:safe_default_value => "********") 31 | end 32 | 33 | def setup_for_flag_with_names(names,options = {}) 34 | @options = { 35 | :desc => 'Filename', 36 | :long_desc => 'The Filename', 37 | :arg_name => 'file', 38 | :default_value => '~/.blah.rc', 39 | :safe_default_value => '~/.blah.rc', 40 | :must_match => /foobar/, 41 | :type => Float, 42 | }.merge(options) 43 | @flag = GLI::Flag.new(names,@options) 44 | @cli_option = @flag 45 | end 46 | 47 | def assert_attributes_set(override={}) 48 | expected = @options.merge(override) 49 | assert_equal(expected[:desc],@flag.description) 50 | assert_equal(expected[:long_desc],@flag.long_description) 51 | assert_equal(expected[:default_value],@flag.default_value) 52 | assert_equal(expected[:safe_default_value],@flag.safe_default_value) 53 | assert_equal(expected[:must_match],@flag.must_match) 54 | assert_equal(expected[:type],@flag.type) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/options_formatter.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | class OptionsFormatter 5 | def initialize(flags_and_switches,sorter,wrapper_class) 6 | @flags_and_switches = sorter.call(flags_and_switches) 7 | @wrapper_class = wrapper_class 8 | end 9 | 10 | def format 11 | list_formatter = ListFormatter.new(@flags_and_switches.map { |option| 12 | if option.respond_to? :argument_name 13 | [option_names_for_help_string(option,option.argument_name),description_with_default(option)] 14 | else 15 | [option_names_for_help_string(option),description_with_default(option)] 16 | end 17 | },@wrapper_class) 18 | stringio = StringIO.new 19 | list_formatter.output(stringio) 20 | stringio.string 21 | end 22 | 23 | private 24 | 25 | def description_with_default(option) 26 | if option.kind_of? Flag 27 | required = option.required? ? 'required, ' : '' 28 | multiple = option.multiple? ? 'may be used more than once, ' : '' 29 | 30 | String(option.description) + " (#{required}#{multiple}default: #{option.safe_default_value || 'none'})" 31 | else 32 | String(option.description) + (option.default_value ? " (default: enabled)" : "") 33 | end 34 | end 35 | 36 | def option_names_for_help_string(option,arg_name=nil) 37 | names = [option.name,Array(option.aliases)].flatten 38 | names = names.map { |name| CommandLineOption.name_as_string(name,option.kind_of?(Switch) ? option.negatable? : false) } 39 | if arg_name.nil? 40 | names.join(', ') 41 | else 42 | if names[-1] =~ /^--/ 43 | names.join(', ') + "=#{arg_name}" 44 | else 45 | names.join(', ') + " #{arg_name}" 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/gli/command_line_token.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Abstract base class for a logical element of a command line, mostly so that subclasses can have similar 3 | # initialization and interface 4 | class CommandLineToken 5 | attr_reader :name #:nodoc: 6 | attr_reader :aliases #:nodoc: 7 | attr_reader :description #:nodoc: 8 | attr_reader :long_description #:nodoc: 9 | 10 | def initialize(names,description,long_description=nil) #:nodoc: 11 | @description = description 12 | @long_description = long_description 13 | @name,@aliases,@names = parse_names(names) 14 | end 15 | 16 | # Sort based on primary name 17 | def <=>(other) 18 | self.name.to_s <=> other.name.to_s 19 | end 20 | 21 | # Array of the name and aliases, as string 22 | def names_and_aliases 23 | [self.name,self.aliases].flatten.compact.map(&:to_s) 24 | end 25 | 26 | private 27 | # Returns a string of all possible forms 28 | # of this flag. Mostly intended for printing 29 | # to the user. 30 | def all_forms(joiner=', ') 31 | forms = all_forms_a 32 | forms.join(joiner) 33 | end 34 | 35 | 36 | # Handles dealing with the "names" param, parsing 37 | # it into the primary name and aliases list 38 | def parse_names(names) 39 | # Allow strings; convert to symbols 40 | names = [names].flatten.map { |name| name.to_sym } 41 | names_hash = {} 42 | names.each do |name| 43 | raise ArgumentError.new("#{name} has spaces; they are not allowed") if name.to_s =~ /\s/ 44 | names_hash[self.class.name_as_string(name)] = true 45 | end 46 | name = names.shift 47 | aliases = names.length > 0 ? names : nil 48 | [name,aliases,names_hash] 49 | end 50 | 51 | def negatable? 52 | false; 53 | end 54 | 55 | def all_forms_a 56 | forms = [self.class.name_as_string(name,negatable?)] 57 | if aliases 58 | forms |= aliases.map { |one_alias| self.class.name_as_string(one_alias,negatable?) }.sort { |one,two| one.length <=> two.length } 59 | end 60 | forms 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/integration/gli_cli_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require "open3" 3 | 4 | class GLICLITest < Minitest::Test 5 | include TestHelper 6 | 7 | class AppHelp < GLICLITest 8 | def test_running_with_no_options_produces_help 9 | out = run_gli 10 | assert_output_looks_like_help out 11 | end 12 | 13 | def test_running_with_help_command_produces_help 14 | out = run_gli("help") 15 | assert_output_looks_like_help out 16 | end 17 | 18 | def test_running_with_help_switch_produces_help 19 | out = run_gli("--help") 20 | assert_output_looks_like_help out 21 | end 22 | 23 | private 24 | 25 | def assert_output_looks_like_help(out) 26 | assert_match /gli - create scaffolding for a GLI-powered application/,out 27 | assert_match /SYNOPSIS/,out 28 | assert_match /GLOBAL OPTIONS/,out 29 | assert_match /COMMANDS/,out 30 | end 31 | 32 | end 33 | 34 | class Scaffolding < GLICLITest 35 | def test_help_on_scaffold_command 36 | out = run_gli("help scaffold") 37 | assert_output_looks_like_help(out) 38 | end 39 | def test_help_on_scaffold_command_as_init 40 | out = run_gli("help init") 41 | assert_output_looks_like_help(out) 42 | end 43 | 44 | private 45 | 46 | def assert_output_looks_like_help(out) 47 | assert_match /init - Create a new GLI-based project/,out 48 | assert_match /SYNOPSIS/,out 49 | assert_match /COMMAND OPTIONS/,out 50 | end 51 | end 52 | 53 | private 54 | 55 | def run_gli(args="", return_err_and_status: false, expect_failure: false) 56 | command_line_invocation = "bin/gli #{args}" 57 | stdout_string, stderr_string, status = Open3.capture3(command_line_invocation) 58 | if expect_failure 59 | refute_equal 0,status.exitstatus,"Expected failure for '#{command_line_invocation}' but it succeeded" 60 | else 61 | assert_equal 0,status.exitstatus,"Expected success for '#{command_line_invocation}' but it failed" 62 | end 63 | if return_err_and_status 64 | [ stdout_string, stderr_string, status ] 65 | else 66 | stdout_string 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /exe/gli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'gli' 4 | require 'gli/commands/scaffold' 5 | 6 | class App 7 | extend GLI::App 8 | 9 | program_desc 'create scaffolding for a GLI-powered application' 10 | 11 | version GLI::VERSION 12 | 13 | # Can't use these without changing the current behavior of gli 14 | # arguments :strict 15 | # subcommand_option_handling :normal 16 | 17 | switch :v, :desc => 'Be verbose' 18 | 19 | switch :n, :desc => 'Dry run; don''t change the disk' 20 | 21 | desc 'Root dir of project' 22 | long_desc < '.' 29 | 30 | desc 'Create a new GLI-based project' 31 | long_desc < 'Create an ext dir' 43 | 44 | c.switch :notest, :desc => 'Do not create a test or features dir', :negatable => false 45 | 46 | c.switch :force, :desc => 'Overwrite/ignore existing files and directories' 47 | 48 | c.switch :rvmrc, :desc => 'Create an .rvmrc based on your current RVM setup' 49 | 50 | c.action do |g,o,args| 51 | if args.length < 1 52 | raise 'You must specify the name of your project' 53 | end 54 | GLI::Commands::Scaffold.create_scaffold(g[:r],!o[:notest],o[:e],args[0],args[1..-1],o[:force],g[:n],o[:rvmrc]) 55 | end 56 | end 57 | 58 | pre do |global,command,options,args| 59 | puts "Executing #{command.name}" if global[:v] 60 | true 61 | end 62 | 63 | post do |global,command,options,args| 64 | puts "Executed #{command.name}" if global[:v] 65 | end 66 | end 67 | 68 | exit App.run(ARGV) 69 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/command_finder.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | # Finds commands from the application/command data structures 5 | class CommandFinder 6 | 7 | attr_reader :last_unknown_command 8 | attr_reader :last_found_command 9 | attr_writer :squelch_stderr 10 | 11 | def initialize(app,arguments,error) 12 | @app = app 13 | @arguments = arguments 14 | @error = error 15 | @squelch_stderr = false 16 | @last_unknown_command = nil 17 | end 18 | 19 | def find_command(name) 20 | command = find_command_from_base(name,@app) 21 | return if unknown_command?(command,name,@error) 22 | @last_found_command = command 23 | while !@arguments.empty? 24 | name = @arguments.shift 25 | command = find_command_from_base(name,command) 26 | return if unknown_command?(command,name,@error) 27 | @last_found_command = command 28 | end 29 | command 30 | end 31 | 32 | private 33 | 34 | # Given the name of a command to find, and a base, either the app or another command, returns 35 | # the command object or nil. 36 | def find_command_from_base(command_name,base) 37 | base.commands.values.select { |command| 38 | if [command.name,Array(command.aliases)].flatten.map(&:to_s).any? { |_| _ == command_name } 39 | command 40 | end 41 | }.first 42 | end 43 | 44 | # Checks if the return from find_command was unknown and, if so, prints an error 45 | # for the user on the error device, returning true or false if the command was unknown. 46 | def unknown_command?(command,name,error) 47 | if command.nil? 48 | @last_unknown_command = name 49 | unless @squelch_stderr 50 | error.puts "error: Unknown command '#{name}'. Use '#{@app.exe_name} help' for a list of commands." 51 | end 52 | true 53 | else 54 | false 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/apps/todo/bin/todo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # These would not be in a real GLI app; we do this so we can easily run this on the command line 4 | $: << File.expand_path(File.join(File.dirname(__FILE__),'..','..','..','..','lib')) 5 | $: << File.expand_path(File.join(File.dirname(__FILE__),'..','lib')) 6 | 7 | require 'gli' 8 | require 'todo/version' 9 | 10 | class App 11 | if ENV['GLI1_COMPATIBILITY'] 12 | extend GLI 13 | else 14 | extend GLI::App 15 | end 16 | 17 | sort_help (ENV['TODO_SORT_HELP'] || 'alpha').to_sym 18 | wrap_help_text (ENV['TODO_WRAP_HELP_TEXT'] || 'to_terminal').to_sym 19 | synopsis_format (ENV['SYNOPSES'] || 'full').to_sym 20 | hide_commands_without_desc ENV['HIDE_COMMANDS_WITHOUT_DESC'] === 'true' 21 | 22 | subcommand_option_handling :normal 23 | arguments :strict 24 | 25 | program_desc 'Manages tasks' 26 | program_long_desc "A test program that has a sophisticated UI that can be used to exercise a lot of GLI's power" 27 | 28 | config_file "gli_test_todo.rc" 29 | 30 | flag :flag 31 | switch :switch 32 | switch :otherswitch, :negatable => true 33 | 34 | version Todo::VERSION 35 | 36 | commands_from 'todo/commands' 37 | commands_from File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'todo_plugins', 'commands')) 38 | 39 | arg_name :argument, :optional 40 | command :first do |c| c.action { |g,o,a| puts "first: #{a.join(',')}" } end 41 | 42 | arg :argument, :optional 43 | command :second do |c| c.action { |g,o,a| puts "second: #{a.join(',')}" } end 44 | 45 | arg :first 46 | arg :second 47 | command :chained => [ :first, :second ] 48 | 49 | arg :first 50 | arg :second 51 | command [:chained2,:ch2] => [ :second, :first ] 52 | 53 | pre do |global,command,options,args| 54 | # Pre logic here 55 | # Return true to proceed; false to abort and not call the 56 | # chosen command 57 | # Use skips_pre before a command to skip this block 58 | # on that command only 59 | true 60 | end 61 | 62 | post do |global,command,options,args| 63 | # Post logic here 64 | # Use skips_post before a command to skip this 65 | # block on that command only 66 | end 67 | 68 | on_error do |exception| 69 | # Error logic here 70 | # return false to skip default error handling 71 | true 72 | end 73 | end 74 | exit App.run(ARGV) 75 | -------------------------------------------------------------------------------- /lib/gli/command_finder.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | class CommandFinder 3 | attr_accessor :options 4 | 5 | DEFAULT_OPTIONS = { 6 | :default_command => nil, 7 | :autocomplete => true 8 | } 9 | 10 | def initialize(commands, options = {}) 11 | self.options = DEFAULT_OPTIONS.merge(options) 12 | self.commands_with_aliases = expand_with_aliases(commands) 13 | end 14 | 15 | def find_command(name) 16 | name = String(name || options[:default_command]).strip 17 | raise UnknownCommand.new("No command name given nor default available") if name == '' 18 | 19 | command_found = commands_with_aliases.fetch(name) do |command_to_match| 20 | if options[:autocomplete] 21 | found_match = find_command_by_partial_name(commands_with_aliases, command_to_match) 22 | if found_match.kind_of? GLI::Command 23 | if ENV["GLI_DEBUG"] == 'true' 24 | $stderr.puts "Using '#{name}' as it's is short for #{found_match.name}." 25 | $stderr.puts "Set autocomplete false for any command you don't want matched like this" 26 | end 27 | elsif found_match.kind_of?(Array) && !found_match.empty? 28 | raise AmbiguousCommand.new("Ambiguous command '#{name}'. It matches #{found_match.sort.join(',')}") 29 | end 30 | found_match 31 | end 32 | end 33 | 34 | raise UnknownCommand.new("Unknown command '#{name}'") if Array(command_found).empty? 35 | command_found 36 | end 37 | 38 | private 39 | attr_accessor :commands_with_aliases 40 | 41 | def expand_with_aliases(commands) 42 | expanded = {} 43 | commands.each do |command_name, command| 44 | expanded[command_name.to_s] = command 45 | Array(command.aliases).each do |command_alias| 46 | expanded[command_alias.to_s] = command 47 | end 48 | end 49 | expanded 50 | end 51 | 52 | def find_command_by_partial_name(commands_with_aliases, command_to_match) 53 | partial_matches = commands_with_aliases.keys.select { |command_name| command_name =~ /^#{command_to_match}/ } 54 | return commands_with_aliases[partial_matches[0]] if partial_matches.size == 1 55 | partial_matches 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/global_help_format.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | 3 | module GLI 4 | module Commands 5 | module HelpModules 6 | class GlobalHelpFormat 7 | def initialize(app,sorter,wrapper_class) 8 | @app = app 9 | @sorter = sorter 10 | @wrapper_class = wrapper_class 11 | end 12 | 13 | def format 14 | program_desc = @app.program_desc 15 | program_long_desc = @app.program_long_desc 16 | if program_long_desc 17 | wrapper = @wrapper_class.new(Terminal.instance.size[0],4) 18 | program_long_desc = "\n #{wrapper.wrap(program_long_desc)}\n\n" if program_long_desc 19 | else 20 | program_long_desc = "\n" 21 | end 22 | 23 | command_formatter = ListFormatter.new(@sorter.call(@app.commands_declaration_order.reject(&:nodoc)).map { |command| 24 | [[command.name,Array(command.aliases)].flatten.join(', '),command.description] 25 | }, @wrapper_class) 26 | stringio = StringIO.new 27 | command_formatter.output(stringio) 28 | commands = stringio.string 29 | 30 | global_option_descriptions = OptionsFormatter.new(global_flags_and_switches,@sorter,@wrapper_class).format 31 | 32 | GLOBAL_HELP.result(binding) 33 | end 34 | 35 | private 36 | 37 | GLOBAL_HELP = ERB.new(%q(NAME 38 | <%= @app.exe_name %> - <%= program_desc %> 39 | <%= program_long_desc %> 40 | SYNOPSIS 41 | <%= usage_string %> 42 | 43 | <% unless @app.version_string.nil? %> 44 | VERSION 45 | <%= @app.version_string %> 46 | 47 | <% end %><% unless global_flags_and_switches.empty? %> 48 | GLOBAL OPTIONS 49 | <%= global_option_descriptions %> 50 | <% end %> 51 | COMMANDS 52 | <%= commands %>)) 53 | 54 | def global_flags_and_switches 55 | @app.flags_declaration_order + @app.switches_declaration_order 56 | end 57 | 58 | def usage_string 59 | "#{@app.exe_name} ".tap do |string| 60 | string << "[global options] " unless global_flags_and_switches.empty? 61 | string << "command " 62 | string << "[command options] [arguments...]" 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/apps/todo/lib/todo/commands/list.rb: -------------------------------------------------------------------------------- 1 | class App 2 | desc "List things, such as tasks or contexts" 3 | long_desc %( 4 | List a whole lot of things that you might be keeping track of 5 | in your overall todo list. 6 | 7 | This is your go-to place or finding all of the things that you 8 | might have 9 | stored in 10 | your todo databases. 11 | ) 12 | 13 | command [:list] do |c| 14 | c.default_command :tasks 15 | 16 | c.desc "Show long form" 17 | c.switch [:l,:long] 18 | 19 | c.flag :required_flag, :required => true 20 | c.flag :required_flag2, :required => true 21 | 22 | c.desc "List tasks" 23 | c.long_desc %( 24 | Lists all of your tasks that you have, in varying orders, and 25 | all that stuff. Yes, this is long, but I need a long description. 26 | ) 27 | 28 | c.arg :task, [:optional, :multiple] 29 | c.command :tasks do |tasks| 30 | tasks.desc "blah blah crud x whatever" 31 | tasks.flag [:x], :must_match => Array 32 | 33 | tasks.flag :flag 34 | 35 | tasks.action do |global,options,args| 36 | puts options[:x].inspect 37 | puts "list tasks: #{args.join(',')}" 38 | end 39 | 40 | tasks.desc 'list open tasks' 41 | tasks.command :open do |open| 42 | open.desc "blah blah crud x whatever" 43 | open.flag [:x], :must_match => Array 44 | 45 | open.flag :flag 46 | 47 | open.example "todo list tasks open --flag=blah", desc: "example number 1" 48 | open.example "todo list tasks open -x foo" 49 | 50 | open.action do |global,options,args| 51 | puts "tasks open" 52 | end 53 | end 54 | 55 | tasks.default_desc 'list all tasks' 56 | end 57 | 58 | c.desc "List contexts" 59 | c.long_desc %( 60 | Lists all of your contexts, which are places you might be 61 | where you can do stuff and all that. 62 | ) 63 | c.command :contexts do |contexts| 64 | 65 | contexts.desc "Foobar" 66 | contexts.switch [:f,'foobar'] 67 | 68 | contexts.desc "Blah" 69 | contexts.switch [:b] 70 | 71 | contexts.flag :otherflag 72 | 73 | contexts.action do |global,options,args| 74 | puts "list contexts: #{args.join(',')}" 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /dx/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd ) 6 | 7 | . "${SCRIPT_DIR}/dx.sh.lib" 8 | 9 | require_command "docker" 10 | load_docker_compose_env 11 | 12 | usage_on_help "Builds the Docker image based on the Dockerfile" "" "build.pre" "build.post" "${@}" 13 | 14 | for ruby_version in ${RUBY_VERSIONS[@]}; do 15 | dockerfile="Dockerfile.dx" 16 | docker_image_name="${IMAGE}:ruby-${ruby_version}" 17 | 18 | log "Building for Ruby '${ruby_version}' using Docker image name '${docker_image_name}'" 19 | 20 | exec_hook_if_exists "build.pre" "${dockerfile}" "${docker_image_name}" 21 | 22 | docker build \ 23 | --file "${dockerfile}" \ 24 | --build-arg="RUBY_VERSION=${ruby_version}" \ 25 | --tag "${docker_image_name}" \ 26 | ./ 27 | 28 | exec_hook_if_exists "build.post" "${dockerfile}" "${docker_image_name}" 29 | log "🌈" "Your Docker image has been built tagged '${docker_image_name}'" 30 | done 31 | 32 | log "✅" "All images built" 33 | 34 | log "✨" "Creating docker-compose.dx.yml" 35 | compose_file="docker-compose.dx.yml" 36 | log "🗑️" "Deleting previous ${compose_file}" 37 | 38 | rm -f "${compose_file}" 39 | echo "# THIS IS GENERATED - DO NOT EDIT" > "${compose_file}" 40 | echo "# To make changes see dx/build or dx/docker-compose.env" >> "${compose_file}" 41 | echo "" >> "${compose_file}" 42 | echo "services:" >> "${compose_file}" 43 | 44 | for ruby_version in ${RUBY_VERSIONS[@]}; do 45 | log "Generating stanza for version '${ruby_version}'" 46 | docker_image_name="${IMAGE}:ruby-${ruby_version}" 47 | echo " gli-${ruby_version}:" >> "${compose_file}" 48 | echo " image: ${docker_image_name}" >> "${compose_file}" 49 | echo " init: true" >> "${compose_file}" 50 | echo " volumes:" >> "${compose_file}" 51 | echo " - type: bind" >> "${compose_file}" 52 | echo " source: \"./\"" >> "${compose_file}" 53 | echo " target: \"/root/work\"" >> "${compose_file}" 54 | echo " consistency: \"consistent\"" >> "${compose_file}" 55 | echo " entrypoint: /root/show-help-in-app-container-then-wait.sh" >> "${compose_file}" 56 | echo " working_dir: /root/work" >> "${compose_file}" 57 | done 58 | log "🎼" "${compose_file} is now created" 59 | log "🔄" "You can run dx/start to start it up, though you may need to stop it first with Ctrl-C" 60 | 61 | # vim: ft=bash 62 | -------------------------------------------------------------------------------- /lib/gli/commands/initconfig.rb: -------------------------------------------------------------------------------- 1 | require 'gli' 2 | require 'gli/command' 3 | require 'yaml' 4 | require 'fileutils' 5 | 6 | module GLI 7 | # Command that initializes the configuration file for apps that use it. 8 | class InitConfig < Command # :nodoc: 9 | COMMANDS_KEY = 'commands' 10 | 11 | def initialize(config_file_name,commands,flags,switches) 12 | @filename = config_file_name 13 | super(:names => :initconfig, 14 | :description => "Initialize the config file using current global options", 15 | :long_desc => 'Initializes a configuration file where you can set default options for command line flags, both globally and on a per-command basis. These defaults override the built-in defaults and allow you to omit commonly-used command line flags when invoking this program', 16 | :skips_pre => true,:skips_post => true, :skips_around => true) 17 | 18 | @app_commands = commands 19 | @app_flags = flags 20 | @app_switches = switches 21 | 22 | self.desc 'force overwrite of existing config file' 23 | self.switch :force 24 | 25 | action do |global_options,options,arguments| 26 | if options[:force] || !File.exist?(@filename) 27 | create_config(global_options,options,arguments) 28 | else 29 | raise "Not overwriting existing config file #{@filename}, use --force to override" 30 | end 31 | end 32 | end 33 | 34 | 35 | private 36 | 37 | def create_config(global_options,options,arguments) 38 | config = Hash[(@app_switches.keys + @app_flags.keys).map { |option_name| 39 | option_value = global_options[option_name] 40 | [option_name,option_value] 41 | }] 42 | config[COMMANDS_KEY] = {} 43 | @app_commands.each do |name,command| 44 | if (command != self) && (name != :rdoc) && (name != :help) 45 | if command != self 46 | config[COMMANDS_KEY][name.to_sym] = config_for_command(@app_commands,name.to_sym) 47 | end 48 | end 49 | end 50 | 51 | FileUtils.mkdir_p(File.dirname(@filename)) unless File.dirname(@filename) == '.' 52 | 53 | File.open(@filename,'w', 0600) do |file| 54 | YAML.dump(config,file) 55 | puts "Configuration file '#{@filename}' written." 56 | end 57 | end 58 | 59 | def config_for_command(commands,command_name) 60 | {}.tap do |hash| 61 | subcommands = commands[command_name].commands 62 | subcommands.each do |name,subcommand| 63 | next unless name.kind_of? Symbol 64 | hash[COMMANDS_KEY] ||= {} 65 | puts "#{command_name}:#{name}" 66 | hash[COMMANDS_KEY][name.to_sym] = config_for_command(subcommands,name) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing/Developing 2 | 3 | ## Types of Contributions 4 | 5 | GLI is relatively stable. As such: 6 | 7 | * No major new features should be added 8 | * No refactorings or "modernization" is desired 9 | * No runtime dependencies should be added. 10 | 11 | If you really want to make a change like this, open an issue first to discuss. 12 | 13 | That said, I welcome: 14 | 15 | * Bugfixes 16 | * Doc changes 17 | * Minor improvements 18 | 19 | I am responsive on issues, so reach out there if you have a question. 20 | 21 | ## How to Do Development 22 | 23 | GLI us using a Docker-based development system to ensure consistency. To use it, you will need to have Docker installed, as well 24 | as Bash. This should all work under Windows Subsystem for Linux. 25 | 26 | ### Setting Up 27 | 28 | 1. Install Docker 29 | 2. `dx/build` 30 | 31 | This will use `Dockerfile.dx` to create one Docker image for each supported Ruby version (see `dx/docker-compose.env`'s `RUBY_VERSIONS` variable for the current list). This will also generate `docker-compose.dx.yml` which will run all versions of Ruby for GLI at the same time. 32 | 3. `dx/start` 33 | 34 | This will start Docker using `docker-compose.dx.yml`, which will run containers for all the images generated by `dx/build` 35 | 36 | 4. From here, you can run commands inside the running containers, or you can run `bash` inside a container to effectively "log 37 | in" and run commands. 38 | 39 | ### Doing Development 40 | 41 | Once you are set up, you should run `dx/exec bash`. This will log you into the container running the oldest supported version of 42 | Ruby. This is the version where you should do your basic work. This container has access to the source code you cloned from 43 | GitHub. 44 | 45 | 1. `on-your-computer> dx/exec bash` 46 | 2. `inside-docker-container> bin/setup # installs all gems` 47 | 3. `inside-docker-container> bin/rake # runs all tests` 48 | 49 | Once you have stuff working, run tests on the other versions. Since the container you were working in was operating on your 50 | checked-out files, the other containers will have access as well. 51 | 52 | 1. `on-your-computer> dx/exec -v 3.3 bash # connects to the conatiner where Ruby 3.3 is installed` 53 | 2. `inside-docker-container> bin/setup # installs all gems` 54 | 3. `inside-docker-container> bin/rake # runs all tests` 55 | 56 | You can also try using `bin/ci` on your computer (not inside a Docker container), which will run tests across the entire build 57 | matrix. 58 | 59 | #### If You Want To Use Your Local Computer 60 | 61 | In theory, GLI can be worked on using RVM, RBEnv, asdf, or whatever other way you want to manage Ruby. You can create a 62 | `.ruby-version` or `.tool-versions`. These are ignored so you can keep them on your machine. I don't support this setup, so if 63 | something is wrong, I probably can't help. This is why I have it set up with Docker. 64 | -------------------------------------------------------------------------------- /gli.cheat: -------------------------------------------------------------------------------- 1 | gli - create command-suite apps, a la git, using this awesome Ruby DSL 2 | ====================================================================== 3 | 4 | Setup and Usage 5 | --------------- 6 | 7 | Installation: 8 | $ gem install gli 9 | 10 | Show list of commands 11 | $ gli help [command] 12 | 13 | Show help for one command 14 | $ gli help init 15 | 16 | Create a new GLI-based project with the commands foo and bar 17 | $ gli init project_name foo bar 18 | 19 | Create a new GLI-based project with an ext directory 20 | $ gli init -e project_name foo bar 21 | 22 | Create a new GLI-based project without a test directory (bad!) 23 | $ gli init --notest project_name foo bar 24 | 25 | Create a new GLI-based project somewhere else than . 26 | $ gli -r /tmp init project_name foo bar 27 | 28 | Just see what GLI would create 29 | $ gli -n init -e project_name foo bar 30 | 31 | 32 | Using GLI's DSL 33 | --------------- 34 | 35 | Create a switch (option that takes no args) 36 | 37 | desc 'Dry-run; don't change the disk 38 | switch [:n,'dry-run'] 39 | # Both -n and --dry-run will work 40 | * --no-dry-run will set the switch to false 41 | # Access in code via global_options[:n] 42 | # or global_options[:'dry-run'] 43 | 44 | Create it on one line 45 | switch :n,'dry-run', :desc => 'Dry-run; don't change the disk 46 | 47 | Don't create a negatable version 48 | switch :n,'dry-run', :negatable => false, :desc => 'Dry-run; don't change the disk 49 | # --no-dry-run is not accepted 50 | 51 | Create a flag (option that takes an argument) 52 | 53 | desc 'Location of the config file' 54 | arg_name 'path_to_file' 55 | default_value '~/.glirc' 56 | flag [:c,:conf], :must_match => /^\..*rc$/ 57 | # Both -c and --conf work, if this flag is omitted 58 | # then the default of ~/.glirc is avaialble to the code 59 | # The argument must match the given regex 60 | # Access in code via global_options[:c] (or :conf) 61 | 62 | Create a flag in a more compact style 63 | 64 | flag :c,:conf, :desc => 'Location of the config file', 65 | :arg_name => 'path_to_file', :default_value => '~/.glirc' 66 | 67 | Create a command 68 | 69 | desc 'Get the list of open tickets' 70 | command [:tickets] do |c| 71 | c.desc 'Only assigned to me' 72 | c.switch [:m,:me] 73 | 74 | c.desc 'Show only tickets for one project' 75 | c.flag [:p,:project,'only-project'] 76 | 77 | c.action do |global_options,options,args| 78 | # global_options has the global options as a hash 79 | # options are the command specific ones (e.g. options[:p]) 80 | # args are the command line arguments that weren't parsed 81 | # raise an exception or exit_now! if things go wrong 82 | end 83 | end 84 | 85 | Set up stuff ahead of time 86 | 87 | pre do |global_options,command,options,args| 88 | return true if things_are_ok 89 | return false if we_should_abort_the_command 90 | end 91 | 92 | Use GLI 93 | 94 | exit run(ARGV) 95 | # run returns a suitable exit status 96 | -------------------------------------------------------------------------------- /lib/gli/option_parser_factory.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Factory for creating an OptionParser based on app configuration and DSL calls 3 | class OptionParserFactory 4 | 5 | # Create an option parser factory for a command. This has the added 6 | # feature of setting up -h and --help on the command if those 7 | # options aren't otherwise configured, e.g. to allow todo add --help as an 8 | # alternate to todo help add 9 | def self.for_command(command,accepts) 10 | self.new(command.flags,command.switches,accepts).tap { |factory| 11 | add_help_switches_to_command(factory.option_parser,command) 12 | } 13 | end 14 | 15 | # Create an OptionParserFactory for the given 16 | # flags, switches, and accepts 17 | def initialize(flags,switches,accepts) 18 | @flags = flags 19 | @switches = switches 20 | @options_hash = {} 21 | @option_parser = OptionParser.new do |opts| 22 | self.class.setup_accepts(opts,accepts) 23 | self.class.setup_options(opts,@switches,@options_hash) 24 | self.class.setup_options(opts,@flags,@options_hash) 25 | end 26 | end 27 | 28 | attr_reader :option_parser 29 | attr_reader :options_hash 30 | 31 | def options_hash_with_defaults_set! 32 | set_defaults(@flags,@options_hash) 33 | set_defaults(@switches,@options_hash) 34 | @options_hash 35 | end 36 | 37 | private 38 | 39 | def set_defaults(options_by_name,options_hash) 40 | options_by_name.values.each do |option| 41 | option.names_and_aliases.each do |option_name| 42 | [option_name,option_name.to_sym].each do |name| 43 | options_hash[name] = option.default_value if options_hash[name].nil? 44 | end 45 | end 46 | end 47 | end 48 | 49 | def self.setup_accepts(opts,accepts) 50 | accepts.each do |object,block| 51 | opts.accept(object) do |arg_as_string| 52 | block.call(arg_as_string) 53 | end 54 | end 55 | end 56 | 57 | def self.setup_options(opts,tokens,options) 58 | tokens.each do |ignore,token| 59 | opts.on(*token.arguments_for_option_parser) do |arg| 60 | token.names_and_aliases.each do |name| 61 | if token.kind_of?(Flag) && token.multiple? 62 | options[name] ||= [] 63 | options[name.to_sym] ||= [] 64 | options[name] << arg 65 | options[name.to_sym] << arg 66 | else 67 | options[name] = arg 68 | options[name.to_sym] = arg 69 | end 70 | end 71 | end 72 | end 73 | end 74 | 75 | def self.add_help_switches_to_command(option_parser,command) 76 | help_args = %w(-h --help).reject { |_| command.has_option?(_) } 77 | 78 | unless help_args.empty? 79 | help_args << "Get help for #{command.name}" 80 | option_parser.on(*help_args) do 81 | raise RequestHelp.new(command) 82 | end 83 | end 84 | end 85 | 86 | 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/gli/gli_option_block_parser.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # An "option block" is a set of parseable options, starting from the beginning of 3 | # the argument list, stopping with the first unknown command-line element. 4 | # This class handles parsing that block 5 | class GLIOptionBlockParser 6 | 7 | # Create the parser using the given +OptionParser+ instance and exception handling 8 | # strategy. 9 | # 10 | # option_parser_factory:: An +OptionParserFactory+ instance, configured to parse wherever you are on the command line 11 | # exception_klass_or_block:: means of handling exceptions from +OptionParser+. One of: 12 | # an exception class:: will be raised on errors with a message 13 | # lambda/block:: will be called with a single argument - the error message. 14 | def initialize(option_parser_factory,exception_klass_or_block) 15 | @option_parser_factory = option_parser_factory 16 | @extra_error_context = nil 17 | @exception_handler = if exception_klass_or_block.kind_of?(Class) 18 | lambda { |message,extra_error_context| 19 | raise exception_klass_or_block,message 20 | } 21 | else 22 | exception_klass_or_block 23 | end 24 | end 25 | 26 | # Parse the given argument list, returning the unparsed arguments and options hash of parsed arguments. 27 | # Exceptions from +OptionParser+ are given to the handler configured in the constructor 28 | # 29 | # args:: argument list. This will be mutated 30 | # 31 | # Returns unparsed args 32 | def parse!(args) 33 | do_parse(args) 34 | rescue OptionParser::InvalidOption => ex 35 | @exception_handler.call("Unknown option #{ex.args.join(' ')}",@extra_error_context) 36 | rescue OptionParser::InvalidArgument => ex 37 | @exception_handler.call("#{ex.reason}: #{ex.args.join(' ')}",@extra_error_context) 38 | end 39 | 40 | protected 41 | 42 | def do_parse(args) 43 | first_non_option = nil 44 | @option_parser_factory.option_parser.order!(args) do |non_option| 45 | first_non_option = non_option 46 | break 47 | end 48 | args.unshift(first_non_option) 49 | end 50 | end 51 | 52 | class CommandOptionBlockParser < GLIOptionBlockParser 53 | 54 | def command=(command_being_parsed) 55 | @extra_error_context = command_being_parsed 56 | end 57 | 58 | protected 59 | 60 | def break_on_non_option? 61 | true 62 | end 63 | 64 | def do_parse(args) 65 | unknown_options = [] 66 | @option_parser_factory.option_parser.order!(args) do |non_option| 67 | unknown_options << non_option 68 | break if break_on_non_option? 69 | end 70 | unknown_options.reverse.each do |unknown_option| 71 | args.unshift(unknown_option) 72 | end 73 | args 74 | end 75 | end 76 | 77 | class LegacyCommandOptionBlockParser < CommandOptionBlockParser 78 | 79 | protected 80 | def break_on_non_option? 81 | false 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/command_help_format.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | 3 | module GLI 4 | module Commands 5 | module HelpModules 6 | class CommandHelpFormat 7 | def initialize(command,app,sorter,synopsis_formatter_class,wrapper_class=TextWrapper) 8 | @app = app 9 | @command = command 10 | @sorter = sorter 11 | @wrapper_class = wrapper_class 12 | @synopsis_formatter = synopsis_formatter_class.new(@app,flags_and_switches(@command,@app)) 13 | end 14 | 15 | def format 16 | command_wrapper = @wrapper_class.new(Terminal.instance.size[0],4 + @command.name.to_s.size + 3) 17 | wrapper = @wrapper_class.new(Terminal.instance.size[0],4) 18 | 19 | options_description = OptionsFormatter.new(flags_and_switches(@command,@app),@sorter,@wrapper_class).format 20 | commands_description = format_subcommands(@command) 21 | command_examples = format_examples(@command) 22 | 23 | synopses = @synopsis_formatter.synopses_for_command(@command) 24 | COMMAND_HELP.result(binding) 25 | end 26 | 27 | private 28 | COMMAND_HELP = ERB.new(%q(NAME 29 | <%= @command.name %> - <%= command_wrapper.wrap(@command.description) %> 30 | 31 | SYNOPSIS 32 | <% synopses.each do |s| %> 33 | <%= s %> 34 | <% end %><% unless @command.long_description.nil? %> 35 | 36 | DESCRIPTION 37 | <%= wrapper.wrap(@command.long_description) %> 38 | <% end %><% if options_description.strip.length != 0 %> 39 | COMMAND OPTIONS 40 | <%= options_description %> 41 | <% end %><% unless @command.commands.empty? %> 42 | COMMANDS 43 | <%= commands_description %> 44 | <% end %><% unless @command.examples.empty? %> 45 | <%= @command.examples.size == 1 ? 'EXAMPLE' : 'EXAMPLES' %> 46 | 47 | <%= command_examples %> 48 | <% end %>)) 49 | 50 | 51 | def flags_and_switches(command,app) 52 | if app.subcommand_option_handling_strategy == :legacy 53 | ( 54 | command.topmost_ancestor.flags_declaration_order + 55 | command.topmost_ancestor.switches_declaration_order 56 | ).select { |option| option.associated_command == command } 57 | else 58 | ( 59 | command.flags_declaration_order + 60 | command.switches_declaration_order 61 | ) 62 | end 63 | end 64 | 65 | def format_subcommands(command) 66 | commands_array = @sorter.call(command.commands_declaration_order).map { |cmd| 67 | if command.get_default_command == cmd.name 68 | [cmd.names,String(cmd.description) + " (default)"] 69 | else 70 | [cmd.names,cmd.description] 71 | end 72 | } 73 | if command.has_action? 74 | commands_array.unshift(["",command.default_description]) 75 | end 76 | formatter = ListFormatter.new(commands_array,@wrapper_class) 77 | StringIO.new.tap { |io| formatter.output(io) }.string 78 | end 79 | 80 | def format_examples(command) 81 | command.examples.map {|example| 82 | string = "" 83 | if example[:desc] 84 | string << " # #{example[:desc]}\n" 85 | end 86 | string << " #{example.fetch(:example)}\n" 87 | }.join("\n") 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/gli/flag.rb: -------------------------------------------------------------------------------- 1 | require 'gli/command_line_option.rb' 2 | 3 | module GLI 4 | # Defines a flag, which is to say a switch that takes an argument 5 | class Flag < CommandLineOption # :nodoc: 6 | 7 | # Regexp that is used to see if the flag's argument matches 8 | attr_reader :must_match 9 | 10 | # Type to which we want to cast the values 11 | attr_reader :type 12 | 13 | # Name of the argument that user configured 14 | attr_reader :argument_name 15 | 16 | # Creates a new option 17 | # 18 | # names:: Array of symbols or strings representing the names of this switch 19 | # options:: hash of options: 20 | # :desc:: the short description 21 | # :long_desc:: the long description 22 | # :default_value:: the default value of this option 23 | # :arg_name:: the name of the flag's argument, default is "arg" 24 | # :must_match:: a regexp that the flag's value must match 25 | # :type:: a class to convert the value to 26 | # :required:: if true, this flag must be specified on the command line 27 | # :multiple:: if true, flag may be used multiple times and values are stored in an array 28 | # :mask:: if true, the default value of this flag will not be output in the help. 29 | # This is useful for password flags where you might not want to show it 30 | # on the command-line. 31 | def initialize(names,options) 32 | super(names,options) 33 | @argument_name = options[:arg_name] || "arg" 34 | @must_match = options[:must_match] 35 | @type = options[:type] 36 | @mask = options[:mask] 37 | @required = options[:required] 38 | @multiple = options[:multiple] 39 | end 40 | 41 | # True if this flag is required on the command line 42 | def required? 43 | @required 44 | end 45 | 46 | # True if the flag may be used multiple times. 47 | def multiple? 48 | @multiple 49 | end 50 | 51 | def safe_default_value 52 | if @mask 53 | "********" 54 | else 55 | # This uses @default_value instead of the `default_value` method because 56 | # this method is only used for display, and for flags that may be passed 57 | # multiple times, we want to display whatever is set in the code as the 58 | # the default, or the string "none" rather than displaying an empty 59 | # array. 60 | @default_value 61 | end 62 | end 63 | 64 | # The default value for this flag. Uses the value passed if one is set; 65 | # otherwise uses `[]` if the flag support multiple arguments and `nil` if 66 | # it does not. 67 | def default_value 68 | if @default_value 69 | @default_value 70 | elsif @multiple 71 | [] 72 | end 73 | end 74 | 75 | def arguments_for_option_parser 76 | args = all_forms_a.map { |name| "#{name} VAL" } 77 | args << @must_match if @must_match 78 | args << @type if @type 79 | args 80 | end 81 | 82 | # Returns a string of all possible forms 83 | # of this flag. Mostly intended for printing 84 | # to the user. 85 | def all_forms(joiner=', ') 86 | forms = all_forms_a 87 | string = forms.join(joiner) 88 | if forms[-1] =~ /^\-\-/ 89 | string += '=' 90 | else 91 | string += ' ' 92 | end 93 | string += @argument_name 94 | return string 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /dx/setupkit.sh.lib: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | fatal() { 4 | remainder=${*:2} 5 | if [ -z "$remainder" ]; then 6 | log "🛑" "${@}" 7 | else 8 | log "${@}" 9 | fi 10 | exit 1 11 | } 12 | 13 | log() { 14 | emoji=$1 15 | remainder=${*:2} 16 | if [ -z "${NO_EMOJI}" ]; then 17 | echo "[ ${0} ] ${*}" 18 | else 19 | # if remainder is empty that means no emoji was passed 20 | if [ -z "$remainder" ]; then 21 | echo "[ ${0} ] ${*}" 22 | else # emoji was passed, but we ignore it 23 | echo "[ ${0} ] ${remainder}" 24 | fi 25 | fi 26 | } 27 | 28 | debug() { 29 | message=$1 30 | if [ -z "${DOCKBOX_DEBUG}" ]; then 31 | return 32 | fi 33 | log "🐛" "${message}" 34 | } 35 | 36 | usage() { 37 | description=$1 38 | arg_names=$2 39 | pre_hook=$3 40 | post_hook=$4 41 | echo "usage: ${0} [-h] ${arg_names}" 42 | if [ -n "${description}" ]; then 43 | echo 44 | echo "DESCRIPTION" 45 | echo " ${description}" 46 | fi 47 | if [ -n "${pre_hook}" ] || [ -n "${post_hook}" ]; then 48 | echo 49 | echo "HOOKS" 50 | if [ -n "${pre_hook}" ]; then 51 | echo " ${pre_hook} - if present, called before the main action" 52 | fi 53 | if [ -n "${post_hook}" ]; then 54 | echo " ${post_hook} - if present, called after the main action" 55 | fi 56 | fi 57 | exit 0 58 | } 59 | 60 | usage_on_help() { 61 | description=$1 62 | arg_names=$2 63 | pre_hook=$3 64 | post_hook=$4 65 | # These are the args passed to the invocation so this 66 | # function can determine if the user requested help 67 | cli_args=( "${@:5}" ) 68 | 69 | for arg in "${cli_args[@]}"; do 70 | if [ "${arg}" = "-h" ] || [ "${arg}" = "--help" ]; then 71 | usage "${description}" "${arg_names}" "${pre_hook}" "${post_hook}" 72 | fi 73 | done 74 | } 75 | 76 | # Read user input into the variable 'INPUT' 77 | # 78 | # Args: 79 | # 80 | # [1] - an emoji to use for messages 81 | # [2] - the message explaining what input is being requested 82 | # [3] - a default value to use if no value is provided 83 | # 84 | # Respects NO_EMOJI when outputing messages to the user 85 | user_input() { 86 | emoji=$1 87 | message=$2 88 | default=$3 89 | prompt=$4 90 | 91 | if [ -z "$message" ]; then 92 | echo "user_input requires a message" 93 | exit 1 94 | fi 95 | 96 | INPUT= 97 | 98 | if [ -z "${prompt}" ]; then 99 | prompt=$(log "${emoji}" "Value: ") 100 | if [ -n "${default}" ]; then 101 | prompt=$(log "${emoji}" "Value (or hit return to use '${default}'): ") 102 | fi 103 | fi 104 | 105 | while [ -z "${INPUT}" ]; do 106 | 107 | log "$emoji" "$message" 108 | read -r -p "${prompt}" INPUT 109 | if [ -z "$INPUT" ]; then 110 | INPUT=$default 111 | fi 112 | if [ -z "$INPUT" ]; then 113 | log "😶", "You must provide a value" 114 | fi 115 | done 116 | } 117 | 118 | user_confirm() { 119 | user_input "$1" "$2" "$3" "y/n> " 120 | } 121 | 122 | require_not_exist() { 123 | file=$1 124 | message=$2 125 | if [ -e "${file}" ]; then 126 | fatal "$message" 127 | fi 128 | } 129 | require_exist() { 130 | file=$1 131 | message=$2 132 | if [ ! -e "${file}" ]; then 133 | fatal "$message" 134 | fi 135 | } 136 | 137 | require_command() { 138 | command_name=$1 139 | if ! command -v "${command_name}" >/dev/null 2>&1; then 140 | fatal "Command '${command_name}' not found - it is required for this script to run" 141 | fi 142 | } 143 | 144 | # vim: ft=bash 145 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'sdoc' 2 | require 'bundler' 3 | require 'rake/clean' 4 | require 'rake/testtask' 5 | require 'rdoc/task' 6 | 7 | include Rake::DSL 8 | 9 | CLEAN << "log" 10 | CLOBBER << FileList['**/*.rbc'] 11 | 12 | 13 | task :rdoc => [:build_rdoc, :hack_css] 14 | Rake::RDocTask.new(:build_rdoc) do |rd| 15 | rd.main = "README.rdoc" 16 | rd.rdoc_files = FileList["lib/**/*.rb","README.rdoc"] - 17 | FileList["lib/gli/commands/help_modules/*.rb"] - 18 | ["lib/gli/commands/help.rb", 19 | "lib/gli/commands/scaffold.rb", 20 | "lib/gli/support/*.rb", 21 | "lib/gli/app_support.rb", 22 | "lib/gli/option_parser_factory.rb", 23 | "lib/gli/gli_option_parser.rb", 24 | "lib/gli/command_support.rb",] 25 | rd.title = 'GLI - Git Like Interface for your command-line apps' 26 | rd.options << '-f' << 'sdoc' 27 | rd.template = 'direct' 28 | end 29 | 30 | FONT_FIX = { 31 | "0.82em" => "16px", 32 | "0.833em" => "16px", 33 | "0.85em" => "16px", 34 | "1.15em" => "20px", 35 | "1.1em" => "20px", 36 | "1.2em" => "20px", 37 | "1.4em" => "24px", 38 | "1.5em" => "24px", 39 | "1.6em" => "32px", 40 | "1em" => "16px", 41 | "2.1em" => "38px", 42 | } 43 | 44 | 45 | task :hack_css do 46 | maincss = File.open('html/css/main.css').readlines 47 | File.open('html/css/main.css','w') do |file| 48 | file.puts '@import url(http://fonts.googleapis.com/css?family=Karla:400,700,400italic,700italic|Alegreya);' 49 | 50 | maincss.each do |line| 51 | if line.strip == 'font-family: "Helvetica Neue", Arial, sans-serif;' 52 | file.puts 'font-family: Karla, "Helvetica Neue", Arial, sans-serif;' 53 | elsif line.strip == 'font-family: monospace;' 54 | file.puts 'font-family: Monaco, monospace;' 55 | elsif line =~ /^pre\s*$/ 56 | file.puts "pre { 57 | font-family: Monaco, monospace; 58 | margin-bottom: 1em; 59 | } 60 | pre.original" 61 | elsif line =~ /^\s*font-size:\s*(.*)\s*;/ 62 | if FONT_FIX[$1] 63 | file.puts "font-size: #{FONT_FIX[$1]};" 64 | else 65 | file.puts line.chomp 66 | end 67 | else 68 | file.puts line.chomp 69 | end 70 | end 71 | end 72 | end 73 | 74 | Bundler::GemHelper.install_tasks 75 | 76 | desc "run unit tests" 77 | Rake::TestTask.new("test:unit") do |t| 78 | t.libs << "test" 79 | t.libs << "lib" 80 | ENV["RUBYOPT"].split(/\s/).each do |opt| 81 | t.ruby_opts << opt 82 | end 83 | if t.ruby_opts.none? { |x| x =~ /^\-W/ } 84 | t.ruby_opts << "-W0" 85 | end 86 | t.test_files = FileList["test/unit/**/*_test.rb"] 87 | end 88 | 89 | desc "run integration tests" 90 | Rake::TestTask.new("test:integration") do |t| 91 | t.libs << "test" 92 | ENV["RUBYOPT"].split(/\s/).each do |opt| 93 | t.ruby_opts << opt 94 | end 95 | if t.ruby_opts.none? { |x| x =~ /^\-W/ } 96 | t.ruby_opts << "-W0" 97 | end 98 | explicitly_named_files = ARGV[1..-1] 99 | if Array(explicitly_named_files).size == 0 100 | t.test_files = FileList["test/integration/**/*_test.rb"] 101 | else 102 | t.test_files = explicitly_named_files 103 | end 104 | end 105 | 106 | 107 | begin 108 | require 'simplecov' 109 | rescue LoadError 110 | end 111 | 112 | desc 'Publish rdoc on github pages and push to github' 113 | task :publish_rdoc => [:rdoc,:publish] 114 | 115 | task :default => ["test:unit", "test:integration"] 116 | 117 | -------------------------------------------------------------------------------- /lib/gli/commands/rdoc_document_listener.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | require 'gli/commands/help_modules/arg_name_formatter' 3 | module GLI 4 | module Commands 5 | class RdocDocumentListener 6 | 7 | def initialize(global_options,options,arguments,app) 8 | @app = app 9 | @io = File.new("#{app.exe_name}.rdoc",'w') 10 | @nest = '' 11 | @arg_name_formatter = GLI::Commands::HelpModules::ArgNameFormatter.new 12 | end 13 | 14 | def beginning 15 | end 16 | 17 | # Called when processing has completed 18 | def ending 19 | @io.close 20 | end 21 | 22 | # Gives you the program description 23 | def program_desc(desc) 24 | @io.puts "== #{@app.exe_name} - #{desc}" 25 | @io.puts 26 | end 27 | 28 | def program_long_desc(desc) 29 | @io.puts desc 30 | @io.puts 31 | end 32 | 33 | # Gives you the program version 34 | def version(version) 35 | @io.puts "v#{version}" 36 | @io.puts 37 | end 38 | 39 | def options 40 | if @nest.size == 0 41 | @io.puts "=== Global Options" 42 | else 43 | @io.puts "#{@nest}=== Options" 44 | end 45 | end 46 | 47 | # Gives you a flag in the current context 48 | def flag(name,aliases,desc,long_desc,default_value,arg_name,must_match,type) 49 | invocations = ([name] + Array(aliases)).map { |_| add_dashes(_) }.join('|') 50 | usage = "#{invocations} #{arg_name || 'arg'}" 51 | @io.puts "#{@nest}=== #{usage}" 52 | @io.puts 53 | @io.puts String(desc).strip 54 | @io.puts 55 | @io.puts "[Default Value] #{default_value || 'None'}" 56 | @io.puts "[Must Match] #{must_match.to_s}" unless must_match.nil? 57 | @io.puts String(long_desc).strip 58 | @io.puts 59 | end 60 | 61 | # Gives you a switch in the current context 62 | def switch(name,aliases,desc,long_desc,negatable) 63 | if negatable 64 | name = "[no-]#{name}" if name.to_s.length > 1 65 | aliases = aliases.map { |_| _.to_s.length > 1 ? "[no-]#{_}" : _ } 66 | end 67 | invocations = ([name] + aliases).map { |_| add_dashes(_) }.join('|') 68 | @io.puts "#{@nest}=== #{invocations}" 69 | @io.puts String(desc).strip 70 | @io.puts 71 | @io.puts String(long_desc).strip 72 | @io.puts 73 | end 74 | 75 | def end_options 76 | end 77 | 78 | def commands 79 | @io.puts "#{@nest}=== Commands" 80 | @nest = "#{@nest}=" 81 | end 82 | 83 | # Gives you a command in the current context and creates a new context of this command 84 | def command(name,aliases,desc,long_desc,arg_name,arg_options) 85 | @io.puts "#{@nest}=== Command: #{([name] + aliases).join('|')} #{@arg_name_formatter.format(arg_name,arg_options,[])}" 86 | @io.puts String(desc).strip 87 | @io.puts 88 | @io.puts String(long_desc).strip 89 | @nest = "#{@nest}=" 90 | end 91 | 92 | # Ends a command, and "pops" you back up one context 93 | def end_command(name) 94 | @nest.gsub!(/=$/,'') 95 | end 96 | 97 | # Gives you the name of the current command in the current context 98 | def default_command(name) 99 | @io.puts "[Default Command] #{name}" unless name.nil? 100 | end 101 | 102 | def end_commands 103 | @nest.gsub!(/=$/,'') 104 | end 105 | 106 | private 107 | 108 | def add_dashes(name) 109 | name = "-#{name}" 110 | name = "-#{name}" if name.length > 2 111 | name 112 | end 113 | 114 | 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/unit/terminal_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class TerminalTest < Minitest::Test 4 | include TestHelper 5 | 6 | # TODO: Make this test not mess with the internals of the class 7 | def xtest_command_exists 8 | assert GLI::Terminal.instance.command_exists?('ls') 9 | assert !GLI::Terminal.instance.command_exists?('asdfasfasdf') 10 | end 11 | 12 | def setup 13 | @old_columns = ENV['COLUMNS'] 14 | @old_lines = ENV['LINES'] 15 | end 16 | 17 | def teardown 18 | ENV['COLUMNS'] = @old_columns 19 | ENV['LINES'] = @old_lines 20 | GLI::Terminal.default_size = [80,24] 21 | end 22 | 23 | def test_shared_instance_is_same 24 | assert_equal GLI::Terminal.instance,GLI::Terminal.instance 25 | end 26 | 27 | def test_size_based_on_columns 28 | ENV['COLUMNS'] = '666' 29 | ENV['LINES'] = '777' 30 | assert_equal [666,777],GLI::Terminal.instance.size 31 | end 32 | 33 | def test_size_using_tput 34 | terminal = GLI::Terminal.new 35 | terminal.make_unsafe! 36 | GLI::Terminal.instance_eval do 37 | def run_command(command) 38 | if command == 'tput cols' 39 | return '888' 40 | elsif command == 'tput lines' 41 | return '999' 42 | else 43 | raise "Unexpected command called: #{command}" 44 | end 45 | end 46 | def command_exists?(command); true; end 47 | def jruby?; true; end 48 | end 49 | ENV['COLUMNS'] = 'foo' 50 | assert_equal [888,999],terminal.size 51 | end 52 | 53 | def test_size_using_stty 54 | terminal = GLI::Terminal.new 55 | terminal.make_unsafe! 56 | GLI::Terminal.instance_eval do 57 | def run_command(command) 58 | 59 | if RUBY_PLATFORM == 'java' 60 | return '5678' if command == 'tput cols' 61 | return '1234' if command == 'tput lines' 62 | else 63 | return '1234 5678' if command == 'stty size' 64 | return '1234 5678' if command == 'stty' 65 | end 66 | 67 | raise "Unexpected command called: #{command} for #{RUBY_PLATFORM}" 68 | end 69 | def command_exists?(command); true; end 70 | def jruby?; false; end 71 | def solaris?; false; end 72 | end 73 | ENV['COLUMNS'] = 'foo' 74 | assert_equal [5678,1234],terminal.size 75 | end 76 | 77 | def test_size_using_stty_but_returns_0 78 | terminal = GLI::Terminal.new 79 | terminal.make_unsafe! 80 | GLI::Terminal.instance_eval do 81 | def run_command(command) 82 | 83 | if RUBY_PLATFORM == 'java' 84 | return '0' if command == 'tput cols' 85 | return '0' if command == 'tput lines' 86 | else 87 | return '0 0' if command == 'stty size' 88 | return '0 0' if command == 'stty' 89 | end 90 | 91 | raise "Unexpected command called: #{command} for #{RUBY_PLATFORM}" 92 | end 93 | def command_exists?(command); true; end 94 | def jruby?; false; end 95 | def solaris?; false; end 96 | end 97 | ENV['COLUMNS'] = 'foo' 98 | assert_equal [80,24],terminal.size 99 | end 100 | 101 | def test_size_using_default 102 | terminal = GLI::Terminal.new 103 | terminal.make_unsafe! 104 | GLI::Terminal.instance_eval do 105 | def command_exists?(command); false; end 106 | def jruby?; false; end 107 | def solaris?; false; end 108 | end 109 | ENV['COLUMNS'] = 'foo' 110 | assert_equal [80,24],terminal.size 111 | # While we have this set up, lets make sure the default change falls through 112 | GLI::Terminal.default_size = [90,45] 113 | assert_equal [90,45],terminal.size 114 | end 115 | 116 | def test_size_using_default_when_exception 117 | terminal = GLI::Terminal.new 118 | GLI::Terminal.instance_eval do 119 | def jruby?; raise "Problem"; end 120 | def solaris?; false; end 121 | end 122 | ENV['COLUMNS'] = 'foo' 123 | assert_equal [80,24],terminal.size 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/gli/terminal.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Class to encapsulate stuff about the terminal. This is useful to application developers 3 | # as a canonical means to get information about the user's current terminal configuraiton. 4 | # GLI uses this to determine the number of columns to use when printing to the screen. 5 | # 6 | # To access it, use Terminal.instance. This is a singleton mostly to facilitate testing, but 7 | # it seems reasonable enough, since there's only one terminal in effect 8 | # 9 | # Example: 10 | # 11 | # Terminal.instance.size[0] # => columns in the terminal 12 | # Terminal.default_size = [128,24] # => change default when we can't figure it out 13 | # raise "no ls?!?!?" unless Terminal.instance.command_exists?("ls") 14 | # 15 | class Terminal 16 | 17 | @@default_size = [80,24] 18 | 19 | # Get the default size of the terminal when we can't figure it out 20 | # 21 | # Returns an array of int [cols,rows] 22 | def self.default_size 23 | @@default_size 24 | end 25 | 26 | # Set the default size of the terminal to use when we can't figure it out 27 | # 28 | # +size+:: array of two int [cols,rows] 29 | def self.default_size=(size) 30 | @@default_size = size 31 | end 32 | 33 | # Provide access to the shared instance. 34 | def self.instance; @@instance ||= Terminal.new; end 35 | 36 | # Call this to cause methods to throw exceptions rather than return a sane default. You 37 | # probably don't want to call this unless you are writing tests 38 | def make_unsafe! #:nodoc: 39 | @unsafe = true 40 | end 41 | 42 | # Returns true if the given command exists on this system 43 | # 44 | # +command+:: The command, as a String, to check for, without any path information. 45 | def self.command_exists?(command) 46 | ENV['PATH'].split(File::PATH_SEPARATOR).any? {|dir| File.exist? File.join(dir, command) } 47 | end 48 | 49 | def command_exists?(command) 50 | self.class.command_exists?(command) 51 | end 52 | 53 | SIZE_DETERMINERS = [ 54 | [ 55 | lambda { (ENV['COLUMNS'] =~ /^\d+$/) && (ENV['LINES'] =~ /^\d+$/) }, 56 | lambda { [ENV['COLUMNS'].to_i, ENV['LINES'].to_i] } 57 | ], 58 | [ 59 | lambda { (jruby? || (!STDIN.tty? && ENV['TERM'])) && command_exists?('tput') }, 60 | lambda { [run_command('tput cols').to_i, run_command('tput lines').to_i] } 61 | ], 62 | [ 63 | lambda { (solaris? && STDIN.tty? && command_exists?('stty')) }, 64 | lambda { run_command('stty').split("\n")[1].scan(/\d+/)[0..1].map { |size_element| size_element.to_i }.reverse } 65 | ], 66 | [ 67 | lambda { STDIN.tty? && command_exists?('stty') }, 68 | lambda { run_command('stty size').scan(/\d+/).map { |size_element| size_element.to_i }.reverse } 69 | ], 70 | [ 71 | lambda { true }, 72 | lambda { Terminal.default_size }, 73 | ], 74 | ] 75 | 76 | # Get the size of the current terminal. 77 | # Ripped from hirb[https://github.com/cldwalker/hirb/blob/master/lib/hirb/util.rb] 78 | # 79 | # Returns an Array of size two Ints representing the terminal width and height 80 | def size 81 | SIZE_DETERMINERS.each do |predicate, get_size| 82 | next unless predicate.call 83 | size = get_size.call 84 | return size unless size == [0, 0] 85 | end 86 | rescue Exception => ex 87 | raise ex if @unsafe 88 | Terminal.default_size 89 | end 90 | 91 | private 92 | 93 | # Runs a command using backticks. Extracted to allow for testing 94 | def self.run_command(command) 95 | `#{command}` 96 | end 97 | 98 | # True if we are JRuby; exposed to allow for testing 99 | def self.jruby?; RUBY_PLATFORM =~ /java/; end 100 | 101 | # True if this is running under Solaris Sparc 102 | def self.solaris?; RUBY_PLATFORM =~ /solaris/; end 103 | 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/gli/commands/help_modules/full_synopsis_formatter.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | module HelpModules 4 | class FullSynopsisFormatter 5 | def initialize(app,flags_and_switches) 6 | @app = app 7 | @basic_invocation = @app.exe_name.to_s 8 | @flags_and_switches = flags_and_switches 9 | end 10 | 11 | def synopses_for_command(command) 12 | synopses = [] 13 | one_line_usage = basic_usage(command) 14 | one_line_usage << ArgNameFormatter.new.format(command.arguments_description,command.arguments_options,command.arguments).strip 15 | if command.commands.empty? 16 | synopses << one_line_usage 17 | else 18 | synopses = sorted_synopses(command) 19 | if command.has_action? 20 | synopses.unshift(one_line_usage) 21 | end 22 | end 23 | synopses 24 | end 25 | 26 | protected 27 | 28 | def sub_options_doc(sub_options) 29 | sub_options_doc = sub_options.map { |_,option| 30 | doc = option.names_and_aliases.map { |name| 31 | CommandLineOption.name_as_string(name,false) + (option.kind_of?(Flag) ? " #{option.argument_name }" : '') 32 | }.join('|') 33 | option.required?? doc : "[#{doc}]" 34 | }.sort.join(' ').strip 35 | end 36 | 37 | private 38 | 39 | def path_to_command(command) 40 | path = [] 41 | c = command 42 | while c.kind_of? Command 43 | path.unshift(c.name) 44 | c = c.parent 45 | end 46 | path.join(' ') 47 | end 48 | 49 | 50 | def basic_usage(command) 51 | usage = @basic_invocation.dup 52 | usage << " [global options]" unless global_flags_and_switches.empty? 53 | usage << " #{path_to_command(command)}" 54 | usage << " [command options]" unless @flags_and_switches.empty? 55 | usage << " " 56 | usage 57 | end 58 | 59 | 60 | def command_with_subcommand_usage(command,sub,is_default_command) 61 | usage = basic_usage(command) 62 | sub_options = if @app.subcommand_option_handling_strategy == :legacy 63 | command.flags.merge(command.switches).select { |_,o| o.associated_command == sub } 64 | else 65 | sub.flags.merge(sub.switches) 66 | end 67 | if is_default_command 68 | usage << "[#{sub.name}]" 69 | else 70 | usage << sub.name.to_s 71 | end 72 | sub_options_doc = sub_options_doc(sub_options) 73 | if sub_options_doc.length > 0 74 | usage << ' ' 75 | usage << sub_options_doc 76 | end 77 | arg_name_doc = ArgNameFormatter.new.format(sub.arguments_description,sub.arguments_options,sub.arguments).strip 78 | if arg_name_doc.length > 0 79 | usage << ' ' 80 | usage << arg_name_doc 81 | end 82 | usage 83 | end 84 | 85 | def sorted_synopses(command) 86 | synopses_command = {} 87 | command.commands.each do |name,sub| 88 | default = command.get_default_command == name 89 | synopsis = command_with_subcommand_usage(command,sub,default) 90 | synopses_command[synopsis] = sub 91 | end 92 | synopses = synopses_command.keys.sort { |one,two| 93 | if synopses_command[one].name == command.get_default_command 94 | -1 95 | elsif synopses_command[two].name == command.get_default_command 96 | 1 97 | else 98 | synopses_command[one] <=> synopses_command[two] 99 | end 100 | } 101 | end 102 | 103 | def global_flags_and_switches 104 | @app.flags.merge(@app.switches) 105 | end 106 | 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/gli/exceptions.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Mixed into all exceptions that GLI handles; you can use this to catch 3 | # anything that came from GLI intentionally. You can also mix this into non-GLI 4 | # exceptions to get GLI's exit behavior. 5 | module StandardException 6 | def exit_code; 1; end 7 | end 8 | 9 | # Hack to request help from within a command 10 | # Will *not* be rethrown when GLI_DEBUG is ON 11 | class RequestHelp < StandardError 12 | include StandardException 13 | def exit_code; 0; end 14 | 15 | # The command for which the argument was unknown 16 | attr_reader :command_in_context 17 | 18 | def initialize(command_in_context) 19 | @command_in_context = command_in_context 20 | end 21 | end 22 | 23 | # Indicates that the command line invocation was bad 24 | class BadCommandLine < StandardError 25 | include StandardException 26 | def exit_code; 64; end 27 | end 28 | 29 | class PreconditionFailed < StandardError 30 | include StandardException 31 | def exit_code; 65; end 32 | end 33 | 34 | # Indicates the bad command line was an unknown command 35 | class UnknownCommand < BadCommandLine 36 | end 37 | 38 | # The command issued partially matches more than one command 39 | class AmbiguousCommand < BadCommandLine 40 | end 41 | 42 | # Indicates the bad command line was an unknown global argument 43 | class UnknownGlobalArgument < BadCommandLine 44 | end 45 | 46 | class CommandException < BadCommandLine 47 | # The command for which the argument was unknown 48 | attr_reader :command_in_context 49 | # +message+:: the error message to show the user 50 | # +command+:: the command we were using to parse command-specific options 51 | def initialize(message,command_in_context,exit_code=nil) 52 | super(message) 53 | @command_in_context = command_in_context 54 | @exit_code = exit_code 55 | end 56 | 57 | def exit_code 58 | @exit_code || super 59 | end 60 | end 61 | 62 | class BadCommandOptionsOrArguments < BadCommandLine 63 | # The command for which the argument was unknown 64 | attr_reader :command_in_context 65 | def initialize(message,command) 66 | super(message) 67 | @command_in_context = command 68 | end 69 | end 70 | 71 | class MissingRequiredArgumentsException < BadCommandOptionsOrArguments 72 | attr_reader :num_arguments_received, :range_arguments_accepted 73 | def initialize(command,num_arguments_received,range_arguments_accepted) 74 | 75 | @num_arguments_received = num_arguments_received 76 | @range_arguments_accepted = range_arguments_accepted 77 | 78 | message = if @num_arguments_received < @range_arguments_accepted.min 79 | "#{command.name} expected at least #{@range_arguments_accepted.min} arguments, but was given only #{@num_arguments_received}" 80 | elsif @range_arguments_accepted.min == 0 81 | "#{command.name} expected no arguments, but received #{@num_arguments_received}" 82 | else 83 | "#{command.name} expected only #{@range_arguments_accepted.max} arguments, but received #{@num_arguments_received}" 84 | end 85 | super(message,command) 86 | end 87 | 88 | end 89 | 90 | class MissingRequiredOptionsException < BadCommandOptionsOrArguments 91 | def initialize(command,missing_required_options) 92 | message = "#{command.name} requires these options: " 93 | required_options = missing_required_options.sort.map(&:name).join(", ") 94 | super(message + required_options,command) 95 | end 96 | end 97 | 98 | # Indicates the bad command line was an unknown command argument 99 | class UnknownCommandArgument < CommandException 100 | end 101 | 102 | # Raise this if you want to use an exit status that isn't the default 103 | # provided by GLI. Note that GLI::App#exit_now! might be a bit more to your liking. 104 | # 105 | # Example: 106 | # 107 | # raise CustomExit.new("Not connected to DB",-5) unless connected? 108 | # raise CustomExit.new("Bad SQL",-6) unless valid_sql?(args[0]) 109 | # 110 | class CustomExit < StandardError 111 | include StandardException 112 | attr_reader :exit_code #:nodoc: 113 | # Create a custom exit exception 114 | # 115 | # +message+:: String containing error message to show the user 116 | # +exit_code+:: the exit code to use (as an Int), overridding GLI's default 117 | def initialize(message,exit_code) 118 | super(message) 119 | @exit_code = exit_code 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/gli/commands/help.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'gli/command' 3 | require 'gli/terminal' 4 | require 'gli/commands/help_modules/list_formatter' 5 | require 'gli/commands/help_modules/text_wrapper' 6 | require 'gli/commands/help_modules/one_line_wrapper' 7 | require 'gli/commands/help_modules/verbatim_wrapper' 8 | require 'gli/commands/help_modules/tty_only_wrapper' 9 | require 'gli/commands/help_modules/options_formatter' 10 | require 'gli/commands/help_modules/global_help_format' 11 | require 'gli/commands/help_modules/command_help_format' 12 | require 'gli/commands/help_modules/help_completion_format' 13 | require 'gli/commands/help_modules/command_finder' 14 | require 'gli/commands/help_modules/arg_name_formatter' 15 | require 'gli/commands/help_modules/full_synopsis_formatter' 16 | require 'gli/commands/help_modules/compact_synopsis_formatter' 17 | require 'gli/commands/help_modules/terminal_synopsis_formatter' 18 | 19 | module GLI 20 | module Commands 21 | SORTERS = { 22 | :manually => lambda { |list| list }, 23 | :alpha => lambda { |list| list.sort }, 24 | } 25 | 26 | WRAPPERS = { 27 | :to_terminal => HelpModules::TextWrapper, 28 | :never => HelpModules::OneLineWrapper, 29 | :one_line => HelpModules::OneLineWrapper, 30 | :tty_only => HelpModules::TTYOnlyWrapper, 31 | :none => HelpModules::VerbatimWrapper, 32 | :verbatim => HelpModules::VerbatimWrapper, 33 | } 34 | 35 | SYNOPSIS_FORMATTERS = { 36 | :full => HelpModules::FullSynopsisFormatter, 37 | :compact => HelpModules::CompactSynopsisFormatter, 38 | :terminal => HelpModules::TerminalSynopsisFormatter, 39 | } 40 | # The help command used for the two-level interactive help system 41 | class Help < Command 42 | @@skips_pre = true 43 | @@skips_post = true 44 | @@skips_around = true 45 | 46 | # Configure help to explicitly skip or not skip the pre block when the help command runs. 47 | # This is here because the creation of the help command is outside of the client programmer's control 48 | def self.skips_pre=(skips_pre) ; @@skips_pre = skips_pre ; end 49 | 50 | # Configure help to explicitly skip or not skip the post block when the help command runs. 51 | # This is here because the creation of the help command is outside of the client programmer's control 52 | def self.skips_post=(skips_post) ; @@skips_post = skips_post ; end 53 | 54 | # Configure help to explicitly skip or not skip the around block when the help command runs. 55 | # This is here because the creation of the help command is outside of the client programmer's control 56 | def self.skips_around=(skips_around) ; @@skips_around = skips_around ; end 57 | 58 | def initialize(app,output=$stdout,error=$stderr) 59 | super(:names => :help, 60 | :description => 'Shows a list of commands or help for one command', 61 | :arguments_name => 'command', 62 | :long_desc => 'Gets help for the application or its commands. Can also list the commands in a way helpful to creating a bash-style completion function', 63 | :arguments => [Argument.new(:command_name, [:multiple, :optional])]) 64 | @app = app 65 | @parent = app 66 | @sorter = SORTERS[@app.help_sort_type] 67 | @text_wrapping_class = WRAPPERS[@app.help_text_wrap_type] 68 | @synopsis_formatter_class = SYNOPSIS_FORMATTERS[@app.synopsis_format_type] 69 | 70 | desc 'List commands one per line, to assist with shell completion' 71 | switch :c 72 | 73 | action do |global_options,options,arguments| 74 | if global_options[:version] && !global_options[:help] 75 | puts "#{@app.exe_name} version #{@app.version_string}" 76 | else 77 | show_help(global_options,options,arguments,output,error) 78 | end 79 | end 80 | end 81 | 82 | def skips_pre ; @@skips_pre ; end 83 | def skips_post ; @@skips_post ; end 84 | def skips_around ; @@skips_around ; end 85 | 86 | private 87 | 88 | def show_help(global_options,options,arguments,out,error) 89 | command_finder = HelpModules::CommandFinder.new(@app,arguments,error) 90 | if options[:c] 91 | help_output = HelpModules::HelpCompletionFormat.new(@app,command_finder,arguments).format 92 | out.puts help_output unless help_output.nil? 93 | elsif arguments.empty? || options[:c] 94 | out.puts HelpModules::GlobalHelpFormat.new(@app,@sorter,@text_wrapping_class).format 95 | else 96 | name = arguments.shift 97 | command = command_finder.find_command(name) 98 | unless command.nil? 99 | out.puts HelpModules::CommandHelpFormat.new( 100 | command, 101 | @app, 102 | @sorter, 103 | @synopsis_formatter_class, 104 | @text_wrapping_class).format 105 | end 106 | end 107 | end 108 | 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/gli/command_support.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Things unrelated to the true public interface of Command that are needed for bookkeeping 3 | # and help support. Generally, you shouldn't be calling these methods; they are technically public 4 | # but are essentially part of GLI's internal implementation and subject to change 5 | module CommandSupport 6 | # The parent of this command, either the GLI app, or another command 7 | attr_accessor :parent 8 | 9 | def context_description 10 | "in the command #{name}" 11 | end 12 | 13 | # Return true to avoid including this command in your help strings 14 | # Will honor the hide_commands_without_desc flag 15 | def nodoc 16 | @hide_commands_without_desc and description.nil? 17 | end 18 | 19 | # Return the arguments description 20 | def arguments_description 21 | @arguments_description 22 | end 23 | 24 | def arguments_options 25 | @arguments_options 26 | end 27 | 28 | def arguments 29 | @arguments 30 | end 31 | 32 | # If true, this command doesn't want the pre block run before it executes 33 | def skips_pre 34 | @skips_pre 35 | end 36 | 37 | # If true, this command doesn't want the post block run before it executes 38 | def skips_post 39 | @skips_post 40 | end 41 | 42 | # If true, this command doesn't want the around block called 43 | def skips_around 44 | @skips_around 45 | end 46 | 47 | # Return the Array of the command's names 48 | def names 49 | all_forms 50 | end 51 | 52 | # Returns the array of examples 53 | def examples 54 | @examples 55 | end 56 | 57 | # Get an array of commands, ordered by when they were declared 58 | def commands_declaration_order # :nodoc: 59 | @commands_declaration_order 60 | end 61 | 62 | def flag(*names) 63 | if send_declarations_to_parent? 64 | new_flag = if parent.kind_of? Command 65 | super(*names) 66 | parent.flag(*names) 67 | else 68 | super(*names) 69 | end 70 | new_flag.associated_command = self 71 | new_flag 72 | else 73 | super(*names) 74 | end 75 | end 76 | 77 | def switch(*names) 78 | if send_declarations_to_parent? 79 | new_switch = if parent.kind_of? Command 80 | super(*names) 81 | parent.switch(*names) 82 | else 83 | super(*names) 84 | end 85 | new_switch.associated_command = self 86 | new_switch 87 | else 88 | super(*names) 89 | end 90 | end 91 | 92 | def desc(d) 93 | parent.desc(d) if parent.kind_of?(Command) && send_declarations_to_parent? 94 | super(d) 95 | end 96 | 97 | def long_desc(d) 98 | parent.long_desc(d) if parent.kind_of?(Command) && send_declarations_to_parent? 99 | super(d) 100 | end 101 | 102 | def arg_name(d,options=[]) 103 | parent.arg_name(d,options) if parent.kind_of?(Command) && send_declarations_to_parent? 104 | super(d,options) 105 | end 106 | 107 | def default_value(d) 108 | parent.default_value(d) if parent.kind_of?(Command) && send_declarations_to_parent? 109 | super(d) 110 | end 111 | 112 | # Return the flags as a Hash 113 | def flags 114 | @flags ||= {} 115 | end 116 | # Return the switches as a Hash 117 | def switches 118 | @switches ||= {} 119 | end 120 | 121 | def commands # :nodoc: 122 | @commands ||= {} 123 | end 124 | 125 | def default_description 126 | @default_desc 127 | end 128 | 129 | # Executes the command 130 | def execute(global_options,options,arguments) 131 | get_action(arguments).call(global_options,options,arguments) 132 | end 133 | 134 | def topmost_ancestor 135 | some_command = self 136 | top = some_command 137 | while some_command.kind_of? self.class 138 | top = some_command 139 | some_command = some_command.parent 140 | end 141 | top 142 | end 143 | 144 | def has_action? 145 | !!@action 146 | end 147 | 148 | def get_default_command 149 | @default_command 150 | end 151 | 152 | private 153 | 154 | def send_declarations_to_parent? 155 | app = topmost_ancestor.parent 156 | app.nil? ? true : (app.subcommand_option_handling_strategy == :legacy) 157 | end 158 | 159 | def get_action(arguments) 160 | if @action 161 | @action 162 | else 163 | generate_error_action(arguments) 164 | end 165 | end 166 | 167 | def generate_error_action(arguments) 168 | lambda { |global_options,options,arguments| 169 | if am_subcommand? && arguments.size > 0 170 | raise UnknownCommand,"Unknown command '#{arguments[0]}'" 171 | elsif have_subcommands? 172 | raise BadCommandLine,"Command '#{name}' requires a subcommand #{self.commands.keys.join(',')}" 173 | else 174 | raise "Command '#{name}' has no action block" 175 | end 176 | } 177 | end 178 | 179 | def am_subcommand? 180 | parent.kind_of?(Command) 181 | end 182 | 183 | def have_subcommands? 184 | !self.commands.empty? 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = GLI, the Git-Like Interface Command Line Parser 2 | 3 | GLI allows you to create command-line app in Ruby that behaves like git in that it takes subcommands to perform a series of complex action, e.g. git remote add. 4 | 5 | * {Overview}[http://davetron5000.github.io/gli] 6 | * {Source on Github}[http://github.com/davetron5000/gli] 7 | * RDoc[http://davetron5000.github.io/gli/rdoc/index.html] 8 | 9 | {Build Status}[https://travis-ci.org/davetron5000/gli] 10 | 11 | == What Problem does GLI Solve? 12 | 13 | Creating a command-line app that uses subcommands, each of which might accept different command-line options, is somewhat difficult with Ruby's built-in OptionParser. GLI provides an API that wraps OptionParser so that you can create a subcommand-based command-line app with minimal boilerplate. This API also produces complete documentation for your command-line app. 14 | 15 | == Why is GLI's solution different from others? 16 | 17 | There are other RubyGems that allow you to create a command-line app that takes subcommands. These solutions are often quite limited (e.g. they don't allow deeply nested subcommand structures or sophisticated command-line options per subcommand), or require more code that we think is needed. Some solutions make it difficult or impossible to properly document your command-line app. 18 | 19 | == What you need to know to use GLI 20 | 21 | You should know Ruby, and have a basic understanding of how the UNIX command line works: standard input, standard output, standard error, and exit codes. 22 | 23 | == Use 24 | 25 | Install if you need to: 26 | 27 | gem install gli 28 | 29 | You can validate you have installed it correctly by running gli help. You should see formatted help output. 30 | 31 | If you are using GLI in another application, add it to your Gemfile: 32 | 33 | gem "gli" 34 | 35 | You can test your install via Bundler by running bundle exec gli help. This should produce formatted help output from GLI. 36 | 37 | == Getting Started 38 | 39 | The simplest way to get started is to create a scaffold project 40 | 41 | gli init todo list add complete 42 | 43 | (note if you installed via Bundler you will need to execute bundle exec gli init todo list add complete) 44 | 45 | This will create a basic scaffold project in ./todo with: 46 | 47 | * executable in ./todo/bin/todo. This file demonstrates most of what you need to describe your command line interface. 48 | * an empty test in ./todo/test/default_test.rb that can bootstrap your tests 49 | * a gemspec shell 50 | * a README shell 51 | * Rakefile that can generate RDoc, package your Gem and run tests 52 | * A Gemfile suitable for use with Bundler to manage development-time dependencies 53 | 54 | Now, you are ready to go: 55 | 56 | > cd todo 57 | > bundle exec bin/todo help 58 | NAME 59 | todo - Describe your application here 60 | 61 | SYNOPSIS 62 | todo [global options] command [command options] [arguments...] 63 | 64 | VERSION 65 | 0.0.1 66 | 67 | GLOBAL OPTIONS 68 | -f, --flagname=The name of the argument - Describe some flag here (default: the default) 69 | --help - Show this message 70 | -s, --[no-]switch - Describe some switch here 71 | 72 | COMMANDS 73 | add - Describe add here 74 | complete - Describe complete here 75 | help - Shows a list of commands or help for one command 76 | list - Describe list here 77 | 78 | > bundle exec bin/todo help list 79 | NAME 80 | list - Describe list here 81 | 82 | SYNOPSIS 83 | todo [global options] list [command options] Describe arguments to list here 84 | 85 | COMMAND OPTIONS 86 | -f arg - Describe a flag to list (default: default) 87 | -s - Describe a switch to list 88 | 89 | All you need to do is fill in the documentation and your code; the help system, command-line parsing and many other awesome features are all handled for you. 90 | 91 | Get a more detailed walkthrough on the {main site}[http://davetron5000.github.io/gli] 92 | 93 | == Supported Platforms 94 | 95 | See `dx/docker-compose.env` and the variable `RUBY_VERSIONS` for the versions that are supported. This should generally track with the supported version of Ruby from Ruby's maintainers. 96 | 97 | That said, GLI should generally work on other Rubies as it doesn't have any runtime dependencies and there are no plans to use more modern features of Ruby in the codebase. 98 | 99 | == Documentation 100 | 101 | Extensive documentation is {available at the wiki}[https://github.com/davetron5000/gli/wiki]. 102 | 103 | API Documentation is available {here}[http://davetron5000.github.io/gli/rdoc/index.html]. Recommend starting with GLI::DSL or GLI::App. 104 | 105 | == Developing 106 | 107 | See `CONTRIBUTING.md` 108 | 109 | == Credits 110 | 111 | Author:: Dave Copeland (mailto:davetron5000 at g mail dot com) 112 | Copyright:: Copyright (c) 2010 by Dave Copeland 113 | License:: Distributes under the Apache License, see LICENSE.txt in the source distro 114 | 115 | == Links 116 | 117 | * [http://davetron5000.github.io/gli] - RubyDoc 118 | * [http://www.github.com/davetron5000/gli] - Source on GitHub 119 | * [http://www.github.com/davetron5000/gli/wiki] - Documentation Wiki 120 | * [http://www.github.com/davetron5000/gli/wiki/Changelog] - Changelog 121 | 122 | = gli CLI documentation 123 | 124 | :include:gli.rdoc 125 | -------------------------------------------------------------------------------- /lib/gli/command.rb: -------------------------------------------------------------------------------- 1 | require 'gli/command_line_token.rb' 2 | require 'gli/dsl.rb' 3 | 4 | module GLI 5 | # A command to be run, in context of global flags and switches. You are given an instance of this class 6 | # to the block you use for GLI::DSL#command. This class mixes in GLI::DSL so all of those methods are available 7 | # to describe the command, in addition to the methods documented here, most importantly 8 | # #action. 9 | # 10 | # Example: 11 | # 12 | # command :list do |c| # <- c is an instance of GLI::Command 13 | # c.desc 'use long form' 14 | # c.switch :l 15 | # 16 | # c.action do |global,options,args| 17 | # # list things here 18 | # end 19 | # 20 | # c.command :tasks do |t| # <- t is an instance of GLI::Command 21 | # # this is a "subcommand" of list 22 | # 23 | # t.action do |global,options,args| 24 | # # do whatever list tasks should do 25 | # end 26 | # end 27 | # end 28 | # 29 | class Command < CommandLineToken 30 | include DSL 31 | include CommandSupport 32 | 33 | class ParentKey 34 | def to_sym 35 | "__parent__".to_sym 36 | end 37 | end 38 | 39 | # Key in an options hash to find the parent's parsed options. Note that if you are 40 | # using openstruct, e.g. via `use_openstruct true` in your app setup, you will need 41 | # to use the method `__parent__` to access parent parsed options. 42 | PARENT = ParentKey.new 43 | 44 | # Create a new command. 45 | # 46 | # options:: Keys should be: 47 | # +names+:: A String, Symbol, or Array of String or Symbol that represents the name(s) of this command (required). 48 | # +description+:: short description of this command as a String 49 | # +arguments_name+:: description of the arguments as a String, or nil if this command doesn't take arguments 50 | # +long_desc+:: a longer description of the command, possibly with multiple lines. A double line-break is treated 51 | # as a paragraph break. No other formatting is respected, though inner whitespace is maintained. 52 | # +skips_pre+:: if true, this command advertises that it doesn't want the pre block called first 53 | # +skips_post+:: if true, this command advertises that it doesn't want the post block called after it 54 | # +skips_around+:: if true, this command advertises that it doesn't want the around block called 55 | # +hide_commands_without_desc+:: if true and there isn't a description the command is not going to be shown in the help 56 | # +examples+:: An array of Hashes, where each hash must have the key +:example+ mapping to a string, and may optionally have the key +:desc+ 57 | # that documents that example. 58 | def initialize(options) 59 | super(options[:names],options[:description],options[:long_desc]) 60 | @arguments_description = options[:arguments_name] || '' 61 | @arguments_options = Array(options[:arguments_options]).flatten 62 | @arguments = options[:arguments] || [] 63 | @skips_pre = options[:skips_pre] 64 | @skips_post = options[:skips_post] 65 | @skips_around = options[:skips_around] 66 | @hide_commands_without_desc = options[:hide_commands_without_desc] 67 | @commands_declaration_order = [] 68 | @flags_declaration_order = [] 69 | @switches_declaration_order = [] 70 | @examples = options[:examples] || [] 71 | clear_nexts 72 | end 73 | 74 | # Specify an example invocation. 75 | # 76 | # example_invocation:: test of a complete command-line invocation you want to show 77 | # options:: refine the example: 78 | # +:desc+:: A description of the example to be shown with it (optional) 79 | def example(example_invocation,options = {}) 80 | @examples << { 81 | example: example_invocation 82 | }.merge(options) 83 | end 84 | 85 | # Set the default command if this command has subcommands and the user doesn't 86 | # provide a subcommand when invoking THIS command. When nil, this will show an error and the help 87 | # for this command; when set, the command with this name will be executed. 88 | # 89 | # +command_name+:: The primary name of the subcommand of this command that should be run by default as a String or Symbol. 90 | def default_command(command_name) 91 | @default_command = command_name 92 | end 93 | 94 | # Define the action to take when the user executes this command. Every command should either define this 95 | # action block, or have subcommands (or both). 96 | # 97 | # +block+:: A block of code to execute. The block will be given 3 arguments: 98 | # +global_options+:: A Hash of the _global_ options specified 99 | # by the user, with defaults set and config file values used (if using a config file, see 100 | # GLI::App#config_file) 101 | # +options+:: A Hash of the command-specific options specified by the 102 | # user, with defaults set and config file values used (if using a config file, see 103 | # GLI::App#config_file). 104 | # +arguments+:: An Array of Strings representing the unparsed command line arguments 105 | # The block's result value is not used; raise an exception or use GLI#exit_now! if you need an early exit based 106 | # on an error condition 107 | # 108 | def action(&block) 109 | @action = block 110 | end 111 | 112 | # Describes this commands action block when it *also* has subcommands. 113 | # In this case, the GLI::DSL#desc value is the general description of the commands 114 | # that this command groups, and the value for *this* method documents what 115 | # will happen if you omit a subcommand. 116 | # 117 | # Note that if you omit the action block and specify a subcommand, that subcommand's 118 | # description will be used to describe what happens by default. 119 | # 120 | # desc:: the description of what this command's action block does. 121 | # 122 | # Example 123 | # 124 | # desc 'list things' 125 | # command :list do |c| 126 | # 127 | # c.desc 'list tasks' 128 | # c.command :tasks do |t| 129 | # t.action do |global,options,args| 130 | # end 131 | # end 132 | # 133 | # c.desc 'list contexts' 134 | # c.command :contexts do |t| 135 | # t.action do |global,options,args| 136 | # end 137 | # end 138 | # 139 | # c.default_desc 'list both tasks and contexts' 140 | # c.action do |global,options,args| 141 | # # list everything 142 | # end 143 | # end 144 | # 145 | # 146 | # > todo help list 147 | # NAME 148 | # list - List things 149 | # 150 | # SYNOPSIS 151 | # todo [global options] list [command options] 152 | # todo [global options] list [command options] tasks 153 | # todo [global options] list [command options] contexts 154 | # 155 | # COMMANDS 156 | # - list both tasks and contexts 157 | # tasks - list tasks 158 | # contexts - list contexts 159 | # 160 | def default_desc(desc) 161 | @default_desc = desc 162 | end 163 | 164 | # Returns true if this command has the given option defined 165 | def has_option?(option) #:nodoc: 166 | option = option.gsub(/^\-+/,'') 167 | ((flags.values.map { |_| [_.name,_.aliases] }) + 168 | (switches.values.map { |_| [_.name,_.aliases] })).flatten.map(&:to_s).include?(option) 169 | end 170 | 171 | # Returns full name for help command including parents 172 | # 173 | # Example 174 | # 175 | # command :remote do |t| 176 | # t.command :add do |global,options,args| 177 | # end 178 | # end 179 | # 180 | # @add_command.name_for_help # => ["remote", "add"] 181 | # 182 | def name_for_help 183 | name_array = [name.to_s] 184 | command_parent = parent 185 | while(command_parent.is_a?(GLI::Command)) do 186 | name_array.unshift(command_parent.name.to_s) 187 | command_parent = command_parent.parent 188 | end 189 | name_array 190 | end 191 | 192 | def self.name_as_string(name,negatable=false) #:nodoc: 193 | name.to_s 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /test/unit/subcommands_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | require_relative "support/fake_std_out" 3 | 4 | class SubcommandsTest < Minitest::Test 5 | include TestHelper 6 | 7 | def setup 8 | @fake_stdout = FakeStdOut.new 9 | @fake_stderr = FakeStdOut.new 10 | 11 | @original_stdout = $stdout 12 | $stdout = @fake_stdout 13 | @original_stderr = $stderr 14 | $stderr = @fake_stderr 15 | 16 | @app = CLIApp.new 17 | @app.reset 18 | @app.subcommand_option_handling :legacy 19 | @app.error_device=@fake_stderr 20 | ENV.delete('GLI_DEBUG') 21 | end 22 | 23 | def teardown 24 | $stdout = @original_stdout 25 | $stderr = @original_stderr 26 | end 27 | 28 | def test_we_run_add_command_using_add 29 | we_have_a_command_with_two_subcommands 30 | run_app('remote',"add",'-f','foo','bar') 31 | assert_command_ran_with(:add, :command_options => {:f => true}, :args => %w(foo bar)) 32 | end 33 | def test_we_run_add_command_using_new 34 | we_have_a_command_with_two_subcommands 35 | run_app('remote',"new",'-f','foo','bar') 36 | assert_command_ran_with(:add, :command_options => {:f => true}, :args => %w(foo bar)) 37 | end 38 | 39 | def test_subcommands_not_used_on_command_line_uses_base_action 40 | we_have_a_command_with_two_subcommands 41 | run_app('remote','foo','bar') 42 | assert_command_ran_with(:base, :command_options => {:f => false}, :args => %w(foo bar)) 43 | end 44 | 45 | def test_switches_and_flags_on_subcommand_available 46 | we_have_a_command_with_two_subcommands(:switches => [:addswitch], :flags => [:addflag]) 47 | run_app('remote','add','--addswitch','--addflag','foo','bar') 48 | assert_command_ran_with(:add,:command_options => { :addswitch => true, :addflag => 'foo', :f => false }, 49 | :args => ['bar']) 50 | end 51 | 52 | def test_help_flag_works_in_normal_mode 53 | @app.subcommand_option_handling :normal 54 | we_have_a_command_with_two_subcommands 55 | @app.run(["remote", "add", "--help"]) rescue nil 56 | refute_match /^error/, @fake_stderr.to_s, "should not output an error message" 57 | end 58 | 59 | def test_help_flag_works_in_legacy_mode 60 | @app.subcommand_option_handling :legacy 61 | we_have_a_command_with_two_subcommands 62 | @app.run(["remote", "add", "--help"]) rescue nil 63 | refute_match /^error/, @fake_stderr.to_s, "should not output an error message" 64 | end 65 | 66 | def test_we_can_reopen_commands_to_add_new_subcommands 67 | @app.command :remote do |p| 68 | p.command :add do |c| 69 | c.action do |global_options,command_options,args| 70 | @ran_command = :add 71 | end 72 | end 73 | end 74 | @app.command :remote do |p| 75 | p.command :new do |c| 76 | c.action do |global_options,command_options,args| 77 | @ran_command = :new 78 | end 79 | end 80 | end 81 | run_app('remote','new') 82 | assert_equal(@ran_command, :new) 83 | run_app('remote', 'add') 84 | assert_equal(@ran_command, :add) 85 | end 86 | 87 | def test_reopening_commands_doesnt_readd_to_output 88 | @app.command :remote do |p| 89 | p.command(:add) { } 90 | end 91 | @app.command :remote do |p| 92 | p.command(:new) { } 93 | end 94 | command_names = @app.instance_variable_get("@commands_declaration_order").collect { |c| c.name } 95 | assert_equal 1, command_names.grep(:remote).size 96 | end 97 | 98 | 99 | def test_we_can_reopen_commands_without_causing_conflicts 100 | @app.command :remote do |p| 101 | p.command :add do |c| 102 | c.action do |global_options,command_options,args| 103 | @ran_command = :remote_add 104 | end 105 | end 106 | end 107 | @app.command :local do |p| 108 | p.command :add do |c| 109 | c.action do |global_options,command_options,args| 110 | @ran_command = :local_add 111 | end 112 | end 113 | end 114 | run_app('remote','add') 115 | assert_equal(@ran_command, :remote_add) 116 | run_app('local', 'add') 117 | assert_equal(@ran_command, :local_add) 118 | end 119 | 120 | 121 | def test_we_can_nest_subcommands_very_deep 122 | @run_results = { :add => nil, :rename => nil, :base => nil } 123 | @app.command :remote do |c| 124 | 125 | c.switch :f 126 | c.command :add do |add| 127 | add.command :some do |some| 128 | some.command :cmd do |cmd| 129 | cmd.switch :s 130 | cmd.action do |global_options,command_options,args| 131 | @run_results[:cmd] = [global_options,command_options,args] 132 | end 133 | end 134 | end 135 | end 136 | end 137 | ENV['GLI_DEBUG'] = 'true' 138 | run_app('remote','add','some','cmd','-s','blah') 139 | assert_command_ran_with(:cmd, :command_options => {:s => true, :f => false},:args => ['blah']) 140 | end 141 | 142 | def test_when_any_command_has_no_action_but_there_are_args_indicate_unknown_command 143 | a_very_deeply_nested_command_structure 144 | assert_raises GLI::UnknownCommand do 145 | When run_app('remote','add','some','foo') 146 | end 147 | assert_match /Unknown command 'foo'/,@fake_stderr.to_s 148 | end 149 | 150 | def test_when_any_command_has_no_action_but_there_are_no_args_indicate_subcommand_needed 151 | a_very_deeply_nested_command_structure 152 | assert_raises GLI::BadCommandLine do 153 | When run_app('remote','add','some') 154 | end 155 | assert_match /Command 'some' requires a subcommand/,@fake_stderr.to_s 156 | end 157 | 158 | private 159 | 160 | def run_app(*args) 161 | @exit_code = @app.run(args) 162 | end 163 | 164 | def a_very_deeply_nested_command_structure 165 | @run_results = { :add => nil, :rename => nil, :base => nil } 166 | @app.command :remote do |c| 167 | 168 | c.switch :f 169 | c.command :add do |add| 170 | add.command :some do |some| 171 | some.command :cmd do |cmd| 172 | cmd.switch :s 173 | cmd.action do |global_options,command_options,args| 174 | @run_results[:cmd] = [global_options,command_options,args] 175 | end 176 | end 177 | end 178 | end 179 | end 180 | ENV['GLI_DEBUG'] = 'true' 181 | end 182 | 183 | # expected_command - name of command exepcted to have been run 184 | # options: 185 | # - global_options => hash of expected options 186 | # - command_options => hash of expected command options 187 | # - args => array of expected args 188 | def assert_command_ran_with(expected_command,options) 189 | global_options = options[:global_options] || { :help => false } 190 | @run_results.each do |command,results| 191 | if command == expected_command 192 | assert_equal(indifferent_hash(global_options),results[0]) 193 | assert_equal(indifferent_hash(options[:command_options]),results[1]) 194 | assert_equal(options[:args],results[2]) 195 | else 196 | assert_nil results 197 | end 198 | end 199 | end 200 | 201 | def indifferent_hash(possibly_nil_hash) 202 | return {} if possibly_nil_hash.nil? 203 | possibly_nil_hash.keys.each do |key| 204 | if key.kind_of? Symbol 205 | possibly_nil_hash[key.to_s] = possibly_nil_hash[key] unless possibly_nil_hash.has_key?(key.to_s) 206 | elsif key.kind_of? String 207 | possibly_nil_hash[key.to_sym] = possibly_nil_hash[key] unless possibly_nil_hash.has_key?(key.to_sym) 208 | end 209 | end 210 | possibly_nil_hash 211 | end 212 | 213 | # options - 214 | # :flags => flags to add to :add 215 | # :switiches => switiches to add to :add 216 | def we_have_a_command_with_two_subcommands(options = {}) 217 | @run_results = { :add => nil, :rename => nil, :base => nil } 218 | @app.command :remote do |c| 219 | 220 | c.switch :f 221 | 222 | c.desc "add a remote" 223 | c.command [:add,:new] do |add| 224 | 225 | Array(options[:flags]).each { |_| add.flag _ } 226 | Array(options[:switches]).each { |_| add.switch _ } 227 | add.action do |global_options,command_options,args| 228 | @run_results[:add] = [global_options,command_options,args] 229 | end 230 | end 231 | 232 | c.desc "rename a remote" 233 | c.command :rename do |rename| 234 | rename.action do |global_options,command_options,args| 235 | @run_results[:rename] = [global_options,command_options,args] 236 | end 237 | end 238 | 239 | c.action do |global_options,command_options,args| 240 | @run_results[:base] = [global_options,command_options,args] 241 | end 242 | end 243 | ENV['GLI_DEBUG'] = 'true' 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /lib/gli/commands/doc.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | module Commands 3 | # Takes a DocListener which will be called with all of the meta-data and documentation 4 | # about your app, so as to create documentation in whatever format you want 5 | class Doc < Command 6 | FORMATS = { 7 | 'rdoc' => GLI::Commands::RdocDocumentListener 8 | } 9 | # Create the Doc generator based on the GLI app passed in 10 | def initialize(app) 11 | super(:names => "_doc", 12 | :description => "Generate documentation of your application's UI", 13 | :long_desc => "Introspects your application's UI meta-data to generate documentation in a variety of formats. This is intended to be extensible via the DocumentListener interface, so that you can provide your own documentation formats without them being a part of GLI", 14 | :skips_pre => true, :skips_post => true, :skips_around => true, :hidden => true) 15 | 16 | @app = app 17 | @parent = @app 18 | @subcommand_option_handling_strategy = @app.subcommand_option_handling_strategy 19 | 20 | desc 'The format name of the documentation to generate or the class name to use to generate it' 21 | default_value 'rdoc' 22 | arg_name 'name_or_class' 23 | flag :format 24 | 25 | action do |global_options,options,arguments| 26 | self.document(format_class(options[:format]).new(global_options,options,arguments,app)) 27 | end 28 | end 29 | 30 | def nodoc 31 | true 32 | end 33 | 34 | # Generates documentation using the listener 35 | def document(document_listener) 36 | document_listener.beginning 37 | document_listener.program_desc(@app.program_desc) unless @app.program_desc.nil? 38 | document_listener.program_long_desc(@app.program_long_desc) unless @app.program_long_desc.nil? 39 | document_listener.version(@app.version_string) 40 | if any_options?(@app) 41 | document_listener.options 42 | end 43 | document_flags_and_switches(document_listener, 44 | @app.flags.values.sort(&by_name), 45 | @app.switches.values.sort(&by_name)) 46 | if any_options?(@app) 47 | document_listener.end_options 48 | end 49 | document_listener.commands 50 | document_commands(document_listener,@app) 51 | document_listener.end_commands 52 | document_listener.ending 53 | end 54 | 55 | # Interface for a listener that is called during various parts of the doc process 56 | class DocumentListener 57 | def initialize(global_options,options,arguments,app) 58 | @global_options = global_options 59 | @options = options 60 | @arguments = arguments 61 | @app = app 62 | end 63 | # Called before processing begins 64 | def beginning 65 | abstract! 66 | end 67 | 68 | # Called when processing has completed 69 | def ending 70 | abstract! 71 | end 72 | 73 | # Gives you the program description 74 | def program_desc(desc) 75 | abstract! 76 | end 77 | 78 | # Gives you the program long description 79 | def program_long_desc(desc) 80 | abstract! 81 | end 82 | 83 | # Gives you the program version 84 | def version(version) 85 | abstract! 86 | end 87 | 88 | # Called at the start of options for the current context 89 | def options 90 | abstract! 91 | end 92 | 93 | # Called when all options for the current context have been vended 94 | def end_options 95 | abstract! 96 | end 97 | 98 | # Called at the start of commands for the current context 99 | def commands 100 | abstract! 101 | end 102 | 103 | # Called when all commands for the current context have been vended 104 | def end_commands 105 | abstract! 106 | end 107 | 108 | # Gives you a flag in the current context 109 | def flag(name,aliases,desc,long_desc,default_value,arg_name,must_match,type) 110 | abstract! 111 | end 112 | 113 | # Gives you a switch in the current context 114 | def switch(name,aliases,desc,long_desc,negatable) 115 | abstract! 116 | end 117 | 118 | # Gives you the name of the current command in the current context 119 | def default_command(name) 120 | abstract! 121 | end 122 | 123 | # Gives you a command in the current context and creates a new context of this command 124 | def command(name,aliases,desc,long_desc,arg_name,arg_options) 125 | abstract! 126 | end 127 | 128 | # Ends a command, and "pops" you back up one context 129 | def end_command(name) 130 | abstract! 131 | end 132 | 133 | private 134 | def abstract! 135 | raise "Subclass must implement" 136 | end 137 | end 138 | 139 | private 140 | 141 | def format_class(format_name) 142 | FORMATS.fetch(format_name) { 143 | begin 144 | return format_name.split(/::/).reduce(Kernel) { |context,part| context.const_get(part) } 145 | rescue => ex 146 | raise IndexError,"Couldn't find formatter or class named #{format_name}" 147 | end 148 | } 149 | end 150 | 151 | def document_commands(document_listener,context) 152 | context.commands.values.reject {|_| _.nodoc }.sort(&by_name).each do |command| 153 | call_command_method_being_backwards_compatible(document_listener,command) 154 | document_listener.options if any_options?(command) 155 | document_flags_and_switches(document_listener,command_flags(command),command_switches(command)) 156 | document_listener.end_options if any_options?(command) 157 | document_listener.commands if any_commands?(command) 158 | document_commands(document_listener,command) 159 | document_listener.end_commands if any_commands?(command) 160 | document_listener.end_command(command.name) 161 | end 162 | document_listener.default_command(context.get_default_command) 163 | end 164 | 165 | def call_command_method_being_backwards_compatible(document_listener,command) 166 | command_args = [command.name, 167 | Array(command.aliases), 168 | command.description, 169 | command.long_description, 170 | command.arguments_description] 171 | if document_listener.method(:command).arity >= 6 172 | command_args << command.arguments_options 173 | if document_listener.method(:command).arity >= 7 174 | command_args << command.arguments 175 | end 176 | if document_listener.method(:command).arity >= 8 177 | command_args << command.examples 178 | end 179 | end 180 | document_listener.command(*command_args) 181 | end 182 | 183 | def by_name 184 | lambda { |a,b| a.name.to_s <=> b.name.to_s } 185 | end 186 | 187 | def command_flags(command) 188 | if @subcommand_option_handling_strategy == :legacy 189 | command.topmost_ancestor.flags.values.select { |flag| flag.associated_command == command }.sort(&by_name) 190 | else 191 | command.flags.values.sort(&by_name) 192 | end 193 | end 194 | 195 | def command_switches(command) 196 | if @subcommand_option_handling_strategy == :legacy 197 | command.topmost_ancestor.switches.values.select { |switch| switch.associated_command == command }.sort(&by_name) 198 | else 199 | command.switches.values.sort(&by_name) 200 | end 201 | end 202 | 203 | def document_flags_and_switches(document_listener,flags,switches) 204 | flags.each do |flag| 205 | document_listener.flag(flag.name, 206 | Array(flag.aliases), 207 | flag.description, 208 | flag.long_description, 209 | flag.safe_default_value, 210 | flag.argument_name, 211 | flag.must_match, 212 | flag.type) 213 | end 214 | switches.each do |switch| 215 | document_listener.switch(switch.name, 216 | Array(switch.aliases), 217 | switch.description, 218 | switch.long_description, 219 | switch.negatable) 220 | end 221 | end 222 | 223 | def any_options?(context) 224 | options = if context.kind_of?(Command) 225 | command_flags(context) + command_switches(context) 226 | else 227 | context.flags.values + context.switches.values 228 | end 229 | !options.empty? 230 | end 231 | 232 | def any_commands?(command) 233 | !command.commands.empty? 234 | end 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /test/unit/doc_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class String 4 | def blank? 5 | self.strip.length == 0 6 | end 7 | end 8 | 9 | class NilClass 10 | def blank? 11 | true 12 | end 13 | end 14 | 15 | class Object 16 | def blank? 17 | false 18 | end 19 | end 20 | 21 | class DocTest < Minitest::Test 22 | include TestHelper 23 | 24 | class TestApp 25 | include GLI::App 26 | end 27 | 28 | class TestListener 29 | @@last = nil 30 | def self.last 31 | @@last 32 | end 33 | def initialize(*ignored) 34 | @stringio = StringIO.new 35 | @indent = '' 36 | @@last = self 37 | end 38 | def options 39 | end 40 | def end_options 41 | end 42 | def commands 43 | end 44 | def end_commands 45 | end 46 | def beginning 47 | @stringio << 'BEGIN' << "\n" 48 | end 49 | 50 | def ending 51 | @stringio << 'END' << "\n" 52 | end 53 | 54 | def program_desc(desc) 55 | @stringio << desc << "\n" 56 | end 57 | 58 | def program_long_desc(desc) 59 | @stringio << desc << "\n" 60 | end 61 | 62 | def version(version) 63 | @stringio << version << "\n" 64 | end 65 | 66 | def default_command(name) 67 | @stringio << @indent << "default_command: " << name << "\n" 68 | end 69 | 70 | def flag(name,aliases,desc,long_desc,default_value,arg_name,must_match,type) 71 | @stringio << @indent << "flag: " << name << "\n" 72 | @indent += ' ' 73 | @stringio << @indent << "aliases: " << aliases.join(',') << "\n" unless aliases.empty? 74 | @stringio << @indent << "desc: " << desc << "\n" unless desc.blank? 75 | @stringio << @indent << "long_desc: " << long_desc << "\n" unless long_desc.blank? 76 | @stringio << @indent << "default_value: " << default_value << "\n" unless default_value.blank? 77 | @stringio << @indent << "arg_name: " << arg_name << "\n" unless arg_name.blank? 78 | @indent.gsub!(/ $/,'') 79 | end 80 | 81 | def switch(name,aliases,desc,long_desc,negatable) 82 | @stringio << @indent << "switch: " << name << "\n" 83 | @indent += ' ' 84 | @stringio << @indent << "aliases: " << aliases.join(',') << "\n" unless aliases.empty? 85 | @stringio << @indent << "desc: " << desc << "\n" unless desc.blank? 86 | @stringio << @indent << "long_desc: " << long_desc << "\n" unless long_desc.blank? 87 | @stringio << @indent << "negatable: " << negatable << "\n" unless negatable.blank? 88 | @indent.gsub!(/ $/,'') 89 | end 90 | 91 | def command(name,aliases,desc,long_desc,arg_name) 92 | @stringio << @indent << "command: " << name << "\n" 93 | @indent += ' ' 94 | @stringio << @indent << "aliases: " << aliases.join(',') << "\n" unless aliases.empty? 95 | @stringio << @indent << "desc: " << desc << "\n" unless desc.blank? 96 | @stringio << @indent << "long_desc: " << long_desc << "\n" unless long_desc.blank? 97 | @stringio << @indent << "arg_name: " << arg_name << "\n" unless arg_name.blank? 98 | end 99 | 100 | def end_command(name) 101 | @indent.gsub!(/ $/,'') 102 | @stringio << @indent << "end #{name}" << "\n" 103 | end 104 | 105 | def to_s 106 | @stringio.string 107 | end 108 | end 109 | 110 | def setup 111 | @@counter = -1 # we pre-increment so this makes 0 first 112 | end 113 | 114 | def test_app_without_docs_gets_callbacks_for_each_element 115 | setup_test_app 116 | construct_expected_output 117 | @documenter = GLI::Commands::Doc.new(@app) 118 | @listener = TestListener.new 119 | @documenter.document(@listener) 120 | lines_expected = @string.split(/\n/) 121 | lines_got = @listener.to_s.split(/\n/) 122 | lines_expected.zip(lines_got).each_with_index do |(expected,got),index| 123 | assert_equal expected,got,"At index #{index}" 124 | end 125 | end 126 | 127 | def test_doc_command_works_as_GLI_command 128 | setup_test_app 129 | construct_expected_output 130 | @documenter = GLI::Commands::Doc.new(@app) 131 | @listener = TestListener.new 132 | @documenter.execute({},{:format => "DocTest::TestListener"},[]) 133 | lines_expected = @string.split(/\n/) 134 | lines_got = TestListener.last.to_s.split(/\n/) 135 | lines_expected.zip(lines_got).each_with_index do |(expected,got),index| 136 | assert_equal expected,got,"At index #{index}" 137 | end 138 | end 139 | 140 | private 141 | 142 | @@counter = 1 143 | def self.counter 144 | @@counter += 1 145 | @@counter 146 | end 147 | 148 | def setup_test_app 149 | @app = TestApp.new 150 | @app.instance_eval do 151 | program_desc "program desc" 152 | program_long_desc "program long desc" 153 | version "1.3.4" 154 | 155 | DocTest.flag_with_everything_specified(self) 156 | DocTest.flag_with_everything_omitted(self) 157 | DocTest.switch_with_everything_specified(self) 158 | DocTest.switch_with_everything_omitted(self) 159 | 160 | desc "command desc" 161 | long_desc "command long desc" 162 | arg_name "cmd_arg_name" 163 | command [:command1,:com1] do |c| 164 | DocTest.flag_with_everything_specified(c) 165 | DocTest.flag_with_everything_omitted(c) 166 | DocTest.switch_with_everything_specified(c) 167 | DocTest.switch_with_everything_omitted(c) 168 | 169 | c.desc "subcommand desc" 170 | c.long_desc "subcommand long desc" 171 | c.arg_name "subcmd_arg_name" 172 | c.action { |g,o,a| } 173 | c.command [:sub,:subcommand] do |sub| 174 | DocTest.flag_with_everything_specified(sub,:subflag) 175 | DocTest.flag_with_everything_omitted(sub,:subflag2) 176 | DocTest.switch_with_everything_specified(sub,:subswitch) 177 | DocTest.switch_with_everything_omitted(sub,:subswitch2) 178 | sub.action { |g,o,a| } 179 | end 180 | c.command [:default] do |sub| 181 | sub.action { |g,o,a| } 182 | end 183 | c.default_command :default 184 | end 185 | 186 | command [:command2,:com2] do |c| 187 | c.action { |g,o,a| } 188 | c.command [:sub2,:subcommand2] do |sub| 189 | sub.action { |g,o,a| } 190 | end 191 | end 192 | end 193 | end 194 | 195 | def self.flag_with_everything_specified(on,name=[:f,:flag]) 196 | on.flag name,:desc => "flag desc #{counter}", 197 | :long_desc => "flag long_desc #{counter}", 198 | :default_value => "flag default_value #{counter}", 199 | :arg_name => "flag_arg_name_#{counter}", 200 | :must_match => /foo.*bar/, 201 | :type => Array 202 | end 203 | 204 | def self.flag_with_everything_omitted(on,name=[:F,:flag2]) 205 | on.flag name 206 | end 207 | 208 | def self.switch_with_everything_specified(on,name=[:s,:switch]) 209 | on.switch name, :desc => "switch desc #{counter}", 210 | :long_desc => "switch long_desc #{counter}", 211 | :negatable => false 212 | end 213 | 214 | def self.switch_with_everything_omitted(on,name=[:S,:switch2]) 215 | on.switch name 216 | end 217 | def construct_expected_output 218 | # Oh yeah. Creating a string representing the structure of the calls. 219 | @string =< "command", 188 | :global_options => global, 189 | :command_options => options, 190 | :args => args 191 | } 192 | end 193 | 194 | c.command "subcommand" do |subcommand| 195 | subcommand.flag ['f','flag'] 196 | subcommand.flag ['foo'] 197 | subcommand.switch ['s','switch'] 198 | subcommand.action do |global,options,args| 199 | @results = { 200 | :command_name => "subcommand", 201 | :global_options => global, 202 | :command_options => options, 203 | :args => args 204 | } 205 | end 206 | end 207 | end 208 | end 209 | 210 | def setup_app_with_subcommands_storing_results(subcommand_option_handling_strategy = :legacy) 211 | @app.subcommand_option_handling subcommand_option_handling_strategy 212 | @app.flag ['f','flag'] 213 | @app.switch ['s','switch'] 214 | 215 | 2.times do |i| 216 | @app.command "command#{i}" do |c| 217 | c.flag ['f','flag'] 218 | c.switch ['s','switch'] 219 | c.action do |global,options,args| 220 | @results = { 221 | :command_name => "command#{i}", 222 | :global_options => global, 223 | :command_options => options, 224 | :args => args 225 | } 226 | end 227 | 228 | 2.times do |j| 229 | c.command "subcommand#{i}#{j}" do |subcommand| 230 | subcommand.flag ['f','flag'] 231 | subcommand.flag ['foo'] 232 | subcommand.switch ['s','switch'] 233 | subcommand.action do |global,options,args| 234 | @results = { 235 | :command_name => "subcommand#{i}#{j}", 236 | :global_options => global, 237 | :command_options => options, 238 | :args => args 239 | } 240 | end 241 | end 242 | end 243 | end 244 | end 245 | end 246 | 247 | def setup_app_with_arguments(number_required_arguments, number_optional_arguments, has_argument_multiple, arguments_handling_strategy = :loose, subcommand_option_handling_strategy = :normal) 248 | @app.arguments arguments_handling_strategy 249 | @app.subcommand_option_handling subcommand_option_handling_strategy 250 | 251 | number_required_arguments.times { |i| @app.arg("needed#{i}") } 252 | number_optional_arguments.times { |i| @app.arg("optional#{i}", :optional) } 253 | @app.arg :multiple, [:multiple, :optional] if has_argument_multiple 254 | 255 | @app.command :cmd do |c| 256 | c.action do |g,o,a| 257 | @results = { 258 | :number_of_args_give_to_action => a.size 259 | } 260 | end 261 | end 262 | end 263 | 264 | def run_app_with_X_arguments(number_arguments) 265 | @exit_code = @app.run [].tap{|args| args << "cmd"; number_arguments.times {|i| args << "arg#{i}"}} 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /lib/gli/app_support.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Internals for make App work 3 | module AppSupport 4 | # Override the device of stderr; exposed only for testing 5 | def error_device=(e) #:nodoc: 6 | @stderr = e 7 | end 8 | 9 | def context_description 10 | "in global context" 11 | end 12 | 13 | # Reset the GLI module internal data structures; mostly useful for testing 14 | def reset # :nodoc: 15 | switches.clear 16 | flags.clear 17 | @commands = nil 18 | @command_missing_block = nil 19 | @commands_declaration_order = [] 20 | @flags_declaration_order = [] 21 | @switches_declaration_order = [] 22 | @version = nil 23 | @config_file = nil 24 | @use_openstruct = false 25 | @prog_desc = nil 26 | @error_block = false 27 | @pre_block = false 28 | @post_block = false 29 | @default_command = :help 30 | @autocomplete = false 31 | @around_block = nil 32 | @subcommand_option_handling_strategy = :legacy 33 | @argument_handling_strategy = :loose 34 | clear_nexts 35 | end 36 | 37 | def exe_name 38 | File.basename($0) 39 | end 40 | 41 | # Get an array of commands, ordered by when they were declared 42 | def commands_declaration_order # :nodoc: 43 | @commands_declaration_order 44 | end 45 | 46 | # Get the version string 47 | def version_string #:nodoc: 48 | @version 49 | end 50 | 51 | # Get the default command for the entire app 52 | def get_default_command 53 | @default_command 54 | end 55 | 56 | # Runs whatever command is needed based on the arguments. 57 | # 58 | # +args+:: the command line ARGV array 59 | # 60 | # Returns a number that would be a reasonable exit code 61 | def run(args) #:nodoc: 62 | args = args.dup if @preserve_argv 63 | the_command = nil 64 | begin 65 | override_defaults_based_on_config(parse_config) 66 | 67 | add_help_switch_if_needed(self) 68 | 69 | gli_option_parser = GLIOptionParser.new(commands, 70 | flags, 71 | switches, 72 | accepts, 73 | :default_command => @default_command, 74 | :autocomplete => autocomplete, 75 | :subcommand_option_handling_strategy => subcommand_option_handling_strategy, 76 | :argument_handling_strategy => argument_handling_strategy, 77 | :command_missing_block => @command_missing_block) 78 | 79 | parsing_result = gli_option_parser.parse_options(args) 80 | parsing_result.convert_to_openstruct! if @use_openstruct 81 | 82 | the_command = parsing_result.command 83 | 84 | if proceed?(parsing_result) 85 | call_command(parsing_result) 86 | 0 87 | else 88 | raise PreconditionFailed, "preconditions failed" 89 | end 90 | rescue Exception => ex 91 | if the_command.nil? && ex.respond_to?(:command_in_context) 92 | the_command = ex.command_in_context 93 | end 94 | handle_exception(ex,the_command) 95 | end 96 | end 97 | 98 | 99 | # Return the name of the config file; mostly useful for generating help docs 100 | def config_file_name #:nodoc: 101 | @config_file 102 | end 103 | def accepts #:nodoc: 104 | @accepts ||= {} 105 | 106 | end 107 | 108 | def parse_config # :nodoc: 109 | config = { 110 | 'commands' => {}, 111 | } 112 | if @config_file && File.exist?(@config_file) 113 | require 'yaml' 114 | config.merge!(File.open(@config_file) { |file| YAML::load(file) }) 115 | end 116 | config 117 | end 118 | 119 | def clear_nexts # :nodoc: 120 | super 121 | @skips_post = false 122 | @skips_pre = false 123 | @skips_around = false 124 | end 125 | 126 | def stderr 127 | @stderr ||= STDERR 128 | end 129 | 130 | def self.included(klass) 131 | @stderr = $stderr 132 | end 133 | 134 | def flags # :nodoc: 135 | @flags ||= {} 136 | end 137 | 138 | def switches # :nodoc: 139 | @switches ||= {} 140 | end 141 | 142 | def commands # :nodoc: 143 | if !@commands 144 | @commands = { :help => GLI::Commands::Help.new(self), :_doc => GLI::Commands::Doc.new(self) } 145 | @commands_declaration_order ||= [] 146 | @commands_declaration_order << @commands[:help] 147 | @commands_declaration_order << @commands[:_doc] 148 | end 149 | @commands 150 | end 151 | 152 | def pre_block 153 | @pre_block ||= Proc.new do 154 | true 155 | end 156 | end 157 | 158 | def post_block 159 | @post_block ||= Proc.new do 160 | end 161 | end 162 | 163 | def around_blocks 164 | @around_blocks || [] 165 | end 166 | 167 | def help_sort_type 168 | @help_sort_type || :alpha 169 | end 170 | 171 | def help_text_wrap_type 172 | @help_text_wrap_type || :to_terminal 173 | end 174 | 175 | def synopsis_format_type 176 | @synopsis_format_type || :full 177 | end 178 | 179 | # Sets the default values for flags based on the configuration 180 | def override_defaults_based_on_config(config) 181 | override_default(flags,config) 182 | override_default(switches,config) 183 | 184 | override_command_defaults(commands,config) 185 | end 186 | 187 | def override_command_defaults(command_list,config) 188 | command_list.each do |command_name,command| 189 | next if command_name == :initconfig || command.nil? 190 | command_config = (config['commands'] || {})[command_name] || {} 191 | 192 | if @subcommand_option_handling_strategy == :legacy 193 | override_default(command.topmost_ancestor.flags,command_config) 194 | override_default(command.topmost_ancestor.switches,command_config) 195 | else 196 | override_default(command.flags,command_config) 197 | override_default(command.switches,command_config) 198 | end 199 | 200 | override_command_defaults(command.commands,command_config) 201 | end 202 | end 203 | 204 | def override_default(tokens,config) 205 | tokens.each do |name,token| 206 | token.default_value=config[name] unless config[name].nil? 207 | end 208 | end 209 | 210 | def argument_handling_strategy 211 | @argument_handling_strategy || :loose 212 | end 213 | 214 | def subcommand_option_handling_strategy 215 | @subcommand_option_handling_strategy || :legacy 216 | end 217 | 218 | def autocomplete 219 | @autocomplete.nil? ? true : @autocomplete 220 | end 221 | 222 | private 223 | 224 | def handle_exception(ex,command) 225 | if regular_error_handling?(ex) 226 | output_error_message(ex) 227 | if ex.kind_of?(OptionParser::ParseError) || ex.kind_of?(BadCommandLine) || ex.kind_of?(RequestHelp) 228 | if commands[:help] 229 | command_for_help = command.nil? ? [] : command.name_for_help 230 | commands[:help].execute({},{},command_for_help) 231 | end 232 | end 233 | elsif ENV['GLI_DEBUG'] == 'true' 234 | stderr.puts "Custom error handler exited false, skipping normal error handling" 235 | end 236 | 237 | raise ex if ENV['GLI_DEBUG'] == 'true' && !ex.kind_of?(RequestHelp) 238 | 239 | ex.extend(GLI::StandardException) 240 | ex.exit_code 241 | end 242 | 243 | def output_error_message(ex) 244 | stderr.puts error_message(ex) unless no_message_given?(ex) 245 | if ex.kind_of?(OptionParser::ParseError) || ex.kind_of?(BadCommandLine) 246 | stderr.puts unless no_message_given?(ex) 247 | end 248 | end 249 | 250 | def no_message_given?(ex) 251 | ex.message == ex.class.name 252 | 253 | end 254 | 255 | def add_help_switch_if_needed(target) 256 | help_switch_exists = target.switches.values.find { |switch| 257 | switch.names_and_aliases.map(&:to_s).find { |an_alias| an_alias == 'help' } 258 | } 259 | unless help_switch_exists 260 | target.desc 'Show this message' 261 | target.switch :help, :negatable => false 262 | end 263 | end 264 | 265 | # True if we should proceed with executing the command; this calls 266 | # the pre block if it's defined 267 | def proceed?(parsing_result) #:nodoc: 268 | if parsing_result.command && parsing_result.command.skips_pre 269 | true 270 | else 271 | pre_block.call(*parsing_result) 272 | end 273 | end 274 | 275 | # Returns true if we should proceed with GLI's basic error handling. 276 | # This calls the error block if the user provided one 277 | def regular_error_handling?(ex) #:nodoc: 278 | if @error_block 279 | return true if (ex.respond_to?(:exit_code) && ex.exit_code == 0) 280 | @error_block.call(ex) 281 | else 282 | true 283 | end 284 | end 285 | 286 | # Returns a String of the error message to show the user 287 | # +ex+:: The exception we caught that launched the error handling routines 288 | def error_message(ex) #:nodoc: 289 | "error: #{ex.message}" 290 | end 291 | 292 | def call_command(parsing_result) 293 | command = parsing_result.command 294 | global_options = parsing_result.global_options 295 | options = parsing_result.command_options 296 | arguments = parsing_result.arguments.map { |arg| arg.dup } # unfreeze 297 | 298 | code = lambda { command.execute(global_options,options,arguments) } 299 | nested_arounds = unless command.skips_around 300 | around_blocks.inject do |outer_around, inner_around| 301 | lambda { |go,c,o,a, code1| 302 | inner = lambda { inner_around.call(go,c,o,a, code1) } 303 | outer_around.call(go,c,o,a, inner) 304 | } 305 | end 306 | end 307 | 308 | if nested_arounds 309 | nested_arounds.call(global_options,command, options, arguments, code) 310 | else 311 | code.call 312 | end 313 | 314 | unless command.skips_post 315 | post_block.call(global_options,command,options,arguments) 316 | end 317 | end 318 | 319 | end 320 | end 321 | -------------------------------------------------------------------------------- /lib/gli/gli_option_parser.rb: -------------------------------------------------------------------------------- 1 | module GLI 2 | # Parses the command-line options using an actual +OptionParser+ 3 | class GLIOptionParser 4 | attr_accessor :options 5 | 6 | DEFAULT_OPTIONS = { 7 | :default_command => nil, 8 | :autocomplete => true, 9 | :subcommand_option_handling_strategy => :legacy, 10 | :argument_handling_strategy => :loose 11 | } 12 | 13 | def initialize(commands,flags,switches,accepts, options={}) 14 | self.options = DEFAULT_OPTIONS.merge(options) 15 | 16 | command_finder = CommandFinder.new(commands, 17 | :default_command => (options[:default_command] || :help), 18 | :autocomplete => options[:autocomplete]) 19 | @global_option_parser = GlobalOptionParser.new(OptionParserFactory.new(flags,switches,accepts),command_finder,flags, :command_missing_block => options[:command_missing_block]) 20 | @accepts = accepts 21 | if options[:argument_handling_strategy] == :strict && options[:subcommand_option_handling_strategy] != :normal 22 | raise ArgumentError, "To use strict argument handling, you must enable normal subcommand_option_handling, e.g. subcommand_option_handling :normal" 23 | end 24 | end 25 | 26 | # Given the command-line argument array, returns an OptionParsingResult 27 | def parse_options(args) # :nodoc: 28 | option_parser_class = self.class.const_get("#{options[:subcommand_option_handling_strategy].to_s.capitalize}CommandOptionParser") 29 | OptionParsingResult.new.tap { |parsing_result| 30 | parsing_result.arguments = args 31 | parsing_result = @global_option_parser.parse!(parsing_result) 32 | option_parser_class.new(@accepts).parse!(parsing_result, options[:argument_handling_strategy], options[:autocomplete]) 33 | } 34 | end 35 | 36 | private 37 | 38 | class GlobalOptionParser 39 | def initialize(option_parser_factory,command_finder,flags,options={}) 40 | @option_parser_factory = option_parser_factory 41 | @command_finder = command_finder 42 | @flags = flags 43 | @options = options 44 | end 45 | 46 | def parse!(parsing_result) 47 | parsing_result.arguments = GLIOptionBlockParser.new(@option_parser_factory,UnknownGlobalArgument).parse!(parsing_result.arguments) 48 | parsing_result.global_options = @option_parser_factory.options_hash_with_defaults_set! 49 | command_name = if parsing_result.global_options[:help] || parsing_result.global_options[:version] 50 | "help" 51 | else 52 | parsing_result.arguments.shift 53 | end 54 | parsing_result.command = begin 55 | @command_finder.find_command(command_name) 56 | rescue UnknownCommand => e 57 | raise e unless @options[:command_missing_block] 58 | command = @options[:command_missing_block].call(command_name.to_sym,parsing_result.global_options) 59 | if command 60 | unless command.is_a?(Command) 61 | raise UnknownCommand.new("Expected the `command_missing` block to return a GLI::Command object, got a #{command.class.name} instead.") 62 | end 63 | else 64 | raise e 65 | end 66 | command 67 | end 68 | 69 | unless command_name == 'help' 70 | verify_required_options!(@flags, parsing_result.command, parsing_result.global_options) 71 | end 72 | parsing_result 73 | end 74 | 75 | protected 76 | def verify_arguments!(arguments, command) 77 | # Lets assume that if the user sets a 'arg_name' for the command it is for a complex scenario 78 | # and we should not validate the arguments 79 | return unless command.arguments_description.empty? 80 | 81 | # Go through all declared arguments for the command, counting the min and max number 82 | # of arguments 83 | min_number_of_arguments = 0 84 | max_number_of_arguments = 0 85 | command.arguments.each do |arg| 86 | if arg.optional? 87 | max_number_of_arguments = max_number_of_arguments + 1 88 | else 89 | min_number_of_arguments = min_number_of_arguments + 1 90 | max_number_of_arguments = max_number_of_arguments + 1 91 | end 92 | 93 | # Special case, as soon as we have a 'multiple' arguments, all bets are off for the 94 | # maximum number of arguments ! 95 | if arg.multiple? 96 | max_number_of_arguments = 99999 97 | end 98 | end 99 | 100 | # Now validate the number of arguments 101 | num_arguments_range = min_number_of_arguments..max_number_of_arguments 102 | if !num_arguments_range.cover?(arguments.size) 103 | raise MissingRequiredArgumentsException.new(command,arguments.size,num_arguments_range) 104 | end 105 | end 106 | 107 | def verify_required_options!(flags, command, options) 108 | missing_required_options = flags.values. 109 | select(&:required?). 110 | select { |option| 111 | options[option.name] == nil || 112 | ( options[option.name].kind_of?(Array) && options[option.name].empty? ) 113 | } 114 | unless missing_required_options.empty? 115 | raise MissingRequiredOptionsException.new(command,missing_required_options) 116 | end 117 | end 118 | end 119 | 120 | class NormalCommandOptionParser < GlobalOptionParser 121 | def initialize(accepts) 122 | @accepts = accepts 123 | end 124 | 125 | def error_handler 126 | lambda { |message,extra_error_context| 127 | raise UnknownCommandArgument.new(message,extra_error_context) 128 | } 129 | end 130 | 131 | def parse!(parsing_result,argument_handling_strategy,autocomplete) 132 | parsed_command_options = {} 133 | command = parsing_result.command 134 | arguments = nil 135 | 136 | loop do 137 | option_parser_factory = OptionParserFactory.for_command(command,@accepts) 138 | option_block_parser = CommandOptionBlockParser.new(option_parser_factory, self.error_handler) 139 | option_block_parser.command = command 140 | arguments = parsing_result.arguments 141 | 142 | arguments = option_block_parser.parse!(arguments) 143 | 144 | parsed_command_options[command] = option_parser_factory.options_hash_with_defaults_set! 145 | command_finder = CommandFinder.new(command.commands, :default_command => command.get_default_command, :autocomplete => autocomplete) 146 | next_command_name = arguments.shift 147 | 148 | verify_required_options!(command.flags, command, parsed_command_options[command]) 149 | 150 | begin 151 | command = command_finder.find_command(next_command_name) 152 | rescue AmbiguousCommand 153 | arguments.unshift(next_command_name) 154 | break 155 | rescue UnknownCommand 156 | arguments.unshift(next_command_name) 157 | # Although command finder could certainly know if it should use 158 | # the default command, it has no way to put the "unknown command" 159 | # back into the argument stack. UGH. 160 | unless command.get_default_command.nil? 161 | command = command_finder.find_command(command.get_default_command) 162 | end 163 | break 164 | end 165 | end 166 | parsed_command_options[command] ||= {} 167 | command_options = parsed_command_options[command] 168 | 169 | this_command = command.parent 170 | child_command_options = command_options 171 | 172 | while this_command.kind_of?(command.class) 173 | this_command_options = parsed_command_options[this_command] || {} 174 | child_command_options[GLI::Command::PARENT] = this_command_options 175 | this_command = this_command.parent 176 | child_command_options = this_command_options 177 | end 178 | 179 | parsing_result.command_options = command_options 180 | parsing_result.command = command 181 | parsing_result.arguments = Array(arguments.compact) 182 | 183 | # Lets validate the arguments now that we know for sure the command that is invoked 184 | verify_arguments!(parsing_result.arguments, parsing_result.command) if argument_handling_strategy == :strict 185 | 186 | parsing_result 187 | end 188 | 189 | end 190 | 191 | class LegacyCommandOptionParser < NormalCommandOptionParser 192 | def parse!(parsing_result,argument_handling_strategy,autocomplete) 193 | command = parsing_result.command 194 | option_parser_factory = OptionParserFactory.for_command(command,@accepts) 195 | option_block_parser = LegacyCommandOptionBlockParser.new(option_parser_factory, self.error_handler) 196 | option_block_parser.command = command 197 | 198 | parsing_result.arguments = option_block_parser.parse!(parsing_result.arguments) 199 | parsing_result.command_options = option_parser_factory.options_hash_with_defaults_set! 200 | 201 | subcommand,args = find_subcommand(command,parsing_result.arguments,autocomplete) 202 | parsing_result.command = subcommand 203 | parsing_result.arguments = args 204 | verify_required_options!(command.flags, parsing_result.command, parsing_result.command_options) 205 | end 206 | 207 | private 208 | 209 | def find_subcommand(command,arguments,autocomplete) 210 | arguments = Array(arguments) 211 | command_name = if arguments.empty? 212 | nil 213 | else 214 | arguments.first 215 | end 216 | 217 | default_command = command.get_default_command 218 | finder = CommandFinder.new(command.commands, :default_command => default_command.to_s, :autocomplete => autocomplete) 219 | 220 | begin 221 | results = [finder.find_command(command_name),arguments[1..-1]] 222 | find_subcommand(results[0],results[1],autocomplete) 223 | rescue UnknownCommand, AmbiguousCommand 224 | begin 225 | results = [finder.find_command(default_command.to_s),arguments] 226 | find_subcommand(results[0],results[1],autocomplete) 227 | rescue UnknownCommand, AmbiguousCommand 228 | [command,arguments] 229 | end 230 | end 231 | end 232 | end 233 | end 234 | end 235 | --------------------------------------------------------------------------------