nub 0.0.55 → 0.0.56

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -54
  3. data/lib/nub/commander.rb +364 -195
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6492bcf60374552fe23049d74396793bccae8f0e11ed9521d58feb19bc592da
4
- data.tar.gz: 1113ee70f0fdef09151c7551c840dec18142af299efc7938050f49e3579e714b
3
+ metadata.gz: 6840a65c5b757eb9cf92cdbfd1548577f2a0abf4b91470477cb133a048259372
4
+ data.tar.gz: 3c5dd5de7ce42a2a2ba91b4ac2d5aa6cf773cda0320723bdd13f79ddbd918de8
5
5
  SHA512:
6
- metadata.gz: ab873f93ede81f44697af52730f61035503a0290769f132f22118567096c1910b94a4adcd4c77b657bb68167dc6303a52bda1f6ab06b1d23be1e5f5bf1b4cc28
7
- data.tar.gz: 39fe7de14e98f37b1f2d224691c801aed3c27ff44731595bc0c72a76255a2e3f847b86e6f29a7e0632c75f028860fc61bf602ce4b58de9f8f09c54e3f81885bb
6
+ metadata.gz: 20051bea8cd2af4aa22c09683673e3a99c6deb0b3e166c6e18175c7e87d96035ce3497b38337de3956274879ad52a47cb172bd80eee7f3be7b2030278c4ddb84
7
+ data.tar.gz: eddef00f72ad07ab8b384317113633d5256d489221f8411128e0db86dbee7da51c514264b93c8c3d749f0ae391d9d2f4d93396fd5806988c254118f44bc14f12
data/README.md CHANGED
@@ -10,53 +10,95 @@ Collection of ruby utils I've used in several of my projects and wanted re-usabl
10
10
  ### Table of Contents
