nub 0.0.55 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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