flagabowski 0.0.4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dd71400c2d0a9dc112f260cf1f444f6382a104bf5d494634365fb9ea7b416fa6
4
+ data.tar.gz: fe332aecfbf6c5d8ee138274f6c04575f957009704bd2572967522aa09bffc1c
5
+ SHA512:
6
+ metadata.gz: a869f7020b49edd8d3fbeb3b568c62c6faf6629f98efee0fe54036b1a7420643fa847148c7416c9d2a78bc157f8ee0f667ef2fc2bcb80b1ec0248a478fadce5e
7
+ data.tar.gz: 76ecdff5dc1ced0e7d661822d05823d4213348d411767ff7d26e39a1e5f2fd26ca06df8c650ad2d96e9a10da869d6b3225b4aa818372333d7b29176133459b50
data/.gitignore ADDED
@@ -0,0 +1,38 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ .env
14
+ .byebug_history
15
+
16
+ .dat*
17
+ .repl_history
18
+ build/
19
+ *.bridgesupport
20
+ build-iPhoneOS/
21
+ build-iPhoneSimulator/
22
+
23
+ /.yardoc/
24
+ /_yardoc/
25
+ /doc/
26
+ /rdoc/
27
+
28
+ /.bundle/
29
+ /vendor/bundle
30
+ /lib/bundler/man/
31
+
32
+ Gemfile.lock
33
+ .ruby-version
34
+ .ruby-gemset
35
+
36
+ .rvmrc
37
+
38
+ .rubocop-https?--*
data/.gitlab-ci.yml ADDED
@@ -0,0 +1,36 @@
1
+ image: ruby:3.3
2
+
3
+ stages:
4
+ - test
5
+ - release
6
+
7
+ variables:
8
+ BUNDLE_PATH: vendor/bundle
9
+
10
+ cache:
11
+ paths:
12
+ - vendor/bundle
13
+
14
+ before_script:
15
+ - ruby -v
16
+ - bundle install --jobs $(nproc)
17
+
18
+ test:
19
+ stage: test
20
+ script:
21
+ - bundle exec rake spec
22
+ coverage: '/\d+\.\d+\%/'
23
+
24
+ release:
25
+ stage: release
26
+ only:
27
+ - tags
28
+ except:
29
+ - branches
30
+ script:
31
+ - gem build flagabowski.gemspec
32
+ - export GEM_HOST_API_KEY="${RUBYGEMS_API_KEY}"
33
+ - gem push flagabowski-*.gem
34
+ environment:
35
+ name: production
36
+ url: https://rubygems.org/gems/flagabowski
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development do
6
+ gem 'rspec', '~> 3.12'
7
+ gem 'rake', '~> 13.0'
8
+ end
data/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # Flagabowski
2
+
3
+ Bunny says you're good for it.
4
+
5
+ A wrapper around Ruby's OptionParser that adds validation, conflict groups, one-of constraints, and automatic short option conflict resolution.
6
+
7
+ Deeply inspired by [micro-optparse](https://github.com/florianpilz/micro-optparse) but with some additional features.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'flagabowski'
15
+ ```
16
+
17
+ Or install it yourself:
18
+
19
+ ```bash
20
+ gem install flagabowski
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Basic Example
26
+
27
+ ```ruby
28
+ require 'flagabowski'
29
+
30
+ parser = Flagabowski::OptionParser.new do |p|
31
+ p.banner "Usage: myapp [options]"
32
+
33
+ p.option name: :get_a_toe,
34
+ desc: "Get me a toe"
35
+
36
+ p.option name: :by,
37
+ default: "3 o'clock",
38
+ desc: "You don't want to know how... trust me!"
39
+
40
+ p.option name: :dollars,
41
+ default: 1000000,
42
+ desc: "amount we thought we were getting"
43
+ end
44
+
45
+ result = parser.process!(ARGV)
46
+ puts result['get_a_toe'] # true/false
47
+ puts result['by'] # "3 o'clock" or user-provided value
48
+ puts result['dollars'] # 1000000 or user-provided value
49
+ puts result[:pos_args] # Array of positional arguments
50
+ ```
51
+
52
+ ### Required Options
53
+
54
+ ```ruby
55
+ parser.option name: :id,
56
+ default: '',
57
+ required: true,
58
+ desc: "is this your only?"
59
+
60
+ # Raises OptionParser::MissingArgument if not provided
61
+ ```
62
+
63
+ ### Validation with Arrays
64
+
65
+ ```ruby
66
+ parser.option name: :the_money,
67
+ default: 'ringer',
68
+ valid: ['ringer', 'ringer for a ringer'],
69
+ desc: "to be thrown"
70
+
71
+ # Raises OptionParser::InvalidArgument if value not in array
72
+ ```
73
+
74
+ ### Validation with Regex
75
+
76
+ ```ruby
77
+ parser.option name: :email,
78
+ default: '',
79
+ valid: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,
80
+ desc: "Email address"
81
+
82
+ # Raises OptionParser::InvalidArgument if value doesn't match regex
83
+ ```
84
+
85
+ ### Custom Long and Short Options
86
+
87
+ ```ruby
88
+ parser.option name: :verbose,
89
+ short: 'x',
90
+ long: 'debug',
91
+ desc: "Debug mode"
92
+
93
+ # Can be invoked with -x or --debug
94
+ ```
95
+
96
+ ### Automatic Short Option Conflict Resolution
97
+
98
+ ```ruby
99
+ parser.option name: :verbose, desc: "Verbose output"
100
+ parser.option name: :version, desc: "Show version"
101
+
102
+ # First option gets -v, second automatically gets -e
103
+ # Both can still use --verbose and --version
104
+ ```
105
+
106
+ ### Conflict Groups
107
+
108
+ Ensure only one option from a group is used:
109
+
110
+ ```ruby
111
+ parser.option name: :json, desc: "JSON output"
112
+ parser.option name: :xml, desc: "XML output"
113
+ parser.option name: :yaml, desc: "YAML output"
114
+
115
+ parser.add_conflicts_constraint(['json', 'xml', 'yaml'])
116
+
117
+ # Raises OptionParser::InvalidOption if more than one is provided
118
+ ```
119
+
120
+ ### One-Of Groups
121
+
122
+ Require exactly one option from a group:
123
+
124
+ ```ruby
125
+ parser.option name: :input, default: '', desc: "Input file"
126
+ parser.option name: :stdin, desc: "Read from stdin"
127
+
128
+ parser.add_one_of_constraint(['input', 'stdin'])
129
+
130
+ # Raises OptionParser::MissingArgument if neither is provided
131
+ # Raises OptionParser::InvalidOption if both are provided
132
+ ```
133
+
134
+ ### Help Text
135
+
136
+ ```ruby
137
+ # Get help text as a string
138
+ help_text = parser.usage
139
+ puts help_text
140
+ ```
141
+
142
+ ## Option Parameters
143
+
144
+ - `name:` (required) - Symbol or string, the option name
145
+ - `desc:` - Description shown in help text (default: "Lazy option authors have failed you.")
146
+ - `default:` - Default value (also determines type casting if `cast:` not specified)
147
+ - `required:` - Boolean, whether option is required (default: false)
148
+ - `valid:` - Array or Regex for validation
149
+ - `short:` - Custom short option letter
150
+ - `long:` - Custom long option name
151
+ - `cast:` - Explicit type to cast to (overrides type inferred from default)
152
+
153
+ ## Type Casting
154
+
155
+ Types are inferred from the default value, or can be explicitly specified with `cast:`:
156
+ - `default: "text"` → String
157
+ - `default: 42` → Integer
158
+ - `default: nil` → Boolean flag (no argument)
159
+ - `cast: Integer` → Integer (regardless of default)
160
+ - `cast: Float` → Float (can override default's type)
161
+
162
+ Example with explicit casting:
163
+ ```ruby
164
+ parser.option name: :percentage,
165
+ default: 0,
166
+ cast: Float,
167
+ desc: "Percentage as decimal"
168
+
169
+ # Accepts: --percentage 0.75
170
+ ```
171
+
172
+ ## Error Handling
173
+
174
+ Flagabowski raises standard OptionParser exceptions:
175
+ - `OptionParser::MissingArgument` - Required option not provided, or no option from one-of group
176
+ - `OptionParser::InvalidArgument` - Validation failed
177
+ - `OptionParser::InvalidOption` - Conflicting options used, or multiple from one-of group
178
+ - `OptionParser::ParseError` - General parsing errors (base class)
179
+
180
+ ## License
181
+
182
+ GPLv2
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new(:spec)
4
+
5
+ task default: :spec
6
+
7
+ desc 'Tag the current commit with the version from gemspec and push'
8
+ task :tag do
9
+ spec = Gem::Specification.load('flagabowski.gemspec')
10
+ version = spec.version
11
+ tag = "v#{version}"
12
+
13
+ sh "git tag #{tag}"
14
+ sh "git push origin #{tag}"
15
+ puts "Tagged and pushed #{tag}"
16
+ end
@@ -0,0 +1,17 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "flagabowski"
3
+ s.version = "0.0.4"
4
+ s.summary = "Bunny says you're good for it."
5
+ s.description = "A wrapper around Ruby's OptionParser with validation, conflict groups, and one-of constraints"
6
+ s.authors = ["Sam Rowe"]
7
+ s.email = "gemspam@samrowe.com"
8
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
9
+ s.homepage = "https://gitlab.com/srowe/flagabowski"
10
+ s.license = "GPL-2.0-or-later"
11
+ s.required_ruby_version = ">= 2.7.0"
12
+
13
+ s.metadata = {
14
+ "source_code_uri" => "https://gitlab.com/srowe/flagabowski",
15
+ "bug_tracker_uri" => "https://gitlab.com/srowe/flagabowski/-/issues"
16
+ }
17
+ end
@@ -0,0 +1,214 @@
1
+ require 'shellwords'
2
+ require 'optparse'
3
+
4
+ module Flagabowski
5
+ class Option
6
+ def initialize(opts)
7
+ @name = opts[:name]
8
+ @default = opts[:default] || nil
9
+ @desc = opts[:desc] || "Lazy option authors have failed you."
10
+ @long = opts[:long]
11
+ @opts = opts #for #cast
12
+ @required = opts[:required] || false
13
+ @satisfied = false
14
+ @short = opts[:short]
15
+ @valid = opts[:valid]
16
+ end
17
+
18
+ def cast
19
+ @cast ||= @opts[:cast] || @default.class
20
+ end
21
+
22
+ def default
23
+ @default
24
+ end
25
+
26
+ def desc
27
+ @desc
28
+ end
29
+
30
+ def long
31
+ @long || name
32
+ end
33
+
34
+ def long_arg
35
+ %Q{--#{long}#{cast_arg}}
36
+ end
37
+
38
+ def name
39
+ @name.to_s
40
+ end
41
+
42
+ def satisfied!
43
+ @satisfied = true
44
+ end
45
+
46
+ def satisfied?
47
+ @satisfied
48
+ end
49
+
50
+ def unsatisfied?
51
+ @required ? ! @satisfied : false
52
+ end
53
+
54
+ def short
55
+ @short ||= namechars.first
56
+ end
57
+
58
+ def short_arg
59
+ %Q{-#{short}#{cast_arg}}
60
+ end
61
+
62
+ def short_try_again
63
+ # perfect is the enemy of good
64
+ @short = namechars[namechars.index(short)+1]
65
+ end
66
+
67
+ def validate!(thing)
68
+ return true if @valid.nil?
69
+
70
+ case @valid
71
+ when Array
72
+ @valid.include?(thing)
73
+ when Regexp
74
+ @valid.match?(thing)
75
+ else
76
+ false
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def cast_arg
83
+ return '' if cast.name == 'NilClass' #really again, ruby?
84
+ %Q{=#{really_ruby}}
85
+ end
86
+
87
+ def namechars
88
+ name.chars
89
+ end
90
+
91
+ def really_ruby
92
+ @cast.to_s.sub(/C/,'_C').upcase
93
+ end
94
+ end
95
+
96
+ class OptionParser
97
+ def initialize
98
+ @banner = ''
99
+ @conflicts = [] #array of arrays of conflicting switches: [['morning','evening'],['breakfast','lunch','dinner']]
100
+ @one_of = [] #array of arrays where any one of the names satsifies the hoax
101
+ @options = []
102
+ @used_short = {}
103
+ yield self if block_given?
104
+ end
105
+
106
+ def add_one_of_constraint(arr)
107
+ @one_of << arr
108
+ end
109
+
110
+ def add_conflicts_constraint(arr)
111
+ @conflicts << arr
112
+ end
113
+
114
+ def banner(banstr)
115
+ @banner = banstr
116
+ end
117
+
118
+ def option(opts)
119
+ @options << Option.new(opts)
120
+ end
121
+
122
+ def process!(args)
123
+ args = args.shellsplit if args.is_a?(String)
124
+ result = {}
125
+
126
+ @options.each do |opt|
127
+ opt.instance_variable_set(:@satisfied, false)
128
+ result[opt.name] = opt.default
129
+ end
130
+
131
+ option_parser = build_option_parser do |opt, value|
132
+ unless opt.validate!(value)
133
+ raise ::OptionParser::InvalidArgument, "Invalid value '#{value}' for option #{opt.long_arg}"
134
+ end
135
+ opt.satisfied!
136
+ result[opt.name] = value
137
+ end
138
+
139
+ option_parser.parse!(args)
140
+
141
+ validate_required
142
+ validate_conflicts
143
+ validate_one_of
144
+
145
+ result[:pos_args] = args
146
+ result
147
+ end
148
+
149
+ def usage
150
+ build_option_parser.to_s
151
+ end
152
+
153
+ private
154
+
155
+ def build_option_parser(&on_option)
156
+ fix_shorts
157
+
158
+ ::OptionParser.new do |optparser|
159
+ optparser.banner = @banner if @banner
160
+
161
+ optparser.on('-h', '--help', 'Show this help message') { } # No-op to prevent exit
162
+
163
+ @options.each do |opt|
164
+ if opt.cast == NilClass
165
+ optparser.on(opt.short_arg, opt.long_arg, opt.desc) do |value|
166
+ on_option.call(opt, value) if on_option
167
+ end
168
+ else
169
+ optparser.on(opt.short_arg, opt.long_arg, opt.cast, opt.desc) do |value|
170
+ on_option.call(opt, value) if on_option
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ def validate_required
178
+ unsatisfied = @options.select(&:unsatisfied?)
179
+ if unsatisfied.any?
180
+ missing_names = unsatisfied.map { |opt| opt.long_arg }.join(', ')
181
+ raise ::OptionParser::MissingArgument, "Missing required options: #{missing_names}"
182
+ end
183
+ end
184
+
185
+ def validate_conflicts
186
+ @conflicts.each do |conflict_group|
187
+ if (satisfied = conflict_group.select { |name| @options.find { |opt| opt.name == name && opt.satisfied? } }).length > 1
188
+ raise ::OptionParser::InvalidOption, "Conflicting options used: #{satisfied.join(', ')}"
189
+ end
190
+ end
191
+ end
192
+
193
+ def validate_one_of
194
+ @one_of.each do |one_of_group|
195
+ satisfied_count = one_of_group.count { |name| @options.find { |opt| opt.name == name && opt.satisfied? } }
196
+ if satisfied_count == 0
197
+ raise ::OptionParser::MissingArgument, "One of the following options is required: #{one_of_group.join(', ')}"
198
+ elsif satisfied_count > 1
199
+ raise ::OptionParser::InvalidOption, "Only one of the following options can be used: #{one_of_group.join(', ')}"
200
+ end
201
+ end
202
+ end
203
+
204
+ def fix_shorts
205
+ @options.each do |opt|
206
+ while @used_short.keys.include?(opt.short)
207
+ break if @used_short[opt.short] == opt
208
+ opt.short_try_again
209
+ end
210
+ @used_short[opt.short] = opt
211
+ end
212
+ end
213
+ end
214
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagabowski
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ platform: ruby
6
+ authors:
7
+ - Sam Rowe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A wrapper around Ruby's OptionParser with validation, conflict groups,
14
+ and one-of constraints
15
+ email: gemspam@samrowe.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".gitignore"
21
+ - ".gitlab-ci.yml"
22
+ - Gemfile
23
+ - README.md
24
+ - Rakefile
25
+ - flagabowski.gemspec
26
+ - lib/flagabowski.rb
27
+ homepage: https://gitlab.com/srowe/flagabowski
28
+ licenses:
29
+ - GPL-2.0-or-later
30
+ metadata:
31
+ source_code_uri: https://gitlab.com/srowe/flagabowski
32
+ bug_tracker_uri: https://gitlab.com/srowe/flagabowski/-/issues
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: 2.7.0
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.5.22
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: Bunny says you're good for it.
52
+ test_files: []