errgonomic 0.7.0 → 0.8.1

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.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../option'
4
+ require_relative '../result'
5
+
6
+ # Lift booleans into Option and Result, following Rust's bool: then_some,
7
+ # and ok_or/ok_or_else from nightly. Rust splits the lazy form into `then`,
8
+ # but that name is core Ruby (Kernel#then), so then_some takes either a
9
+ # value or a block. Rust's ok_or returns Result<(), E>; Ruby has no unit
10
+ # type, so Ok carries true.
11
+ class TrueClass
12
+ # @example
13
+ # true.then_some(:hello) # => Some(:hello)
14
+ # true.then_some { :hello } # => Some(:hello)
15
+ # true.then_some(nil) # => Some(nil)
16
+ # true.then_some # => raise Errgonomic::ArgumentError, "then_some takes either a value or a block"
17
+ def then_some(*args, &block)
18
+ if args.length == 1 && !block
19
+ Some(args[0])
20
+ elsif args.empty? && block
21
+ Some(block.call)
22
+ else
23
+ raise Errgonomic::ArgumentError, 'then_some takes either a value or a block'
24
+ end
25
+ end
26
+
27
+ # @example
28
+ # true.ok_or(:ohno) # => Ok(true)
29
+ def ok_or(_err)
30
+ Ok(true)
31
+ end
32
+
33
+ # @example
34
+ # true.ok_or_else { :ohno } # => Ok(true)
35
+ def ok_or_else
36
+ Ok(true)
37
+ end
38
+ end
39
+
40
+ # The false halves of the conversions above.
41
+ class FalseClass
42
+ # The block is never called; arguments are validated all the same, so a
43
+ # misuse fails regardless of which way the flag happens to point.
44
+ #
45
+ # @example
46
+ # false.then_some(:hello) # => None()
47
+ # false.then_some { :hello } # => None()
48
+ # false.then_some # => raise Errgonomic::ArgumentError, "then_some takes either a value or a block"
49
+ def then_some(*args, &block)
50
+ unless (args.length == 1 && !block) || (args.empty? && block)
51
+ raise Errgonomic::ArgumentError, 'then_some takes either a value or a block'
52
+ end
53
+
54
+ None()
55
+ end
56
+
57
+ # @example
58
+ # false.ok_or(:ohno) # => Err(:ohno)
59
+ def ok_or(err)
60
+ Err(err)
61
+ end
62
+
63
+ # @example
64
+ # false.ok_or_else { :ohno } # => Err(:ohno)
65
+ def ok_or_else(&block)
66
+ Err(block.call)
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../option'
4
+ require_relative '../optional_hash'
5
+
6
+ # Two additive lookups; no existing Hash behavior changes. This is as deep as
7
+ # the gem reaches into Hash: the wider Ruby ecosystem leans on Hash semantics
8
+ # too heavily to patch them, so richer behavior lives in
9
+ # Errgonomic::OptionalHash instead.
10
+ class Hash
11
+ # Retrieve the value for a key, wrapped in an Option, following key
12
+ # presence as Rust's HashMap#get does: a present key with a nil value is
13
+ # Some(nil); only a missing key is None.
14
+ #
15
+ # @example
16
+ # h = { color: :blue, shade: nil }
17
+ # h.fetch_option(:color) # => Some(:blue)
18
+ # h.fetch_option(:shade) # => Some(nil)
19
+ # h.fetch_option(:smell) # => None()
20
+ def fetch_option(key)
21
+ return None() unless key?(key)
22
+
23
+ Some(self[key])
24
+ end
25
+
26
+ # Wrap this hash in an Errgonomic::OptionalHash view. The wrapper reads and
27
+ # writes this same hash; use its to_h for a detached copy.
28
+ #
29
+ # @example
30
+ # h = { color: :blue }.into_optional
31
+ # h[:color] # => Some(:blue)
32
+ # h[:smell] # => None()
33
+ def into_optional
34
+ Errgonomic::OptionalHash.new(self)
35
+ end
36
+ end
@@ -5,20 +5,65 @@ module Errgonomic
5
5
  # The base class for all options. Some and None are subclasses.
6
6
  #
7
7
  class Any
