errgonomic 0.8.3 → 0.9.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.
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+ require 'stringio'
5
+
3
6
  module Errgonomic
4
7
  module Option
5
8
  # The base class for all options. Some and None are subclasses.
6
9
  #
10
+ # An Option is an object, so it is always truthy. A None does not stand
11
+ # in for nil in a conditional, and `||` hands back the wrapper rather
12
+ # than the fallback. Reach for a combinator to get at the inner value.
13
+ #
7
14
  class Any
8
15
  include Comparable
9
16
 
@@ -16,6 +23,11 @@ module Errgonomic
16
23
  is_none_or: :none_or?
17
24
  }.freeze
18
25
 
26
+ # Names already nudged about. A soft deprecation is a message to a
27
+ # developer, and one per process says it; one per call turns a hot
28
+ # path into a stderr flood.
29
+ NUDGED = Set.new
30
+
19
31
  # An Option deliberately forwards nothing to its inner value, so a miss
20
32
  # here is almost always someone treating the container as its contents.
21
33
  # Teach the route out instead of leaving a bare NoMethodError. Rust
@@ -72,7 +84,44 @@ module Errgonomic
72
84
  # None() == None() # => true
73
85
  # Some(1) == 1 # => false
74
86
  # None() == nil # => false
87
+ #
88
+ # @example strict equality makes a cross-type comparison an error
89
+ # Errgonomic.with_strict_equality do
90
+ # begin
91
+ # Some(5) == 5
92
+ # rescue Errgonomic::TypeMismatchError => e
93
+ # e.class
94
+ # end
95
+ # end # => Errgonomic::TypeMismatchError
96
+ # Errgonomic.with_strict_equality do
97
+ # begin
98
+ # Some(5) != 5
99
+ # rescue Errgonomic::TypeMismatchError => e
100
+ # e.message.include?("!=")
101
+ # end
102
+ # end # => true
103
+ # Errgonomic.with_strict_equality { Some(5) == Some(5) } # => true
104
+ # Errgonomic.with_strict_equality { Some(5) == None() } # => false
105
+ #
106
+ # @example a Result is another container, not another Option
107
+ # Errgonomic.with_strict_equality do
108
+ # begin
109
+ # Some(1) == Ok(1)
110
+ # rescue Errgonomic::TypeMismatchError => e
111
+ # e.message.include?("different containers")
112
+ # end
113
+ # end # => true
114
+ #
115
+ # @example nil is another type, and absence here is the discriminant
116
+ # Errgonomic.with_strict_equality do
117
+ # begin
118
+ # None() == nil
119
+ # rescue Errgonomic::TypeMismatchError => e
120
+ # e.message.include?("none?")
121
+ # end
122
+ # end # => true
75
123
  def ==(other)
124
+ strict_equality!(other, '==')
76
125
  return false if self.class != other.class
77
126
  return true if none?
78
127
 
@@ -90,7 +139,25 @@ module Errgonomic
90
139
  # None().eql?(None()) # => true
91
140
  # { Some(5) => 1 }[Some(5)] # => 1
92
141
  # [Some(1), Some(1), None(), None()].uniq # => [Some(1), None()]
142
+ #
143
+ # @example strict equality reaches eql?, and leaves hash alone
144
+ # Errgonomic.with_strict_equality do
145
+ # begin
146
+ # Some(5).eql?(5)
147
+ # rescue Errgonomic::TypeMismatchError => e
148
+ # e.class
149
+ # end
150
+ # end # => Errgonomic::TypeMismatchError
151
+ # Errgonomic.with_strict_equality { Some(5).hash == Some(5).hash } # => true
152
+ # Ruby derives != from ==, so a strict-equality message would name the
153
+ # operator the caller did not write.
154
+ def !=(other)
155
+ strict_equality!(other, '!=')
156
+ super
157
+ end
158
+
93
159
  def eql?(other)
160
+ strict_equality!(other, 'eql?')
94
161
  return false if self.class != other.class
95
162
  return true if none?
