switches.rb 0.10.5 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 073acfd47105841bff0a6643eebf2a8f1af398224feac75af268715a7dc666fa
4
- data.tar.gz: ed0d87dd771ff98dbf4da3f8622fc6c6622ad78a630ec3c1f1108b07416c89cf
3
+ metadata.gz: 60984de78f4a7074bd1f41f4293bcf5c4fb4d837c138bb59f2b7d3519cf17372
4
+ data.tar.gz: ed1fd8d58a77fd2335d047dea552326759291b7f985cc027004b0bde774e603b
5
5
  SHA512:
6
- metadata.gz: 5f4d4da5a5632926928b9bb357d1bad5bfc7e34f4e727d59887cd0ead5b7fd0b01c1de5468f563cea9137ca0bc730715026624b85ee71a7bd176ac3fe78193b6
7
- data.tar.gz: 15d4316951b1ddb6e7bb5647e90964b1df3720d7b43d3313034a99f134d41008e91711f7c603654c64b9aba3a43c4e9daa6507333ee6f6a9ee7bbe3001498e8b
6
+ metadata.gz: '080a361c32b9e9db115abe54e677cbd4ceff39d8f895c8a2d4e4819bf27b81393e5f3636550f12b52e547d9aa58398302d29506fe67c0ff9e2215a27f78f6aa9'
7
+ data.tar.gz: 4dc836fb4dc0195323b31013af2367657eb1d8ac21cb87a1ec09d0dc7b2d0e6aebe164ae49475a866053765ec5b9047734d64e580076fcf78f75c802107dc569
data/CHANGELOG CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## 20260816
4
4
 
5
+ 0.12.0: Refuse a switch whose accessor is a method the instance already has, rather than losing it.
6
+
7
+ 1. + lib/Switches.rb: Switches#check_switch_names, called first in #do_set and #do_action, which raises where respond_to?(accessor) is true, #method_missing firing only where no method exists.
8
+ 2. + lib/Switches.rb: SwitchNameCollision < ArgumentError.
9
+ 3. ~ README.md: + a Notes entry for the refusal, and for the booleans and OptionParser names it does not cover.
10
+ 4. + spec/all.rb: the refusal through #set and #perform, the message, that nothing reaches the parser first, and the two legal cases.
11
+
12
+ 0.11.0: Forward to OptionParser, which #method_missing has never actually done.
13
+
14
+ 1. ~ lib/Switches.rb: #method_missing tested (@op.methods - Switches.instance_methods).include?(method_name.to_s), which compares a String against an array of Symbols and so was never true. Every call fell through to the settings OpenStruct, which accepts anything, so s.banner = '...' — documented in the README's "With a banner" example — silently became a setting named banner while the parser kept its default of "Usage: <program_name> [options]". The comparison is now made in symbols, and the forwarding works: #banner=, #separator, #program_name= and the other 58 OptionParser methods reach the parser.
15
+ 2. ~ lib/Switches.rb: a declared switch takes precedence over an OptionParser method of the same name, + Switches#declared_switch?. Making the forwarding work otherwise shadows any switch sharing a name with the parser, and OptionParser has version, help, release, load, order, top, base, parse, warn, abort and banner among its 61, several of which are switches a program would reasonably want. The rule is that the program's own declarations beat the wrapper's interface, which also leaves any existing program's switches reading as they did.
16
+ 3. ~ lib/Switches.rb: + Switches::NEVER_FORWARDED, the names Ruby may call implicitly, which are excluded from the forwarded set. The set has always been built by subtraction — everything the parser responds to, less everything Switches defines — which admits any such name Switches does not happen to define. OptionParser#to_a is `summarize("#{banner}".split(/^/))`, so without the exclusion Array(switches) and [*switches] answer with the parser's help. That was latent for as long as the branch was dead and became reachable the moment it was not, so it is part of this fix rather than a consequence of it. to_h was never at risk, but only because Switches happens to define it; the constant makes the exclusion deliberate rather than incidental.
17
+ 4. ~ lib/Switches.rb: + Switches#option_parser_methods, memoised, since the difference was recomputed upon every settings read, and + Switches#option_parser_method?.
18
+ 5. + spec/all.rb: forwarding of a writer, of a method taking an argument, and of a reader, each checked through the parser's own help output rather than by reading back what was written; that a declared switch wins over the OptionParser method of the same name, read and written; that a name which is neither still reaches the settings; and that #to_a, Array() and the splat are answered by the switches rather than the parser.
19
+ 6. ~ README.md: the Notes entry claiming a Switches instance can't define a method whose name matches an OptionParser method now describes what happens, which is the reverse: the switch wins.
20
+
21
+ Not done: #respond_to_missing?, which ordinarily accompanies #method_missing. Note that it would not have prevented the to_a case above — Ruby's conversion check falls back to calling #method_missing directly when the ordinary dispatch fails, so Array() reaches #to_a whatever respond_to? answers. It wants deciding alongside the collision question below, both being about which names the object should own.
22
+
23
+ Still outstanding: the converse fault, where #method_missing does not fire at all because the method exists. A switch named after a method Object already has — :tap, :class, :display, :hash, :method, :type — never reaches the settings, and Kernel#tap without a block raises LocalJumpError nowhere near the declaration.
24
+
5
25
  0.10.5: Specify and document that a multi-word switch may be supplied hyphenated, and name that form when it is missing.