8
- # def method_missing(_name, *_args)
9
- # raise 'do it right noob'
10
- # end
11
-
12
- # An option of the same type with an equal inner value is equal.
13
- #
14
- # Because we're going to monkey patch this into other libraries Rails, we
15
- # allow some "pass through" functionality into the inner value of a Some,
16
- # such as comparability here.
8
+ include Comparable
9
+
10
+ # Rust spellings we accept but do not advertise: they delegate to the
11
+ # Ruby-idiomatic predicate and nudge the caller there via stderr.
12
+ RUST_SPELLINGS = {
13
+ is_some: :some?,
14
+ is_none: :none?,
15
+ is_some_and: :some_and?,
16
+ is_none_or: :none_or?
17
+ }.freeze
18
+
19
+ # An Option deliberately forwards nothing to its inner value, so a miss
20
+ # here is almost always someone treating the container as its contents.
21
+ # Teach the route out instead of leaving a bare NoMethodError. Rust
22
+ # spellings of the predicates delegate, with a nudge on stderr.
17
23
  #
18
- # TODO: does None == null?
24
+ # @example
25
+ # begin
26
+ # Some(5) + 1
27
+ # rescue NoMethodError => e
28
+ # e.class
29
+ # end # => Errgonomic::UnwrappedAccessError
30
+ # Some(5).respond_to?(:+) # => false
31
+ # Some(1).is_some_and { |x| x > 0 } # => true
32
+ # None().is_none # => true
33
+ # Some(5).respond_to?(:is_some) # => true
34
+ def method_missing(name, *args, &block)
35
+ if (canonical = RUST_SPELLINGS[name])
36
+ warn "Errgonomic: `#{name}` is the Rust spelling; prefer `#{canonical}`. Delegating."
37
+ return public_send(canonical, *args, &block)
38
+ end
39
+
40
+ raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
41
+ undefined method `#{name}' for #{inspect}, an Option, which does not forward methods to its inner value.
42
+ Reach for a combinator instead:
43
+ map, and_then, filter: transform the value if present
44
+ unwrap_or, unwrap_or_else: supply a fallback
45
+ ok_or, ok_or_else: convert to a Result
46
+ some_and?, none_or?: test a predicate against the inner value
47
+ unwrap! and expect! also exist, but are intended for tests rather than application code.
48
+ MSG
49
+ end
50
+
51
+ def respond_to_missing?(name, include_private = false)
52
+ RUST_SPELLINGS.key?(name) || super
53
+ end
54
+
55
+ # An Option equals another Option of the same class with an equal inner
56
+ # value. Anything else, including nil and the raw inner value, is not
57
+ # equal: quietly false, never an error. Rust rejects Some(5) == 5 at
58
+ # compile time; Ruby cannot, and raising here would break the many
59
+ # places Ruby compares heterogeneous operands (Array#include?,
60
+ # assertion diffs, dirty tracking). Compare Options (opt == Some(5)) or
61
+ # test the inner value (opt.some_and? { |v| v == 5 }) instead.
19
62
  #
20
- # strict:
21
- # Some(1) == 1 # => raise Errgonomic::NotComparableError, "Cannot compare Errgonomic::Option::Some with Integer"
63
+ # None() == nil is likewise false: None is a value that represents
64
+ # absence, not an absence Ruby can see. (The Rails integration
65
+ # separately makes None#nil? answer true, as an ActiveRecord
66
+ # compromise; equality does not follow it.)
22
67
  #
23
68
  # @example
24
69
  # Some(1) == Some(1) # => true
@@ -34,6 +79,34 @@ module Errgonomic
34
79
  value == other.value
35
80
  end
36
81
 
82
+ # Hash-based collections (Hash keys, Set, uniq, group_by) use eql? and
83
+ # hash, not ==. Follow the inner value's own eql? semantics, so Options
84
+ # behave as keys exactly like their inner values: Some(1) and Some(1.0)
85
+ # are distinct keys, just as 1 and 1.0 are.
86
+ #
87
+ # @example
88
+ # Some(5).eql?(Some(5)) # => true
89
+ # Some(1).eql?(Some(1.0)) # => false
90
+ # None().eql?(None()) # => true
91
+ # { Some(5) => 1 }[Some(5)] # => 1
92
+ # [Some(1), Some(1), None(), None()].uniq # => [Some(1), None()]
93
+ def eql?(other)
94
+ return false if self.class != other.class
95
+ return true if none?
96
+
97
+ value.eql?(other.value)
98
+ end
99
+
100
+ # @example
101
+ # Some(5).hash == Some(5).hash # => true
102
+ # None().hash == None().hash # => true
103
+ # Some(5).hash == None().hash # => false
104
+ def hash
105
+ return self.class.hash if none?
106
+
107
+ [self.class, value].hash
108
+ end
109
+
37
110
  # @example
38
111
  # measurement = Errgonomic::Option::Some.new(1)
39
112
  # case measurement
@@ -50,6 +123,28 @@ module Errgonomic
50
123
  [Errgonomic::Option::None]
51
124
  end
52
125
 
126
+ # 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.
130
+ #
131
+ # @example
132
+ # (Some(5) <=> Some(6)) # => -1
133
+ # (None() <=> Some(5)) # => -1
134
+ # (Some(5) <=> None()) # => 1
135
+ # (None() <=> None()) # => 0
136
+ # (Some(1) <=> Some("x")) # => nil
137
+ # (Some(1) <=> 1) # => nil
138
+ # [Some(2), None(), Some(1)].sort # => [None(), Some(1), Some(2)]
139
+ # [Some(2), Some(1)].min # => Some(1)
140
+ def <=>(other)
141
+ return nil unless other.is_a?(Errgonomic::Option::Any)
142
+ return none? ? 0 : 1 if other.none?
143
+ return -1 if none?
144
+
145
+ value <=> other.value
146
+ end
147
+
53
148
  # return true if the contained value is Some and the block returns truthy
54
149
  #
55
150
  # @example
@@ -78,6 +173,135 @@ module Errgonomic
78
173
 
79
174
  alias none_or? none_or
80
175
 
176
+ # Presence follows the discriminant, not the inner value: Some is
177
+ # present, None is blank. So Some(false) and Some(nil) are present,
178
+ # unlike their unwrapped values.
179
+ #
180
+ # @example
181
+ # Some(1).present? # => true
182
+ # Some(false).present? # => true
183
+ # Some("").present? # => true
184
+ # None().present? # => false
185
+ def present?
186
+ some?
187
+ end
188
+
189
+ # @example
190
+ # None().blank? # => true
191
+ # Some(1).blank? # => false
192
+ # Some(nil).blank? # => false
193
+ def blank?
194
+ none?
195
+ end
196
+
197
+ # The presence helpers on Object keep their receiver; on an Option that
198
+ # would hand back the wrapper where the caller asked for a value. Here
199
+ # 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.
204
+
205
+ # Returns the inner value of a Some, and raises on a None. Presence
206
+ # follows the discriminant, so Some(nil) yields nil.
207
+ #
208
+ # @param message [String] The error message to raise on a None.
209
+ # @return [Object] The inner value of a Some.
210
+ #
211
+ # @example
212
+ # Some("secret").present_or_raise!("no secret") # => "secret"
213
+ # Some(nil).present_or_raise!("no secret") # => nil
214
+ # 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?
218
+
219
+ value
220
+ end
221
+
222
+ alias present_or_raise present_or_raise!
223
+
224
+ # Returns the inner value of a Some, and the given default on a None.
225
+ # No pedantic type check on the default: this family is deprecated on
226
+ # Options, and unwrap_or, which the nudge points to, has none either.
227
+ #
228
+ # @param default [Object] The value to return on a None.
229
+ # @return [Object] The inner value of a Some, otherwise the default.
230
+ #
231
+ # @example
232
+ # Some("secret").present_or("fallback") # => "secret"
233
+ # None().present_or("fallback") # => "fallback"
234
+ def present_or(default)
235
+ presence_nudge('present_or', 'unwrap_or')
236
+ return default if none?
237
+
238
+ value
239
+ end
240
+
241
+ # Returns the inner value of a Some, and the result of the block on a
242
+ # None.
243
+ #
244
+ # @param block [Proc] The block to call on a None.
245
+ # @return [Object] The inner value of a Some, otherwise the block's value.
246
+ #
247
+ # @example
248
+ # Some("secret").present_or_else { "fallback" } # => "secret"
249
+ # None().present_or_else { "fallback" } # => "fallback"
250
+ def present_or_else(&block)
251
+ presence_nudge('present_or_else', 'unwrap_or_else')
252
+ return block.call if none?
253
+
254
+ value
255
+ end
256
+
257
+ # Returns the inner value of a Some, and nil on a None, so the Rails
258
+ # +presence || default+ idiom reaches the value rather than the wrapper.
259
+ #
260
+ # @return [Object, nil] The inner value of a Some, otherwise nil.
261
+ #
262
+ # @example
263
+ # Some("secret").presence # => "secret"
264
+ # None().presence # => nil
265
+ # None().presence || "fallback" # => "fallback"
266
+ def presence
267
+ presence_nudge('presence', 'unwrap_or(nil)')
268
+ return nil if none?
269
+
270
+ value
271
+ end
272
+
273
+ # @example the blank side of the presence family teaches the combinators
274
+ # begin
275
+ # None().blank_or("x")
276
+ # rescue NoMethodError => e
277
+ # e.class
278
+ # end # => Errgonomic::UnwrappedAccessError
279
+ def blank_or(_default)
280
+ raise_blank_side_teaching(:blank_or)
281
+ end
282
+
283
+ # @example
284
+ # begin
285
+ # Some(1).blank_or_else { :x }
286
+ # rescue NoMethodError => e
287
+ # e.class
288
+ # end # => Errgonomic::UnwrappedAccessError
289
+ def blank_or_else(&_block)
290
+ raise_blank_side_teaching(:blank_or_else)
291
+ end
292
+
293
+ # @example
294
+ # begin
295
+ # None().blank_or_raise!("msg")
296
+ # rescue NoMethodError => e
297
+ # e.class
298
+ # end # => Errgonomic::UnwrappedAccessError
299
+ def blank_or_raise!(_message)
300
+ raise_blank_side_teaching(:blank_or_raise!)
301
+ end
302
+
303
+ alias blank_or_raise blank_or_raise!
304
+
81
305
  # return an Array with the contained value, if any
82
306
  # @example
83
307
  # Some(1).to_a # => [1]
@@ -119,16 +343,6 @@ module Errgonomic
119
343
  value
120
344
  end
121
345
 
122
- # # returns the inner value if present, else returns the default value
123
- # # @example
124
- # # Some(1).unwrap_or(2) # => 1
125
- # # None().unwrap_or(2) # => 2
126
- # def unwrap_or_default
127
- # self.class.respond_to?(:default) or raise
128
- # return self.class.default if none?
129
- # value
130
- # end
131
-
132
346
  # returns the inner value if present, else returns the result of the
133
347
  # provided block
134
348
  # @example
@@ -342,14 +556,77 @@ module Errgonomic
342
556
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
343
557
  end
344
558
 
345
- # filter
346
- # xor
347
- # insert
348
- # get_or_insert
349
- # get_or_insert_with
350
- # take
351
- # take_if
352
- # replace
559
+ # pp uses its own object dump unless told otherwise; keep it consistent
560
+ # with inspect.
561
+ def pretty_print(pp)
562
+ pp.text(inspect)
563
+ end
564
+
565
+ # Return self if the predicate is truthy for the inner value, else None.
566
+ # None passes through.
567
+ #
568
+ # @example
569
+ # Some(1).filter(&:odd?) # => Some(1)
570
+ # Some(2).filter(&:odd?) # => None()
571
+ # None().filter(&:odd?) # => None()
572
+ def filter(&block)
573
+ return self if none?
574
+
575
+ block.call(value) ? self : None()
576
+ end
577
+
578
+ # Remove one level of Option nesting. Pedantically raises when the inner
579
+ # value is not itself an Option, which in Rust would not have compiled.
580
+ #
581
+ # @example
582
+ # Some(Some(1)).flatten # => Some(1)
583
+ # Some(None()).flatten # => None()
584
+ # None().flatten # => None()
585
+ # Some(Some(Some(1))).flatten # => Some(Some(1))
586
+ # Some(1).flatten # => raise Errgonomic::TypeMismatchError, "cannot flatten Integer; it is not an Option"
587
+ def flatten
588
+ return self if none?
589
+
590
+ unless value.is_a?(Errgonomic::Option::Any)
591
+ raise Errgonomic::TypeMismatchError,
592
+ "cannot flatten #{value.class}; it is not an Option"
593
+ end
594
+
595
+ value
596
+ end
597
+
598
+ # Return Some when either self or other are Some, otherwise return None
599
+ # when both are None or both are Some.
600
+ #
601
+ # @example
602
+ # Some(:left).xor(Some(:right)) # => None()
603
+ # Some(:left).xor(None()) #=> Some(:left)
604
+ # None().xor(Some(:right)) #=> Some(:right)
605
+ #
606
+ def xor(other)
607
+ return self if some? && other.none?
608
+ return other if other.some? && none?
609
+
610
+ None()
611
+ end
612
+
613
+ private
614
+
615
+ def presence_nudge(from, to)
616
+ warn "Errgonomic: `#{from}` on an Option is soft-deprecated; prefer `#{to}`."
617
+ end
618
+
619
+ def raise_blank_side_teaching(name)
620
+ raise Errgonomic::UnwrappedAccessError.new(<<~MSG, name)
621
+ `#{name}` is not supported on an Option, whose blankness is its discriminant.
622
+ Test it with none?, or supply a fallback with unwrap_or / unwrap_or_else.
623
+ MSG
624
+ end
625
+
626
+ public
627
+
628
+ # Rust's mutating combinators (insert, get_or_insert, take, replace)
629
+ # are deliberately omitted: an Option here is a value, not a slot.
353
630
  end
354
631
 
355
632
  # Represent a value
@@ -357,6 +634,7 @@ module Errgonomic
357
634
  attr_accessor :value
358
635
 
359
636
  def initialize(value)
637
+ super()
360
638
  @value = value
361
639
  end
362
640
 
@@ -367,8 +645,21 @@ module Errgonomic
367
645
  def none?
368
646
  false
369
647
  end
648
+
649
+ # Render like Rust's Debug, delegating to the inner value's inspect so
650
+ # nesting stays unambiguous.
651
+ #
652
+ # @example
653
+ # Some(5).inspect # => "Some(5)"
654
+ # Some("x").inspect # => "Some(\"x\")"
655
+ # Some(nil).inspect # => "Some(nil)"
656
+ # Some(Some(1)).inspect # => "Some(Some(1))"
657
+ def inspect
658
+ "Some(#{value.inspect})"
659
+ end
370
660
  end
371
661
 
662
+ # Represent the absence of a value.
372
663
  class None < Any
373
664
  def some?
374
665
  false
@@ -377,6 +668,12 @@ module Errgonomic
377
668
  def none?
378
669
  true
379
670
  end
671
+
672
+ # @example
673
+ # None().inspect # => "None"
674
+ def inspect
675
+ 'None'
676
+ end
380
677
  end