96
163
 
@@ -124,9 +191,11 @@ module Errgonomic
124
191
  end
125
192
 
126
193
  # Options order like Rust's: None sorts before any Some, and Somes
127
- # order by their inner values. Follows Ruby's <=> convention of
128
- # returning nil for incomparable operands, whether the other object is
129
- # not an Option or the inner values do not themselves compare.
194
+ # order by their inner values. Two Options whose inner values do not
195
+ # themselves compare follow Ruby's convention and answer nil. A
196
+ # non-Option operand raises instead: Comparable turns a nil here into
197
+ # an ArgumentError that names the Option as the operand at fault, where
198
+ # what went wrong is that a wrapper was ordered against a bare value.
130
199
  #
131
200
  # @example
132
201
  # (Some(5) <=> Some(6)) # => -1
@@ -134,11 +203,20 @@ module Errgonomic
134
203
  # (Some(5) <=> None()) # => 1
135
204
  # (None() <=> None()) # => 0
136
205
  # (Some(1) <=> Some("x")) # => nil
137
- # (Some(1) <=> 1) # => nil
138
206
  # [Some(2), None(), Some(1)].sort # => [None(), Some(1), Some(2)]
139
207
  # [Some(2), Some(1)].min # => Some(1)
208
+ #
209
+ # @example a bare value is not ordered against an Option
210
+ # Some(5) <= 6 # => raise Errgonomic::TypeMismatchError, "cannot compare Some(5) with Integer; test the inner value (some_and? { |v| v <= other }) or reach for it (map, unwrap_or)"
211
+ # Some(5).some_and? { |v| v <= 6 } # => true
212
+ # Some(5).map { |v| v <= 6 } # => Some(true)
140
213
  def <=>(other)
141
- return nil unless other.is_a?(Errgonomic::Option::Any)
214
+ unless other.is_a?(Errgonomic::Option::Any)
215
+ raise Errgonomic::TypeMismatchError,
216
+ "cannot compare #{inspect} with #{other.class}; test the inner value " \
217
+ '(some_and? { |v| v <= other }) or reach for it (map, unwrap_or)'
218
+ end
219
+
142
220
  return none? ? 0 : 1 if other.none?
143
221
  return -1 if none?
144
222
 
@@ -197,13 +275,15 @@ module Errgonomic
197
275
  # The presence helpers on Object keep their receiver; on an Option that
198
276
  # would hand back the wrapper where the caller asked for a value. Here
199
277
  # the present side unwraps instead, so a name that reads like an
200
- # accessor behaves like one. The whole family is soft-deprecated on
201
- # Options in favor of the combinators, so each call nudges via stderr,
202
- # and the blank side, which has no working call sites to preserve,
203
- # teaches rather than guesses at semantics.
278
+ # accessor behaves like one. The +_or+ spellings are soft-deprecated on
279
+ # Options in favor of the combinators and nudge via stderr; `presence`
280
+ # is the Rails idiom for unwrap_or(nil) and stays. The blank side, which
281
+ # has no working call sites to preserve, teaches rather than guesses at
282
+ # semantics.
204
283
 
205
284
  # Returns the inner value of a Some, and raises on a None. Presence
206
- # follows the discriminant, so Some(nil) yields nil.
285
+ # follows the discriminant, so Some(nil) yields nil. A block is called
286
+ # only on the None branch, as it is for expect!.
207
287
  #
208
288
  # @param message [String] The error message to raise on a None.
209
289
  # @return [Object] The inner value of a Some.
@@ -212,9 +292,10 @@ module Errgonomic
212
292
  # Some("secret").present_or_raise!("no secret") # => "secret"
213
293
  # Some(nil).present_or_raise!("no secret") # => nil
214
294
  # None().present_or_raise!("no secret") # => raise Errgonomic::NotPresentError, "no secret"