6
26
 
7
27
  1. + spec/all.rb: multi-word switches, in both their underscored and their hyphenated form, for a boolean, an optional argument (#set), a required argument (#set!) and a required switch (#required); that either form counts as supplied for a required switch; and that the setting is named for the symbol declared whichever form was supplied. This behaviour is OptionParser's rather than this library's — a long switch is filed under a lookup key with its underscores turned to hyphens, and whatever is supplied is converted the same way before the lookup, so --update_template and --update-template have always been the one switch. It went untested and undocumented here, which left it resting upon an implementation detail of a library this one only wraps. The specs now pin it, and would fail here rather than in a caller's script were it ever to change.
data/README.md CHANGED
@@ -192,7 +192,8 @@ switches.class # => Hash
192
192
  ## Notes
193
193
 
194
194
  - The switch `-?` can't be used, since there is no Ruby method `#?` for it to map to.
195
- - A `Switches` instance can't define a method whose name matches an `OptionParser` method, since such calls are forwarded to the underlying `OptionParser` instance.
195
+ - A switch whose accessor would be a method the instance already has is refused at declaration, with a `SwitchNameCollision`. `s.set(:tap)` raises, `#tap` being `Kernel#tap` and so never reaching the settings; so do `Switches`' own methods, `s.set(:settings)` and `s.set(:set)` among them. The same name is fine as a boolean, since `s.boolean(:tap)` reads back through `#tap?`, and fine where the collision is with `OptionParser` rather than the instance, a declared switch winning over the parser.
196
+ - A call which is neither a switch nor a `Switches` method is forwarded to the underlying `OptionParser`, which is what makes `s.banner = '...'` and `s.separator '...'` work. A declared switch wins over an `OptionParser` method of the same name, so `s.set(:version)` still reads back through `#version`; without that rule a switch called `--version`, `--help`, `--load` or `--order` would be shadowed by the parser, all four being names `OptionParser` has. The names Ruby may call implicitly — `#to_a`, `#to_s` and the rest of `Switches::NEVER_FORWARDED` — are never forwarded, since `OptionParser#to_a` answers with the help and would otherwise be what `Array(switches)` and `[*switches]` gave you.
196
197
  - There is a clear demarcation between whether a switch's argument is required and whether the switch itself is required.
197
198
  - The bang interface methods (for example `set!` and `required!`) modify the switch in place with a required argument, rather than relying on a default or a value set elsewhere and later.
198
199
  - A long switch whose name carries an underscore may be supplied in its hyphenated form as well, so `s.boolean(:update_template)` accepts both `--update_template` and `--update-template`. This comes from `OptionParser`, which files a long switch under a lookup key with its underscores turned to hyphens and applies the same conversion to whatever is supplied, so the two forms are one switch rather than two. The setting is named for the symbol declared, and so remains `#update_template?` whichever form was supplied. Declaring the attribute hyphenated, as `:'update-template'`, buys nothing and costs the accessor, which would then be reachable only through `#send`.
@@ -2,5 +2,5 @@
2
2
  # Switches::VERSION
3
3
 
4
4
  class Switches
5
- VERSION = '0.10.5'
5
+ VERSION = '0.12.0'
6
6
  end
data/lib/Switches.rb CHANGED
@@ -150,9 +150,13 @@ module CastingInterfaceMethods
150
150
  end
151
151
 
152
152
  class RequiredSwitchMissing < RuntimeError; end
153
+ class SwitchNameCollision < ArgumentError; end
153
154
 
154
155
  class Switches
155
156
 
157
+ # Names Ruby may call implicitly, and which must reach the settings rather than OptionParser. #to_a would otherwise answer Array() and the splat with the parser's help.
158
+ NEVER_FORWARDED = [:to_a, :to_ary, :to_h, :to_hash, :to_s, :to_str, :pretty_print]
159
+
156
160
  class << self
157
161
 
158
162
  def as_h(*args)
@@ -254,6 +258,7 @@ class Switches
254
258
  private
255
259
 
256
260
  def do_set(requires_argument, *attrs, &block)
261
+ check_switch_names(*attrs)
257
262
  set_if_required_switch(*attrs)
258
263
  options = attrs.extract_options!
259
264
  @all_switches = @all_switches + attrs.collect{|a| a.to_s}
@@ -265,6 +270,7 @@ class Switches
265
270
  end
266
271
 
267
272
  def do_action(requires_argument, *attrs, &block)
273
+ check_switch_names(*attrs)
268
274
  attrs.each do |attr|
269
275
  @settings.send(attr.to_s + '=', nil) # Needs to be set prior to checking for required switches, since that check relies upon the key having been set in @settings.
270
276
  end
@@ -277,13 +283,33 @@ class Switches
277
283
  end
278
284
 
279
285
  def method_missing(method_name, *args, &block)
280
- if (@op.methods - Switches.instance_methods).include?(method_name.to_s)
281
- @op.send(method_name.to_s, *args, &block)
286
+ if option_parser_method?(method_name) && !declared_switch?(method_name)
287
+ @op.send(method_name, *args, &block)
282
288
  else
283
- @settings.send(method_name.to_s, *args, &block)
289
+ @settings.send(method_name, *args, &block)
284
290
  end
285
291
  end
286
292
 
293
+ def option_parser_methods
294
+ @option_parser_methods ||= @op.methods - Switches.instance_methods - NEVER_FORWARDED
295
+ end
296
+
297
+ def option_parser_method?(method_name)
298
+ option_parser_methods.include?(method_name.to_sym)
299
+ end
300
+
301
+ def declared_switch?(method_name)
302
+ @all_switches.include?(method_name.to_s.delete_suffix('='))
303
+ end
304
+
305
+ def check_switch_names(*attrs)
306
+ attrs.each do |attr|
307
+ next unless attr.is_a?(Symbol) || attr.is_a?(String)
308
+ next unless respond_to?(attr.to_s)
309
+ raise SwitchNameCollision, "switch, #{switch_string(attr)}, is already #{method(attr.to_s).owner}##{attr}, so it would never reach the settings"
310
+ end
311
+ end
312
+
287
313
  def switch_string(attr)
288
314
  attr = attr.to_s
289
315
  "-#{attr.long_switch? ? '-' : ''}#{attr.delete('?')}"
data/spec/all.rb CHANGED
@@ -164,6 +164,102 @@ describe Switches do
164
164
  end
165
165
  end
166
166
 
167
+ describe "switch names which collide with a method" do
168
+ def build(&block)
169
+ ARGV.clear
170
+ Switches.new(&block)
171
+ end
172
+
173
+ it "refuses a switch named after a core method" do
174
+ expect{build{|s| s.set(:tap)}}.to raise_error(SwitchNameCollision, /Kernel#tap/)
175
+ end
176
+
177
+ it "refuses a switch named after a Switches method" do
178
+ expect{build{|s| s.set(:settings)}}.to raise_error(SwitchNameCollision, /Switches#settings/)
179
+ end
180
+
181
+ it "refuses a colliding name declared through #perform" do
182
+ expect{build{|s| s.perform(:display){}}}.to raise_error(SwitchNameCollision)
183
+ end
184
+
185
+ it "names the switch in the form it would be supplied in" do
186
+ expect{build{|s| s.set(:instance_variable_get)}}.to raise_error(SwitchNameCollision, /--instance_variable_get/)
187
+ end
188
+
189
+ it "raises before the switch is declared to the parser" do
190
+ switches = Switches.new
191
+ begin; switches.set(:tap); rescue SwitchNameCollision; end
192
+ expect(switches.to_h).to eq({})
193
+ end
194
+
195
+ it "accepts the name as a boolean, the accessor then carrying a question mark" do
196
+ ARGV.clear
197
+ ARGV << '--tap'
198
+ expect(Switches.new{|s| s.boolean(:tap)}.tap?).to eq(true)
199
+ end
200
+
201
+ it "accepts a name OptionParser has, a declared switch winning over the parser" do
202
+ ARGV.clear
203
+ %w{--banner text}.each{|a| ARGV << a}
204
+ expect(Switches.new{|s| s.set(:banner)}.banner).to eq('text')
205
+ end
206
+
207
+ it "raises a SwitchNameCollision, which is an ArgumentError" do
208
+ expect(SwitchNameCollision.ancestors).to include(ArgumentError)
209
+ end
210
+ end
211
+
212
+ describe "OptionParser methods" do
213
+ def build(&block)
214
+ ARGV.clear
215
+ Switches.new(&block)
216
+ end
217
+
218
+ it "forwards a writer to the OptionParser" do
219
+ expect(build{|s| s.banner = 'Usage: tap-audit [options]'}.help).to match(/\AUsage: tap-audit \[options\]/)
220
+ end
221
+
222
+ it "forwards a method taking an argument to the OptionParser" do
223
+ expect(build{|s| s.separator 'Options:'}.help).to match(/^Options:$/)
224
+ end
225
+
226
+ it "forwards a reader to the OptionParser" do
227
+ switches = build{|s| s.program_name = 'tap-audit'}
228
+ expect(switches.program_name).to eq('tap-audit')
229
+ expect(switches.help).to match(/\AUsage: tap-audit/)
230
+ end
231
+
232
+ it "prefers a declared switch to the OptionParser method of the same name" do
233
+ ARGV.clear
234
+ %w{--version 2.1}.each{|a| ARGV << a}
235
+ expect(Switches.new{|s| s.set(:version)}.version).to eq('2.1')
236
+ end
237
+
238
+ it "prefers a declared switch when it is written to as well" do
239
+ switches = build{|s| s.set(:banner)}
240
+ switches.banner = 'a setting, not the banner'
241
+ expect(switches.to_h[:banner]).to eq('a setting, not the banner')
242
+ end
243
+
244
+ it "falls through to the settings for a name which is neither" do
245
+ expect(build{|s| s.set(:port)}.nonesuch).to eq(nil)
246
+ end
247
+
248
+ it "does not forward #to_a, which OptionParser answers with its help" do
249
+ expect(build{|s| s.set(:port)}.to_a).to eq(nil)
250
+ end
251
+
252
+ it "leaves Array() with the switches rather than the parser's help" do
253
+ switches = build{|s| s.set(:port)}
254
+ expect(Array(switches)).to eq([switches])
255
+ end
256
+
257
+ it "leaves the splat with the switches rather than the parser's help" do
258
+ switches = build{|s| s.set(:port)}
259
+ expect([*switches]).to eq([switches])
260
+ end
261
+ end
262
+
167
263
  describe "multi-word switches" do
168
264
  def build(switches_string = '')
169
265
  ARGV.clear
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: switches.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.5
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran