strop 0.1.0 → 0.3.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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -17
  3. data/lib/strop/version.rb +1 -1
  4. data/lib/strop.rb +104 -84
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7e4c1b959f079ed2beb07575918ae878b23051fa8fabd983e346aef8b340223
4
- data.tar.gz: 88366bb7a1b7bf57328fd9a149e2b21c61b052508155a84d48e286e87d323550
3
+ metadata.gz: c021ef142a7a5155da860f70e400c232afdf91bfe2b160d4bf12fd44356714b9
4
+ data.tar.gz: ba3a9549ff23d7636d09f4eece2aad318490b2143b78f0af86bd7e0178e75885
5
5
  SHA512:
6
- metadata.gz: ea8491685f9edebf4364c96886c1ab18cca85d628306ed2fbd76827cc7b1db06b8021851db33e2b337d765805700c12e6bf80e4e4d5479886818654d35f6c566
7
- data.tar.gz: c14c0ac725fd9530adc4eaf474c142218c1f8eb88001de14907b34d79ee29db15564f535eb6d41dadffc5a7cd3b3cfe42fbaa8a73d7d46d8fcdf7a3c5ab94bd0
6
+ metadata.gz: ef12ddce52bac421a5a9320bd07b554bbb1ead04a4f0b7627e43ba835ac9c90bc9a7262d74fdbc4dad23e1cb834180575e60b5eb4a4d2c25aac54f57bcf4f24e
7
+ data.tar.gz: edb2a597fd1fff45d38b90c2b62c97b06742055c0f3ec9d12c5220c0dbf1ab80bcc595388db2a4b1183e71c5b3e2b894f4948ab0c77e9e9983f0a56320dd93c8
data/README.md CHANGED
@@ -4,11 +4,18 @@
4
4
 
5
5
  - Build options from parsing help text, instead of the other way around
6
6
  - Pattern matching for result processing (with `case`/`in …`)
7
+ - Short, single-file implementation: easy to read and modify
7
8
 
8
9
  ## Core workflow
9
10
 
10
11
  ```ruby
11
- opts = Optlist.from_help(help_text) # extract from help
12
+ help_text = <<~HELP
13
+ Options:
14
+ -f, --flag Flag
15
+ -v, --verbose LEVEL Required arg
16
+ -o, --output [FILE] Optional arg
17
+ HELP
18
+ opts = Strop.parse_help(help_text) # extract from help
12
19
  result = Strop.parse(opts, ARGV) # parse argv -> Result
13
20
  result = Strop.parse!(opts, ARGV) # same but on error prints message and exits
14
21
  result = Strop.parse!(help_text) # Automatically parse help text, ARGV default
@@ -35,7 +42,8 @@ Or, more succinctly:
35
42
  Strop.parse!(help).each do |item|
36
43
  case item
37
44
  in label: "help" then show_help # only Opt has .label
38
- in arg: then files << arg # `value:` might match an Opt, so Arg offters alias .arg
45
+ in arg: then files << arg # `value:` might match an Opt, so Arg offers alias .arg
46
+ in Strop::Sep then # same. leave blank to keep looping, but exhaust the case
39
47
  end
40
48
  end
41
49
  ```
@@ -43,29 +51,31 @@ end
43
51
  You can generate the case expression above with:
44
52
 
45
53
  ```ruby
46
- puts Strop::Optlist.from_help(help_text).to_s(:case)
54
+ puts Strop.parse_help(help_text).to_s(:case)
47
55
  ```
48
56
 
49
57
 
50
58
  ## Result members (Array of Opt, Arg, Sep)
51
59
 