215
- def present_or_raise!(message)
216
- presence_nudge('present_or_raise', 'expect!')
217
- raise Errgonomic::NotPresentError, message if none?
295
+ # None().present_or_raise! { "no secret for #{7}" } # => raise Errgonomic::NotPresentError, "no secret for 7"
296
+ def present_or_raise!(message = nil, &block)
297
+ presence_nudge('present_or_raise!', 'expect!')
298
+ raise Errgonomic::NotPresentError, block ? block.call : message if none?
218
299
 
219
300
  value
220
301
  end
@@ -231,6 +312,18 @@ module Errgonomic
231
312
  # @example
232
313
  # Some("secret").present_or("fallback") # => "secret"
233
314
  # None().present_or("fallback") # => "fallback"
315
+ #
316
+ # @example the nudge fires once per process, so a hot path stays quiet
317
+ # Some(1).present_or(2)
318
+ # nudges = StringIO.new
319
+ # original = $stderr
320
+ # begin
321
+ # $stderr = nudges
322
+ # Some(1).present_or(2)
323
+ # ensure
324
+ # $stderr = original
325
+ # end
326
+ # nudges.string # => ""
234
327
  def present_or(default)
235
328
  presence_nudge('present_or', 'unwrap_or')
236
329
  return default if none?
@@ -256,15 +349,30 @@ module Errgonomic
256
349
 
257
350
  # Returns the inner value of a Some, and nil on a None, so the Rails
258
351
  # +presence || default+ idiom reaches the value rather than the wrapper.
352
+ # Presence follows the discriminant, so a blank inner value is still a
353
+ # value: Some("").presence is "", where Object#presence answers nil.
259
354
  #
260
355
  # @return [Object, nil] The inner value of a Some, otherwise nil.
261
356
  #
262
357
  # @example
263
358
  # Some("secret").presence # => "secret"
359
+ # Some("").presence # => ""
264
360
  # None().presence # => nil
265
361
  # None().presence || "fallback" # => "fallback"
362
+ #
363
+ # @example the Rails spelling of unwrap_or(nil), and no nudge with it
364
+ # nudges = StringIO.new
365
+ # original = $stderr
366
+ # begin
367
+ # $stderr = nudges
368
+ # captured = Some("").presence
369
+ # None().presence
370
+ # ensure
371
+ # $stderr = original
372
+ # end
373
+ # captured # => ""
374
+ # nudges.string # => ""
266
375
  def presence
267
- presence_nudge('presence', 'unwrap_or(nil)')
268
376
  return nil if none?
269
377
 
270
378
  value
@@ -312,6 +420,30 @@ module Errgonomic
312
420
  [value]
313
421
  end
314
422
 
423
+ # Yields the inner value once for a Some and not at all for a None, so
424
+ # an Option reads as the zero-or-one collection it is, and answers an
425
+ # Enumerator without a block. Option does not include Enumerable: its
426
+ # own filter and first answer Options, where Enumerable's answer plain
427
+ # values, and one name cannot mean both.
428
+ #
429
+ # @example
430
+ # seen = []
431
+ # Some(1).each { |x| seen << x } # => Some(1)
432
+ # seen # => [1]
433
+ # None().each { |x| seen << x } # => None()
434
+ # seen # => [1]
435
+ # Some(1).each.to_a # => [1]
436
+ # None().each.to_a # => []
437
+ # Some(2).each.map { |x| x * 3 } # => [6]
438
+ # Some(1).each.size # => 1
439
+ # None().each.size # => 0
440
+ def each(&block)
441
+ return to_enum(:each) { some? ? 1 : 0 } unless block
442
+
443
+ block.call(value) if some?
444
+ self
445
+ end
446
+
315
447
  # returns the inner value if present, else raises an error
316
448
  # @example
317
449
  # Some(1).unwrap! # => 1
@@ -322,21 +454,28 @@ module Errgonomic
322
454
  value
323
455
  end
324
456
 
325
- # returns the inner value if pressent, else raises an error with the given
326
- # message
457
+ # Returns the inner value of a Some, else raises with the given message.
458
+ # A block is called only on the None branch, so a message that
459
+ # interpolates costs nothing on the path that succeeds.
460
+ #
327
461
  # @example
