├── shard.yml ├── .drone.yml ├── LICENSE ├── README.md ├── src ├── command.cr └── clicr.cr └── spec └── clicr_spec.cr /shard.yml: -------------------------------------------------------------------------------- 1 | name: clicr 2 | version: 1.1.1 3 | 4 | authors: 5 | - Julien Reichardt 6 | 7 | description: A simple declarative command line interface builder 8 | 9 | license: ISC 10 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | platform: 5 | os: linux 6 | arch: amd64 7 | 8 | steps: 9 | - name: format 10 | image: crystallang/crystal:latest-alpine 11 | commands: 12 | - crystal tool format --check 13 | 14 | - name: test 15 | image: crystallang/crystal:latest-alpine 16 | commands: 17 | - crystal spec --warnings all --error-on-warnings 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020 Julien Reichardt 4 | 5 | Permission to use, copy, modify, and distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clicr 2 | 3 | [![Build Status](https://cloud.drone.io/api/badges/j8r/clicr/status.svg)](https://cloud.drone.io/j8r/clicr) 4 | [![ISC](https://img.shields.io/badge/License-ISC-blue.svg?style=flat-square)](https://en.wikipedia.org/wiki/ISC_license) 5 | 6 | Command Line Interface for Crystal 7 | 8 | A simple Command line interface builder which aims to be easy to use. 9 | 10 | ## Installation 11 | 12 | Add the dependency to your `shard.yml`: 13 | 14 | ```yaml 15 | dependencies: 16 | clicr: 17 | github: j8r/clicr 18 | ``` 19 | 20 | ## Features 21 | 22 | This library uses generics, thanks to Crystal's powerful type-inference, and few macros, to provide this following advantages: 23 | 24 | - Compile time validation - methods must accept all possible options and arguments 25 | - No possible double commands/options at compile-time 26 | - Declarative `NamedTuple` configuration 27 | - Customizable configuration - supports all languages (See [Clicr.new](src/clicr.cr) parameters) 28 | - Fast execution, limited runtime 29 | 30 | ## Usage 31 | 32 | ### Simple example 33 | 34 | ```crystal 35 | require "clicr" 36 | 37 | Clicr.new( 38 | label: "This is my app.", 39 | commands: { 40 | talk: { 41 | label: "Talk", 42 | action: {"CLI.say": "t"}, 43 | arguments: %w(directory), 44 | options: { 45 | name: { 46 | label: "Your name", 47 | default: "foo", 48 | }, 49 | no_confirm: { 50 | short: 'y', 51 | label: "Print the name", 52 | }, 53 | }, 54 | }, 55 | }, 56 | ).run 57 | 58 | module CLI 59 | def self.say(arguments, name, no_confirm) 60 | puts arguments, name, no_confirm 61 | end 62 | end 63 | ``` 64 | 65 | Example of commands: 66 | ``` 67 | $ myapp --help 68 | Usage: myapp COMMANDS [OPTIONS] 69 | 70 | Myapp can do everything 71 | 72 | COMMANDS 73 | t, talk Talk 74 | 75 | OPTIONS 76 | --name=foo Your name 77 | -y, --no-confirm Print the name 78 | 79 | 'myapp --help' to show the help. 80 | ``` 81 | ``` 82 | $ myapp talk /tmp name=bar 83 | no, bar in /tmp 84 | ``` 85 | ``` 86 | $ myapp talk home name=bar -y 87 | yes, bar in home 88 | ``` 89 | ``` 90 | $ myapp talk test 91 | no, foo in test 92 | ``` 93 | 94 | ### Advanced example 95 | 96 | See the one in the [spec test](spec/clicr_spec.cr) 97 | 98 | ### CLI Composition 99 | 100 | It's also possible to merge several commands or options together. 101 | 102 | ```crystal 103 | other = { 104 | pp: { 105 | label: "It pp", 106 | action: "pp" 107 | } 108 | } 109 | 110 | Clicr.new( 111 | label: "Test app", 112 | commands: { 113 | puts: { 114 | alias: 'p', 115 | label: "It puts", 116 | action: "puts", 117 | }.merge(other) 118 | } 119 | ) 120 | ``` 121 | 122 | Help output: 123 | ``` 124 | Usage: myapp COMMAND 125 | 126 | Test app 127 | 128 | COMMAND 129 | p, puts It puts 130 | pp It pp 131 | 132 | 'myapp --help' to show the help. 133 | ``` 134 | 135 | ## Reference 136 | 137 | ### Commands 138 | 139 | Example: `s`, `start` 140 | 141 | ```crystal 142 | commands: { 143 | short: { 144 | action: { "say": "s" }, 145 | label: "Starts the server", 146 | description: <<-E.to_s, 147 | This is a full multi-line description 148 | explaining the command 149 | E, 150 | } 151 | } 152 | ``` 153 | 154 | * `action` is a `NamedTuple` with as key the method to call, and as a value a command alia, which can be empty for none. 155 | * in `action`, parentheses can be added to determine the arguments placement, like `File.new().file` 156 | * `label` is supposed to be short, one-line description 157 | * `description` can be a multi-line description of the command. If not set, `label` will be used. 158 | 159 | ### Arguments 160 | 161 | Example: `command FooBar`, `command mysource mytarget` 162 | 163 | ```crystal 164 | arguments: %w(directory names) 165 | ``` 166 | 167 | ```crystal 168 | arguments: {"source", "target"} 169 | ``` 170 | 171 | * if a `Tuple` is given, the arguments number **must** be exactly the `Tuple` size. 172 | * if an `Array` is given, the arguments number must be at least, or more, the `Array` size 173 | 174 | ### Options 175 | 176 | #### Boolean options 177 | 178 | Example: `-y`, `--no-confirm` 179 | 180 | ```crystal 181 | options: { 182 | no_confirm: { 183 | short: 'y', 184 | label: "No confirmations", 185 | } 186 | } 187 | ``` 188 | 189 | * `short` creates a short alias of one character - must be a `Char` 190 | * concatenating single characters arguments like `-Ry1` is possible 191 | * dashes `-`, being invalid named arguments, will be replaced by `_` when calling the action method. 192 | 193 | Special case: the `help_option`, which is set to `"help"` with the options `-h, --help` by default, 194 | shows the help of the current (sub)command 195 | 196 | #### String options 197 | 198 | Example: `--name=foo`, or `--name foo` 199 | 200 | ```crystal 201 | options: { 202 | name: { 203 | label: "This is your name", 204 | default: "Foobar", 205 | } 206 | } 207 | ``` 208 | 209 | * an optional `default` value can be set. 210 | * if a `default` is not set, a `type` can be defined to cast from a given type, instead of a raw `String`. For example, `type: Int32` will call `Int32.new`. 211 | * can only be `String` (because arguments passed as `ARGV` are `Array(String)`) - if others type are needed, the cast must be done after the `action` method call 212 | 213 | ## License 214 | 215 | Copyright (c) 2020 Julien Reichardt - ISC License 216 | -------------------------------------------------------------------------------- /src/command.cr: -------------------------------------------------------------------------------- 1 | class Clicr 2 | module Subcommand 3 | end 4 | end 5 | 6 | struct Clicr::Command(Action, Arguments, Commands, Options) 7 | include Clicr::Subcommand 8 | getter name, short, label, description, arguments, inherit, exclude, sub_commands, options 9 | 10 | struct Option(T, D) 11 | getter short : Char?, 12 | label : String?, 13 | default : D, 14 | type : T.class = T 15 | getter? string_option : Bool = false 16 | 17 | private def initialize(@label, @short, @type : T.class, @default : D, @string_option) 18 | end 19 | 20 | def self.new(label : String? = nil, short : Char? = nil) 21 | new label, short, Nil, nil, false 22 | end 23 | 24 | def self.new(type : T.class, label : String? = nil, short : Char? = nil) 25 | new label, short, type, nil, true 26 | end 27 | 28 | def self.new(default : D, label : String? = nil, short : Char? = nil) 29 | new label, short, D, default, true 30 | end 31 | 32 | # yields in cast of 33 | def cast_value(raw_value : String) : T 34 | {% if T == String %} 35 | raw_value 36 | {% else %} 37 | T.new raw_value 38 | {% end %} 39 | end 40 | end 41 | 42 | private def initialize( 43 | @name : String, 44 | @short : String?, 45 | @label : String?, 46 | @description : String?, 47 | @inherit : Array(String)?, 48 | @exclude : Array(String)?, 49 | @action : Action, 50 | @arguments : Arguments, 51 | @sub_commands : Commands, 52 | @options : Options 53 | ) 54 | end 55 | 56 | def self.create( 57 | name : String, 58 | short : String, 59 | label : String? = nil, 60 | description : String? = nil, 61 | inherit : Array(String)? = nil, 62 | exclude : Array(String)? = nil, 63 | action : Action = nil, 64 | arguments : Arguments = nil, 65 | commands : Commands = nil, 66 | options : Options = nil 67 | ) 68 | {% !(Action < NamedTuple) && Commands == Nil && raise "At least an action to perform or sub-commands that have actions to perfom is needed." %} 69 | {% if Options < NamedTuple %} 70 | casted_options = { 71 | {% for name in Options.keys.sort_by { |k| k } %}\ 72 | # {{name}} 73 | {{name.stringify}}: Option.new(**options[{{name.symbolize}}]), 74 | {% end %} 75 | } 76 | {% else %} 77 | casted_options = nil 78 | {% end %} 79 | {% if Commands < NamedTuple %} 80 | casted_commands = { 81 | {% for name in Commands.keys.sort_by { |k| k } %}\ 82 | create( 83 | **commands[{{name.symbolize}}].merge({ 84 | name: {{name.stringify}}, 85 | short: commands[{{name.symbolize}}][:action].values.first 86 | }) 87 | ), 88 | {% end %} 89 | } 90 | {% else %} 91 | casted_commands = nil 92 | {% end %} 93 | {% begin %} 94 | new( 95 | name, 96 | (short if !short.empty?), 97 | label, 98 | description, 99 | inherit, 100 | exclude, 101 | action, 102 | arguments, 103 | casted_commands, 104 | casted_options 105 | ) 106 | {% end %} 107 | end 108 | 109 | private def to_real_option(option : Char | String) : String 110 | option.is_a?(Char) ? "-#{option}" : "--#{option}" 111 | end 112 | 113 | # Executes an action, if availble. 114 | def exec(command_name : String, clicr : Clicr) 115 | {% begin %} 116 | {% options = Options < NamedTuple ? Options : {} of String => String %} 117 | {% for option, sub in options %}\ 118 | # {{option}} 119 | {% if sub.type_vars.first == Nil %}\ 120 | %options{option} = false 121 | {% else %} 122 | %options{option} = @options[{{option.symbolize}}].default 123 | {% end %}\ 124 | {% end %}\ 125 | 126 | sub_command = clicr.parse_options command_name, self do |option_name, value| 127 | case option_name 128 | {% for option, sub in options %}\ 129 | when {{option.stringify}}, @options[{{option.symbolize}}].short 130 | {% if sub.type_vars.first == Nil %} 131 | %options{option} = true 132 | {% else %} 133 | raw_value = value.is_a?(String) ? value : clicr.args.shift? 134 | 135 | if raw_value 136 | begin 137 | %options{option} = @options[{{option.symbolize}}].cast_value raw_value 138 | rescue ex 139 | return clicr.error_callback.call( 140 | clicr.invalid_option_value.call( 141 | command_name, to_real_option(option_name), ex 142 | ) + clicr.help_footer.call(command_name) 143 | ) 144 | end 145 | else 146 | return clicr.error_callback.call( 147 | clicr.argument_required.call(command_name, to_real_option(option_name)) + clicr.help_footer.call(command_name) 148 | ) 149 | end 150 | {% end %} 151 | {% end %} 152 | else 153 | return clicr.error_callback.call( 154 | clicr.unknown_option.call(command_name, to_real_option(option_name)) + clicr.help_footer.call(command_name) 155 | ) 156 | end 157 | end 158 | 159 | if sub_command.is_a? Subcommand 160 | return sub_command.exec("#{command_name} #{sub_command.name}", clicr) 161 | elsif !sub_command.is_a? Clicr 162 | # a callback has been called, stop 163 | return 164 | end 165 | 166 | {% if Action < NamedTuple %} 167 | {% action = Action.keys[0].split("()") %} 168 | {{ action[0].id }}( 169 | {% if Arguments < Tuple %} 170 | arguments: Arguments.from(clicr.arguments) 171 | {% elsif Arguments < Array %} 172 | arguments: clicr.arguments, 173 | {% end %} 174 | {% for option, sub in options %}\ 175 | {{option.gsub(/-/, "_")}}: %options{option}, 176 | {% end %} 177 | ){% if action.size > 1 %}{{ action[1].id }}{% end %} 178 | {% else %} 179 | clicr.help command_name, self 180 | {% end %} 181 | {% end %} 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /src/clicr.cr: -------------------------------------------------------------------------------- 1 | require "./command" 2 | 3 | class Clicr 4 | @sub : Subcommand 5 | 6 | property help_callback : Proc(String, Nil) = ->(msg : String) do 7 | STDOUT.puts msg 8 | exit 0 9 | end 10 | 11 | property error_callback : Proc(String, Nil) = ->(msg : String) do 12 | STDERR.puts msg 13 | exit 1 14 | end 15 | 16 | property help_footer : Proc(String, String) = ->(command : String) do 17 | "\n'#{command} --help' to show the help." 18 | end 19 | 20 | property argument_required : Proc(String, String, String) = ->(command : String, argument : String) do 21 | "argument required for '#{command}': #{argument}" 22 | end 23 | 24 | property unknown_argument : Proc(String, String, String) = ->(command : String, argument : String) do 25 | "unknown argument for '#{command}': #{argument}" 26 | end 27 | 28 | property unknown_command : Proc(String, String, String) = ->(command : String, sub_command : String) do 29 | "unknown command for '#{command}': '#{sub_command}'" 30 | end 31 | 32 | property unknown_option : Proc(String, String, String) = ->(command : String, option : String) do 33 | "unknown option for '#{command}': #{option}" 34 | end 35 | 36 | property invalid_option_value : Proc(String, String, Exception, String) = ->(command : String, option : String, ex : Exception) do 37 | "invalid option value for '#{command}': #{option} (#{ex})" 38 | end 39 | 40 | getter help_option : String 41 | 42 | protected getter args : Array(String) 43 | 44 | # CLI arguments 45 | protected getter arguments = Array(String).new 46 | 47 | def initialize( 48 | *, 49 | @name : String = Path[PROGRAM_NAME].basename, 50 | label : String? = nil, 51 | description : String? = nil, 52 | @usage_name : String = "Usage: ", 53 | @commands_name : String = "COMMANDS", 54 | @options_name : String = "OPTIONS", 55 | @help_option : String = "help", 56 | args : Array(String) = ARGV, 57 | action = nil, 58 | arguments = nil, 59 | commands = nil, 60 | options = nil 61 | ) 62 | @sub = Command.create( 63 | name: @name, 64 | short: "", 65 | label: label, 66 | description: description, 67 | action: action, 68 | arguments: arguments, 69 | commands: commands, 70 | options: options, 71 | ) 72 | @args = args.dup 73 | end 74 | 75 | def run(@args : Array(String) = @args) 76 | @sub.exec @name, self 77 | end 78 | 79 | protected def parse_options(command_name : String, command : Command, & : String | Char, String? ->) : Subcommand | Clicr | Nil 80 | while arg = @args.shift? 81 | if string_option = arg.lchop? "--" 82 | if @help_option == string_option 83 | return help command_name, command 84 | else 85 | var, equal_sign, val = string_option.partition '=' 86 | if equal_sign.empty? 87 | yield var, nil 88 | else 89 | yield var, val 90 | end 91 | end 92 | elsif option = arg.lchop? '-' 93 | if @help_option[0] === option[0] 94 | return help command_name, command 95 | else 96 | option.each_char do |char_opt| 97 | yield char_opt, nil 98 | end 99 | end 100 | elsif cmd_arguments = command.arguments 101 | if !cmd_arguments.is_a?(Array) && @arguments.size + 1 > cmd_arguments.size 102 | return @error_callback.call( 103 | @unknown_argument.call(command_name, arg) + @help_footer.call(command_name) 104 | ) 105 | end 106 | @arguments << arg 107 | else 108 | next if arg.empty? 109 | command.sub_commands.try &.each do |sub_command| 110 | if sub_command.name == arg || sub_command.short == arg 111 | return sub_command 112 | end 113 | end 114 | 115 | return @error_callback.call( 116 | @unknown_command.call(command_name, arg) + @help_footer.call(command_name) 117 | ) 118 | end 119 | end 120 | 121 | if (cmd_arguments = command.arguments) && cmd_arguments.is_a?(Tuple) && cmd_arguments.size > @arguments.size 122 | @error_callback.call( 123 | @argument_required.call(command_name, cmd_arguments[-1]) + @help_footer.call(command_name) 124 | ) 125 | else 126 | self 127 | end 128 | end 129 | 130 | protected def help(command_name : String, command : Command, reason : String? = nil) : Nil 131 | @help_callback.call(String.build do |io| 132 | io << @usage_name << command_name << ' ' 133 | command.arguments.try &.each do |arg| 134 | io << arg << ' ' 135 | end 136 | if command.sub_commands 137 | io << @commands_name << ' ' 138 | end 139 | io << '[' << @options_name << "]\n" 140 | if description = command.description || command.label 141 | io << '\n' << description << '\n' 142 | end 143 | if sub_commands = command.sub_commands 144 | io << '\n' << @commands_name 145 | array = Array({String, String?}).new 146 | sub_commands.each do |sub_command| 147 | name = sub_command.name 148 | if short = sub_command.short 149 | name += ", " + short 150 | end 151 | array << {name, (sub_command.label || sub_command.description)} 152 | end 153 | align io, array 154 | io.puts 155 | end 156 | 157 | if options = command.options 158 | io << '\n' << @options_name 159 | array = Array({String, String?}).new 160 | options.each do |name, option| 161 | key = "--" + name.to_s 162 | if short = option.short 163 | key += ", -" + short 164 | end 165 | if option.string_option? 166 | if default = option.default 167 | key += ' ' + default 168 | else 169 | key += " #{option.type}" 170 | end 171 | end 172 | array << {key, option.label} 173 | end 174 | align io, array 175 | io.puts 176 | end 177 | 178 | io << @help_footer.call command_name 179 | end 180 | ) 181 | end 182 | 183 | private def align(io, array : Array({String, String?})) : Nil 184 | max_size = array.max_of { |arg, _| arg.size } 185 | array.each do |name, help| 186 | io << "\n " << name 187 | if help 188 | (max_size - name.size).times do 189 | io << ' ' 190 | end 191 | io << " " << help 192 | end 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /spec/clicr_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/clicr.cr" 3 | 4 | class TestCLI 5 | getter help : String = "" 6 | getter error : String = "" 7 | @clicr : Clicr 8 | 9 | def initialize(args) 10 | other_options = { 11 | "sub-var": { 12 | label: "sub variable", 13 | type: String, 14 | }, 15 | } 16 | 17 | main_options = { 18 | name: { 19 | label: "Your name", 20 | default: "foo", 21 | short: 'n', 22 | }, 23 | yes: { 24 | short: 'y', 25 | label: "Print the name", 26 | }, 27 | } 28 | 29 | @clicr = Clicr.new( 30 | args: args, 31 | name: "myapp", 32 | commands: { 33 | talk: { 34 | label: "Talk", 35 | action: {"TestCLI.talk": "t"}, 36 | options: main_options, 37 | }, 38 | "test-multiple-no-limit": { 39 | action: {"TestCLI.args": ""}, 40 | arguments: %w(app numbers), 41 | options: { 42 | var: { 43 | type: String, 44 | }, 45 | }.merge(main_options), 46 | }, 47 | "test-simple": { 48 | action: {"TestCLI.args": ""}, 49 | arguments: %w(application folder), 50 | options: {name: main_options[:name]}, 51 | }, 52 | "tuple-args": { 53 | action: {"TestCLI.tuple_args": ""}, 54 | label: "Test args", 55 | arguments: {"one", "two"}, 56 | }, 57 | "test-parens": { 58 | action: {"TestCLI.args().to_s": ""}, 59 | }, 60 | "cast-option": { 61 | action: {"TestCLI.cast_option": ""}, 62 | options: { 63 | port: { 64 | type: Int32, 65 | short: 'p', 66 | }, 67 | other: { 68 | default: 123, 69 | }, 70 | }, 71 | }, 72 | options_variables: { 73 | label: "Test sub options/variables", 74 | description: <<-E.to_s, 75 | Multi-line 76 | description 77 | E 78 | action: {"TestCLI.args": ""}, 79 | options: { 80 | sub_opt: { 81 | short: 's', 82 | label: "sub options", 83 | }, 84 | }.merge(**other_options, **main_options), 85 | }, 86 | }, 87 | options: main_options, 88 | ) 89 | @clicr.help_callback = ->(msg : String) { 90 | @help = msg 91 | # puts msg 92 | } 93 | 94 | @clicr.error_callback = ->(msg : String) { 95 | @error = msg 96 | # puts msg 97 | } 98 | end 99 | 100 | def run 101 | @clicr.run 102 | end 103 | 104 | def self.cast_option(port : Int32?, other : Int32) : {Int32?, Int32} 105 | {port, other} 106 | end 107 | 108 | def self.tuple_args(arguments : Tuple(String, String)) 109 | arguments 110 | end 111 | 112 | def self.talk(name : String, yes : Bool) 113 | {name, yes} 114 | end 115 | 116 | def self.args(**args) 117 | args 118 | end 119 | end 120 | 121 | describe Clicr do 122 | describe "commands" do 123 | it "runs a full command" do 124 | TestCLI.new(["talk"]).run.should eq({"foo", false}) 125 | end 126 | 127 | it "runs a single character command" do 128 | TestCLI.new(["t"]).run.should eq({"foo", false}) 129 | end 130 | 131 | it "tests calling a method with parenthesis" do 132 | TestCLI.new(["test-parens"]).run.should eq "{}" 133 | end 134 | end 135 | 136 | describe "arguments" do 137 | it "uses simple" do 138 | TestCLI.new(["test-simple", "myapp", "/tmp"]).run.should eq({ 139 | arguments: ["myapp", "/tmp"], 140 | name: "foo", 141 | }) 142 | end 143 | 144 | it "uses multiple with no limit" do 145 | TestCLI.new(["test-multiple-no-limit", "myapp", "-y", "2", "3"]).run.should eq({ 146 | arguments: ["myapp", "2", "3"], 147 | var: nil, 148 | name: "foo", 149 | yes: true, 150 | }) 151 | end 152 | 153 | it "sets a variable with arguments" do 154 | TestCLI.new(["test-multiple-no-limit", "myapp", "2", "--var", "Value", "3"]).run.should eq({ 155 | arguments: ["myapp", "2", "3"], 156 | var: "Value", 157 | name: "foo", 158 | yes: false, 159 | }) 160 | end 161 | 162 | describe "tuple args" do 163 | it "provides the expected number" do 164 | TestCLI.new(["tuple-args", "1", "2"]).run.should eq({"1", "2"}) 165 | end 166 | 167 | it "fails because of too many" do 168 | cli = TestCLI.new(["tuple-args", "1"]) 169 | cli.run.should be_nil 170 | cli.error.should_not be_empty 171 | end 172 | 173 | it "fails because of too few" do 174 | cli = TestCLI.new(["tuple-args", "1", "2", "3"]) 175 | cli.run.should be_nil 176 | cli.error.should_not be_empty 177 | end 178 | end 179 | end 180 | 181 | describe "unknown command" do 182 | it "not known sub-command" do 183 | cli = TestCLI.new(["talk", "Not exists"]) 184 | cli.run.should be_nil 185 | cli.error.should_not be_empty 186 | end 187 | end 188 | 189 | describe "options" do 190 | describe "boolean" do 191 | it "use one at the end" do 192 | TestCLI.new(["talk", "--yes"]).run.should eq({"foo", true}) 193 | end 194 | 195 | it "uses a single char one at the end" do 196 | TestCLI.new(["talk", "-y"]).run.should eq({"foo", true}) 197 | end 198 | 199 | it "uses concatenated single chars" do 200 | TestCLI.new(["options_variables", "-ys"]).run.should eq({ 201 | sub_opt: true, 202 | sub_var: nil, 203 | name: "foo", 204 | yes: true, 205 | }) 206 | end 207 | end 208 | 209 | describe "string" do 210 | it "sets one with equal '='" do 211 | TestCLI.new(["talk", "--name=bar"]).run.should eq({"bar", false}) 212 | end 213 | 214 | it "sets one with space ' '" do 215 | TestCLI.new(["talk", "--name", "bar"]).run.should eq({"bar", false}) 216 | end 217 | 218 | it "sets a short one" do 219 | TestCLI.new(["talk", "-n", "bar"]).run.should eq({"bar", false}) 220 | end 221 | 222 | it "casts string option with a type given" do 223 | TestCLI.new(["cast-option", "-p", "8080"]).run.should eq({8080, 123}) 224 | end 225 | 226 | it "casts string option with a default given" do 227 | TestCLI.new(["cast-option", "--other=8080"]).run.should eq({nil, 8080}) 228 | end 229 | end 230 | end 231 | 232 | describe "help" do 233 | describe "print main help" do 234 | help = <<-HELP 235 | Usage: myapp COMMANDS [OPTIONS] 236 | 237 | COMMANDS 238 | cast-option 239 | options_variables Test sub options/variables 240 | talk, t Talk 241 | test-multiple-no-limit 242 | test-parens 243 | test-simple 244 | tuple-args Test args 245 | 246 | OPTIONS 247 | --name, -n foo Your name 248 | --yes, -y Print the name 249 | 250 | 'myapp --help' to show the help. 251 | HELP 252 | it "with -h" do 253 | cli = TestCLI.new(["-h"]) 254 | cli.run 255 | cli.help.should eq help 256 | end 257 | 258 | it "with no arguments" do 259 | cli = TestCLI.new Array(String).new 260 | cli.run 261 | cli.help.should eq help 262 | end 263 | end 264 | 265 | it "prints for sub command" do 266 | cli = TestCLI.new(["options_variables", "--help"]) 267 | cli.run 268 | cli.help.should eq <<-HELP 269 | Usage: myapp options_variables [OPTIONS] 270 | 271 | Multi-line 272 | description 273 | 274 | OPTIONS 275 | --name, -n foo Your name 276 | --sub-var String sub variable 277 | --sub_opt, -s sub options 278 | --yes, -y Print the name 279 | 280 | 'myapp options_variables --help' to show the help. 281 | HELP 282 | end 283 | end 284 | end 285 | --------------------------------------------------------------------------------