52
60
  ```ruby
53
- res.opts # all Opt objects
54
- res.args # all Arg objects
55
- res.rest # args after -- separator
56
- res["flag"] # find opt by name
61
+ res.opts # all Opt objects
62
+ res.args # all Arg objects
63
+ res.rest # args after -- separator
64
+ res["flag"] # find opt by name
65
+ res[["flag"]] # find all opts matching name
57
66
 
58
67
  opt = res.opts.first
59
68
  arg = res.args.first
60
- opt.decl # Optdecl matched for this Opt instance
61
- opt.name # name used in invocation (could differ from label)
62
- opt.value # argument passed to this option
63
- opt.label # primary name (first long name or first name), used for pattern matching
64
- opt.no? # true if --no-foo variant used
65
- opt.yes? # opposite of `no?`
66
- arg.value # positional argument
67
- arg.arg # same as .value, useful for pattern matching
68
- Sep # -- end of options marker; Const, not instantiated
69
+ opt.decl # Optdecl matched for this Opt instance
70
+ opt.name # name used in invocation (could differ from label)
71
+ opt.value # argument passed to this option
72
+ opt.label # primary name (first long name or first name), used for pattern matching
73
+ opt.no? # true if --no-foo variant used
74
+ opt.yes? # opposite of `no?`
75
+ arg.value # positional argument
76
+ arg.arg # same as .value, useful for pattern matching
77
+ arg.to_s # implicit string conversion (same as .value)
78
+ Sep # -- end of options marker; Const, not instantiated
69
79
  ```
70
80
 
71
81
  Notice that parsing `--[no-]flag` from help results in a single `Optdecl[:flag, :"no-flag"]`, and you can use `yes?`/`no?` to check which was passed.
@@ -97,7 +107,7 @@ Use at least two spaces before description, and only a single space before args.
97
107
 
98
108
  ```
99
109
  --file PATH # !! PATH seen as description and ignored, --file considered a flag (no arg)
100
- --quiet Supresses output # !! interpreted as --quiet=Supresses
110
+ --quiet Suppresses output # !! interpreted as --quiet=Suppresses
101
111
  ```
102
112
 
103
113
  The latter case is detected and a warning is printed, but best to avoid this situation altogether.
@@ -110,11 +120,13 @@ cmd -fVAL, --foo=VAL # attached values
110
120
  cmd -f VAL, --foo VAL # separate values
111
121
  cmd --foo val -- --bar # --bar becomes positional after --
112
122
  cmd --intermixed args and --options # flexible ordering
123
+ cmd --ver # partial matching (--ver matches --verbose if unique)
113
124
  ```
114
125
 
115
126
  ## Manual option declaration building
116
127
 
117
128
  ```ruby
129
+ include Strop::Exports # For brevity in exanples. Not required.
118
130
  Optdecl[:f] # flag only: -f
119
131
  Optdecl[:f?] # optional arg: -f [X]
120
132
  Optdecl[:f!] # required arg: -f x
@@ -132,6 +144,7 @@ Optdecl["foo_bar"] # --foo_bar: but not in strings.
132
144
  ```ruby
133
145
  optlist = Optlist[optdecl1, optdecl2] # combine decls into optlist
134
146
  optlist["f"] # lookup by name
147
+ optlist["fl"] # partial match (finds "flag" if unique)
135
148
  ```
136
149
 
137
150
  ## Argument requirements
data/lib/strop/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Strop
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/strop.rb CHANGED
@@ -3,10 +3,14 @@
3
3
  # require "debug"; DEBUGGER__.add_catch_breakpoint "Exception"
4
4
  require_relative "strop/version"
5
5
 
6
+ # Command-line option parser that builds options from help text
6
7
  module Strop
8
+ def self.prefix(name) = (name[1] ? "--" : "-") + name # helper for printing back option names with the right prefix
7
9
 
10
+ # Option declaration: names, argument requirement, and canonical label (auto-determined)
11
+ # Optdecl[:f, :foo, arg: :may] #=> Optdecl(names: ["f", "foo"], arg: :may, label: "foo")
8
12
  Optdecl = Data.define(:names, :arg, :label) do
9
- def self.[](*names, arg: nil) = new(names:, arg:)
13
+ def self.[](*names, arg: nil) = new(names:, arg:) # Custom builder: Optdecl[names, ..., arg: ...]
10
14
  def initialize(names:, arg: nil)
11
15
  names = [*names].map{ Symbol === it ? it.to_s.gsub(?_, ?-) : it } # :foo_bar to "foo-bar" for symbols
12
16
  names[0] = names[0].sub(/[!?]$/, "") unless arg # opt? / opt! to opt, and... (unless arg given)