328
462
  # Some(1).expect!("msg") # => 1
329
463
  # None().expect!("here's why this failed") # => raise Errgonomic::ExpectError, "here's why this failed"
330
- def expect!(msg)
331
- raise Errgonomic::ExpectError, msg if none?
464
+ # Some(1).expect! { "built only where it is raised" } # => 1
465
+ # None().expect! { "no tier for #{7}" } # => raise Errgonomic::ExpectError, "no tier for 7"
466
+ def expect!(msg = nil, &block)
467
+ raise Errgonomic::ExpectError, block ? block.call : msg if none?
332
468
 
333
469
  value
334
470
  end
335
471
 
336
- # returns the inner value if present, else returns the default value
472
+ # returns the inner value if present, else returns the default value.
473
+ # This is the spelling `opt || default` cannot give you: an Option is
474
+ # truthy, so `||` never reaches the fallback.
337
475
  # @example
338
476
  # Some(1).unwrap_or(2) # => 1
339
477
  # None().unwrap_or(2) # => 2
478
+ # None() || 2 # => None()
340
479
  def unwrap_or(default)
341
480
  return default if none?
342
481
 
@@ -372,46 +511,49 @@ module Errgonomic
372
511
  end
373
512
 
374
513
  # Maps the Option to another Option by applying a function to the
375
- # contained value (if Some) or returns None. Raises a pedantic exception
376
- # if the return value of the block is not an Option.
514
+ # contained value (if Some) or returns None. Whatever the block returns
515
+ # is wrapped, as in Rust: a block that returns an Option gives
516
+ # Some(Some(x)). and_then is the spelling for a block that returns an
517
+ # Option.
377
518
  #
378
519
  # @example
379
520
  # Some(1).map { |x| x + 1 } # => Some(2)
380
521
  # None().map { |x| x + 1 } # => None()
522
+ # Some(1).map { |x| Some(x + 1) } # => Some(Some(2))
523
+ # Some(1).and_then { |x| Some(x + 1) } # => Some(2)
381
524
  def map(&block)
382
525
  return self if none?
383
526
 
384
527
  Some(block.call(value))
385
528
  end
386
529
 
387
- # Returns the provided default (if none), or applies a function to the
388
- # contained value (if some). If you want lazy evaluation for the provided
389
- # value, use +map_or_else+.
530
+ # Returns the provided default (if none), or the block applied to the
531
+ # contained value (if some). Both come back bare, as Rust's map_or
532
+ # gives: this is the exit from the Option, where map stays inside it.
533
+ # Use +map_or_else+ when the default is expensive to build.
390
534
  #
391
535
  # @example
392
- # None().map_or(1) { 100 } # => Some(1)
393
- # Some(1).map_or(100) { |x| x + 1 } # => Some(2)
394
- # Some("foo").map_or(0) { |str| str.length } # => Some(3)
536
+ # None().map_or(1) { 100 } # => 1
537
+ # Some(1).map_or(100) { |x| x + 1 } # => 2
538
+ # Some("foo").map_or(0) { |str| str.length } # => 3
539
+ # Some(2).map_or(0) { |x| x * 2 } # => 4
395
540
  def map_or(default, &block)
396
- return Some(default) if none?
541
+ return default if none?
397
542
 
398
- Some(block.call(value))
543
+ block.call(value)
399
544
  end
400
545
 
401
546
  # Computes a default from the given Proc if None, or applies the block to
402
- # the contained value (if Some).
547
+ # the contained value (if Some). Both come back bare, as map_or's do.
403
548
  #
404
549
  # @example
405
- # None().map_or_else(-> { :foo }) { :bar } # => Some(:foo)
406
- # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => Some(3)
407
- # None().map_or_else( -> { nil }) { |str| str.length } # => None()
550
+ # None().map_or_else(-> { :foo }) { :bar } # => :foo
551
+ # Some("str").map_or_else(-> { 100 }) { |str| str.length } # => 3
552
+ # None().map_or_else(-> { nil }) { |str| str.length } # => nil
408
553
  def map_or_else(proc, &block)
