errgonomic 0.6.0 → 0.8.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.
@@ -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,27 @@ 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
+
81
197
  # return an Array with the contained value, if any
82
198
  # @example
83
199
  # Some(1).to_a # => [1]
@@ -119,16 +235,6 @@ module Errgonomic
119
235
  value
120
236
  end
121
237
 
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
238
  # returns the inner value if present, else returns the result of the
133
239
  # provided block
134
240
  # @example
@@ -342,14 +448,62 @@ module Errgonomic
342
448
  raise Errgonomic::SerializeError, 'cannot serialize an unwrapped Option'
343
449
  end
344
450
 
345
- # filter
346
- # xor
347
- # insert
348
- # get_or_insert
349
- # get_or_insert_with
350
- # take
351
- # take_if
352
- # replace
451
+ # pp uses its own object dump unless told otherwise; keep it consistent
452
+ # with inspect.
453
+ def pretty_print(pp)
454
+ pp.text(inspect)
455
+ end
456
+
457
+ # Return self if the predicate is truthy for the inner value, else None.
458
+ # None passes through.
459
+ #
460
+ # @example
461
+ # Some(1).filter(&:odd?) # => Some(1)
462
+ # Some(2).filter(&:odd?) # => None()
463
+ # None().filter(&:odd?) # => None()
464
+ def filter(&block)
465
+ return self if none?
466
+
467
+ block.call(value) ? self : None()
468
+ end
469
+
470
+ # Remove one level of Option nesting. Pedantically raises when the inner
471
+ # value is not itself an Option, which in Rust would not have compiled.
472
+ #
473
+ # @example
474
+ # Some(Some(1)).flatten # => Some(1)
475
+ # Some(None()).flatten # => None()
476
+ # None().flatten # => None()
477
+ # Some(Some(Some(1))).flatten # => Some(Some(1))
478
+ # Some(1).flatten # => raise Errgonomic::TypeMismatchError, "cannot flatten Integer; it is not an Option"
479
+ def flatten
480
+ return self if none?
481
+
482
+ unless value.is_a?(Errgonomic::Option::Any)
483
+ raise Errgonomic::TypeMismatchError,
484
+ "cannot flatten #{value.class}; it is not an Option"
485
+ end
486
+
487
+ value
488
+ end
489
+
490
+ # Return Some when either self or other are Some, otherwise return None
491
+ # when both are None or both are Some.
492
+ #
493
+ # @example
494
+ # Some(:left).xor(Some(:right)) # => None()
495
+ # Some(:left).xor(None()) #=> Some(:left)
496
+ # None().xor(Some(:right)) #=> Some(:right)
497
+ #
498
+ def xor(other)
499
+ return self if some? && other.none?
500
+ return other if other.some? && none?
501
+
502
+ None()
503
+ end
504
+
505
+ # Rust's mutating combinators (insert, get_or_insert, take, replace)
506
+ # are deliberately omitted: an Option here is a value, not a slot.
353
507
  end
354
508
 
355
509
  # Represent a value
@@ -357,6 +511,7 @@ module Errgonomic
357
511
  attr_accessor :value
358
512
 
359
513
  def initialize(value)
514
+ super()
360
515
  @value = value
361
516
  end
362
517
 
@@ -367,8 +522,21 @@ module Errgonomic
367
522
  def none?
368
523
  false
369
524
  end
525
+
526
+ # Render like Rust's Debug, delegating to the inner value's inspect so
527
+ # nesting stays unambiguous.
528
+ #
529
+ # @example
530
+ # Some(5).inspect # => "Some(5)"
531
+ # Some("x").inspect # => "Some(\"x\")"
532
+ # Some(nil).inspect # => "Some(nil)"
533
+ # Some(Some(1)).inspect # => "Some(Some(1))"
534
+ def inspect
535
+ "Some(#{value.inspect})"
536
+ end
370
537
  end
371
538
 
539
+ # Represent the absence of a value.
372
540
  class None < Any
373
541
  def some?
374
542
  false
@@ -377,6 +545,12 @@ module Errgonomic
377
545
  def none?
378
546
  true
379
547
  end
548
+
549
+ # @example
550
+ # None().inspect # => "None"
551
+ def inspect
552
+ 'None'
553
+ end
380
554
  end
381
555
  end
382
556
  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