11
11
  * [Deploy](#deploy)
12
12
  * [Commander](#commander)
13
- * [Commands](#commands)
14
- * [Options](#options)
15
- * [Help](#help)
13
+ * [Commands](#commands)
14
+ * [Command Parameters ](#command-paramaters)
15
+ * [Chained Commands](#chained-commands)
16
+ * [Options](#options)
17
+ * [Positional Options](#positional-options)
18
+ * [Value Types](#value-types)
19
+ * [Allowed Values](#allowed-values)
20
+ * [Global Options](#global-options)
21
+ * [Configuration](#configuration)
22
+ * [Help](#help)
23
+ * [Examples](#examples)
24
+ * [Indicators](#indicators)
16
25
  * [Config](#config)
17
26
  * [Ruby Gem Creation](#ruby-gem-creation)
18
- * [Package Layout](#package-layout)
19
- * [Build Gem](#build-gem)
20
- * [Install Gem](#install-gem)
21
- * [Push Gem](#push-gem)
27
+ * [Package Layout](#package-layout)
28
+ * [Build Gem](#build-gem)
29
+ * [Install Gem](#install-gem)
30
+ * [Push Gem](#push-gem)
22
31
  * [Integrate with Travis-CI](#integrate-with-travis-ci)
23
- * [Install Travis Client](#install-travis-client)
24
- * [Deploy Ruby Gem on Tag](#deploy-ruby-gem-on-tag)
32
+ * [Install Travis Client](#install-travis-client)
33
+ * [Deploy Ruby Gem on Tag](#deploy-ruby-gem-on-tag)
25
34
 
26
35
  ## Deploy <a name="deploy"></a>
27
36
  Run: `bundle install --system`
28
37
 
29
38
  ## Commander <a name="commander"></a>
30
- Commander was created mainly because all available options parsers seemed complicated and overweight
31
- and partly because I enjoyed understanding every bit going into it. Commander offers ***git*** like
32
- command syntax that is becoming so popular. Personally I like the syntax as it feels cleaner and
33
- faster to type.
39
+ Commander was created mainly because all available options parsers seemed overly complicated and
40
+ overweight and partly because I enjoyed understanding every bit going into it. Commander offers
41
+ ***git*** like command syntax that is becoming so popular.
34
42
 
35
43
  There are two kinds of paramaters that commander deals with ***commands*** and ***options***.
36
- Commands are specific named parameters that may or may not have options specific to it. Commands
37
- have their own help to display their usage and available options.
38
44
 
39
45
  ### Commands <a name="commands"></a>
40
- Commands are defined via configuration as key words that trigger different branches of functionality
41
- for the application. Each command may have zero or more options that modify how this behaveior is
42
- invoked. Whenever more than one command is used in the command line expression the expression is
43
- interpreted as being a ***chained command expression***. Chained command expressions are executed
44
- left to right, such that you can execute the ***clean*** command then the ***build*** command or
45
- more in a single command line expression. Each command in a chained command expression may have its
46
- own specific options (those coming after the command but before the next command) or if options are
47
- omitted the required options from the next command will be used. The chained command options syntax
48
- allows one to have a cleaner multi-command line expression with reusable options. Options are said
49
- to apply in a chained command syntax when they are of the same type and position in the positional
50
- case or same type and name in the named case.
46
+ Commands are specific named parameters that may or may not have options specific to it. Commands
47
+ have their own help to display their usage and available options. Commands are used to trigger
48
+ different branches of functionality in an application.
49
+
50
+ #### Command Parameters <a name="command-parameters"></a>
51
+ Each command may have zero or more command parameters. Command parameters may be either a
52
+ sub-command, which follow the same rules in a recursive fashion as any command, or an option.
53
+ Command options modify how the command behaves.
51
54
 
55
+ #### Chained Commands <a name="chained-commands"></a>
56
+ The chained command expressions allow a cleaner multi-command type expression with reusable options.
57
+
58
+ Whenever more than one command is used in the command line expression the expression is interpreted
59
+ as being a ***chained command expression*** a.k.a ***chained commands***. Chained commands are
60
+ executed left to right, such that you can execute the first command then the second command or more
61
+ in a single command line expression. Each command in a chained command expression may have its own
62
+ specific options (those coming after the command but before the next command) or if options are
63
+ omitted the options from the next command will be used in the order they are given to satisfy the
64
+ options of the command before. Only options of the same type and position will be used.
65
+
66
+ ### Options <a name="options"></a>
67
+ Options are additional parameters that are given that modify the behavior of a command. There are
68
+ two kinds of options available for use, ***positional*** and ***named***.
69
+
70
+ #### Positional Options <a name="positional-options"></a>
71
+ Positional options are identified by the absence of preceding dash/dashes and are interpreted
72
+ according to the order in which they were found. Positional options always pass a value into the
73
+ application. Positional options are named internally with the command name concatted with a zero
74
+ based int representing its order ***e.g. clean0*** where *clean* is the command name and *0* is the
75
+ positional options order given during configuration. Positional options are given sequentially so
76
+ you can't skip one and specify the second, it must be one then two etc...
77
+
78
+ #### Named Options
79
+ Named options have a name that is prefixed with a dash (short hand) or two dashes (long hand) e.g.
80
+ ***-h*** or ***--help*** and may be a value passed in or simply a boolean flag. **Long Hand** form
81
+ is always required for named options, short hand may or may not be given. An incoming
82
+ **value/values** are indicated by the hint configuration e.g. ***-s|--skip=COMPONENTS*** indicates
83
+ there is an incoming value/values to be expected because of the hint ***COMPONENTS***.
84
+
85
+ #### Value Types <a name="value-types"></a>
86
+ Option values require a ***type*** so that Commander can interpret how to use them. The supported
87
+ value types are ***true, false, Integer, String, Array***. Positional options default to
88
+ type String while named options default to false. The named option flag default of false can be
89
+ changed to default to true by setting the ***type:true*** configuration param.
90
+
91
+ #### Allowed Values <a name="allowed-values"></a>
92
+ Commander will check the values given against an allowed list if so desired. This is done via the
93
+ ***allowed*** configuration parameter.
94
+
95
+ #### Global Options <a name="global-options"></a>
52
96
  ***Global*** options are options that are added with the ***add_global*** function and will show up
53
97
  set in the command results using the ***:global*** symbol. Global positional options must be given
54
98
  before any other commands but global named options may appear anywhere in the command line
55
99
  expression.
56
100
 
57
- ***Shared*** options are options that are added with the command ***add_shared*** function. They
58
- should be added before any commands are added. They are added to each command as an explicit option.
59
-
101
+ ### Configuration <a name="configuration"></a>
60
102
  ***Commander.new*** must be run from the app's executable file for it to pick up the app's filename
61
103
  properly.
62
104
 
@@ -98,24 +140,6 @@ Example command line expressions:
98
140
  ./app clean all build all
99
141
  ```
100
142
 
101
- ### Options <a name="options"></a>
102
- There are two kinds of options available for use, ***positional*** and ***named***. Positional
103
- options are identified by the absence of preceding dash/dashes and interpreted according to the
104
- order and number in which they were found. Positional options are a value being passed into the
105
- application. Named options have a name that is prefixed with a dash (short hand) or two dashes
106
- (long hand) e.g. ***-h*** or ***--help*** and may simply be a flag or pass in a value. Option
107
- values require a ***type*** so that Commander can interpret how to use them. The supported value
108
- types are ***Flag, Integer, String, Array***. Values may be checked or not checked via the
109
- ***allowed*** config param. Positional options default to type String while named options default to
110
- type Flag. Positional options are named internally with the command concatted with a an int for
111
- order ***e.g. clean0*** zero based. Positional options are given sequentially so you can't
112
- skip one and specify the second, it must be one then two etc...
113
-
114
- **Long Hand** form is always required for named options, short hand may or may not be given.
115
-
116
- **Values** are indicated by the hint given e.g. ***-s|--skip=COMPONENTS*** indicates there is an
117
- incoming value/values to be expected because of the hint ***COMPONENTS***.
118
-
119
143
  Example ruby configuration:
120
144
  ```ruby
121
145
  # Creates a new instance of commander with app settings as given
@@ -155,10 +179,15 @@ Example command line expressions:
155
179
  ### Help <a name="help"></a>
156
180
  Help for your appliation and commands is automatically supported with the ***-h*** and ***--help***
157
181
  flags and is generated from the app ***name***, ***version***, ***examples***, ***commands***,
158
- ***descriptions*** and ***options*** given in Commander's configuration. Examples is just a free
159
- form string that is displayed before usage so user's have an idea of how to put together the
160
- commands and options. Allowed checks are added to the end of option descriptions in parenthesis.
161
- Type and required indicators are added after allowed check descriptions.
182
+ ***descriptions*** and ***options*** given in Commander's configuration.
183
+
184
+ #### Examples <a name="examples"></a>
185
+ Examples is just a free form string that is displayed before usage so user's have an idea of how to
186
+ put together the commands and options.
187
+
188
+ #### Indicators <a name="indicators"></a>
189
+ Allowed checks, types, and required flags specified in the configuration are known as indicators in
190
+ the help context and are added to the end of the option descriptions.
162
191
 
163
192
  Example ruby configuration:
164
193
  ```ruby
@@ -235,9 +264,6 @@ Usage: ./builder build [options]
235
264
  -h|--help Print command/options help
236
265
  ```
237
266
 
238
- **Required**
239
- Options can be required using the ***required:true*** options param
240
-
241
267
  ## Config <a name="config"></a>
242
268
  Config is a simple YAML wrapper with some extra features. Since it implements the ***Singleton***
243
269
  pattern you can easily use it through out your app without carrying around instances everywhere.
data/lib/nub/commander.rb CHANGED
@@ -18,8 +18,8 @@
18
18
  #LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
19
  #OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
20
  #SOFTWARE.
21
-
22
21
  require 'colorize'
22
+ require 'ostruct'
23
23
  require_relative 'log'
24
24
  require_relative 'sys'
25
25
  require_relative 'string'
@@ -34,26 +34,26 @@ class Option
34
34
  attr_reader(:type)
35
35
  attr_accessor(:allowed)
36
36
  attr_accessor(:required)
37
- attr_accessor(:shared)
38
37
 
39
38
  # Create a new option instance
40
39
  # @param key [String] option short hand, long hand and hint e.g. -s|--skip=COMPONENTS
41
40
  # @param desc [String] the option's description
42
41
  # @param type [Type] the option's type
43
42
  # @param required [Bool] require the option if true else optional
44
- # @param allowed [Array] array of allowed string values
45
- def initialize(key, desc, type:nil, required:false, allowed:[])
43
+ # @param allowed [Hash] hash of allowed strings to descriptions maps
44
+ def initialize(key, desc, type:nil, required:false, allowed:{})
46
45
  @hint = nil
47
46
  @long = nil
48
47
  @short = nil
49
48
  @desc = desc
50
- @shared = false
51
- @allowed = allowed || []
49
+ @allowed = allowed || {}
52
50
  @required = required || false
51
+ Log.die("allowed should be a hash of values to descriptions") if allowed.class != Hash
52
+ Log.die("required should be a boolean value") if ![TrueClass, FalseClass].include?(required.class)
53
53
 
54
54
  # Parse the key into its components (short hand, long hand, and hint)
55
55
  #https://bneijt.nl/pr/ruby-regular-expressions/
56
- # Valid forms to look for with chars [a-zA-Z0-9-_=|]
56
+ # Valid forms to look for with chars [a-zA-Z0-9-_=|]
57
57
  # --help, --help=HINT, -h|--help, -h|--help=HINT
58
58
  Log.die("invalid option key #{key}") if key && (key.count('=') > 1 or key.count('|') > 1 or !key[/[^\w\-=|]/].nil? or
59
59
  key[/(^--[a-zA-Z0-9\-_]+$)|(^--[a-zA-Z\-_]+=\w+$)|(^-[a-zA-Z]\|--[a-zA-Z0-9\-_]+$)|(^-[a-zA-Z]\|--[a-zA-Z0-9\-_]+=\w+$)/].nil?)
@@ -64,22 +64,72 @@ class Option
64
64
  @long = key[/(--[\w\-]+)(=.+)*$/, 1]
65
65
  end
66
66
 
67
- # Validate and set type
68
- Log.die("invalid option type #{type}") if ![String, Integer, Array, nil].any?{|x| type == x}
67
+ # Convert true/false to TrueClass/FalseClass
68
+ type = TrueClass if type.class == TrueClass
69
+ type = FalseClass if type.class == FalseClass
70
+
71
+ # Validate and set type, allow Flag defaults to be true or false
72
+ Log.die("invalid option type #{type}") if ![String, Integer, Array, TrueClass, FalseClass, nil].any?{|x| type == x}
69
73
  Log.die("option type must be set") if @hint && !type
70
74
  @type = String if !key && !type
71
75
  @type = FalseClass if key and !type
72
76
  @type = type if type
73
77
 
74
78
  # Validate hint is given for non flags
75
- Log.die("option hint must be set") if @key && !@hint && @type != FalseClass
79
+ Log.die("option hint must be set") if @key && !@hint && @type != FalseClass && @type != TrueClass
76
80
 
77
81
  # Validate allowed
78
82
  if @allowed.any?
79
- allowed_type = @allowed.first.class
80
- Log.die("mixed allowed types") if @allowed.any?{|x| x.class != allowed_type}
83
+ allowed_type = @allowed.first.first.class
84
+ Log.die("mixed allowed types") if @allowed.any?{|k,v| k.class != allowed_type}
81
85
  end
82
86
  end
87
+
88
+ # Get a symbol representing the command
89
+ # @returns symbol
90
+ def to_sym
91
+ return @long[2..-1].gsub("-", "_").to_sym
92
+ end
93
+
94
+ # Return a human readable string of this object
95
+ # @param level [Integer] level to indent
96
+ def to_s(level:0)
97
+ return "#{" " * level * 2}Option => key:#{@key}, desc:'#{@desc}', type:#{@type}, allowed:#{@allowed}, required:#{@required}"
98
+ end
99
+ end
100
+
101
+ class Command
102
+ attr_reader(:name)
103
+ attr_reader(:desc)
104
+ attr_accessor(:nodes)
105
+ attr_accessor(:help)
106
+
107
+ # Create a new command
108
+ # @param name [String] command name used on command line
109
+ # @param desc [String] the command's description
110
+ # @param nodes [String] the command's description
111
+ def initialize(name, desc, nodes:[])
112
+ @name = name
113
+ @desc = desc
114
+ @nodes = nodes
115
+ @help = ""
116
+ end
117
+
118
+ # Get a symbol representing the command
119
+ # @returns symbol
120
+ def to_sym
121
+ return @name.gsub('-', '_').to_sym
122
+ end
123
+
124
+ # Return a human readable string of this object
125
+ # @param level [Integer] level to indent
126
+ def to_s(level:0)
127
+ str = "#{" " * level * 2}Command => name:#{@name}, desc:'#{@desc}'"
128
+ @nodes.each{|x|
129
+ str += "\n#{x.to_s(level: level + 1)}"
130
+ }
131
+ return str
132
+ end
83
133
  end
84
134
 
85
135
  # An implementation of git like command syntax for ruby applications:
@@ -89,13 +139,15 @@ class Commander
89
139
  attr_reader(:banner)
90
140
  attr_accessor(:cmds)
91
141
 
92
- Command = Struct.new(:name, :desc, :opts, :help)
93
-
94
142
  # Initialize the commands for your application
95
143
  # @param app [String] application name e.g. reduce
96
144
  # @param version [String] version of the application e.g. 1.0.0
97
145
  # @param examples [String] optional examples to list after the title before usage
98
146
  def initialize(app:nil, version:nil, examples:nil)
147
+ @k = OpenStruct.new({
148
+ global: 'global'
149
+ })
150
+
99
151
  @app = app
100
152
  @app_default = Sys.caller_filename
101
153
  @version = version
@@ -107,18 +159,15 @@ class Commander
107
159
  @long_regex = /(--[\w\-]+)(=.+)*$/
108
160
  @value_regex = /.*=(.*)$/
109
161
 
110
- # Incoming user set commands/options
162
+ # Command line expression results
111
163
  # {command_name => {}}
112
164
  @cmds = {}
113
165
 
114
166
  # Configuration - ordered list of commands
115
167
  @config = []
116
168
 
117
- # List of options that will be added to all commands
118
- @shared = []
119
-
120
169
  # Configure default global options
121
- add_global(Option.new('-h|--help', 'Print command/options help'))
170
+ add_global('-h|--help', 'Print command/options help')
122
171
  end
123
172
 
124
173
  # Hash like accessor for checking if a command or option is set
@@ -134,44 +183,41 @@ class Commander
134
183
  # Add a command to the command list
135
184
  # @param cmd [String] name of the command
136
185
  # @param desc [String] description of the command
137
- # @param opts [List] list of command options
138
- def add(cmd, desc, options:[])
139
- Log.die("'global' is a reserved command name") if cmd == 'global'
140
- Log.die("'shared' is a reserved command name") if cmd == 'shared'
186
+ # @param nodes [List] list of command nodes (i.e. options or commands)
187
+ def add(cmd, desc, nodes:[])
188
+ Log.die("'#{@k.global}' is a reserved command name") if cmd == @k.global
141
189
  Log.die("'#{cmd}' already exists") if @config.any?{|x| x.name == cmd}
142
- Log.die("'help' is a reserved option name") if options.any?{|x| !x.key.nil? && x.key.include?('help')}
143
-
144
- # Add shared options
145
- @shared.each{|x| options.unshift(x)}
190
+ Log.die("'help' is a reserved option name") if nodes.any?{|x| x.class == Option && !x.key.nil? && x.key.include?('help')}
191
+ Log.die("command names must be pure lowercase letters or hypen") if cmd =~ /[^a-z-]/
192
+
193
+ # Validate sub command key words
194
+ validate_subcmd = ->(subcmd){
195
+ subcmd.nodes = [] if !subcmd.nodes
196
+ Log.die("'#{@k.global}' is a reserved command name") if subcmd.name == @k.global
197
+ Log.die("'help' is a reserved option name") if subcmd.nodes.any?{|x| x.class == Option && !x.key.nil? && x.key.include?('help')}
198
+ Log.die("command names must be pure lowercase letters or hypen") if subcmd.name =~ /[^a-z-]/
199
+ subcmd.nodes.select{|x| x.class != Option}.each{|x| validate_subcmd.(x)}
200
+ }
201
+ nodes.select{|x| x.class != Option}.each{|x| validate_subcmd.(x)}
146
202
 
147
- cmd = add_cmd(cmd, desc, options)
148
- @config << cmd
203
+ @config << add_cmd(cmd, desc, nodes)
149
204
  end
150
205
 
151
206
  # Add global options (any option coming before all commands)
152
- # @param option/s [Array/Option] array or single option/s
153
- def add_global(options)
154
- options = [options] if options.class == Option
207
+ # @param key [String] option short hand, long hand and hint e.g. -s|--skip=COMPONENTS
208
+ # @param desc [String] the option's description
209
+ # @param type [Type] the option's type
210
+ # @param required [Bool] require the option if true else optional
211
+ # @param allowed [Hash] hash of allowed values to description map
212
+ def add_global(key, desc, type:nil, required:false, allowed:{})
213
+ options = [Option.new(key, desc, type:type, required:required, allowed:allowed)]
155
214
 
156
215
  # Aggregate global options
157
- if (global = @config.find{|x| x.name == 'global'})
158
- global.opts.each{|x| options << x}
159
- @config.reject!{|x| x.name == 'global'}
216
+ if (global = @config.find{|x| x.name == @k.global})
217
+ global.nodes.each{|x| options << x}
218
+ @config.reject!{|x| x.name == @k.global}
160
219
  end
161
- @config << add_cmd('global', 'Global options:', options)
162
- end
163
-
164
- # Add shared option (options that are added to all commands)
165
- # @param option/s [Array/Option] array or single option/s
166
- def add_shared(options)
167
- options = [options] if options.class == Option
168
- options.each{|x|
169
- Log.die("duplicate shared option '#{x.desc}' given") if @shared
170
- .any?{|y| y.key == x.key && y.desc == x.desc && y.type == x.type}
171
- x.shared = true
172
- x.required = true
173
- @shared << x
174
- }
220
+ @config << add_cmd(@k.global, 'Global options:', options)
175
221
  end
176
222
 
177
223
  # Returns banner string
@@ -185,6 +231,8 @@ class Commander
185
231
  # Return the app's help string
186
232
  # @return [String] the app's help string
187
233
  def help
234
+
235
+ # Global help
188
236
  help = @app.nil? ? "" : "#{banner}\n"
189
237
  if !@examples.nil? && !@examples.empty?
190
238
  newline = @examples.strip_color[-1] != "\n" ? "\n" : ""
@@ -192,9 +240,9 @@ class Commander
192
240
  end
193
241
  app = @app || @app_default
194
242
  help += "Usage: ./#{app} [commands] [options]\n"
195
- help += @config.find{|x| x.name == 'global'}.help
243
+ help += @config.find{|x| x.name == @k.global}.help
196
244
  help += "COMMANDS:\n"
197
- @config.select{|x| x.name != 'global'}.each{|x| help += " #{x.name.ljust(@just)}#{x.desc}\n" }
245
+ @config.select{|x| x.name != @k.global}.each{|x| help += " #{x.name.ljust(@just)}#{x.desc}\n" }
198
246
  help += "\nsee './#{app} COMMAND --help' for specific command help\n"
199
247
 
200
248
  return help
@@ -202,94 +250,22 @@ class Commander
202
250
 
203
251
  # Construct the command line parser and parse
204
252
  def parse!
205
- cmd_names = @config.map{|x| x.name }
253
+
254
+ # Clear out the previous run every time, in case run more than once
255
+ @cmds = {}
206
256
 
207
257
  # Set help if nothing was given
208
258
  ARGV.clear and ARGV << '-h' if ARGV.empty?
209
-
210
- # Process command options
211
- #---------------------------------------------------------------------------
212
- order_globals!
213
- expand_chained_options!
214
- loop {
215
- break if ARGV.first.nil?
216
-
217
- if !(cmd = @config.find{|x| x.name == ARGV.first}).nil?
218
- @cmds[ARGV.shift.to_sym] = {} # Create command results entry
219
- cmd_names.reject!{|x| x == cmd.name} # Remove command from possible commands
220
-
221
- # Collect command options from args to compare against
222
- opts = ARGV.take_while{|x| !cmd_names.include?(x) }
223
- ARGV.shift(opts.size)
224
-
225
- # Handle help upfront before anything else
226
- if opts.any?{|x| m = match_named(x, cmd); m.hit? && m.sym == :help }
227
- !puts(help) and exit if cmd.name == 'global'
228
- !puts(cmd.help) and exit
229
- end
230
259
 
231
- # Check that all required options were given
232
- cmd_pos_opts = cmd.opts.select{|x| x.key.nil? }
233
- cmd_named_opts = cmd.opts.select{|x| !x.key.nil? }
234
-
235
- !puts("Error: positional option required!".colorize(:red)) && !puts(cmd.help) and
236
- exit if opts.select{|x| !x.start_with?('-')}.size < cmd_pos_opts.select{|x| x.required}.size
237
-
238
- named_opts = opts.select{|x| x.start_with?('-')}
239
- cmd_named_opts.select{|x| x.required}.each{|x|
240
- !puts("Error: required option #{x.key} not given!".colorize(:red)) && !puts(cmd.help) and
241
- exit if !named_opts.find{|y| y.start_with?(x.short) || y.start_with?(x.long)}
242
- }
243
-
244
- # Process command options
245
- pos = -1
246
- loop {
247
- break if opts.first.nil?
248
- opt = opts.shift
249
- cmd_opt = nil
250
- value = nil
251
- sym = nil
252
-
253
- # Validate/set named options
254
- # --------------------------------------------------------------------
255
- # e.g. -s, --skip, --skip=VALUE
256
- if (match = match_named(opt, cmd)).hit?
257
- sym = match.sym
258
- cmd_opt = match.opt
259
- value = match.value
260
- value = match.flag? || opts.shift if !value
261
-
262
- # Validate/set positional options
263
- # --------------------------------------------------------------------
264
- else
265
- pos += 1
266
- value = opt
267
- cmd_opt = cmd_pos_opts.shift
268
- !puts("Error: invalid positional option '#{opt}'!".colorize(:red)) && !puts(cmd.help) and
269
- exit if cmd_opt.nil? || cmd_names.include?(value)
270
- sym = "#{cmd.name}#{pos}".to_sym
271
- end
272
-
273
- # Convert value to appropriate type and validate against allowed
274
- # --------------------------------------------------------------------
275
- value = convert_value(value, cmd, cmd_opt)
276
-
277
- # Set option with value
278
- # --------------------------------------------------------------------
279
- !puts("Error: unknown named option '#{opt}' given!".colorize(:red)) && !puts(cmd.help) and exit if !sym
280
- @cmds[cmd.name.to_sym][sym] = value
281
- if cmd_opt.shared
282
- sym = "shared#{pos}".to_sym if cmd_opt.key.nil?
283
- @cmds[:shared] = {} if !@cmds.key?(:shared)
284
- @cmds[:shared][sym] = value
285
- end
286
- }
287
- end
288
- }
260
+ # Parse commands recursively
261
+ move_globals_to_front!
262
+ expand_chained_options!
263
+ while (cmd = @config.find{|x| x.name == ARGV.first})
264
+ ARGV.shift && parse_commands(cmd, nil, @config.select{|x| x.name != cmd.name}, ARGV, @cmds)
265
+ end
289
266
 
290
- # Ensure specials (global, shared) are always set
267
+ # Ensure specials (global) are always set
291
268
  @cmds[:global] = {} if !@cmds[:global]
292
- @cmds[:shared] = {} if !@cmds[:shared]
293
269
 
294
270
  # Ensure all options were consumed
295
271
  Log.die("invalid options #{ARGV}") if ARGV.any?
@@ -307,15 +283,132 @@ class Commander
307
283
  return !!sym
308
284
  end
309
285
  def flag?
310
- return opt.type == FalseClass
286
+ return opt.type == FalseClass || opt.type == TrueClass
287
+ end
288
+ end
289
+
290
+ # Parse the given args recursively
291
+ # @param cmd [Command] command to work with
292
+ # @param parent [Command] command to work with
293
+ # @param others [Array] sibling cmds to cmd
294
+ # @param args [Array] array of arguments
295
+ # @param results [Hash] of cmd results
296
+ def parse_commands(cmd, parent, others, args, results)
297
+ results[cmd.to_sym] = {} # Create command results entry
298
+ cmd_names = others.map{|x| x.name} # Get other command names as markers
299
+ subcmds = cmd.nodes.select{|x| x.class == Command} # Get sub-commands for this command
300
+
301
+ # Collect all params until the next sibling command
302
+ #---------------------------------------------------------------------------
303
+ params = args.take_while{|x| !cmd_names.include?(x)}
304
+ args.shift(params.size)
305
+
306
+ # Strip off this command's preceeding options
307
+ opts = subcmds.any? ? params.take_while{|x| !subcmds.any?{|y| x == y.name}} : params
308
+ otherparams = params[opts.size..-1]
309
+
310
+ #---------------------------------------------------------------------------
311
+ # Handle sub-commands recursively first
312
+ #---------------------------------------------------------------------------
313
+ while subcmds.any? && (subcmd = subcmds.find{|x| x.name == otherparams.first})
314
+ otherparams.shift # Consume sub-cmd from opts
315
+ subcmds.reject!{|x| x.name == subcmd.name} # Drop sub-command from further use
316
+ parse_commands(subcmd, cmd, subcmds, otherparams, results[cmd.to_sym])
317
+ end
318
+
319
+ # Account for any left over options
320
+ otherparams.reverse.each{|x| opts.unshift(x)}
321
+
322
+ #---------------------------------------------------------------------------
323
+ # Base case: dealing with options for a given command.
324
+ # Only consume options for this command and bubble up unused to parent
325
+ #---------------------------------------------------------------------------
326
+
327
+ # Handle help upfront before anything else
328
+ #---------------------------------------------------------------------------
329
+ if opts.any?{|x| m = match_named(x, cmd); m.hit? && m.sym == :help }
330
+ !puts(help) and exit if cmd.name == @k.global
331
+ !puts(cmd.help) and exit
332
+ end
333
+
334
+ # Parse/consume named options first
335
+ #---------------------------------------------------------------------------
336
+
337
+ # Check that all required named options were given
338
+ cmd.nodes.select{|x| x.class == Option && !x.key.nil? && x.required}.each{|x|
339
+ !puts("Error: required option #{x.key} not given!".colorize(:red)) && !puts(cmd.help) and
340
+ exit if !match_named(x, opts).hit?
341
+ }
342
+
343
+ # Consume and set all named options
344
+ i = 0
345
+ while i < opts.size
346
+ if (match = match_named(opts[i], cmd)).hit?
347
+ value = match.flag? || match.value # Inline or Flag value
348
+
349
+ # Separate value
350
+ separate = false
351
+ if !value && i + 1 < opts.size
352
+ separate = true
353
+ value = opts[i + 1]
354
+ elsif !value
355
+ !puts("Error: named option '#{opts[i]}' value not found!".colorize(:red)) and
356
+ !puts(cmd.help) and exit
357
+ end
358
+
359
+ # Set result and consume options
360
+ results[cmd.to_sym][match.sym] = convert_value(value, cmd, match.opt)
361
+ opts.delete_at(i) # Consume option
362
+ opts.delete_at(i) if separate # Consume separate value
363
+ else
364
+ i += 1
365
+ end
366
+ end
367
+
368
+ # Parse/consume positional options next
369
+ #---------------------------------------------------------------------------
370
+ cmd_pos_opts = cmd.nodes.select{|x| x.class == Option && x.key.nil?}
371
+
372
+ # Check that all required positionals were given
373
+ !puts("Error: positional option required!".colorize(:red)) && !puts(cmd.help) and
374
+ exit if opts.select{|x| !x.start_with?('-')}.size < cmd_pos_opts.select{|x| x.required}.size
375
+
376
+ # Consume and set all positional options
377
+ i = 0
378
+ pos = -1
379
+ while i < opts.size && cmd_pos_opts.any?
380
+ if !opts[i].start_with?('-')
381
+ pos += 1
382
+ cmd_opt = cmd_pos_opts.shift
383
+ !puts("Error: invalid positional option '#{opts[i]}'!".colorize(:red)) and
384
+ !puts(cmd.help) and exit if cmd_opt.nil?
385
+
386
+ # Set result and consume options
387
+ results[cmd.to_sym]["#{cmd.to_sym}#{pos}".to_sym] = convert_value(opts[i], cmd, cmd_opt)
388
+ opts.delete_at(i) # Consume option
389
+ else
390
+ i += 1
391
+ end
392
+ end
393
+
394
+ # Add any unconsumed options back to parent to ensure everything is accounted for
395
+ if parent
396
+ opts.reverse.each{|x| args.unshift(x)}
397
+ else
398
+ opts.each{|x|
399
+ !puts("Error: invalid positional option '#{x}'!".colorize(:red)) and
400
+ !puts(cmd.help) and exit if !x.start_with?('-')
401
+ !puts("Error: invalid named option '#{x}'!".colorize(:red)) and
402
+ !puts(cmd.help) and exit if x.start_with?('-')
403
+ }
311
404
  end
312
405
  end
313
406
 
314
407
  # Parses the command line, moving all global options to the begining
315
408
  # and inserting the global command
316
- def order_globals!
317
- if !(global_cmd = @config.find{|x| x.name == 'global'}).nil?
318
- ARGV.delete('global')
409
+ def move_globals_to_front!
410
+ if !(global_cmd = @config.find{|x| x.name == @k.global}).nil?
411
+ ARGV.delete(@k.global)
319
412
 
320
413
  # Collect positional and named options from begining
321
414
  globals = ARGV.take_while{|x| !@config.any?{|y| y.name == x}}
@@ -324,7 +417,7 @@ class Commander
324
417
  # Collect named options throughout
325
418
  i = -1
326
419
  cmd = nil
327
- while (i += 1) < ARGV.size do
420
+ while (i += 1) < ARGV.size
328
421
 
329
422
  # Set command and skip command and matching options
330
423
  if !(_cmd = @config.find{|x| x.name == ARGV[i]}).nil?
@@ -342,7 +435,7 @@ class Commander
342
435
 
343
436
  # Re-insert options in correct order at end with command
344
437
  globals.reverse.each{|x| ARGV.unshift(x)}
345
- ARGV.unshift('global')
438
+ ARGV.unshift(@k.global)
346
439
  end
347
440
  end
348
441
 
@@ -352,66 +445,118 @@ class Commander
352
445
  def expand_chained_options!
353
446
  args = ARGV[0..-1]
354
447
  results = {}
355
- cmd_names = @config.map{|x| x.name }
356
-
448
+ cmd_order = []
449
+ cmd_names = @config.map{|x| x.name}
450
+
357
451
  chained = []
358
- while args.any? do
452
+ while args.any?
359
453
  if !(cmd = @config.find{|x| x.name == args.first}).nil?
360
- results[args.shift] = [] # Add the command to the results
361
- cmd_names.reject!{|x| x == cmd.name} # Remove command from possible commands
362
- cmd_required = cmd.opts.select{|x| x.required}
454
+ cmd_order << args.shift # Maintain oder of given commands
455
+ results[cmd.name] = [] # Add the command to the results
456
+ cmd_names.reject!{|x| x == cmd.name} # Remove command from possible commands
363
457
 
364
458
  # Collect command options from args to compare against
365
459
  opts = args.take_while{|x| !cmd_names.include?(x)}
366
460
  args.shift(opts.size)
367
461
 
368
462
  # Globals are not to be considered for chaining
369
- results[cmd.name].concat(opts) and next if cmd.name == 'global'
463
+ results[cmd.name].concat(opts) and next if cmd.name == @k.global
370
464
 
371
- # Chained case is when no options are given but some are required
372
- if opts.size == 0 && cmd.opts.any?{|x| x.required}
465
+ # Chained case is when no options are given but the command has options
466
+ cmd_options = cmd.nodes.select{|x| x.class == Option}
467
+ if opts.size == 0 && cmd_options.any?
373
468
  chained << cmd
374
469
  else
375
470
  # Add cmd with options
376
471
  results[cmd.name].concat(opts)
377
472
 
378
- # Check chained cmds against current cmd
379
- chained.each{|x|
380
- other_required = x.opts.select{|x| x.required}
381
- !puts("Error: chained commands must satisfy required options!".colorize(:red)) && !puts(x.help) and
382
- exit if cmd_required.size < other_required.size
383
- other_required.each_with_index{|y,i|
384
- !puts("Error: chained command options are not type consistent!".colorize(:red)) && !puts(x.help) and
385
- exit if y.type != cmd_required[i].type || y.key != cmd_required[i].key
386
- }
387
- results[x.name].concat(opts.take(other_required.size))
473
+ # Add applicable options to chained as well
474
+ chained.each{|other|
475
+ _opts = opts[0..-1]
476
+ named_results = []
477
+ positional_results = []
478
+
479
+ # Add all matching named options
480
+ #-------------------------------------------------------------------
481
+ i = 0
482
+ while i < _opts.size
483
+ if (match = match_named(_opts[i], other)).hit?
484
+ named_results << _opts[i];
485
+ _opts.delete_at(i)
486
+
487
+ # Get the next option to as the value was separate
488
+ if i < _opts.size && !(match.flag? || match.value)
489
+ named_results << _opts[i]
490
+ _opts.delete_at(i)
491
+ end
492
+ else
493
+ i += 1
494
+ end
495
+ end
496
+
497
+ # Add all matching positional options
498
+ #-------------------------------------------------------------------
499
+ i = 0
500
+ other_positional = other.nodes.select{|x| x.class == Option && x.key.nil?}
501
+ while i < _opts.size
502
+ if !_opts[i].start_with?('-') && other_positional.any?
503
+ positional_results << _opts[i]
504
+ other_positional.shift
505
+ _opts.delete_at(i)
506
+ else
507
+ i += 1
508
+ end
509
+ end
510
+
511
+ positional_results.each{|x| results[other.name] << x}
512
+ named_results.each{|x| results[other.name] << x}
388
513
  }
389
514
  end
390
515
  end
391
516
  end
392
517
 
393
518
  # Set results as new ARGV command line expression
394
- ARGV.clear and results.each{|k, v| ARGV << k; ARGV.concat(v)}
519
+ ARGV.clear and cmd_order.each{|x| ARGV << x; ARGV.concat(results[x]) }
395
520
  end
396
521
 
397
522
  # Match the given command line arg with a configured named option
398
- # @param opt [String] the command line argument given
399
- # @param cmd [Command] configured command to match against
523
+ # or match configured named option against a list of command line args
524
+ # @param arg [String/Option] the command line argument or configured Option
525
+ # @param other [Command/Array] configured command or command line args
400
526
  # @return [OptionMatch]] struct with some helper functions
401
- def match_named(opt, cmd)
402
- match = OptionMatch.new(opt)
403
- cmd_named_opts = cmd.opts.select{|x| !x.key.nil? }
404
-
405
- if opt.start_with?('-')
406
- short = opt[@short_regex, 1]
407
- long = opt[@long_regex, 1]
408
- match.value = opt[@value_regex, 1]
409
-
410
- # Set symbol converting dashes to underscores for named options
411
- if (cmd_opt = cmd_named_opts.find{|x| x.short == short || x.long == long})
412
- match.opt = cmd_opt
413
- match.sym = cmd_opt.long[2..-1].gsub("-", "_").to_sym
527
+ def match_named(arg, other)
528
+ match = OptionMatch.new
529
+
530
+ # Match command line arg against command options
531
+ if arg.class == String && other.class == Command
532
+ match.arg = arg
533
+ options = other.nodes.select{|x| x.class == Option && !x.key.nil? }
534
+
535
+ if arg.start_with?('-')
536
+ short = arg[@short_regex, 1]
537
+ long = arg[@long_regex, 1]
538
+ match.value = arg[@value_regex, 1]
539
+
540
+ # Set symbol converting dashes to underscores for named options
541
+ if (match.opt = options.find{|x| x.short == short || x.long == long})
542
+ match.sym = match.opt.to_sym
543
+ end
414
544
  end
545
+
546
+ # Match command option against command line args
547
+ elsif arg.class == Option && other.class == Array
548
+ match.arg = arg.key
549
+
550
+ other.select{|x| x.start_with?('-')}.any?{|x|
551
+ short = x[@short_regex, 1]
552
+ long = x[@long_regex, 1]
553
+ value = x[@value_regex, 1]
554
+ if short == arg.short || long == arg.long
555
+ match.opt = arg
556
+ match.value = value
557
+ match.sym = match.opt.to_sym
558
+ end
559
+ }
415
560
  end
416
561
 
417
562
  return match
@@ -427,20 +572,20 @@ class Commander
427
572
  if opt.type == String
428
573
  if opt.allowed.any?
429
574
  !puts("Error: invalid string value '#{value}'!".colorize(:red)) && !puts(cmd.help) and
430
- exit if !opt.allowed.include?(value)
575
+ exit if !opt.allowed.key?(value) && !opt.allowed.key?(value.to_sym)
431
576
  end
432
577
  elsif opt.type == Integer
433
578
  value = value.to_i
434
579
  if opt.allowed.any?
435
580
  !puts("Error: invalid integer value '#{value}'!".colorize(:red)) && !puts(cmd.help) and
436
- exit if !opt.allowed.include?(value)
581
+ exit if !opt.allowed.key?(value)
437
582
  end
438
583
  elsif opt.type == Array
439
584
  value = value.split(',')
440
585
  if opt.allowed.any?
441
586
  value.each{|x|
442
587
  !puts("Error: invalid array value '#{x}'!".colorize(:red)) && !puts(cmd.help) and
443
- exit if !opt.allowed.include?(x)
588
+ exit if !opt.allowed.key?(x) && !opt.allowed.key?(x.to_sym)
444
589
  }
445
590
  end
446
591
  end
@@ -450,37 +595,61 @@ class Commander
450
595
  end
451
596
 
452
597
  # Add a command to the command list
453
- # @param cmd [String] name of the command
598
+ # @param name [String] name of the command
454
599
  # @param desc [String] description of the command
455
- # @param opts [List] list of command options
600
+ # @param nodes [Array] list of command nodes (i.e. options or commands)
601
+ # @param hierarchy [Array] list of commands
456
602
  # @return [Command] new command
457
- def add_cmd(cmd, desc, options)
458
- Log.die("command names must be pure lowercase letters") if cmd =~ /[^a-z]/
603
+ def add_cmd(name, desc, nodes, hierarchy:[])
604
+ hierarchy << name
605
+ cmd = Command.new(name, desc)
606
+ subcmds = nodes.select{|x| x.class == Command}.sort{|x,y| x.name <=> y.name}
459
607
 
460
608
  # Build help for command
609
+ #---------------------------------------------------------------------------
610
+ cmd.help = "#{desc}\n"
461
611
  app = @app || @app_default
462
- help = "#{desc}\n"
463
- help += "\nUsage: ./#{app} #{cmd} [options]\n" if cmd != 'global'
464
- help = "#{banner}\n#{help}" if @app && cmd != 'global'
612
+ cmd_prompt = subcmds.any? ? "[commands] " : ""
613
+ cmd.help += "\nUsage: ./#{app} #{hierarchy * ' '} #{cmd_prompt}[options]\n" if name != @k.global
614
+ cmd.help = "#{banner}\n#{cmd.help}" if @app && name != @k.global
465
615
 
466
- # Add help option if not global command
467
- options << @config.find{|x| x.name == 'global'}.opts.find{|x| x.long == '--help'} if cmd != 'global'
616
+ # Add help for each sub-command before options
617
+ cmd.help += "COMMANDS:\n" if subcmds.any?
618
+ subcmds.each{|x| cmd.help += " #{x.name.ljust(@just)}#{x.desc}\n" }
619
+
620
+ # Insert standard help option for command (re-using one from global, all identical)
621
+ nodes << @config.find{|x| x.name == @k.global}.nodes.find{|x| x.long == '--help'} if name != @k.global
468
622
 
469
623
  # Add positional options first
470
- sorted_options = options.select{|x| x.key.nil?}
471
- sorted_options += options.select{|x| !x.key.nil?}.sort{|x,y| x.key <=> y.key}
624
+ sorted_options = nodes.select{|x| x.class == Option && x.key.nil?}
625
+ sorted_options += nodes.select{|x| x.class == Option && !x.key.nil?}.sort{|x,y| x.key <=> y.key}
626
+ cmd.help += "OPTIONS:\n" if subcmds.any? && sorted_options.any?
472
627
  positional_index = -1
473
- sorted_options.each{|x|
628
+ sorted_options.each{|x|
474
629
  required = x.required ? ", Required" : ""
475
- allowed = x.allowed.empty? ? "" : " (#{x.allowed * ','})"
476
630
  positional_index += 1 if x.key.nil?
477
- key = x.key.nil? ? "#{cmd}#{positional_index}" : x.key
478
- type = x.type == FalseClass ? "Flag" : x.type
479
- help += " #{key.ljust(@just)}#{x.desc}#{allowed}: #{type}#{required}\n"
631
+ key = x.key.nil? ? "#{name}#{positional_index}" : x.key
632
+ if x.type == FalseClass || x.type == TrueClass
633
+ type = "Flag(#{x.type.to_s[/(.*)Class/, 1].downcase})"
634
+ elsif x.type == Array
635
+ type = "#{x.type}(String)"
636
+ else
637
+ type = x.type
638
+ end
639
+ cmd.help += " #{key.ljust(@just)}#{x.desc}: #{type}#{required}\n"
640
+ x.allowed.sort{|x,y| x <=> y}.each{|x| cmd.help += " #{''.ljust(@just)} #{x.first}: #{x.last}\n" }
480
641
  }
481
642
 
482
- # Create the command in the command config
483
- return Command.new(cmd, desc, sorted_options, help)
643
+ # Add hint as to how to get specific sub command help
644
+ cmd.help += "\nsee './#{app} #{name} COMMAND --help' for specific command help\n" if subcmds.any?
645
+
646
+ # Configure help for each sub command
647
+ subcmds.each{|x| cmd.nodes << add_cmd(x.name, x.desc, x.nodes, hierarchy:hierarchy)}
648
+
649
+ # Add options after any sub-commands
650
+ cmd.nodes += sorted_options
651
+
652
+ return cmd
484
653
  end
485
654
  end
486
655
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nub
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.55
4
+ version: 0.0.56
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick Crummett
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-05-02 00:00:00.000000000 Z
11
+ date: 2018-05-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: colorize