381
678
  end
382
679
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option'
4
+ require_relative 'optional_dig'
5
+
6
+ module Errgonomic
7
+ # A companion to Array whose lookups return Options, composed around a
8
+ # plain Array for the same reasons OptionalHash composes around a Hash.
9
+ # Lookups follow element presence: an element holding nil is Some(nil),
10
+ # and only an out-of-bounds index is None, as with Rust's slice get.
11
+ class OptionalArray
12
+ include OptionalDig
13
+
14
+ def initialize(array = [])
15
+ unless array.is_a?(::Array)
16
+ raise Errgonomic::TypeMismatchError,
17
+ "OptionalArray wraps an Array, got #{array.class}"
18
+ end
19
+
20
+ @array = array
21
+ end
22
+
23
+ # Retrieve the element at an integer index, wrapped in an Option.
24
+ # Negative indexes count from the end, as usual. A non-integer index
25
+ # raises, pedantically: the silent nil of Array#[] with a bad argument is
26
+ # the ambiguity this class exists to remove.
27
+ #
28
+ # @example
29
+ # a = [:a, nil].into_optional
30
+ # a[0] # => Some(:a)
31
+ # a[1] # => Some(nil)
32
+ # a[2] # => None()
33
+ # a[-1] # => Some(nil)
34
+ # a[:nope] # => raise Errgonomic::TypeMismatchError, "index must be an Integer, got Symbol"
35
+ def [](index)
36
+ unless index.is_a?(::Integer)
37
+ raise Errgonomic::TypeMismatchError,
38
+ "index must be an Integer, got #{index.class}"
39
+ end
40
+ return None() unless (-@array.length...@array.length).cover?(index)
41
+
42
+ Some(@array[index])
43
+ end
44
+
45
+ # Write through to the underlying array.
46
+ #
47
+ # @example
48
+ # a = [].into_optional
49
+ # a[0] = :a
50
+ # a[0] # => Some(:a)
51
+ def []=(index, value)
52
+ @array[index] = value
53
+ end
54
+
55
+ # Like Array#dig, but every step checks presence, so an absent path
56
+ # (None) stays distinct from a present nil (Some(nil)).
57
+ #
58
+ # @example
59
+ # a = [{ name: 'Ada' }].into_optional
60
+ # a.dig(0, :name) # => Some("Ada")
61
+ # a.dig(0, :nickname) # => None()
62
+ # a.dig(1, :name) # => None()
63
+ def dig(index, *rest)
64
+ optional_dig(@array, [index, *rest])
65
+ end
66
+
67
+ # The first element as an Option, as with Rust's slice first.
68
+ #
69
+ # @example
70
+ # [1, 2].into_optional.first # => Some(1)
71
+ # [nil].into_optional.first # => Some(nil)
72
+ # [].into_optional.first # => None()
73
+ def first
74
+ return None() if @array.empty?
75
+
76
+ Some(@array.first)
77
+ end
78
+
79
+ # The last element as an Option, as with Rust's slice last.
80
+ #
81
+ # @example
82
+ # [1, 2].into_optional.last # => Some(2)
83
+ # [].into_optional.last # => None()
84
+ def last
85
+ return None() if @array.empty?
86
+
87
+ Some(@array.last)
88
+ end
89
+
90
+ # @example
91
+ # [].into_optional.empty? # => true
92
+ # [1].into_optional.empty? # => false
93
+ def empty?
94
+ @array.empty?
95
+ end
96
+
97
+ # @example
98
+ # [1, 2].into_optional.size # => 2
99
+ def size
100
+ @array.size
101
+ end
102
+
103
+ # The escape hatch back to a plain Array: a shallow copy, so array-shaped
104
+ # code cannot mutate the wrapped state behind the Option semantics.
105
+ #
106
+ # @example
107
+ # [1].into_optional.to_a # => [1]
108
+ def to_a
109
+ @array.dup
110
+ end
111
+
112
+ # Equal to another OptionalArray wrapping an equal array; never equal to
113
+ # a plain Array, mirroring how Some(x) is never equal to x.
114
+ #
115
+ # @example
116
+ # [1].into_optional == [1].into_optional # => true
117
+ # [1].into_optional == [2].into_optional # => false
118
+ # [1].into_optional == [1] # => false
119
+ def ==(other)
120
+ other.is_a?(OptionalArray) && inner == other.inner
121
+ end
122
+
123
+ # @example
124
+ # [1].into_optional.eql?([1].into_optional) # => true
125
+ # { [1].into_optional => :hit }[[1].into_optional] # => :hit
126
+ def eql?(other)
127
+ other.is_a?(OptionalArray) && inner.eql?(other.inner)
128
+ end
129
+
130
+ def hash
131
+ [self.class, inner].hash
132
+ end
133
+
134
+ # @example
135
+ # [1].into_optional.inspect # => "OptionalArray([1])"
136
+ def inspect
137
+ "OptionalArray(#{@array.inspect})"
138
+ end
139
+
140
+ protected
141
+
142
+ def inner
143
+ @array
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option'
4
+
5
+ module Errgonomic
6
+ # The presence-checking walk behind OptionalHash#dig and OptionalArray#dig:
7
+ # every step checks key or bounds presence, so an absent path (None) stays
8
+ # distinct from a present nil (Some(nil)), which the core dig methods
9
+ # conflate. A nested wrapper handles the rest of the walk itself, by
10
+ # recursion. Digging into a non-collection raises, pedantically, where
11
+ # core dig would raise TypeError.
12
+ module OptionalDig
13
+ private
14
+
15
+ def optional_dig(start, keys)
16
+ current = start
17
+ keys.each_with_index do |key, idx|
18
+ case current
19
+ when OptionalHash, OptionalArray
20
+ return current.dig(key, *keys[(idx + 1)..])
21
+ when ::Hash
22
+ return None() unless current.key?(key)
23
+ when ::Array
24
+ return None() unless key.is_a?(Integer) && (-current.length...current.length).cover?(key)
25
+ else
26
+ raise Errgonomic::TypeMismatchError, "cannot dig into #{current.class}"
27
+ end
28
+ current = current[key]
29
+ end
30
+ Some(current)
31
+ end
32
+ end
33
+ end