strop 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +26 -17
- data/lib/strop/version.rb +1 -1
- data/lib/strop.rb +88 -81
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f104516e3a61250be80d317bb041ab59f8e39fadf0fd2b4aefe18de8af90165
|
|
4
|
+
data.tar.gz: b55d4717c195ec9373b495b933758435eb764fd4f51073761a45456e2de47784
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e629cc1a18457b54482398d8d1578d787f57ddd182c627a076f2ce7d2be9c639be3c893734422d0a23cd2be5fa2a534c527888f1437d1474a527078f5365a63
|
|
7
|
+
data.tar.gz: 8ef9f135caede03849470cd17ee470c8440b34838736bc100ef5c41502e41b35d7c99caa18c6f832edeeac72c9c67f6c926b03cc6d393f0679d34609ac8f112f
|
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
|
-
|
|
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
|
|
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,29 @@ end
|
|
|
43
51
|
You can generate the case expression above with:
|
|
44
52
|
|
|
45
53
|
```ruby
|
|
46
|
-
puts Strop
|
|
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
|
|
54
|
-
res.args
|
|
55
|
-
res.rest
|
|
56
|
-
res["flag"]
|
|
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
|
|
57
65
|
|
|
58
66
|
opt = res.opts.first
|
|
59
67
|
arg = res.args.first
|
|
60
|
-
opt.decl
|
|
61
|
-
opt.name
|
|
62
|
-
opt.value
|
|
63
|
-
opt.label
|
|
64
|
-
opt.no?
|
|
65
|
-
opt.yes?
|
|
66
|
-
arg.value
|
|
67
|
-
arg.arg
|
|
68
|
-
Sep
|
|
68
|
+
opt.decl # Optdecl matched for this Opt instance
|
|
69
|
+
opt.name # name used in invocation (could differ from label)
|
|
70
|
+
opt.value # argument passed to this option
|
|
71
|
+
opt.label # primary name (first long name or first name), used for pattern matching
|
|
72
|
+
opt.no? # true if --no-foo variant used
|
|
73
|
+
opt.yes? # opposite of `no?`
|
|
74
|
+
arg.value # positional argument
|
|
75
|
+
arg.arg # same as .value, useful for pattern matching
|
|
76
|
+
Sep # -- end of options marker; Const, not instantiated
|
|
69
77
|
```
|
|
70
78
|
|
|
71
79
|
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 +105,7 @@ Use at least two spaces before description, and only a single space before args.
|
|
|
97
105
|
|
|
98
106
|
```
|
|
99
107
|
--file PATH # !! PATH seen as description and ignored, --file considered a flag (no arg)
|
|
100
|
-
--quiet
|
|
108
|
+
--quiet Suppresses output # !! interpreted as --quiet=Suppresses
|
|
101
109
|
```
|
|
102
110
|
|
|
103
111
|
The latter case is detected and a warning is printed, but best to avoid this situation altogether.
|
|
@@ -115,6 +123,7 @@ cmd --intermixed args and --options # flexible ordering
|
|
|
115
123
|
## Manual option declaration building
|
|
116
124
|
|
|
117
125
|
```ruby
|
|
126
|
+
include Strop::Exports # For brevity in exanples. Not required.
|
|
118
127
|
Optdecl[:f] # flag only: -f
|
|
119
128
|
Optdecl[:f?] # optional arg: -f [X]
|
|
120
129
|
Optdecl[:f!] # required arg: -f x
|
data/lib/strop/version.rb
CHANGED
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,11 +23,13 @@ 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{
|
|
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)
|
|
32
|
+
def self.from_help(doc) = Strop.parse_help(doc) #=> Optlist[decl, ...] # Build from help text
|
|
27
33
|
def [](k, ...) = [String, Symbol].any?{ it === k } ? self.find{ it.names.member? k.to_s } : super(k, ...)
|
|
28
34
|
def to_s(as=:plain)
|
|
29
35
|
case as
|
|
@@ -36,8 +42,8 @@ module Strop
|
|
|
36
42
|
for item in Strop.parse!(optlist)
|
|
37
43
|
case item
|
|
38
44
|
#{caseins.map{ " #{it}" }.join("\n").lstrip}
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
in arg: then # positional
|
|
46
|
+
in Strop::Sep then break # if you want to handle result.rest separately
|
|
41
47
|
else raise "Unhandled result \#{item}"
|
|
42
48
|
end
|
|
43
49
|
end
|
|
@@ -47,11 +53,15 @@ module Strop
|
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
|
|
56
|
+
# Positional argument value wrapper. Used internally. Seen as member of Result.
|
|
57
|
+
# Arg[value: "file.txt"] #=> Arg(value: "file.txt", arg: "file.txt") # arg alias for pattern matching
|
|
50
58
|
Arg = Data.define :value, :arg do
|
|
51
59
|
def initialize(value:) = super(value:, arg: value)
|
|
52
60
|
alias to_s value
|
|
53
61
|
end
|
|
54
62
|
|
|
63
|
+
# Parsed option with declaration, invocation name, value, and negation state. Used internally. Seen as member of Result.
|
|
64
|
+
# Opt[decl: optdecl, name: "verbose", value: "2"] #=> Opt(decl: ..., name: "verbose", value: "2", label: "verbose", no: false)
|
|
55
65
|
Opt = Data.define :decl, :name, :value, :label, :no do
|
|
56
66
|
def initialize(decl:, name:, value: nil)
|
|
57
67
|
label = decl.label # repeated here so can be pattern-matched against in case/in
|
|
@@ -62,25 +72,11 @@ module Strop
|
|
|
62
72
|
def yes? = !no?
|
|
63
73
|
end
|
|
64
74
|
|
|
75
|
+
# Const for parsed `--` end of option markers. Seen as member of Result.
|
|
65
76
|
Sep = :end_marker
|
|
66
77
|
|
|
67
|
-
#
|
|
68
|
-
|
|
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
|
|
83
|
-
|
|
78
|
+
# Parse result containing options, arguments, and separators; Returned by `parse`
|
|
79
|
+
# Result[opt1, arg1, Sep] #=> [Opt(...), Arg(...), Sep]
|
|
84
80
|
class Result < Array # of Opt, Arg, Sep
|
|
85
81
|
def rest = drop_while{ it != Sep }.drop(1) # args after sep
|
|
86
82
|
def args = Result.new(select { Arg === it })
|
|
@@ -93,10 +89,29 @@ module Strop
|
|
|
93
89
|
end
|
|
94
90
|
end
|
|
95
91
|
|
|
96
|
-
class
|
|
97
|
-
|
|
92
|
+
class OptionError < ArgumentError; end # Raised during parse, with error msgs
|
|
93
|
+
|
|
94
|
+
# Convenience. Include if you don't wanna Strop:: everywhere.
|
|
95
|
+
module Exports
|
|
96
|
+
Optlist = Optlist
|
|
97
|
+
Optdecl = Optdecl
|
|
98
|
+
Opt = Opt
|
|
99
|
+
Arg = Arg
|
|
100
|
+
Sep = Sep
|
|
101
|
+
OptionError = OptionError
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# for debugging only, TODO remove later probably
|
|
105
|
+
class Arg
|
|
106
|
+
def encode_with(coder) = (coder.scalar = self.value; coder.tag = nil)
|
|
107
|
+
end
|
|
108
|
+
class Opt
|
|
109
|
+
def encode_with(coder) = (coder.map = { self.name => self.value }; coder.tag = nil)
|
|
110
|
+
end
|
|
98
111
|
|
|
99
|
-
|
|
112
|
+
# Parse command line arguments array against option declarations. Defaults to parsing ARGV
|
|
113
|
+
# Accepts help text, file object for help file, or Optlist
|
|
114
|
+
def self.parse(optlist, argv=ARGV) #=> Result[...]
|
|
100
115
|
Array === argv && argv.all?{ String === it } or raise "argv must be an array of strings (given #{argv.class})"
|
|
101
116
|
optlist = case optlist
|
|
102
117
|
when IO then parse_help(optlist.read)
|
|
@@ -108,60 +123,56 @@ module Strop
|
|
|
108
123
|
res = Result.new
|
|
109
124
|
ctx = :top
|
|
110
125
|
name, token, opt = nil
|
|
111
|
-
rx_value = /\A[^-]|\A\z/
|
|
126
|
+
rx_value = /\A[^-]|\A\z/ # not an opt
|
|
112
127
|
loop do
|
|
113
128
|
case ctx
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
in :end then return res.concat tokens.map{ Arg[it] } # opt parsing ended, rest is positional args
|
|
130
|
+
in :value then ctx = :top; res << Arg[token] # interspersed positional arg amidst opts
|
|
116
131
|
|
|
117
|
-
|
|
118
|
-
token = tokens.shift or next ctx = :end
|
|
132
|
+
in :top
|
|
133
|
+
token = tokens.shift or next ctx = :end # next token or done
|
|
119
134
|
case token
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else raise Unreachable
|
|
135
|
+
in "--" then ctx = :end; res << Sep # end of options
|
|
136
|
+
in /\A--(.+)\z/m then token, ctx = $1, :long # long (--foo, --foo xxx), long with attached value (--foo=xxx)
|
|
137
|
+
in /\A-(.+)\z/m then token, ctx = $1, :short # short or clump (-a, -abc)
|
|
138
|
+
in ^rx_value then ctx = :value # value
|
|
125
139
|
end
|
|
126
140
|
|
|
127
|
-
|
|
128
|
-
name, value = token
|
|
141
|
+
in :long
|
|
142
|
+
name, value = *token.split(?=, 2)
|
|
129
143
|
opt = optlist[name] or raise OptionError, "Unknown option: --#{name}"
|
|
130
|
-
case
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
else raise Unreachable
|
|
144
|
+
case [opt.arg?, value]
|
|
145
|
+
in true, String then ctx = :top; res << Opt[opt, name, value] # --foo=XXX
|
|
146
|
+
in false, nil then ctx = :top; res << Opt[opt, name] # --foo
|
|
147
|
+
in true, nil then ctx = :arg # --foo XXX
|
|
148
|
+
in false, String then raise OptionError, "Option --#{name} takes no argument"
|
|
136
149
|
end
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
name, token = token[0], token[1..].then{ it != ""
|
|
151
|
+
in :short
|
|
152
|
+
name, token = token[0], token[1..].then{ it if it != "" } # -abc -> a, bc
|
|
140
153
|
opt = optlist[name] or raise OptionError, "Unknown option: -#{name}"
|
|
141
|
-
case
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
else raise Unreachable
|
|
154
|
+
case [opt.arg?, token]
|
|
155
|
+
in true, String then ctx = :top; res << Opt[opt, name, token] # -aXXX
|
|
156
|
+
in false, nil then ctx = :top; res << Opt[opt, name] # end of -abc
|
|
157
|
+
in true, nil then ctx = :arg # -a XXX
|
|
158
|
+
in false, String then res << Opt[opt, name] # -abc -> took -a, will parse -bc
|
|
147
159
|
end
|
|
148
160
|
|
|
149
|
-
|
|
150
|
-
token = tokens[0]
|
|
151
|
-
case
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
else raise Unreachable
|
|
161
|
+
in :arg
|
|
162
|
+
token = tokens[0]&.match(rx_value) && tokens.shift
|
|
163
|
+
case [opt.arg, token]
|
|
164
|
+
in :may, String then ctx = :top; res << Opt[opt, name, token] # --opt val
|
|
165
|
+
in :must, String then ctx = :top; res << Opt[opt, name, token] # --req val
|
|
166
|
+
in :may, nil then ctx = :top; res << Opt[opt, name] # --opt followed by --foo, --opt as last token
|
|
167
|
+
in :must, nil then raise OptionError, "Expected argument for option -#{?- if name[1]}#{name}" # --req missing value
|
|
157
168
|
end
|
|
158
169
|
|
|
159
|
-
else raise Unreachable
|
|
160
170
|
end
|
|
161
171
|
end
|
|
162
172
|
end
|
|
163
173
|
|
|
164
|
-
|
|
174
|
+
# Parse with error handling: print message and exit on OptionError
|
|
175
|
+
def self.parse!(...) #=> Result[...]
|
|
165
176
|
parse(...)
|
|
166
177
|
rescue OptionError => e
|
|
167
178
|
$stderr.puts e.message
|
|
@@ -179,27 +190,23 @@ module Strop
|
|
|
179
190
|
RX_OPT = /#{RX_SOPT}|#{RX_LOPT}/ # either opt
|
|
180
191
|
RX_OPTS = /#{RX_OPT}(?:, {0,2}#{RX_OPT})*/ # list of opts, comma separated
|
|
181
192
|
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
# Extract option declarations from formatted help text
|
|
194
|
+
def self.parse_help(help, pad: /(?: ){1,2}/) #=> Optlist[...]
|
|
195
|
+
decls = help.scan(/^#{pad}(#{RX_OPTS})(.*)/).map do |line, rest| # get all optdecl lines
|
|
184
196
|
# Ambiguous: --opt Desc with only one space before will interpret "Desc" as arg.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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] }
|
|
197
|
+
ambiguous = rest =~ /^ \S/ && line =~ / (#{RX_SARG})$/ # desc preceeded by sringle space && last arg is " "+word. Capture arg name for error below
|
|
198
|
+
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."
|
|
199
|
+
pairs = line.scan(RX_OPT).map { it.split(/(?=\[=)|=| +/, 2) } # take options from each line, separate name from arg
|
|
200
|
+
pairs.map! { |name, arg| [name.sub(/^--?/, ''), arg.nil? ? :shant : arg[0] == "[" ? :may : :must] } # remove opt markers -/--, transform arg str into requirement
|
|
201
|
+
names, args = pairs.transpose # [[name, arg], ...] -> [names, args]
|
|
202
|
+
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)
|
|
203
|
+
raise "Option #{names} has conflicting arg requirements: #{args}" if rest.any? # raise if still conflict, like -f X, --ff [X]
|
|
204
|
+
names = (names.flat_map{ it.start_with?(RX_NO) ? [$', $&[1...-1] + $'] : it }).uniq # expand --[no]flag into --flag and --noflag (also --[no-])
|
|
205
|
+
[names, arg] # [names and noflags, resolved single arg]
|
|
206
|
+
end.uniq # allow identical opts
|
|
207
|
+
dupes = decls.flat_map(&:first).tally.reject{|k,v|v==1} # detect repeated names with diff specs
|
|
208
|
+
raise "Options #{dupes.keys.inspect} seen more than once in distinct definitions" if dupes.any?
|
|
209
|
+
decls.map{ |names, arg| Optdecl[*names, arg:] }.then{ Optlist[*it] } # Return an Optlist from decls
|
|
202
210
|
end
|
|
203
211
|
|
|
204
|
-
|
|
205
212
|
end
|