409
- if none?
410
- val = proc.call
411
- return val ? Some(val) : None()
412
- end
554
+ return proc.call if none?
413
555
 
414
- Some(block.call(value))
556
+ block.call(value)
415
557
  end
416
558
 
417
559
  # convert the option into a result where Some is Ok and None is Err
@@ -536,13 +678,18 @@ module Errgonomic
536
678
  Some(other)
537
679
  end
538
680
 
539
- # Refuse to serialize an unwrapped Option as a String. Options must be
540
- # correctly handled to access their inner value.
681
+ # Render as inspect does. Rust gives Option a Debug and no Display, so
682
+ # refusing was faithful, but a to_s that raises replaces the real
683
+ # exception while a rescue builds its log line, and the rendered form
684
+ # says plainly that a wrapper arrived where a value was meant.
541
685
  #
542
686
  # @example
543
- # None().to_s # => raise Errgonomic::SerializeError, "cannot serialize an unwrapped Option"
687
+ # Some(1).to_s # => "Some(1)"
688
+ # Some("x").to_s # => "Some(\"x\")"
689
+ # None().to_s # => "None"
690
+ # "value: #{Some(1)}" # => "value: Some(1)"
544
691
  def to_s
545
- raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
692
+ inspect
546
693
  end
547
694
 
548
695
  # Refuse to serialize an unwrapped Option as JSON. Not only should we
@@ -551,9 +698,14 @@ module Errgonomic
551
698
  # Object#to_json implementations.
552
699
  #
553
700
  # @example
554
- # None().to_json # => raise Errgonomic::SerializeError, "cannot serialize an unwrapped Option"
701
+ # None().to_json # => raise Errgonomic::SerializeError, 'cannot serialize an unwrapped None'
702
+ # begin
703
+ # Some('a' * 100).to_json
704
+ # rescue Errgonomic::SerializeError => e
705
+ # e.message.end_with?('...')
706
+ # end # => true
555
707
  def to_json(*_args)
556
- raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
708
+ raise Errgonomic::SerializeError, serialize_refusal
557
709
  end
558
710
 
559
711
  # ActiveSupport's Hash#as_json and Array#as_json recurse through their
@@ -562,7 +714,7 @@ module Errgonomic
562
714
  # variables. Refuse there too, and the guard holds wherever an Option
563
715
  # travels.
564
716
  def as_json(*_args)
565
- raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
717
+ raise Errgonomic::SerializeError, serialize_refusal
566
718
  end
567
719
 
568
720
  # pp uses its own object dump unless told otherwise; keep it consistent
@@ -621,21 +773,48 @@ module Errgonomic
621
773
 
622
774
  private
623
775
 
776
+ # Name the value the caller failed to handle, bounded: an inspect of a
777
+ # record or a long payload would bury the message carrying it.
778
+ def serialize_refusal
779
+ rendered = inspect
780
+ rendered = "#{rendered[0, 57]}..." if rendered.length > 60
781
+ "cannot serialize an unwrapped #{rendered}"
782
+ end
783
+
624
784
  def presence_nudge(from, to)
785
+ return unless NUDGED.add?(from)
786
+
625
787
  warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
626
788
  end
627
789
 
790
+ def strict_equality!(other, operator)
791
+ return unless Errgonomic.strict_equality?
792
+ return if other.is_a?(Errgonomic::Option::Any)
793
+
794
+ raise Errgonomic::TypeMismatchError,
795
+ "#{self.class} #{operator} #{other.class}, which strict equality refuses.\n" \
796
+ "#{strict_equality_remedy(other)}"
797
+ end
798
+
799
+ def strict_equality_remedy(other)
800
+ case other
801
+ when Errgonomic::Result::Any
802
+ 'An Option and a Result are different containers, and neither is the other. ' \
803
+ 'Unwrap the one you meant (opt.unwrap_or(nil) == res.unwrap_or(nil)).'
804
+ when nil
805
+ 'Absence here is the discriminant: ask none?, or nil? under the Rails integration.'
806
+ else
807
+ "Compare Options (opt == Some(#{other.inspect})), test the inner value " \
808
+ "(opt.some_and? { |v| v == #{other.inspect} }), or unwrap_or a fallback first."
809
+ end
810
+ end
811
+
628
812
  def raise_blank_side_teaching(name)
