aargs 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []