aargs 0.1.0

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 (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/aargs.rb +441 -0
  3. metadata +45 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 72b94ed35ff9c3b27edacb27589a1d9da3aa08aa3c710033deb76666f218ff46
4
+ data.tar.gz: 7f530a6f57da8117bd146dedcffea0a2b78fa4b02b20e6261f61707e1c123149
5
+ SHA512:
6
+ metadata.gz: '08fd7a05ad0570d93f3e7ddcaa66950b0e9b814c9b56ee6810e9685076254cf49331b3221d3e4b1f7bdb8f7cd2e1c995ebcdcc740c82860815d3161d8934ee86'
7
+ data.tar.gz: 2cdc298a10b4371c16419f152034217d1408965c081812c57395322d25606eef447596d79db5e3d4dc63fc32da735101be7b05520ea9b92c2b84fe6586684f96
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ # Basic aargs parser
6
+ class Aargs
7
+ def self.kebab(sym)
8
+ sym.to_s.gsub(/[^[:alnum:]]/, '-')
9
+ end
10
+
11
+ def self.underscore(src)
12
+ src.gsub(/[^[:alnum:]]/, '_').to_sym
13
+ end
14
+
15
+ def self.flagify_arg(arg)
16
+ case arg
17
+ when Symbol
18
+ "--#{kebab(arg)}"
19
+ when Hash
20
+ arg.map(&method(:flagify_kwarg)).flatten
21
+ else
22
+ arg
23
+ end
24
+ end
25
+
26
+ def self.flagify_kwarg(arg, value)
27
+ case value
28
+ when TrueClass
29
+ "--#{kebab(arg)}"
30
+ when FalseClass
31
+ "--no-#{kebab(arg)}"
32
+ when Array
33
+ value.map { |v| "--#{kebab(arg)}=#{v}" }
34
+ else
35
+ "--#{kebab(arg)}=#{value}"
36
+ end
37
+ end
38
+
39
+ # Convert symbolic arguments and keyword-arguments into an equivalent `ARGV`. Non-symbol argments remain unchanged.
40
+ # Note that to generate a epilogue portion of an ARGV you need to pass keyword arguments as explicit hashes followed
41
+ # by non-hash, non-symbol values.
42
+ def self.to_argv(*args)
43
+ args.map(&method(:flagify_arg)).flatten
44
+ end
45
+
46
+ def self.parse(args_or_argv, aliases: {}, flag_configs: {})
47
+ argv = to_argv(*args_or_argv)
48
+
49
+ literal_only = false
50
+ prologue = []
51
+ epilogue = []
52
+ flags = {}
53
+ last_sym = nil
54
+ last_sym_pending = nil
55
+
56
+ resolve = lambda do |src|
57
+ raise "Missing value after '#{last_sym_pending}'" if last_sym_pending
58
+
59
+ sym = underscore(src)
60
+ aliases[sym] || sym
61
+ end
62
+
63
+ argv.each do |arg|
64
+ if literal_only
65
+ epilogue << arg
66
+ next
67
+ end
68
+ case arg
69
+ when /^--$/
70
+ literal_only = true
71
+ last_sym = nil
72
+ when /^-([[:alnum:]])$/
73
+ last_sym = sym = resolve.call(Regexp.last_match(1))
74
+ case flags[sym]
75
+ when true
76
+ flags[sym] = 2
77
+ when Integer
78
+ flags[sym] += 1
79
+ when nil
80
+ flags[sym] = true
81
+ else
82
+ raise "Unexpected boolean '#{arg}' after set to value #{flags[sym].inspect}"
83
+ end
84
+
85
+ when /^--(?<no>no-)?(?<flag>[[:alnum:]-]+)(?:=(?<value>.*))?$/
86
+ flag = Regexp.last_match[:flag]
87
+ value = Regexp.last_match[:value]
88
+ no = Regexp.last_match[:no]
89
+ sym = resolve.call(flag)
90
+ boolean = boolean?(sym, flag_configs: flag_configs)
91
+ if no
92
+ raise "Unexpected value specified with no- prefix: #{arg}" unless value.nil?
93
+
94
+ flags[sym] = false
95
+ last_sym = nil
96
+ elsif value.nil?
97
+ last_sym = boolean ? nil : sym
98
+ case flags[sym]
99
+ when true
100
+ flags[sym] = 2
101
+ when Integer
102
+ flags[sym] += 1
103
+ when nil, false
104
+ flags[sym] = true
105
+ else
106
+ last_sym_pending = arg
107
+ end
108
+ else
109
+ raise "Unexpected value for #{inspect_flag(arg)}: #{value.inspect}" if boolean
110
+
111
+ last_sym = nil
112
+ case flags[sym]
113
+ when nil
114
+ flags[sym] = value
115
+ when Array
116
+ flags[sym] << value
117
+ else
118
+ flags[sym] = [flags[sym], value]
119
+ end
120
+ end
121
+
122
+ else
123
+ if last_sym
124
+ case flags[last_sym]
125
+ when true
126
+ flags[last_sym] = arg
127
+ when Array
128
+ flags[last_sym] << arg
129
+ else
130
+ flags[last_sym] = [flags[last_sym], arg]
131
+ end
132
+ last_sym_pending = nil
133
+ elsif flags.empty?
134
+ prologue << arg
135
+ else # first non-switch after switches + values
136
+ literal_only = true
137
+ epilogue << arg
138
+ end
139
+ end
140
+ next if arg.nil?
141
+ end
142
+ raise "Missing value after '#{last_sym_pending}'" if last_sym_pending
143
+
144
+ result = {}
145
+ result[:prologue] = prologue unless prologue.empty?
146
+ result[:flags] = flags unless flags.empty?
147
+ result[:epilogue] = epilogue unless epilogue.empty?
148
+ result unless result.empty?
149
+ end
150
+
151
+ # @return Hash
152
+ attr_reader :aliases
153
+
154
+ # @returns Array
155
+ attr_reader :required_prologue
156
+ attr_reader :optional_prologue
157
+ attr_reader :prologue_key
158
+ attr_reader :flag_configs
159
+ attr_reader :required_epilogue
160
+ attr_reader :optional_epilogue
161
+ attr_reader :epilogue_key
162
+
163
+ DEFAULT = Object.new
164
+
165
+ def initialize(
166
+ prologue: DEFAULT,
167
+ flag_config: DEFAULT,
168
+ flag_configs: nil,
169
+ epilogue: DEFAULT,
170
+ aliases: {},
171
+ program: nil)
172
+ @program = program || begin
173
+ %r{^(?:.*/)?(?<file>[^/]+):\d+:in} =~ caller.first
174
+ file
175
+ end
176
+ @aliases = aliases.freeze
177
+ prologue_set = prologue && prologue != DEFAULT
178
+ flag_configs_set = flag_configs && flag_configs != DEFAULT
179
+ epilogue_set = epilogue && epilogue != DEFAULT
180
+ prologue = epilogue_set || flag_configs_set ? false : true if prologue == DEFAULT
181
+ initialize_prologue(prologue)
182
+ flag_config = flag_configs_set ? false : true if flag_config == DEFAULT
183
+ @flag_configs = Hash.new(flag_config).merge(flag_configs || {}).freeze
184
+ epilogue = prologue_set || flag_configs_set ? false : true if epilogue == DEFAULT
185
+ initialize_epilogue(epilogue)
186
+ @valid = false
187
+ end
188
+
189
+ private
190
+
191
+ def initialize_prologue(prologue)
192
+ @required_prologue = []
193
+ @optional_prologue = []
194
+ @prologue_key = :prologue if prologue == true
195
+ @prologue_key = false if prologue == false
196
+ return unless @prologue_key.nil?
197
+
198
+ Array(prologue).each do |key|
199
+ /^(?<key>[[:alnum:]-]*)(?<optional>\?)?$/ =~ key
200
+ key = key.to_sym
201
+ if optional
202
+ @optional_prologue << key if optional
203
+ else
204
+ raise 'required prologue cannot follow optional prologue' unless @optional_prologue.empty?
205
+
206
+ @required_prologue << key
207
+ end
208
+ end
209
+ @required_prologue.freeze
210
+ @optional_prologue.freeze
211
+ end
212
+
213
+ def initialize_epilogue(epilogue)
214
+ @required_epilogue = []
215
+ @optional_epilogue = []
216
+ @epilogue_key = :epilogue if epilogue == true
217
+ @epilogue_key = false if epilogue == false
218
+ @epilogue_key = epilogue if epilogue.is_a?(Symbol)
219
+ return unless @epilogue_key.nil?
220
+
221
+ Array(epilogue).each do |key|
222
+ /^(?<key>[[:alnum:]-]*)(?<optional>\?)?$/ =~ key
223
+ key = key.to_sym
224
+ if optional
225
+ @optional_epilogue << key if optional
226
+ else
227
+ raise 'required epilogue cannot follow optional epilogue' unless @optional_epilogue.empty?
228
+
229
+ @required_epilogue << key
230
+ end
231
+ end
232
+ @required_epilogue = @required_epilogue.freeze
233
+ @optional_epilogue = @optional_epilogue.freeze
234
+ end
235
+
236
+ public
237
+
238
+ def self.flag_config(sym, flag_configs:)
239
+ flag_config = flag_configs[sym]
240
+ case flag_config
241
+ when true
242
+ { type: :anything }
243
+ when Symbol
244
+ { type: flag_config }
245
+ when nil
246
+ nil
247
+ when String
248
+ { help: flag_config }
249
+ else
250
+ flag_config
251
+ end
252
+ end
253
+
254
+ def self.flag_type(sym, flag_configs:)
255
+ config = flag_config(sym, flag_configs: flag_configs)
256
+ config[:type] if config
257
+ end
258
+
259
+ def self.boolean?(sym, flag_configs:)
260
+ flag_type(sym, flag_configs: flag_configs) == :boolean
261
+ end
262
+
263
+ def flag_config(sym)
264
+ Aargs.flag_config(sym, flag_configs: flag_configs)
265
+ end
266
+
267
+ def flag_type(sym)
268
+ Aargs.flag_type(sym, flag_configs: flag_configs)
269
+ end
270
+
271
+ def boolean?(sym)
272
+ Aargs.boolean?(sym, flag_configs: flag_configs)
273
+ end
274
+
275
+ def required?(sym)
276
+ [required_prologue, required_epilogue].map(&method(:Array)).flatten.member?(sym)
277
+ end
278
+
279
+ def optional?(sym)
280
+ [optional_prologue, optional_epilogue].map(&method(:Array)).flatten.member?(sym)
281
+ end
282
+
283
+ def splat?(sym)
284
+ [prologue_key, epilogue_key].member?(sym)
285
+ end
286
+
287
+ def inspect_flag(sym)
288
+ arg = Aargs.kebab(sym)
289
+ return "#{arg.upcase}" if required?(sym)
290
+ return "[#{arg.upcase}]" if optional?(sym)
291
+ return "[aargs]" if sym == :any_key
292
+ return "[#{arg.to_s.upcase} ... [#{arg.to_s.upcase}]]" if splat?(sym)
293
+ return "--[no-]#{arg}" if boolean?(sym)
294
+
295
+ "--#{arg}=VALUE"
296
+ end
297
+
298
+ def help
299
+ prologue_keys = [required_prologue, optional_prologue, prologue_key ? prologue_key : nil].map(&method(:Array)).flatten
300
+ epilogue_keys = [required_epilogue, optional_epilogue, epilogue_key ? epilogue_key : nil].map(&method(:Array)).flatten
301
+ flag_keys = flag_configs.keys
302
+ flag_keys << :any_key if flag_configs[:any_key]
303
+ all_flags = prologue_keys + (flag_keys - prologue_keys) + epilogue_keys
304
+ usage = "Usage: #{@program} #{all_flags.map(&method(:inspect_flag)).join(' ')}"
305
+ any_real_help = false
306
+ lines = all_flags.map do |flag|
307
+ config = flag_config(flag)
308
+ next unless config
309
+
310
+ real_help = config[:help]
311
+ any_real_help ||= real_help
312
+ flag_help = real_help || case config[:type]
313
+ when :boolean
314
+ '(switch)'
315
+ else
316
+ "(#{config[:type]})"
317
+ end
318
+ [inspect_flag(flag), flag_help] if flag_help
319
+ end.compact
320
+ return [usage] if lines.empty? || !any_real_help
321
+
322
+ width = lines.map(&:first).map(&:length).max
323
+ lines.map! { |(flag, help)| format(" %<flag>-#{width}s : %<help>s", flag: flag, help: help) }
324
+ [usage, nil] + lines
325
+ end
326
+
327
+ def valid?
328
+ @valid
329
+ end
330
+
331
+ def parse(*args)
332
+ raise 'Aargs are frozen once parsed' if @valid
333
+
334
+ @parsed = Aargs.parse(args, aliases: aliases, flag_configs: flag_configs) || {}
335
+ @values = @parsed[:flags] || {}
336
+ parsed_prologue = @parsed[:prologue] || []
337
+
338
+ validate_sufficient_prologue(parsed_prologue)
339
+ consumed_prologue = apply_prologue(parsed_prologue)
340
+ apply_epilogue(parsed_prologue, consumed_prologue)
341
+ @valid = true
342
+ self
343
+ end
344
+
345
+ # @return if the given key is a known flag that should appear as part of the object's API
346
+ def api_key?(key)
347
+ @values.member?(key) || @optional_prologue.member?(key) || @flag_configs.member?(key)
348
+ end
349
+
350
+ def respond_to_missing?(sym, *_)
351
+ /^(?<key>.*?)(?:(?<_boolean>\?))?$/ =~ sym
352
+ key = key.to_sym
353
+ # puts(sym: sym, key: key, values: @values)
354
+ return super unless api_key?(key)
355
+
356
+ true
357
+ end
358
+
359
+ def method_missing(sym, *_)
360
+ return super unless @parsed
361
+
362
+ /^(?<key>.*?)(?:(?<boolean>\?))?$/ =~ sym
363
+ key = key.to_sym
364
+ return super unless api_key?(key)
365
+
366
+ value = @values[key]
367
+ return !(!value) if boolean
368
+
369
+ value
370
+ end
371
+
372
+ private
373
+
374
+ # Validate that we have enough arguments given to satisfy our required prologue, taking into account any that were
375
+ # specified as flags.
376
+ def validate_sufficient_prologue(parsed_prologue)
377
+ return if prologue_key
378
+
379
+ pp(required_prologue: required_prologue, values: @values)
380
+ actual_required_prologue = required_prologue - @values.keys
381
+ return if actual_required_prologue.length <= parsed_prologue.length
382
+
383
+ missing_flags = actual_required_prologue.drop(parsed_prologue.length)
384
+ raise "Missing positional arguments: #{missing_flags.map(&method(:inspect_flag)).join(', ')}"
385
+ end
386
+
387
+ # Validate that we have enough arguments given to satisfy our required prologue, taking into account any that were
388
+ # specified as flags.
389
+ def validate_sufficient_epilogue(parsed_epilogue)
390
+ return if epilogue_key
391
+
392
+ actual_required_epilogue = required_epilogue - @values.keys
393
+ return if actual_required_epilogue.length <= parsed_epilogue.length
394
+
395
+ missing_flags = actual_required_epilogue.drop(parsed_epilogue.length)
396
+ raise "Missing positional arguments: #{missing_flags.map(&method(:inspect_flag)).join(', ')}"
397
+ end
398
+
399
+ # Reverse-merge prologue values into {@link @values}
400
+ # @return [Hash] the recognized prologue flags
401
+ def apply_prologue(parsed_prologue)
402
+ return @values[prologue_key] = parsed_prologue if prologue_key
403
+
404
+ # Remove any prologue keys whose values appeared as flags:
405
+ expected_prologue = (required_prologue + optional_prologue) - @values.keys
406
+ # Convert the prologue into a hash based on the prologue keys we're still waiting for:
407
+ consumed_prologue = expected_prologue.zip(parsed_prologue).reject do |_, v|
408
+ # Avoid nil values since they're never returned from {@link Aargs.parse}
409
+ v.nil?
410
+ end.to_h
411
+ @values = consumed_prologue.merge(@values)
412
+ consumed_prologue
413
+ end
414
+
415
+ # Any extra prologue values become the beginning of the epilogue.
416
+ # Reverse-merge epilogue values into {@link @values}
417
+ # @raise if there's an epilogue given but we don't expect one
418
+ # @see epilogue_key
419
+ def apply_epilogue(parsed_prologue, consumed_prologue)
420
+ parsed_epilogue = parsed_prologue.drop(consumed_prologue.length).concat(Array(@parsed[:epilogue]))
421
+
422
+ # TODO: allow ... after required/optional consumed
423
+
424
+ # Remove any epilogue keys whose values appeared as flags:
425
+ epilogue_keys = [required_epilogue, optional_epilogue].map(&method(:Array)).flatten
426
+ expected_epilogue = epilogue_keys - @values.keys
427
+ # Convert the epilogue into a hash based on the epilogue keys we're still waiting for:
428
+ consumed_epilogue = expected_epilogue.zip(parsed_epilogue).reject do |_, v|
429
+ # Avoid nil values since they're never returned from {@link Aargs.parse}
430
+ v.nil?
431
+ end.to_h
432
+ @values = consumed_epilogue.merge(@values)
433
+
434
+ epilogue = parsed_epilogue.drop(consumed_epilogue.length)
435
+ return if epilogue.empty?
436
+ raise "Unexpected epilogue: #{epilogue.inspect}" unless epilogue_key
437
+
438
+ @values[epilogue_key] = epilogue
439
+ nil
440
+ end
441
+ end
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aargs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joshua Pollak
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-10-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |-
14
+ == `aargs` is a Better Ruby Options Parser
15
+ Provide a consistent shell and IRB experience for your users with `aargs`. “Do we have an accord?”
16
+ email: abottomlesspit@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/aargs.rb
22
+ homepage: https://rubygems.org/gems/aargs
23
+ licenses:
24
+ - MIT
25
+ metadata: {}
26
+ post_install_message:
27
+ rdoc_options: []
28
+ require_paths:
29
+ - lib
30
+ required_ruby_version: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ required_rubygems_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ requirements: []
41
+ rubygems_version: 3.0.3
42
+ signing_key:
43
+ specification_version: 4
44
+ summary: "“…more what you’d call “guidelines” than actual rules.”"
45
+ test_files: []