629
813
  raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
630
814
  `#{name}` is not supported on an Option, whose blankness is its discriminant.
631
815
  Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
632
816
  MSG
633
817
  end
634
-
635
- public
636
-
637
- # Rust's mutating combinators (insert, get_or_insert, take, replace)
638
- # are deliberately omitted: an Option here is a value, not a slot.
639
818
  end
640
819
 
641
820
  # Represent a value
@@ -4,10 +4,81 @@ module Errgonomic
4
4
  module Rails
5
5
  # Adds a `delegate_optional` class method in the spirit of Rails'
6
6
  # `delegate`, returning an Option instead of nil or NoMethodError when
7
- # the delegation target is absent.
7
+ # the delegation target is absent. The generated reader forwards with
8
+ # `...` from inside a block, which Ruby 3.4, the version CI runs, accepts.
8
9
  module ActiveRecordDelegateOptional
9
10
  extend ActiveSupport::Concern
10
11
 
12
+ # What a declaration that cannot mean what it says is told, where it is
13
+ # written. A writer is refused rather than delegated: an assignment
14
+ # through an absent target has nowhere to put the value, and dropping it
15
+ # silently is the failure an Option exists to prevent.
16
+ NO_TARGET = "Delegation needs a target. Supply a keyword argument 'to' " \
17
+ '(e.g. delegate_optional :hello, to: :greeter).'
18
+ NO_WRITERS = 'delegate_optional does not delegate a writer; an absent target would drop the value assigned'
19
+ NO_NAME_TO_PREFIX = "prefix: true takes the target's own name, and a module target has none; name the prefix"
20
+ NO_METHOD_TO_PREFIX = 'Can only automatically set the delegation prefix when delegating to a method.'
21
+ ALWAYS_NONE = 'delegate_optional reads an absent target as None; allow_nil: false asks for something else'
22
+ private_constant :NO_TARGET, :NO_WRITERS, :NO_NAME_TO_PREFIX, :NO_METHOD_TO_PREFIX, :ALWAYS_NONE
23
+
24
+ # YARD does not see through a concern's class_methods block, so the
25
+ # method it documents is declared rather than read.
26
+ #
27
+ # @!method delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil)
28
+ # @!scope class
29
+ # Delegates to an optional target, answering an Option: None where the target is absent.
30
+ # @example prefix forms name the reader, as they do for Rails' delegate
31
+ # article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes'))
32
+ # article.writer_name # => Some('Ursula')
33
+ # article.author_name # => Some('Ursula')
34
+ # article.bio # => Some('writes')
35
+ # @example a delegated call forwards what it was handed
36
+ # article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula'))
37
+ # article.writer_greeting('Hello') # => Some('Hello, Ursula.')
38
+ # article.writer_greeting('Hi', punctuation: '!') # => Some('Hi, Ursula!')
39
+ # article.writer_styled_name(&:upcase) # => Some('URSULA')
40
+ # @example a target named for a Ruby keyword is reached through self
41
+ # Article.create!(title: 'Omelas').table_name # => Some('articles')
42
+ # @example the target is lifted, and an Option it hands back is not nested
43
+ # draft = Draft.create!(title: 'Omelas', writer_id: Writer.create!(name: 'Ursula').id)
44
+ # draft.writer_name # => Some('Ursula')
45
+ # draft.byline_name # => Some('Ursula')
46
+ # Draft.create!(title: 'Untitled').writer_name # => None()
47
+ # Article.create!(title: 'Untitled').writer_name # => None()
48
+ # @example a delegated reader points at the model that declared it
49
+ # Article.instance_method(:writer_name).source_location.first.end_with?('doctest_helper.rb') # => true
50
+ # @example an absent target is a value here, so allow_nil: true says nothing new
51
+ # Reprint.create!(title: 'Untitled').writer_name # => None()
52
+ # Reprint.create!(title: 'Untitled').respond_to?(:bio) # => false
53
+ # begin
54
+ # Class.new(Reprint) { delegate_optional :name, to: :writer, allow_nil: false }
55
+ # rescue ArgumentError => e
56
+ # e.message
57
+ # end # => 'delegate_optional reads an absent target as None; allow_nil: false asks for something else'
58
+ # @example a delegation needs a target
59
+ # begin
60
+ # Class.new(Reprint) { delegate_optional :name }
61
+ # rescue ArgumentError => e
62
+ # e.message
63
+ # end.start_with?("Delegation needs a target. Supply a keyword argument 'to'") # => true
64
+ # @example a writer is not delegated
65
+ # begin
66
+ # Class.new(Reprint) { delegate_optional :name=, to: :writer }
67
+ # rescue ArgumentError => e
68
+ # e.message
69
+ # end # => 'delegate_optional does not delegate a writer; an absent target would drop the value assigned'
70
+ # @example a module target has no name to prefix with
71
+ # begin
72
+ # Class.new(Reprint) { delegate_optional :name, to: Errgonomic, prefix: true }
73
+ # rescue ArgumentError => e
74
+ # e.message
75
+ # end # => "prefix: true takes the target's own name, and a module target has none; name the prefix"
76
+ # @example an automatic prefix needs a target it can name a method after
77
+ # begin
78
+ # Class.new(Article) { delegate_optional :name, to: :@writer, prefix: true }
79
+ # rescue ArgumentError => e
80
+ # e.message
81
+ # end # => 'Can only automatically set the delegation prefix when delegating to a method.'
11
82
  class_methods do
12
83
  # Names attributes that ActiveRecordOptional must leave alone. It has to
13
84
  # be callable before the include, which is what starts the wrapping for
@@ -25,19 +96,108 @@ module Errgonomic
25
96
  superclass.respond_to?(:errgonomic_optional_exceptions) ? superclass.errgonomic_optional_exceptions.dup : []
26
97
  end
27
98
 
28
- def delegate_optional(*methods, to: nil, prefix: nil, private: nil)
29
- return if to.nil?
99
+ # How a None reaches a payload. :null writes it as null, which is
100
+ # what Rails does with nil and what serde does with None unless a
101
+ # field asks otherwise, so it is the default and needs no
102
+ # declaration. :omit leaves the key out instead. only: and except:
103
+ # scope the mode to named readers, and a reader outside the scope
104
+ # keeps the default. Configuration reads as well above the include
105
+ # as below it, so it lives here rather than in the concern.
106
+ def errgonomic_serialize_none(mode, only: nil, except: nil)
107
+ complaint = errgonomic_serialize_none_complaint(mode, only, except)
108
+ raise ::ArgumentError, "errgonomic_serialize_none #{complaint}" if complaint
109
+
110
+ @errgonomic_serialize_none = {
111
+ mode: mode,
112
+ only: only && Array(only).map(&:to_s),
113
+ except: except && Array(except).map(&:to_s)
114
+ }
115
+ end
116
+
117
+ # The nearest declaration is the whole story for a class: it replaces
118
+ # whatever it inherits rather than layering onto it, so a scoped one
119
+ # leaves every reader it does not name at the default.
120
+ def errgonomic_serialize_none_declaration
121
+ return @errgonomic_serialize_none if defined?(@errgonomic_serialize_none)
122
+ return nil unless superclass.respond_to?(:errgonomic_serialize_none_declaration)
123
+
124
+ superclass.errgonomic_serialize_none_declaration
125
+ end
126
+
127
+ # A declaration that cannot change what a payload looks like is a
128
+ # mistake rather than a no-op, so say what to write instead. :null is
129
+ # already what every unnamed reader gets, so scoping it names one set
130
+ # of readers for the default and leaves the rest at the default too.
131
+ def errgonomic_serialize_none_complaint(mode, only, except)
132
+ return "takes :null or :omit, not #{mode.inspect}" unless %i[null omit].include?(mode)
133
+ return 'takes only: or except:, not both; name the readers on one of them' if only && except
134
+ return unless mode == :null && (only || except)
135
+
136
+ ':null is the default for every reader and takes no only: or except:; ' \
137
+ 'declare :omit on the readers to leave out'
138
+ end
139
+
140
+ def delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil)
141
+ declared_at = caller_locations(1, 1).first
142
+ complaint = delegate_optional_complaint(methods, to, prefix, allow_nil)
143
+ raise ::ArgumentError, complaint if complaint
30
144
 