@@ -19,12 +23,25 @@ module Strop
19
23
  def no? = names.each_cons(2).any?{|a,b| b =~ /\Ano-?#{Regexp.escape a}\z/ } # is a flag like --[no-]foo, --[no]bar
20
24
  def arg? = self.arg != :shant # accepts arg
21
25
  def arg! = self.arg == :must # requires arg
22
- def to_s = names.map{ (it[1] ? "--" : "-")+it }.join(", ") + { must: " X", may: " [X]", shant: "" }[arg]
26
+ def to_s = names.map{ Strop.prefix it }.join(", ") + { must: " X", may: " [X]", shant: "" }[arg]
23
27
  end
24
28
 
29
+ # List of option declarations with lookup via #[]. Used by `parse`. Generated by `parse_help`
30
+ # Optlist[decl1, decl2] #=> [Optdecl(...), Optdecl(...)]
25
31
  class Optlist < Array # a list of Optdecls
26
- def self.from_help(doc) = Strop.parse_help(doc)
27
- def [](k, ...) = [String, Symbol].any?{ it === k } ? self.find{ it.names.member? k.to_s } : super(k, ...)
32
+ def self.from_help(doc) = Strop.parse_help(doc) #=> Optlist[decl, ...] # Build from help text
33
+
34
+ def [](k, ...)
35
+ case k
36
+ in String | Symbol
37
+ s = k.to_s
38
+ found = find{ it.names.member? s } and return found
39
+ found, *others = select{ it.names.any?{ it.start_with? s }} if s[1]
40
+ found if found && others.empty?
41
+ else super(k, ...)
42
+ end
43
+ end
44
+
28
45
  def to_s(as=:plain)
29
46
  case as
30
47
  when :plain then join("\n")
@@ -36,8 +53,8 @@ module Strop
36
53
  for item in Strop.parse!(optlist)
37
54
  case item
38
55
  #{caseins.map{ " #{it}" }.join("\n").lstrip}
39
- case Strop::Arg[value:] then
40
- case Strop::Sep then break # if you want to handle result.rest separately
56
+ in arg: then # positional
57
+ in Strop::Sep then break # if you want to handle result.rest separately
41
58
  else raise "Unhandled result \#{item}"
42
59
  end
43
60
  end
@@ -47,11 +64,16 @@ module Strop
47
64
  end
48
65
 
49
66
 
67
+ # Positional argument value wrapper. Used internally. Seen as member of Result.
68
+ # Arg[value: "file.txt"] #=> Arg(value: "file.txt", arg: "file.txt") # arg alias for pattern matching
50
69
  Arg = Data.define :value, :arg do
51
70
  def initialize(value:) = super(value:, arg: value)
52
71
  alias to_s value
72
+ alias to_str value
53
73
  end
54
74
 
75
+ # Parsed option with declaration, invocation name, value, and negation state. Used internally. Seen as member of Result.
76
+ # Opt[decl: optdecl, name: "verbose", value: "2"] #=> Opt(decl: ..., name: "verbose", value: "2", label: "verbose", no: false)
55
77
  Opt = Data.define :decl, :name, :value, :label, :no do
56
78
  def initialize(decl:, name:, value: nil)
57
79
  label = decl.label # repeated here so can be pattern-matched against in case/in
@@ -62,41 +84,47 @@ module Strop
62
84
  def yes? = !no?
63
85
  end
64
86
 
65
- Sep = :end_marker
66
-
67
- # for debugging only, TODO remove later probably
68
- class Arg
69
- def encode_with(coder) = (coder.scalar = self.value; coder.tag = nil)
70
- end
71
- class Opt
72
- def encode_with(coder) = (coder.map = { self.name => self.value }; coder.tag = nil)
73
- end
74
-
75
-
76
- module Exports
77
- Optlist = Strop::Optlist
78
- Optdecl = Strop::Optdecl
79
- Opt = Strop::Opt
80
- Arg = Strop::Arg
81
- Sep = Strop::Sep
82
- end
87
+ # Const for parsed `--` end of option markers. Seen as member of Result.
88
+ Sep = :sep
83
89
 
90
+ # Parse result containing options, arguments, and separators; Returned by `parse`
91
+ # Result[opt1, arg1, Sep] #=> [Opt(...), Arg(...), Sep]
84
92
  class Result < Array # of Opt, Arg, Sep
85
93
  def rest = drop_while{ it != Sep }.drop(1) # args after sep
86
94
  def args = Result.new(select { Arg === it })
87
95
  def opts = Result.new(select { Opt === it })
88
96
  def [](k, ...)
89
97
  case k
90
- when String, Symbol then find{ Opt === it && it.decl.names.member?(k.to_s) }
98
+ in [String | Symbol => name] then opts.select{ it.decl.names.include? name.to_s }
99
+ in String | Symbol then find{ Opt === it && it.decl.names.member?(k.to_s) }
91
100
  else super(k, ...)
92
101
  end
93
102
  end
94
103
  end
95
104
 
96
- class Unreachable < RuntimeError; end
97
- class OptionError < ArgumentError; end
105
+ class OptionError < ArgumentError; end # Raised during parse, with error msgs
106
+
107
+ # Convenience. Include if you don't wanna Strop:: everywhere.
108
+ module Exports
109
+ Optlist = Optlist
110
+ Optdecl = Optdecl
111
+ Opt = Opt
112
+ Arg = Arg
113
+ Sep = Sep
114
+ OptionError = OptionError
115
+ end
116
+
117
+ # for debugging only, TODO remove later probably
118
+ class Arg
119
+ def encode_with(coder) = (coder.scalar = self.value; coder.tag = nil)
120
+ end
121
+ class Opt
122
+ def encode_with(coder) = (coder.map = { self.name => self.value }; coder.tag = nil)
123
+ end
98
124
 
99
- def self.parse(optlist, argv=ARGV)
125
+ # Parse command line arguments array against option declarations. Defaults to parsing ARGV
126
+ # Accepts help text, file object for help file, or Optlist
127
+ def self.parse(optlist, argv=ARGV) #=> Result[...]
100
128
  Array === argv && argv.all?{ String === it } or raise "argv must be an array of strings (given #{argv.class})"
101
129
  optlist = case optlist
102
130
  when IO then parse_help(optlist.read)
@@ -108,60 +136,56 @@ module Strop
108
136
  res = Result.new
109
137
  ctx = :top
110
138
  name, token, opt = nil
111
- rx_value = /\A[^-]|\A\z/
139
+ rx_value = /\A[^-]|\A-?\z/ # not an opt
112
140
  loop do
113
141
  case ctx
114
- when :end then return res.concat tokens.map{ Arg[it] } # opt parsing ended, rest is positional args
115
- when :value then ctx = :top; res << Arg[token] # interspersed positional arg amidst opts
142
+ in :end then return res.concat tokens.map{ Arg[it] } # opt parsing ended, rest is positional args
143
+ in :value then ctx = :top; res << Arg[token] # interspersed positional arg amidst opts
116
144
 
117
- when :top
118
- token = tokens.shift or next ctx = :end # next token or done
145
+ in :top
146
+ token = tokens.shift or next ctx = :end # next token or done
119
147
  case token
120
- when "--" then ctx = :end; res << Sep # end of options
121
- when /\A--(.+)\z/m then token, ctx = $1, :long # long (--foo, --foo xxx), long with attached value (--foo=xxx)
122
- when /\A-(.+)\z/m then token, ctx = $1, :short # short or clump (-a, -abc)
123
- when rx_value then ctx = :value # value
124
- else raise Unreachable
148
+ in "--" then ctx = :end; res << Sep # end of options
149
+ in /\A--(.+)\z/m then token, ctx = $1, :long # long (--foo, --foo xxx), long with attached value (--foo=xxx)
150
+ in /\A-(.+)\z/m then token, ctx = $1, :short # short or clump (-a, -abc)
151
+ in ^rx_value then ctx = :value # value
125
152
  end
126
153
 
127
- when :long
128
- name, value = token =~ /\A(.*?)=/m ? [$1, $'] : [token, nil]
154
+ in :long
155
+ name, value = *token.split(?=, 2)
129
156
  opt = optlist[name] or raise OptionError, "Unknown option: --#{name}"
130
- case
131
- when opt.arg? && value then ctx = :top; res << Opt[opt, name, value] # --foo=XXX
132
- when !opt.arg? && !value then ctx = :top; res << Opt[opt, name] # --foo
133
- when opt.arg? && !value then ctx = :arg # --foo XXX
134
- when !opt.arg? && value then raise OptionError, "Option --#{name} takes no argument"
135
- else raise Unreachable
157
+ case [opt.arg?, value]
158
+ in true, String then ctx = :top; res << Opt[opt, name, value] # --foo=XXX
159
+ in false, nil then ctx = :top; res << Opt[opt, name] # --foo
160
+ in true, nil then ctx = :arg # --foo XXX
161
+ in false, String then raise OptionError, "Option --#{name} takes no argument"
136
162
  end
137
163
 
138
- when :short
139
- name, token = token[0], token[1..].then{ it != "" ? it : nil } # -abc -> a, bc
164
+ in :short
165
+ name, token = token[0], token[1..].then{ it if it != "" } # -abc -> a, bc
140
166
  opt = optlist[name] or raise OptionError, "Unknown option: -#{name}"
141
- case
142
- when opt.arg? && token then ctx = :top; res << Opt[opt, name, token] # -aXXX
143
- when !opt.arg? && !token then ctx = :top; res << Opt[opt, name] # end of -abc
144
- when opt.arg? && !token then ctx = :arg # -a XXX
145
- when !opt.arg? && token then res << Opt[opt, name] # -abc -> took -a, will parse -bc
146
- else raise Unreachable
167
+ case [opt.arg?, token]
168
+ in true, String then ctx = :top; res << Opt[opt, name, token] # -aXXX
169
+ in false, nil then ctx = :top; res << Opt[opt, name] # end of -abc
170
+ in true, nil then ctx = :arg # -a XXX
171
+ in false, String then res << Opt[opt, name] # -abc -> took -a, will parse -bc
147
172
  end
148
173
 
149
- when :arg
150
- token = tokens[0]&.=~(rx_value) ? tokens.shift : nil
151
- case
152
- when opt.arg! && !token then raise OptionError, "Expected argument for option -#{?- if name[1]}#{name}" # --req missing value
153
- when opt.arg! && token then ctx = :top; res << Opt[opt, name, token] # --req val
154
- when opt.arg? && token then ctx = :top; res << Opt[opt, name, token] # --opt val
155
- when opt.arg? && !token then ctx = :top; res << Opt[opt, name] # --opt followed by --foo, --opt as last token
156
- else raise Unreachable
174
+ in :arg
175
+ token = tokens[0]&.match(rx_value) && tokens.shift
176
+ case [opt.arg, token]
177
+ in :may, String then ctx = :top; res << Opt[opt, name, token] # --opt val
178
+ in :must, String then ctx = :top; res << Opt[opt, name, token] # --req val
179
+ in :may, nil then ctx = :top; res << Opt[opt, name] # --opt followed by --foo, --opt as last token
180
+ in :must, nil then raise OptionError, "Expected argument for option -#{?- if name[1]}#{name}" # --req missing value
157
181
  end
158
182
 
159
- else raise Unreachable
160
183
  end
161
184
  end
162
185
  end
163
186
 
164
- def self.parse!(...) # same but catches errors, print msg, exit
187
+ # Parse with error handling: print message and exit on OptionError
188
+ def self.parse!(...) #=> Result[...]
165
189
  parse(...)
166
190
  rescue OptionError => e
167
191
  $stderr.puts e.message
@@ -179,27 +203,23 @@ module Strop
179
203
  RX_OPT = /#{RX_SOPT}|#{RX_LOPT}/ # either opt
180
204
  RX_OPTS = /#{RX_OPT}(?:, {0,2}#{RX_OPT})*/ # list of opts, comma separated
181
205
 
182
- def self.parse_help(help, pad: /(?: ){1,2}/)
183
- help.scan(/^#{pad}(#{RX_OPTS})(.*)/).map do |line, rest| # get all opts lines
206
+ # Extract option declarations from formatted help text
207
+ def self.parse_help(help, pad: /(?: ){1,2}/) #=> Optlist[...]
208
+ decls = help.scan(/^#{pad}(#{RX_OPTS})(.*)/).map do |line, rest| # get all optdecl lines
184
209
  # Ambiguous: --opt Desc with only one space before will interpret "Desc" as arg.
185
- if rest =~ /^ \S/ && line =~ / (#{RX_SARG})$/ # desc preceeded by sringle space && last arg is " "+word. Capture arg name
186
- $stderr.puts "#{$1.inspect} was interpreted as argument, In #{(line+rest).inspect}. Use at least two spaces before description to avoid this warning."
187
- end
188
- line.scan(RX_OPT).map do |opt| # take options from each line
189
- opt.split(/(?=\[=)|=| +/, 2) # separate name from arg
190
- end.map do |name, arg| # remove opt markers -/--, transform arg str into requirement
191
- [name.sub(/^--?/, ''), arg.nil? ? :shant : arg[0] == "[" ? :may : :must]
192
- end.transpose # [[name,arg], ...] -> [names, args]
193
- .then do |names, args| # handle -f,--foo=x style (without arg on short opt); expand --[no]flag into --flag and --noflag (also --[no-])
194
- args = args.uniq.tap{ it.delete :shant if it.size > 1 } # delete excess :shant (from -f in -f,--foo=x)
195
- raise "Option #{names} has conflicting arg requirements: #{args}" if args.size > 1 # raise if still conflict, like -f X, --ff [X]
196
- [(names.flat_map{|f| f.start_with?(RX_NO) ? [$', $&[1...-1] + $'] : f }).uniq, args[0]] # [flags and noflags, resolved single arg]
197
- end
198
- end.uniq.tap do |list| # [[[name, name, ...], arg, more opts ...]
199
- dupes = list.flat_map(&:first).tally.reject{|k,v|v==1}
200
- raise "Options #{dupes.keys.inspect} seen more than once in distinct definitions" if dupes.any?
201
- end.map{ |names, arg| Optdecl[*names, arg:] }.then{ Optlist[*it] }
210
+ ambiguous = rest =~ /^ \S/ && line =~ / (#{RX_SARG})$/ # desc preceeded by sringle space && last arg is " "+word. Capture arg name for error below
211
+ ambiguous and $stderr.puts "#{$1.inspect} was interpreted as argument, In #{(line+rest).inspect}. Use at least two spaces before description to avoid this warning."
212
+ pairs = line.scan(RX_OPT).map { it.split(/(?=\[=)|=| +/, 2) } # take options from each line, separate name from arg
213
+ pairs.map! { |name, arg| [name.sub(/^--?/, ''), arg.nil? ? :shant : arg[0] == "[" ? :may : :must] } # remove opt markers -/--, transform arg str into requirement
214
+ names, args = pairs.transpose # [[name, arg], ...] -> [names, args]
215
+ arg, *rest = args.uniq.tap{ it.delete :shant if it.size > 1 } # delete excess :shant (from -f in -f,--foo=x, without arg on short opt)
216
+ raise "Option #{names} has conflicting arg requirements: #{args}" if rest.any? # raise if still conflict, like -f X, --ff [X]
217
+ names = (names.flat_map{ it.start_with?(RX_NO) ? [$', $&[1...-1] + $'] : it }).uniq # expand --[no]flag into --flag and --noflag (also --[no-])
218
+ [names, arg] # [names and noflags, resolved single arg]
219
+ end.uniq # allow identical opts
220
+ dupes = decls.flat_map(&:first).tally.reject{|k,v|v==1} # detect repeated names with diff specs
221
+ raise "Options #{dupes.keys.inspect} seen more than once in distinct definitions" if dupes.any?
222
+ decls.map{ |names, arg| Optdecl[*names, arg:] }.then{ Optlist[*it] } # Return an Optlist from decls
202
223
  end
203
224
 
204
-
205
225
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caio Chassot