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.
- checksums.yaml +4 -4
- data/README.md +30 -17
- data/lib/strop/version.rb +1 -1
- data/lib/strop.rb +104 -84
- 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: c021ef142a7a5155da860f70e400c232afdf91bfe2b160d4bf12fd44356714b9
|
|
4
|
+
data.tar.gz: ba3a9549ff23d7636d09f4eece2aad318490b2143b78f0af86bd7e0178e75885
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,31 @@ 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
|
|
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
|
|
61
|
-
opt.name
|
|
62
|
-
opt.value
|
|
63
|
-
opt.label
|
|
64
|
-
opt.no?
|
|
65
|
-
opt.yes?
|
|
66
|
-
arg.value
|
|
67
|
-
arg.arg
|
|
68
|
-
|
|
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
|
|
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
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{
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
139
|
+
rx_value = /\A[^-]|\A-?\z/ # not an opt
|
|
112
140
|
loop do
|
|
113
141
|
case ctx
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
token = tokens.shift or next ctx = :end
|
|
145
|
+
in :top
|
|
146
|
+
token = tokens.shift or next ctx = :end # next token or done
|
|
119
147
|
case token
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
name, value = token
|
|
154
|
+
in :long
|
|
155
|
+
name, value = *token.split(?=, 2)
|
|
129
156
|
opt = optlist[name] or raise OptionError, "Unknown option: --#{name}"
|
|
130
|
-
case
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
name, token = token[0], token[1..].then{ it != ""
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
token = tokens[0]
|
|
151
|
-
case
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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] }
|
|
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
|