help_parser 4.0.0 → 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +42 -130
- data/lib/help_parser.rb +34 -6
- data/lib/help_parser/aliases.rb +19 -0
- data/lib/help_parser/completion.rb +135 -0
- data/lib/help_parser/constants.rb +17 -69
- data/lib/help_parser/exceptions.rb +51 -0
- data/lib/help_parser/k2t2r.rb +33 -0
- data/lib/help_parser/macros.rb +199 -0
- data/lib/help_parser/options.rb +40 -0
- data/lib/help_parser/parsea.rb +29 -0
- data/lib/help_parser/parseh.rb +29 -0
- data/lib/help_parser/parseu.rb +28 -0
- data/lib/help_parser/validations.rb +98 -0
- metadata +22 -10
- data/lib/help_parser/help_parser.rb +0 -99
- data/lib/help_parser/pattern.rb +0 -140
- data/lib/help_parser/usage.rb +0 -100
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 15eed085a057dd27423f3a363e2a172cceecd664
|
4
|
+
data.tar.gz: 9159259d556060cebe4d1241d0065b6252a0280a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3b8aeb803ca69f593a861d49e574175178a57574fd42664ff853ba229be9b84118fb26c9bed3ed3029c52b1b60727fc3368ab6409a1987da527a645f46f41e4d
|
7
|
+
data.tar.gz: f40a072547ec26b18c174f3f313106132f64299c4356ba07a1b3e915558d73a8ee9674b28dad9533bdcecc21ed678460e4c21e96432fb31f83372d2e85cfcedf
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 carlosjhr64
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
@@ -1,151 +1,63 @@
|
|
1
|
-
#
|
2
|
-
##
|
1
|
+
# Help Parser
|
2
|
+
## V: Infamous
|
3
3
|
|
4
|
-
* [github](https://www.github.com/carlosjhr64/
|
4
|
+
* [github](https://www.github.com/carlosjhr64/Ruby-HelpParser)
|
5
5
|
* [rubygems](https://rubygems.org/gems/help_parser)
|
6
6
|
|
7
7
|
## DESCRIPTION:
|
8
|
-
|
8
|
+
All help is about to get parsed...
|
9
|
+
Again!!!
|
9
10
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
## LICENSE:
|
15
|
-
[MIT](https://en.wikipedia.org/wiki/MIT_License) Copyright (c) 2017 CarlosJHR64
|
16
|
-
|
17
|
-
## MINIMAL
|
18
|
-
|
19
|
-
#!/usr/bin/env ruby
|
20
|
-
require 'help_parser'
|
21
|
-
|
22
|
-
OPTIONS = HelpParser.new
|
23
|
-
p OPTIONS._hash
|
11
|
+
And this time,
|
12
|
+
the battle between complexity and simplicity
|
13
|
+
has as familiar text.
|
24
14
|
|
25
15
|
## SYNOPSIS:
|
26
16
|
|
27
|
-
|
28
|
-
require
|
29
|
-
|
30
|
-
OPTIONS = HelpParser.new('1.2.3', <<-HELP)
|
31
|
-
Usage:
|
32
|
-
synopsis [:options] <x> <y>
|
33
|
-
Options:
|
34
|
-
--verbose
|
35
|
-
HELP
|
36
|
-
|
37
|
-
# Existentials via missing "?" methods
|
38
|
-
puts 'Whats x * y?' if OPTIONS.verbose?
|
39
|
-
|
40
|
-
# Values via missing methods.
|
41
|
-
puts OPTIONS.x.to_f * OPTIONS.y.to_f
|
42
|
-
|
43
|
-
## MORE:
|
44
|
-
|
45
|
-
#!/usr/bin/env ruby
|
46
|
-
require 'help_parser'
|
47
|
-
|
48
|
-
# Any version string, does not have to be semantic versioning.
|
49
|
-
VERSION = '1.2.3'
|
17
|
+
require "pp"
|
18
|
+
require "help_parser"
|
50
19
|
|
51
20
|
HELP = <<-HELP
|
52
|
-
|
53
|
-
# Lines starting with the pound sign are comments.
|
54
|
-
# HelpPaser parses the help text by sections
|
55
|
-
# with a heading of the form `<Section Name>:`.
|
56
|
-
# The Usage section gives the allowed forms of the
|
57
|
-
# command. Keys, arguments, and selectors in
|
58
|
-
# square bracket are optional and can be nested.
|
59
|
-
# A plus sign, `+`, marks the associated token as
|
60
|
-
# taking multiple values. Note that variable
|
61
|
-
# arguments have the form `<name>`.
|
62
|
-
####################################################
|
21
|
+
The Awesome Command.
|
63
22
|
Usage:
|
64
|
-
|
65
|
-
|
66
|
-
####################################################
|
67
|
-
# Tipically you'll have an `Options:` section, but
|
68
|
-
# you can give the section any name.
|
69
|
-
# An `Options:` section should match an `:options`
|
70
|
-
# selector in at least one of your usages.
|
71
|
-
# Assigning values to keys via the command line
|
72
|
-
# is done in the form `--key=value`.
|
73
|
-
####################################################
|
23
|
+
awesome [:options+] <args>+
|
24
|
+
awesome :alternate <arg=NAME>
|
74
25
|
Options:
|
75
|
-
-
|
76
|
-
-
|
77
|
-
--
|
78
|
-
--
|
79
|
-
--
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
# your defaults, but for arguments, it's done here.
|
85
|
-
####################################################
|
86
|
-
Defaults:
|
87
|
-
name Carlos \tDefault for name
|
88
|
-
####################################################
|
89
|
-
# HelpPaser has two automatic types, `Float` and
|
90
|
-
# `Int`, which will convert values from String to
|
91
|
-
# Floats and Integers repectively.
|
92
|
-
# You can also provide you own custom validation
|
93
|
-
# with regular expressions.
|
94
|
-
####################################################
|
26
|
+
-v --version \t Give version and quit
|
27
|
+
-h --help \t Give help and quit
|
28
|
+
-s --long \t Short long synonyms
|
29
|
+
--name=NAME \t Typed
|
30
|
+
--number 5 \t Defaulted
|
31
|
+
--value=FLOAT 1.23 \t Typed and Defaulted
|
32
|
+
-a --all=YN y \t Short, long, typed, and defaulted
|
33
|
+
Alternate:
|
34
|
+
-V \t Just short
|
95
35
|
Types:
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
####################################################
|
100
|
-
# The start of a `Notes:` section tells HelpParser
|
101
|
-
# to break out of it's parsing.
|
102
|
-
# Anything else written after this heading is of
|
103
|
-
# no consequence to HelpParser.
|
104
|
-
####################################################
|
105
|
-
Notes:
|
106
|
-
Stuff the help parser will ignore.
|
36
|
+
NAME /^[A-Z][a-z]+$/
|
37
|
+
FLOAT /^\\d+\\.\\d+$/
|
38
|
+
YN /^[YNyn]$/
|
107
39
|
HELP
|
108
40
|
|
109
|
-
|
41
|
+
VERSION = "5.0.0"
|
110
42
|
|
111
|
-
#
|
112
|
-
|
113
|
-
#
|
114
|
-
#
|
115
|
-
|
116
|
-
puts "Count: #{_=OPTIONS.count}\t#{_.class}"
|
117
|
-
puts "Price: #{_=OPTIONS.price}\t#{_.class}"
|
118
|
-
puts "Name: #{_=OPTIONS.name}\t#{_.class}"
|
119
|
-
else
|
120
|
-
puts "Args: #{OPTIONS.args.join(', ')}"
|
121
|
-
end
|
122
|
-
puts "Name: #{_=OPTIONS.name}\t#{_.class}"
|
123
|
-
puts "Val: #{_=OPTIONS.val}\t#{_.class}"
|
124
|
-
puts "Number: #{_=OPTIONS.number}\t#{_.class}"
|
43
|
+
# Macros:
|
44
|
+
HelpParser.string(name) # for options.name : String
|
45
|
+
HelpParser.strings(args) # for options.args : Array(String)
|
46
|
+
HelpParser.float(value) # for options.value : Float
|
47
|
+
HelpParser.int?(number) # for options.number : Int32 | Nil
|
125
48
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
if OPTIONS.usage?
|
130
|
-
p OPTIONS._usage._hash
|
131
|
-
else
|
132
|
-
p OPTIONS._hash
|
133
|
-
end
|
49
|
+
HelpParser.run(VERSION, HELP) do |options|
|
50
|
+
hash = options._hash
|
51
|
+
pp hash # to inspect the hash
|
134
52
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
Just to have to go back and reimplement with a proper options parser
|
141
|
-
when the little script ends up being useful and grows into something bigger.
|
142
|
-
And I was doing this after I had written my own previous versions of HelpParser.
|
143
|
-
So now I should have no excuse to just use HelpParser. Always:
|
53
|
+
pp options.name if hash["name"]?
|
54
|
+
pp options.args if hash["args"]?
|
55
|
+
pp options.value if hash["value"]?
|
56
|
+
pp options.number?
|
57
|
+
end
|
144
58
|
|
145
|
-
|
146
|
-
OPTIONS = HelpParser.new # DONE!
|
59
|
+
YES!!!
|
147
60
|
|
148
|
-
|
61
|
+
## INSTALL:
|
149
62
|
|
150
|
-
|
151
|
-
no need to rework the code for not having started out with a proper options parser.
|
63
|
+
$ sudo gem install help_parser
|
data/lib/help_parser.rb
CHANGED
@@ -1,9 +1,37 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
1
|
+
require_relative "./help_parser/constants"
|
2
|
+
require_relative "./help_parser/exceptions"
|
3
|
+
require_relative "./help_parser/aliases"
|
4
|
+
require_relative "./help_parser/parsea"
|
5
|
+
require_relative "./help_parser/validations"
|
6
|
+
require_relative "./help_parser/parseu"
|
7
|
+
require_relative "./help_parser/parseh"
|
8
|
+
require_relative "./help_parser/k2t2r"
|
9
|
+
require_relative "./help_parser/completion"
|
10
|
+
require_relative "./help_parser/options"
|
11
|
+
require_relative "./help_parser/macros"
|
12
|
+
|
13
|
+
module HelpParser
|
14
|
+
VERSION = "5.0.0"
|
15
|
+
|
16
|
+
def self.[](
|
17
|
+
version = nil,
|
18
|
+
help = nil,
|
19
|
+
argv = [File.basename($0)]+ARGV)
|
20
|
+
Options.new(version, help, argv)
|
21
|
+
rescue HelpParserException => exception
|
22
|
+
exception.exit
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.run(
|
26
|
+
version = nil,
|
27
|
+
help = nil,
|
28
|
+
argv = [File.basename($0)]+ARGV)
|
29
|
+
options = Options.new(version, help, argv)
|
30
|
+
yield options
|
31
|
+
rescue HelpParserException => exception
|
32
|
+
exception.exit
|
33
|
+
end
|
34
|
+
end
|
7
35
|
|
8
36
|
# Requires:
|
9
37
|
#`ruby`
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module HelpParser
|
2
|
+
class NoDupHash < Hash
|
3
|
+
def []=(k,v)
|
4
|
+
raise HelpError, "Duplicate key: #{k}" if self.has_key?(k)
|
5
|
+
super
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class ArgvHash < Hash
|
10
|
+
def []=(k,v)
|
11
|
+
raise UsageError, "Duplicate key: #{k}" if self.has_key?(k)
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.f2k(f)
|
17
|
+
f[1]=='-' ? f[2..((f.index('=')||0)-1)] : f[1]
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
module HelpParser
|
2
|
+
class Completion
|
3
|
+
def initialize(hash, specs, cache = NoDupHash.new)
|
4
|
+
@hash,@specs,@cache = hash,specs,cache
|
5
|
+
usage if @specs.has_key?(USAGE)
|
6
|
+
pad
|
7
|
+
types
|
8
|
+
end
|
9
|
+
|
10
|
+
def usage
|
11
|
+
@specs[USAGE].each do |cmd|
|
12
|
+
begin
|
13
|
+
i = matches(cmd)
|
14
|
+
raise NoMatch unless @hash.size==i
|
15
|
+
@cache.each{|k,v|@hash[k]=v} # Variables
|
16
|
+
return
|
17
|
+
rescue NoMatch
|
18
|
+
next
|
19
|
+
ensure
|
20
|
+
@cache.clear
|
21
|
+
end
|
22
|
+
end
|
23
|
+
raise UsageError, "Please match usage."
|
24
|
+
end
|
25
|
+
|
26
|
+
def types
|
27
|
+
if t2r = HelpParser.t2r(@specs)
|
28
|
+
k2t = HelpParser.k2t(@specs)
|
29
|
+
HelpParser.validate_k2t2r(@specs, k2t, t2r)
|
30
|
+
@hash.each do |key,value|
|
31
|
+
next unless key.is_a?(String)
|
32
|
+
if type = k2t[key]
|
33
|
+
regex = t2r[type]
|
34
|
+
case value
|
35
|
+
when String
|
36
|
+
raise UsageError, "Not a #{type}: #{key}" unless value=~regex
|
37
|
+
when Array
|
38
|
+
value.each do |string|
|
39
|
+
raise UsageError, "Not a #{type}: #{key}" unless string=~regex
|
40
|
+
end
|
41
|
+
else
|
42
|
+
raise UsageError, "Need a #{type}: #{key}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def pad
|
50
|
+
# Synonyms and defaults:
|
51
|
+
@specs.each do |section,options|
|
52
|
+
next if section==USAGE || section==TYPES
|
53
|
+
options.each do |words|
|
54
|
+
next unless words.size>1
|
55
|
+
first,second,default = words[0],words[1],words[2]
|
56
|
+
if first[0]=='-'
|
57
|
+
if second[0]=='-'
|
58
|
+
i = second.index('=') || 0
|
59
|
+
short,long = first[1],second[2..(i-1)]
|
60
|
+
if @hash.has_key?(short)
|
61
|
+
if @hash.has_key?(long)
|
62
|
+
raise UsageError, "Option #{short} is a synonym for #{long}."
|
63
|
+
end
|
64
|
+
@hash[long] = (default.nil?) ? true : default
|
65
|
+
elsif value = @hash[long]
|
66
|
+
@hash[short] = true
|
67
|
+
if value==true && !default.nil?
|
68
|
+
@hash.delete(long)
|
69
|
+
@hash[long]=default
|
70
|
+
end
|
71
|
+
end
|
72
|
+
else
|
73
|
+
i = first.index('=') || 0
|
74
|
+
long,default = first[2..(i-1)],second
|
75
|
+
value = @hash[long]
|
76
|
+
if value==true
|
77
|
+
@hash.delete(long)
|
78
|
+
@hash[long] = default
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def matches(cmd, i = 0)
|
87
|
+
keys = @hash.keys
|
88
|
+
cmd.each do |token|
|
89
|
+
if token.is_a?(Array)
|
90
|
+
begin
|
91
|
+
i = matches(token, i)
|
92
|
+
rescue NoMatch
|
93
|
+
# OK, NEVERMIND!
|
94
|
+
end
|
95
|
+
next
|
96
|
+
elsif m=FLAG_GROUP.match(token)
|
97
|
+
group,plus = m["k"],m["p"]
|
98
|
+
key = keys[i]
|
99
|
+
raise NoMatch if key.nil? || key.is_a?(Integer)
|
100
|
+
list = @specs[group].flatten.select{|f|f[0]=='-'}.map{|f| HelpParser.f2k(f)}
|
101
|
+
raise NoMatch unless list.include?(key)
|
102
|
+
unless plus.nil?
|
103
|
+
loop do
|
104
|
+
key = keys[i+1]
|
105
|
+
break if key.nil? || key.is_a?(Integer) || !list.include?(key)
|
106
|
+
i+=1
|
107
|
+
end
|
108
|
+
end
|
109
|
+
elsif m=VARIABLE.match(token)
|
110
|
+
key = keys[i]
|
111
|
+
raise NoMatch unless key.is_a?(Integer)
|
112
|
+
variable,plus = m["k"],m["p"]
|
113
|
+
if plus.nil?
|
114
|
+
@cache[variable] = @hash[key]
|
115
|
+
else
|
116
|
+
strings = []
|
117
|
+
strings.push @hash[key]
|
118
|
+
loop do
|
119
|
+
key = keys[i+1]
|
120
|
+
break unless key.is_a?(Integer)
|
121
|
+
strings.push @hash[key]
|
122
|
+
i+=1
|
123
|
+
end
|
124
|
+
@cache[variable] = strings
|
125
|
+
end
|
126
|
+
else # literal
|
127
|
+
key = keys[i]
|
128
|
+
raise NoMatch if key.nil? || @hash[key]!=token
|
129
|
+
end
|
130
|
+
i += 1
|
131
|
+
end
|
132
|
+
return i
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -1,75 +1,23 @@
|
|
1
|
-
module
|
1
|
+
module HelpParser
|
2
|
+
USAGE = "usage"
|
3
|
+
TYPES = "types"
|
2
4
|
|
3
|
-
|
5
|
+
# usage
|
6
|
+
FLAG = /^[-][-]?(?<k>\w+)$/
|
7
|
+
LITERAL = /^(?<k>\w[\w.-]*:?)$/
|
8
|
+
VARIABLE = /^<(?<k>\w+)(=(?<t>[A-Z]+))?>(?<p>[+])?$/
|
9
|
+
FLAG_GROUP = /^:(?<k>\w+)(?<p>[+])?$/
|
4
10
|
|
5
|
-
|
6
|
-
|
11
|
+
# spec --?w+
|
12
|
+
SHORT = /^[-](?<s>\w)$/
|
13
|
+
LONG = /^[-][-](?<k>\w+)(=(?<t>[A-Z]+))?(,?\s+(?<d>[^-\s]\S*))?$/
|
7
14
|
|
8
|
-
|
9
|
-
|
15
|
+
# spec -w,? --w+
|
16
|
+
SHORT_LONG = /^[-](?<s>\w),?\s+[-][-](?<k>\w+)$/
|
17
|
+
SHORT_LONG_DEFAULT = /^[-](?<s>\w),?\s+[-][-](?<k>\w+)(=(?<t>[A-Z]+))?,?\s+(?<d>[^-\s]\S*)$/
|
10
18
|
|
11
|
-
|
12
|
-
|
13
|
-
NOTES,USAGE,TYPES,DEFAULTS = 'notes','usage','types','defaults'
|
14
|
-
|
15
|
-
### ERROR COLOR CODES ###
|
16
|
-
|
17
|
-
COLOR = [
|
18
|
-
"\e[0m", # no color
|
19
|
-
"\e[1;31m", # red
|
20
|
-
]
|
21
|
-
|
22
|
-
### TYPE MAPPINGS ###
|
23
|
-
|
24
|
-
FLOAT,INT = :Float, :Int
|
25
|
-
Types = {
|
26
|
-
INT => /^[+-]?\d+$/,
|
27
|
-
FLOAT => /^[+-]?\d+\.\d+$/,
|
28
|
-
}
|
29
|
-
|
30
|
-
### PATTERNS ###
|
31
|
-
|
32
|
-
S = /\s/ # space
|
33
|
-
SNS = /\s*\n\s*/
|
34
|
-
KEY = /^[-][-]?(.*)$/ #
|
35
|
-
SPS = /\s+/ # spaces
|
36
|
-
MNS = /^--?/
|
37
|
-
ERROR_KEY = /:\s+(\S+)/
|
38
|
-
COMMENTARY = /\s*\t.*$/ # tab marks start of field comment
|
39
|
-
|
40
|
-
SELECTION = /^(\w+):$/
|
41
|
-
SELECTION_P = /^:(.*?)([+]?)$/
|
42
|
-
|
43
|
-
VARIABLE = /^<(.*)>$/
|
44
|
-
VARIABLE_P = /^<(.*)>([+])?$/
|
45
|
-
|
46
|
-
### Error Messages ###
|
47
|
-
|
48
|
-
MATCH_USAGE = 'Please match usage!'
|
49
|
-
DUP_KEY = 'Duplicate Key: '
|
50
|
-
ILLEGAL_KEY = 'Illegal Key: '
|
51
|
-
SECTION_REDEFINITION = 'Section Redefinition: '
|
52
|
-
UNBALANCE_BRACKETS = 'Unbalanced Brackets in Usage: '
|
53
|
-
MISSING_MINUS = 'Missing Minus For Flag: '
|
54
|
-
NOT_A_FLAG = 'No Minus For Types Or Defaults: '
|
55
|
-
|
56
|
-
### Errors ###
|
57
|
-
|
58
|
-
class UsageError < RuntimeError
|
59
|
-
end
|
60
|
-
class HelpError < RuntimeError
|
61
|
-
end
|
62
|
-
class NoMatch < RuntimeError
|
63
|
-
end
|
64
|
-
|
65
|
-
### REFINEMENTS ###
|
66
|
-
|
67
|
-
module Refinements
|
68
|
-
refine Array do
|
69
|
-
def detect_duplicate
|
70
|
-
self.detect{|_|self.rindex(_) != self.index(_)}
|
71
|
-
end
|
72
|
-
end
|
73
|
-
end
|
19
|
+
# spec W+ /~/
|
20
|
+
TYPE_DEF = /^(?<t>[A-Z]+),?\s+\/(?<r>\S+)\/$/
|
74
21
|
|
22
|
+
CSV = /,?\s+/
|
75
23
|
end
|