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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -17
  3. data/lib/strop/version.rb +1 -1
  4. data/lib/strop.rb +88 -81
  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: 7f104516e3a61250be80d317bb041ab59f8e39fadf0fd2b4aefe18de8af90165
4
+ data.tar.gz: b55d4717c195ec9373b495b933758435eb764fd4f51073761a45456e2de47784
5
5
  SHA512:
6
- metadata.gz: ea8491685f9edebf4364c96886c1ab18cca85d628306ed2fbd76827cc7b1db06b8021851db33e2b337d765805700c12e6bf80e4e4d5479886818654d35f6c566
7
- data.tar.gz: c14c0ac725fd9530adc4eaf474c142218c1f8eb88001de14907b34d79ee29db15564f535eb6d41dadffc5a7cd3b3cfe42fbaa8a73d7d46d8fcdf7a3c5ab94bd0
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
- 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,29 @@ 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
57
65
 
58
66
  opt = res.opts.first
59
67
  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
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 Supresses output # !! interpreted as --quiet=Supresses
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Strop
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.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,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{ (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)
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
- case Strop::Arg[value:] then
40
- case Strop::Sep then break # if you want to handle result.rest separately
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
- # 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
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 Unreachable < RuntimeError; end
97
- class OptionError < ArgumentError; end
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
- def self.parse(optlist, argv=ARGV)
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
- 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
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
- when :top
118
- token = tokens.shift or next ctx = :end # next token or done
132
+ in :top
133
+ token = tokens.shift or next ctx = :end # next token or done
119
134
  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
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
- when :long
128
- name, value = token =~ /\A(.*?)=/m ? [$1, $'] : [token, nil]
141
+ in :long
142
+ name, value = *token.split(?=, 2)
129
143
  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
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
- when :short
139
- name, token = token[0], token[1..].then{ it != "" ? it : nil } # -abc -> a, bc
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
- 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
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
- 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
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
- def self.parse!(...) # same but catches errors, print msg, exit
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
- def self.parse_help(help, pad: /(?: ){1,2}/)
183
- help.scan(/^#{pad}(#{RX_OPTS})(.*)/).map do |line, rest| # get all opts lines
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
- 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] }
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
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caio Chassot