145
+ receiver = delegate_optional_receiver(to)
31
146
  methods.each do |method_name|
32
- prefixed_method_name = prefix == true ? "#{to}_#{method_name}" : method_name
33
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
34
- def #{prefixed_method_name}
35
- #{to}.map { |obj| obj.send(:#{method_name}) }
36
- end
37
- RUBY
38
- send(:private, prefixed_method_name) if private
147
+ reader = "#{delegate_optional_prefix(to, prefix)}#{method_name}"
148
+ define_optional_delegation(receiver, method_name, reader, declared_at)
149
+ private(reader) if private
39
150
  end
40
151
  end
152
+
153
+ # Both ends lift exactly one layer, so a record, a nil and an Option
154
+ # all delegate, and an Option the call returns is not wrapped twice.
155
+ # The call is written out rather than sent, so the target's method is
156
+ # reached on the same terms a caller would reach it on, and the reader
157
+ # takes the declaration's file and line so a backtrace names the model.
158
+ def define_optional_delegation(receiver, method_name, reader, declared_at)
159
+ class_eval <<-RUBY, declared_at.path, declared_at.lineno # rubocop:disable Style/EvalWithLocation
160
+ def #{reader}(...)
161
+ #{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }
162
+ end
163
+ RUBY
164
+ end
165
+
166
+ # A target named for a Ruby keyword reads as the keyword in the body
167
+ # it is written into, so it needs an explicit receiver. Rails answers
168
+ # the same question for delegate, and answers it for the same names.
169
+ def delegate_optional_receiver(to)
170
+ return to.to_s unless ::ActiveSupport::Delegation::RESERVED_METHOD_NAMES.include?(to.to_s)
171
+
172
+ "self.#{to}"
173
+ end
174
+
175
+ # true asks for the target's own name; any other prefix is the name.
176
+ def delegate_optional_prefix(to, prefix)
177
+ return '' unless prefix
178
+
179
+ "#{prefix == true ? to : prefix}_"
180
+ end
181
+
182
+ # A mistake is worth more where the declaration is written than as a
183
+ # method nothing can call. allow_nil: true is what a delegation does
184
+ # here anyway, so a swap from delegate carries; its opposite does not.
185
+ def delegate_optional_complaint(methods, to, prefix, allow_nil)
186
+ return NO_TARGET if to.nil?
187
+ return NO_WRITERS if methods.any? { |method_name| /\A\w+=\z/.match?(method_name.to_s) }
188
+ return ALWAYS_NONE if allow_nil == false
189
+
190
+ delegate_optional_prefix_complaint(to, prefix)
191
+ end
192
+
193
+ # An automatic prefix is the target's own name, so the target needs
194
+ # one, and one that can start a method name.
195
+ def delegate_optional_prefix_complaint(to, prefix)
196
+ return unless prefix == true
197
+ return NO_NAME_TO_PREFIX if to.is_a?(::Module)
198
+
199
+ NO_METHOD_TO_PREFIX if /^[^a-z_]/.match?(to.to_s)
200
+ end
41
201
  end
42
202
  end
43
203
  end